diff --git a/apps/website/src/components/docs/DocsControlPlane.spec.tsx b/apps/website/src/components/docs/DocsControlPlane.spec.tsx index 6825b25a6..2ef5dbb4b 100644 --- a/apps/website/src/components/docs/DocsControlPlane.spec.tsx +++ b/apps/website/src/components/docs/DocsControlPlane.spec.tsx @@ -3,6 +3,9 @@ import React from 'react'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DocsControlPlane, DocsContextContent } from './DocsControlPlane'; +import { docsConfig } from '../../lib/docs-config'; + +const LIBRARY_TITLES = docsConfig.filter((l) => l.group === 'library').map((l) => l.title); const push = vi.fn(); vi.mock('next/navigation', () => ({ @@ -93,6 +96,113 @@ describe('DocsControlPlane', () => { expect(document.getElementById(controlledId)).toBeTruthy(); }); + it('stacks the adapter title and tagline on separate lines', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'LangGraph' })); + const item = screen.getByRole('menuitemradio', { name: /LangGraph/ }); + const title = item.querySelector('.docs-sidebar-lib-item-title'); + const tagline = item.querySelector('.docs-sidebar-lib-item-desc'); + if (!title || !tagline) throw new Error('Expected a title and a tagline'); + + // The collision regression: both spans rendered inline on one line, so the + // row read as "LangGraphTalk to LangGraph directly". + expect(title.textContent).toBe('LangGraph'); + expect(tagline.textContent).toBe('Talk to LangGraph directly'); + const wrapper = title.parentElement; + if (!wrapper) throw new Error('Expected a text wrapper'); + expect(wrapper.className).toContain('docs-sidebar-lib-item-text'); + }); + + it('splits the menu into labelled adapter and library groups', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'LangGraph' })); + const adapters = screen.getByRole('group', { name: 'Adapters' }); + const libraries = screen.getByRole('group', { name: 'Libraries' }); + + // Adapters are asserted exactly: there are two, and only they carry + // taglines. A new library must not quietly land in this group. + expect(within(adapters).getAllByRole('menuitemradio').map((i) => i.textContent)).toEqual([ + 'LangGraphTalk to LangGraph directly', + 'AG-UIAny AG-UI backend', + ]); + // Libraries are checked against config so adding one does not churn this + // test — misclassifying one still fails the assertion above. + expect(within(libraries).getAllByRole('menuitemradio').map((i) => i.textContent)).toEqual( + LIBRARY_TITLES, + ); + }); + + it('renders menu entries as real links with the current one checked', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'LangGraph' })); + const agUi = screen.getByRole('menuitemradio', { name: /AG-UI/ }); + expect(agUi.tagName).toBe('A'); + expect(agUi.getAttribute('href')).toBe('/docs/ag-ui/getting-started/introduction'); + expect(agUi.getAttribute('aria-checked')).toBe('false'); + expect( + screen.getByRole('menuitemradio', { name: /LangGraph/ }).getAttribute('aria-checked'), + ).toBe('true'); + }); + + it('drops the library row from environment now the picker owns it', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Environment' })); + expect(screen.getByText('Angular')).toBeTruthy(); + expect(screen.getByText('npm')).toBeTruthy(); + expect(screen.queryByText('Library')).toBeNull(); + }); + + it('caps the open menu to the space left below the trigger', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'LangGraph' })); + const menu = screen.getByRole('menu'); + + // A viewport-percentage cap cannot work: the menu opens ~342px down the + // pane, so `60vh` still overflows a short window by ~100px. The cap has to + // be measured from the trigger's own position. + expect(menu.style.maxHeight).toMatch(/^\d+(\.\d+)?px$/); + }); + it('supports keyboard entry and dismissal for the library menu', () => { render( { const trigger = screen.getByRole('button', { name: 'LangGraph' }); fireEvent.keyDown(trigger, { key: 'ArrowDown' }); - const firstItem = screen.getByRole('menuitem', { name: /LangGraph/ }); + const firstItem = screen.getByRole('menuitemradio', { name: /LangGraph/ }); + expect(document.activeElement).toBe(firstItem); + + // Traversal must still cross the Adapters/Libraries group boundary. + fireEvent.keyDown(firstItem, { key: 'End' }); + expect(document.activeElement).toBe( + screen.getByRole('menuitemradio', { name: LIBRARY_TITLES[LIBRARY_TITLES.length - 1] }), + ); + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' }); expect(document.activeElement).toBe(firstItem); fireEvent.keyDown(firstItem, { key: 'Escape' }); diff --git a/apps/website/src/components/docs/DocsControlPlane.tsx b/apps/website/src/components/docs/DocsControlPlane.tsx index d2a9ccb08..b4c8b62b7 100644 --- a/apps/website/src/components/docs/DocsControlPlane.tsx +++ b/apps/website/src/components/docs/DocsControlPlane.tsx @@ -6,7 +6,6 @@ import { Braces, Code2, ExternalLink, - Package, Play, Search, } from 'lucide-react'; @@ -58,7 +57,6 @@ export function DocsContextContent({ window.setTimeout(dispatchSearch, 0); }; const environmentRows = [ - { label: 'Library', value: library?.title ?? activeLibrary, icon: }, { label: 'Framework', value: 'Angular', icon: }, { label: 'Package manager', value: 'npm', icon: }, ]; diff --git a/apps/website/src/components/docs/DocsSidebar.tsx b/apps/website/src/components/docs/DocsSidebar.tsx index 209bfe4bd..d85d8c406 100644 --- a/apps/website/src/components/docs/DocsSidebar.tsx +++ b/apps/website/src/components/docs/DocsSidebar.tsx @@ -9,7 +9,7 @@ import { type KeyboardEvent, } from 'react'; import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { Blocks, BookOpen, @@ -24,8 +24,10 @@ import { import { docsConfig, getLibraryConfig, + libraryIntroPath, specialDocsPages, type DocsSection, + type LibraryGroup, type LibraryId, } from '../../lib/docs-config'; import { LibraryMark } from './LibraryMark'; @@ -39,6 +41,23 @@ export interface DocsNavigationProps { onNavigate?: () => void; } +/** + * Menu entries are `menuitemradio` (single-select from a set), not `menuitem`. + * Keyboard traversal reads this — if it drifts from the rendered role, arrow + * keys and Escape silently stop finding anything. + */ +const MENU_ITEM_SELECTOR = '[role="menuitemradio"]'; + +/** Breathing room between the open menu and the bottom of the viewport. */ +const MENU_VIEWPORT_GUTTER = 16; +/** Below this the menu is uselessly short; let the pane scroll instead. */ +const MENU_MIN_HEIGHT = 180; + +const LIBRARY_GROUPS: { id: LibraryGroup; label: string }[] = [ + { id: 'adapter', label: 'Adapters' }, + { id: 'library', label: 'Libraries' }, +]; + function LibraryDropdown({ activeLibrary, onNavigate, @@ -52,7 +71,6 @@ function LibraryDropdown({ const menuRef = useRef(null); const initialFocusRef = useRef(0); const menuId = useId(); - const router = useRouter(); useEffect(() => { const handler = (event: MouseEvent) => { @@ -64,10 +82,30 @@ function LibraryDropdown({ useEffect(() => { if (!open) return; - const items = menuRef.current?.querySelectorAll('[role="menuitem"]'); + const items = menuRef.current?.querySelectorAll(MENU_ITEM_SELECTOR); items?.[initialFocusRef.current]?.focus(); }, [open]); + /** + * Cap the menu to the room actually left below the trigger. A CSS `vh` cap + * cannot do this: the menu opens ~340px down the pane, so even `60vh` still + * overflows a short window by ~100px. Measured from the trigger, it never + * runs past the fold — it scrolls internally instead. + */ + useEffect(() => { + if (!open) return; + const resize = () => { + const trigger = triggerRef.current; + const menu = menuRef.current; + if (!trigger || !menu) return; + const available = window.innerHeight - trigger.getBoundingClientRect().bottom - MENU_VIEWPORT_GUTTER; + menu.style.maxHeight = `${Math.max(MENU_MIN_HEIGHT, available)}px`; + }; + resize(); + window.addEventListener('resize', resize); + return () => window.removeEventListener('resize', resize); + }, [open]); + const openMenu = (initialIndex: number) => { initialFocusRef.current = initialIndex; setOpen(true); @@ -80,9 +118,9 @@ function LibraryDropdown({ const onMenuKeyDown = (event: KeyboardEvent) => { const items = Array.from( - menuRef.current?.querySelectorAll('[role="menuitem"]') ?? [], + menuRef.current?.querySelectorAll(MENU_ITEM_SELECTOR) ?? [], ); - const current = Math.max(0, items.indexOf(document.activeElement as HTMLButtonElement)); + const current = Math.max(0, items.indexOf(document.activeElement as HTMLElement)); const nextIndex = event.key === 'Home' ? 0 @@ -145,33 +183,45 @@ function LibraryDropdown({ className="docs-sidebar-lib-menu" onKeyDown={onMenuKeyDown} > - {docsConfig.map((library) => ( - { - closeMenu(); - onNavigate?.(); - router.push(`/docs/${library.id}/getting-started/introduction`); - }} - className="docs-sidebar-lib-item" - data-active={library.id === activeLibrary || undefined} - > - - - - - - {library.title} - - {library.description} + {LIBRARY_GROUPS.map((group, groupIndex) => ( + + {groupIndex > 0 ? : null} + + {group.label} - + {docsConfig + .filter((library) => library.group === group.id) + .map((library) => { + const isActive = library.id === activeLibrary; + return ( + { + closeMenu(); + onNavigate?.(); + }} + className="docs-sidebar-lib-item" + data-active={isActive || undefined} + > + + + + + + {library.title} + + {library.tagline ? ( + {library.tagline} + ) : null} + + + ); + })} + ))} ) : null} diff --git a/apps/website/src/components/shared/Nav.spec.tsx b/apps/website/src/components/shared/Nav.spec.tsx index e80127b4b..a870ceeeb 100644 --- a/apps/website/src/components/shared/Nav.spec.tsx +++ b/apps/website/src/components/shared/Nav.spec.tsx @@ -66,7 +66,7 @@ describe('Docs mobile navigation', () => { const libraryTrigger = within(dialog).getByRole('button', { name: 'LangGraph' }); fireEvent.click(libraryTrigger); - fireEvent.keyDown(within(dialog).getByRole('menuitem', { name: /LangGraph/ }), { + fireEvent.keyDown(within(dialog).getByRole('menuitemradio', { name: /LangGraph/ }), { key: 'Escape', }); diff --git a/apps/website/src/lib/docs-config.ts b/apps/website/src/lib/docs-config.ts index d802630ab..0fb6e89b0 100644 --- a/apps/website/src/lib/docs-config.ts +++ b/apps/website/src/lib/docs-config.ts @@ -27,10 +27,27 @@ export interface DocsSection { pages: DocsPage[]; } +/** + * Adapters connect a backend agent runtime; libraries are the companion + * packages around them. The picker groups on this. + */ +export type LibraryGroup = 'adapter' | 'library'; + export interface DocsLibrary { id: LibraryId; title: string; + /** + * Long form. Fallback for the page `` via + * {@link resolveDocDescription} — not shown in the picker. + */ description: string; + group: LibraryGroup; + /** + * Shown under the name in the library picker. Adapters only — the companion + * libraries are self-describing, and a tagline there is just noise. Keep to + * three or four words so picker rows cannot wrap. + */ + tagline?: string; /** Optional external live-demo URL, surfaced contextually in docs nav. */ demoUrl?: string; /** Optional label override for the demo link. Defaults to 'Live demo'. */ @@ -59,6 +76,8 @@ export const docsConfig: DocsLibrary[] = [ id: 'langgraph', title: 'LangGraph', description: 'LangChain/LangGraph adapter for Angular UI', + group: 'adapter', + tagline: 'Talk to LangGraph directly', sections: [ { title: 'Getting Started', @@ -116,6 +135,7 @@ export const docsConfig: DocsLibrary[] = [ id: 'render', title: 'Render', description: 'Declarative UI rendering from JSON specifications', + group: 'library', sections: [ { title: 'Getting Started', @@ -165,6 +185,7 @@ export const docsConfig: DocsLibrary[] = [ id: 'chat', title: 'Chat', description: 'Pre-built chat UI components for agent interfaces', + group: 'library', sections: [ { title: 'Getting Started', @@ -256,6 +277,8 @@ export const docsConfig: DocsLibrary[] = [ id: 'ag-ui', title: 'AG-UI', description: 'Adapter for AG-UI-compatible backends including CrewAI, Mastra, Microsoft AF, AG2, Pydantic AI, and AWS Strands', + group: 'adapter', + tagline: 'Any AG-UI backend', demoUrl: 'https://ag-ui.threadplane.ai', sections: [ { @@ -314,6 +337,7 @@ export const docsConfig: DocsLibrary[] = [ id: 'a2ui', title: 'A2UI', description: 'Protocol types and helpers for agent-driven UI surfaces', + group: 'library', sections: [ { title: 'Getting Started', @@ -349,6 +373,7 @@ export const docsConfig: DocsLibrary[] = [ id: 'middleware', title: 'Middleware', description: 'Backend helpers for browser-executed client tools', + group: 'library', sections: [ { title: 'Getting Started', @@ -397,6 +422,7 @@ export const docsConfig: DocsLibrary[] = [ id: 'telemetry', title: 'Telemetry', description: 'Browser and Node telemetry setup, privacy controls, and events', + group: 'library', sections: [ { title: 'Getting Started', @@ -431,6 +457,8 @@ export const docsConfig: DocsLibrary[] = [ id: 'runtimes', title: 'Runtimes', description: 'Measured AG-UI runtime integrations behind @threadplane/ag-ui', + // Reference material *behind* the AG-UI adapter, not an adapter you pick. + group: 'library', sections: [ { title: 'Getting Started', diff --git a/apps/website/src/styles/docs-sidebar-styles.spec.ts b/apps/website/src/styles/docs-sidebar-styles.spec.ts new file mode 100644 index 000000000..37f819f63 --- /dev/null +++ b/apps/website/src/styles/docs-sidebar-styles.spec.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * The picker's title and tagline are two sibling spans. They only stack because + * `.docs-sidebar-lib-item-text` is a column flex container — there is no other + * rule keeping them apart. + * + * PR #892 moved the JSX off Tailwind onto semantic class names and dropped the + * `flex flex-col` utilities without porting them here. Both spans fell back to + * `display: inline`, every menu row rendered as one run-on line + * ("LangGraphLangChain/LangGraph adapter for Angular UI"), and it shipped to + * production unnoticed. + * + * jsdom does not apply this stylesheet, so the component tests cannot see it. + * This is the only guard for that failure mode. + */ +const css = readFileSync(join(__dirname, 'docs.css'), 'utf8'); + +function ruleFor(selector: string): string { + const blocks = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)] + .filter((m) => m[1].split(',').some((s) => s.trim() === selector)) + .map((m) => m[2]); + return blocks.join(';'); +} + +describe('docs sidebar library picker styles', () => { + it('stacks the menu item title above its tagline', () => { + const rule = ruleFor('.docs-sidebar-lib-item-text'); + + expect(rule).toMatch(/display:\s*flex/); + expect(rule).toMatch(/flex-direction:\s*column/); + }); + + it('caps the menu height so it cannot run past the fold', () => { + const rule = ruleFor('.docs-sidebar-lib-menu'); + + expect(rule).toMatch(/max-height:/); + expect(rule).toMatch(/overflow-y:\s*auto/); + }); +}); diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css index e3dda6fb4..3b0c9f41b 100644 --- a/apps/website/src/styles/docs.css +++ b/apps/website/src/styles/docs.css @@ -783,6 +783,10 @@ margin-top: 1px; } .docs-sidebar-lib-item-text { + /* The title and tagline are sibling spans; this column is the only thing + * stacking them. Losing it renders every row as one run-on line. */ + display: flex; + flex-direction: column; min-width: 0; } .docs-sidebar-lib-item-title { @@ -1733,16 +1737,38 @@ inset-inline: 0; top: calc(100% + 4px); z-index: 30; - overflow: hidden; + /* The menu lives inside the pane's own scroll container, so an uncapped + * height runs straight past the fold. Cap it and scroll internally. */ + max-height: 60vh; + overflow-y: auto; border-radius: 9px; + padding: 5px; +} +.docs-sidebar-lib-group { + display: block; + padding: 7px 9px 4px; + color: var(--color-text-muted); + font-family: var(--font-inter); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.docs-sidebar-lib-divider { + display: block; + height: 1px; + margin: 5px 9px; + background: var(--color-border); } .docs-sidebar-lib-item { width: 100%; - padding: 9px 10px; + padding: 7px 9px; + border-radius: 7px; display: flex; - align-items: flex-start; + align-items: center; gap: 9px; text-align: left; + text-decoration: none; } .docs-sidebar-lib-item-title { font-family: var(--font-inter); } .docs-sidebar-top-links, diff --git a/docs/superpowers/specs/2026-08-31-docs-adapter-picker-design.md b/docs/superpowers/specs/2026-08-31-docs-adapter-picker-design.md new file mode 100644 index 000000000..177a2f573 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-docs-adapter-picker-design.md @@ -0,0 +1,218 @@ +# Docs adapter picker — visual and structural refresh + +**Date:** 2026-08-31 +**Scope:** the library picker in the docs control-plane sidebar (`LibraryDropdown`) +**Status:** approved, ready to implement + +## Context + +PR #892 ("Unify Docs and Cockpit sidebar control planes") replaced the docs +sidebar with a control plane: an icon rail, then named sections — **Scope**, +**Learn**, **Environment**, **Actions**. The library picker moved into **Learn** +but did not get the same design pass as the rest of the sidebar, and one style +rule was lost in the migration. + +The picker is the control a reader uses to move between `@threadplane/langgraph` +and `@threadplane/ag-ui`. It should read as the deliberate choice it is, in the +vocabulary the rest of the sidebar now speaks (Inter, 12–13px, 7–9px radii, +quiet muted labels). + +## Problems + +1. **The title and description collide.** `.docs-sidebar-lib-item-text` carries + only `min-width: 0`; the `flex flex-col` that stacked the two spans was + dropped when #892 moved the JSX off Tailwind onto semantic class names. Both + spans compute to `display: inline`, so every row renders as one run-on line: + `LangGraphLangChain/LangGraph adapter for Angular UI`. + **This is live on threadplane.ai** — production CSS serves + `.docs-sidebar-lib-item-text{min-width:0}`. + +2. **The menu is clipped.** Measured at a 720px viewport: menu spans y=342→902 + inside a pane ending at y=720. **182px is off-screen.** + +3. **Rows are ragged.** Measured item heights: `66, 66, 66, 138, 66, 66, 90`. + AG-UI's 100+ character description is the 138. + +4. **The list is undifferentiated.** Seven entries, flat. Two are adapters; five + are companion libraries. The picker reads as a library index rather than an + adapter choice. + +5. **Items are not links.** `` — no ⌘-click, no + middle-click, no open-in-new-tab. + +6. **The library is stated twice.** The picker sits in **Learn**; **Environment** + separately lists a `Library — LangGraph` row. + +Accessibility is *not* a problem here: #892 added `role="menu"`, +`aria-expanded`, `aria-haspopup`, `aria-controls`, arrow keys, Home/End, Escape, +and focus restore. That work stands and is preserved. + +## Design + +### Placement + +The picker **stays in Learn**, directly above the nav it rescopes. Duplication is +resolved by removing the `Library` row from **Environment**. + +Considered and rejected: promoting the picker into **Scope**. Semantically exact +— changing the library *is* changing scope, and Scope never collapses — but that +card is a quiet 11px muted readout, and its quietness is load-bearing for the +sidebar's calm. A 38px bordered control inside it makes one card half-control, +half-readout. Also rejected: making it the **Environment** `Library` row — +Environment is collapsed by default and sits at the bottom, so the primary way to +switch adapters would hide behind a disclosure most readers never open. + +### Menu anatomy + +Two labelled groups: + +- **Adapters** — LangGraph, AG-UI. Mark + name + a 3–4 word tagline. ~46px rows. +- **Libraries** — Render, Chat, A2UI, Middleware, Telemetry. Mark + name only. + ~34px rows. + +Rows are uniform *within* a group and deliberately different *between* groups; +that difference is what makes the two adapters read as the weighted choice. +Taglines are capped at 3–4 words so rows can never wrap ragged again. + +Bare names for the five libraries are intentional: "Chat", "Render", and +"Telemetry" are self-describing, and a tagline there is the grey noise the +current design already suffers from. + +### Data model — `src/lib/docs-config.ts` + +```ts +export type LibraryGroup = 'adapter' | 'library'; + +export interface DocsLibrary { + id: LibraryId; + title: string; + /** Long form. Fallback for the page meta description — not shown in the picker. */ + description: string; + group: LibraryGroup; + /** Shown in the picker. Adapters only — libraries are self-describing. */ + tagline?: string; + // …unchanged +} +``` + +`DocsLibrary.description` is **kept**, unchanged. An earlier draft of this spec +removed it as dead data; that was wrong. Besides the picker it is the fallback +for each page's `` via `resolveDocDescription()` +(`src/lib/docs.ts:126`), which also feeds the page's JSON-LD. Deleting it would +have silently changed search snippets across the docs — the exact budget tuned +in #880. `tagline` is added *alongside* it: `description` is long-form metadata, +`tagline` is the short picker string. + +Every `description` string stays byte-identical to `main`. Group assignment: + +| Library | Group | Tagline | +| ---------- | ------- | ---------------------------- | +| LangGraph | adapter | Talk to LangGraph directly | +| AG-UI | adapter | Any AG-UI backend | +| Render | library | — | +| Chat | library | — | +| A2UI | library | — | +| Middleware | library | — | +| Telemetry | library | — | + +### Markup — `src/components/docs/DocsSidebar.tsx` + +```tsx + + + Adapters + +``` + +- **`` replaces ``.** Restores + ⌘-click / middle-click / new-tab. Uses the existing `libraryIntroPath()` + helper instead of an inline template string. +- **`role="menuitemradio"` + `aria-checked`** — single-select from a set, which + is what the checkmark means. +- **`role="group"` + `aria-label`** — makes the visual group labels programmatic + rather than decorative. + +**Migration hazard:** the keyboard handler queries +`querySelectorAll('[role="menuitem"]')`. Both the selector and +the element type must change (`[role="menuitemradio"]`, `HTMLElement`) or arrow +keys, Home/End and Escape silently stop finding items — a failure whose mode is +silence, so it must be covered by a test that fails before the fix. + +### Styles — `src/styles/docs.css` + +```css +.docs-sidebar-lib-item-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.docs-sidebar-lib-menu { + max-height: 60vh; /* replaces overflow: hidden — floor only, see below */ + overflow-y: auto; +} +``` + +**A CSS cap alone is not enough**, and an earlier draft of this spec claimed +otherwise. The menu opens ~342px down the pane, so at a 600px viewport `60vh` +(360px) still puts its bottom edge at 702px — **102px past the fold**, measured. +A viewport percentage cannot account for a large fixed top offset. + +So the real cap is measured from the trigger, in an effect that runs on open and +on resize: + +```ts +const available = window.innerHeight - trigger.getBoundingClientRect().bottom - 16; +menu.style.maxHeight = `${Math.max(180, available)}px`; +``` + +Verified at a 600px viewport: the menu caps to 245px, its bottom lands 12px +inside the viewport, it scrolls internally, and Telemetry — the last entry — +stays reachable. The CSS `max-height` remains as a pre-hydration floor. + +### Environment — `src/components/docs/DocsControlPlane.tsx` + +Remove the `Library` row from `environmentRows`. `Framework` and +`Package manager` stay. + +## Testing + +Three existing tests will fail and should: + +- `DocsControlPlane.spec.tsx` → `'shows truthful scope and collapsed environment + defaults'` — asserts the removed `Library` row. +- `DocsControlPlane.spec.tsx` → `'supports keyboard entry and dismissal for the + library menu'` — assumes button elements and the `[role="menuitem"]` selector. +- `Nav.spec.tsx` → `'keeps the drawer open when Escape dismisses the nested + library menu'` — the mobile drawer renders the same menu, so it asserts the + same role. + +New coverage: + +1. **Title and tagline render on separate lines** — a regression test for the + collision. Must fail against current `main`. +2. **Keyboard navigation still traverses items after the anchor migration** — + guards the silent-selector hazard above. +3. **Groups are labelled** — `role="group"` with accessible names + "Adapters" / "Libraries". +4. **Items are anchors with real hrefs** — guards the ⌘-click regression. +5. **The menu carries a measured `max-height` when open** — guards the cap, + whose absence is invisible until someone opens the picker in a short window. +6. **A CSS-level guard** (`src/styles/docs-sidebar-styles.spec.ts`) asserting + both new rules exist. jsdom does not apply the stylesheet, so no component + test can see the collision — this is the only guard for the failure mode that + actually reached production. + +## Out of scope + +Deliberately deferred, both worth doing separately: + +- `/docs/choosing-an-adapter` renders without the control plane, so following + that link drops the reader into a page with no nav. +- There is no docs navigation below the `lg` breakpoint. + +Neither is about the picker; folding them in would blur what this change is.