diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts
index 37fe6a0f0..a57b6017e 100644
--- a/apps/website/e2e/website.spec.ts
+++ b/apps/website/e2e/website.spec.ts
@@ -182,10 +182,20 @@ test('docs page renders sidebar and content', async ({ page }) => {
test('docs landing page shows library cards', async ({ page }) => {
await page.goto('/docs');
- await expect(page.getByText('LangGraph').first()).toBeVisible();
- await expect(page.getByText('Render').first()).toBeVisible();
- await expect(page.getByText('Chat').first()).toBeVisible();
- await expect(page.getByText('AG-UI').first()).toBeVisible();
+ // Assert on card titles, not page text. A bare getByText('Render') passed on
+ // a substring of "json-render"; hasText on the card would match the Chat
+ // card too, whose blurb mentions json-render. Only the title is the card.
+ const titles = page.locator('.docs-index-card-title');
+ await expect(titles.filter({ hasText: /^LangGraph$/ })).toBeVisible();
+ await expect(titles.filter({ hasText: /^json-render$/ })).toBeVisible();
+ await expect(titles.filter({ hasText: /^AG-UI$/ })).toBeVisible();
+ await expect(titles.filter({ hasText: /^Chat$/ })).toBeVisible();
+});
+
+test('docs landing page carries the control plane', async ({ page }) => {
+ await page.goto('/docs');
+ await expect(page.getByRole('navigation', { name: 'Docs modes' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Choose a library' })).toBeVisible();
});
test('api reference renders in docs', async ({ page }) => {
diff --git a/apps/website/src/app/docs/docs-index-shell.spec.tsx b/apps/website/src/app/docs/docs-index-shell.spec.tsx
new file mode 100644
index 000000000..567672a05
--- /dev/null
+++ b/apps/website/src/app/docs/docs-index-shell.spec.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+import React from 'react';
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import DocsLandingPage from './page';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/docs',
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+beforeEach(() => {
+ window.localStorage.clear();
+});
+
+describe('docs index', () => {
+ it('wears the same control plane as every other docs route', () => {
+ render();
+
+ const scope = screen.getByRole('heading', { name: 'Scope' }).closest('section');
+ if (!scope) throw new Error('Expected a Scope section');
+ // Library-neutral: the index is where you pick one, so it claims none.
+ expect(within(scope).getByText('Docs')).toBeTruthy();
+ expect(within(scope).getByText('Overview')).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Choose a library' })).toBeTruthy();
+ });
+
+ it('keeps the landing content out of the prose measure', () => {
+ const { container } = render();
+
+ // The card grids need their own width; the [slug] route's md:max-w-3xl
+ // article measure would flatten them into a single column.
+ const body = container.querySelector('.docs-index-body');
+ expect(body).toBeTruthy();
+ expect(body?.className).not.toContain('max-w-3xl');
+ });
+
+ it('calls the render library what the docs call it', () => {
+ render();
+
+ // The picker menu is closed on mount, so its labels are not in the DOM
+ // until it is opened. Asserting without this click passes even when
+ // docsConfig still says "Render" — it only ever sees the index card.
+ fireEvent.click(screen.getByRole('button', { name: 'Choose a library' }));
+ const menu = screen.getByRole('menu');
+ const pickerNames = within(menu)
+ .getAllByRole('menuitemradio')
+ .map((item) => item.querySelector('.docs-sidebar-lib-item-title')?.textContent);
+
+ // 85 occurrences of "json-render" across docs content vs one "Render" in
+ // docsConfig. With the control plane on this page both labels are on
+ // screen at once, so they have to agree.
+ expect(pickerNames).toContain('json-render');
+ expect(pickerNames).not.toContain('Render');
+ });
+});
diff --git a/apps/website/src/app/docs/page.tsx b/apps/website/src/app/docs/page.tsx
index b36ebbf8e..bad5598a7 100644
--- a/apps/website/src/app/docs/page.tsx
+++ b/apps/website/src/app/docs/page.tsx
@@ -6,6 +6,9 @@ import { Eyebrow } from '../../components/ui/Eyebrow';
import { Card } from '../../components/ui/Card';
import { Pill } from '../../components/ui/Pill';
import { CopyButton } from '../../components/docs/CopyButton';
+import { DocsControlPlane } from '../../components/docs/DocsControlPlane';
+import { DocsSearch } from '../../components/docs/DocsSearch';
+import { DOCS_INDEX_TITLE } from '../../lib/docs-config';
import { createPageMetadata } from '../../lib/site-metadata';
export const metadata = createPageMetadata({
@@ -152,7 +155,19 @@ function GlyphChip({ size, children }: { size: number; children: ReactNode }) {
export default function DocsLandingPage() {
return (
- <>
+
+
+ {/* The index is library-neutral: it is where you pick one. */}
+
+ {/* Deliberately outside the article measure the [slug] route uses — the
+ * card grids need their own width, and the prose column would flatten
+ * them. The shell supplies the chrome, not the content width. */}
+
+
);
}
diff --git a/apps/website/src/components/shared/Nav.spec.tsx b/apps/website/src/components/shared/Nav.spec.tsx
index 54cd141dc..0d75e83e0 100644
--- a/apps/website/src/components/shared/Nav.spec.tsx
+++ b/apps/website/src/components/shared/Nav.spec.tsx
@@ -26,6 +26,21 @@ describe('Docs mobile navigation', () => {
pathnameRef.current = '/docs/langgraph/guides/streaming';
});
+ it('names the docs index the same way the page does', () => {
+ pathnameRef.current = '/docs';
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Open menu' }));
+ const dialog = screen.getByRole('dialog', { name: 'Mobile navigation' });
+
+ // The page passes pageTitle="Overview"; Nav derives its own title. When
+ // they drift, the same page is called two different things depending on
+ // viewport width.
+ const scope = within(dialog).getByRole('heading', { name: 'Scope' }).closest('section');
+ if (!scope) throw new Error('Expected a Scope section');
+ expect(within(scope).getByText('Overview')).toBeTruthy();
+ expect(within(scope).queryByText('Documentation')).toBeNull();
+ });
+
it('does not invent a library on a library-neutral docs page', () => {
pathnameRef.current = '/docs/choosing-an-adapter';
render();
diff --git a/apps/website/src/components/shared/Nav.tsx b/apps/website/src/components/shared/Nav.tsx
index 6f79c75cf..d0b5fe94e 100644
--- a/apps/website/src/components/shared/Nav.tsx
+++ b/apps/website/src/components/shared/Nav.tsx
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
+ DOCS_INDEX_TITLE,
findDocsPage,
getLibraryConfig,
specialDocsPages,
@@ -108,7 +109,7 @@ export function Nav() {
const docsPageTitle =
findDocsPage(activeLibrary, activeSection, activeSlug)?.title ??
specialDocsPage?.title ??
- 'Documentation';
+ (pathname === '/docs' ? DOCS_INDEX_TITLE : 'Documentation');
const navRef = useRef(null);
const mobileTriggerRef = useRef(null);
const mobileDialogRef = useRef(null);
diff --git a/apps/website/src/lib/docs-config.ts b/apps/website/src/lib/docs-config.ts
index 0fb6e89b0..eba538fb3 100644
--- a/apps/website/src/lib/docs-config.ts
+++ b/apps/website/src/lib/docs-config.ts
@@ -62,6 +62,13 @@ export interface SpecialDocsPage {
description: string;
}
+/**
+ * What the docs root calls itself in the control plane's Scope card. Shared so
+ * the page and the mobile drawer, which resolve the title independently,
+ * cannot drift into naming the same page two different things.
+ */
+export const DOCS_INDEX_TITLE = 'Overview';
+
export const specialDocsPages: SpecialDocsPage[] = [
{
path: '/docs/choosing-an-adapter',
@@ -133,7 +140,9 @@ export const docsConfig: DocsLibrary[] = [
},
{
id: 'render',
- title: 'Render',
+ // Display label only. The package is @threadplane/render and the docs URL
+ // stays /docs/render/ — but every page of prose calls it json-render.
+ title: 'json-render',
description: 'Declarative UI rendering from JSON specifications',
group: 'library',
sections: [
diff --git a/docs/superpowers/specs/2026-09-01-docs-index-control-plane-design.md b/docs/superpowers/specs/2026-09-01-docs-index-control-plane-design.md
new file mode 100644
index 000000000..42ef4985b
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-01-docs-index-control-plane-design.md
@@ -0,0 +1,85 @@
+# Aligning the docs index with the control plane
+
+**Date:** 2026-09-01
+**Scope:** `/docs`, and the `Render` / `json-render` naming split
+**Status:** approved, ready to implement
+
+## Context
+
+After #920, `/docs` is the only `/docs/*` route without the control plane. That
+was a deliberate call at the time — it is a designed landing page, and forcing
+its 2-up card grids into the prose column would flatten it. In review the
+inconsistency was judged worse than that risk, so the page adopts the shell.
+
+## Design
+
+### The shell, without the prose measure
+
+`/docs` renders `docs-shell-page` with ``,
+reusing the library-neutral state added in #920.
+
+The landing content goes inside `docs-shell-body` but **not** inside the
+`md:max-w-3xl` article measure the `[slug]` route uses. Its `Section`/`Container`
+structure keeps its own width. The chrome becomes consistent; the layout does
+not get squeezed.
+
+Scope reads `Docs / Overview`. Passing the page title verbatim would render
+`Docs / Documentation`, which is redundant.
+
+`Nav` resolves the drawer's title independently of the page, so setting this on
+the page alone made the desktop say `Docs / Overview` while the mobile drawer
+said `Docs / Documentation` — the same page named two ways by viewport width.
+Both now read a shared `DOCS_INDEX_TITLE` constant so they cannot drift.
+
+### The picker stays, deliberately
+
+The page's main content *is* a backend picker, so the sidebar picker is
+arguably duplicative — the same class of problem removed in #911, where the
+library was stated twice.
+
+Kept anyway, because the two do different jobs: the cards are a decision aid
+(compare, copy the install line, follow the quickstart), the picker is a
+shortcut for a returning reader who already knows where they are going. The
+#911 duplication was two *statements of the same fact*; this is a statement and
+a shortcut.
+
+### `Render` → `json-render`
+
+The library is called `json-render` 85 times across docs content and on the
+marketing page, and on the `/docs` card. It is called `Render` in exactly one
+place: `docsConfig[].title`, which feeds the picker, breadcrumbs, structured
+data and search.
+
+Once the index has the control plane, both names appear on screen at once — the
+sidebar saying `Render`, the card saying `json-render`.
+
+`docsConfig` title becomes `json-render`. The package stays
+`@threadplane/render` and the URL stays `/docs/render/`; only the display label
+changes. Marketing surfaces (`/render`, the footer, `solutions-data`) keep
+`Render` — those describe the product page, a different context, and are not
+part of this alignment.
+
+### Test hygiene
+
+`e2e/website.spec.ts` → `'docs landing page shows library cards'` asserts
+`getByText('Render')`, which passes on a substring of `json-render` and would
+also pass on the new sidebar. It is tightened to assert the cards themselves.
+
+## Testing
+
+1. **The index renders the control plane** with a library-neutral Scope of
+ `Docs / Overview` and a `Choose a library` picker.
+2. **The picker reads `json-render`**, not `Render`.
+3. **The landing content keeps its width** — it is not inside the article
+ measure.
+4. **The drawer and the page agree on the index's name.**
+
+Each 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. It now opens the menu
+first.
+
+## Out of scope
+
+Marketing surfaces keep `Render`. Renaming those is a product-vocabulary
+decision about the `/render` page, not about docs consistency.