Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
56 changes: 56 additions & 0 deletions apps/website/src/app/docs/docs-index-shell.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<DocsLandingPage />);

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(<DocsLandingPage />);

// 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(<DocsLandingPage />);

// 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');
});
});
20 changes: 18 additions & 2 deletions apps/website/src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -152,7 +155,19 @@ function GlyphChip({ size, children }: { size: number; children: ReactNode }) {

export default function DocsLandingPage() {
return (
<>
<div className="flex min-h-screen docs-shell-page">
<DocsSearch />
{/* The index is library-neutral: it is where you pick one. */}
<DocsControlPlane
activeLibrary={null}
activeSection=""
activeSlug=""
pageTitle={DOCS_INDEX_TITLE}
/>
{/* 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. */}
<div className="flex-1 min-w-0 docs-shell-body docs-index-body">

{/* Hero */}
<Section surface="canvas" ariaLabelledBy="docs-heading">
Expand Down Expand Up @@ -309,6 +324,7 @@ export default function DocsLandingPage() {
</div>
</Container>
</Section>
</>
</div>
</div>
);
}
15 changes: 15 additions & 0 deletions apps/website/src/components/shared/Nav.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Nav />);
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(<Nav />);
Expand Down
3 changes: 2 additions & 1 deletion apps/website/src/components/shared/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>(null);
const mobileTriggerRef = useRef<HTMLButtonElement>(null);
const mobileDialogRef = useRef<HTMLDivElement>(null);
Expand Down
11 changes: 10 additions & 1 deletion apps/website/src/lib/docs-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<DocsControlPlane activeLibrary={null}>`,
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.
Loading