From 312a35c4374feb9323092b6f29c72920bd638557 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 31 Aug 2026 19:13:24 -0700 Subject: [PATCH 1/2] docs: spec for the docs adapter picker refresh Records the design agreed for the library picker in the docs control plane: keep it in Learn, group Adapters vs Libraries, taglines on the two adapters only, real links, and the lost flex-column rule that is currently colliding title and description in production. Co-Authored-By: Claude Opus 5 --- .../2026-08-31-docs-adapter-picker-design.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-31-docs-adapter-picker-design.md 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..a574fe708 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-docs-adapter-picker-design.md @@ -0,0 +1,192 @@ +# 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.** ` + {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 index a574fe708..177a2f573 100644 --- a/docs/superpowers/specs/2026-08-31-docs-adapter-picker-design.md +++ b/docs/superpowers/specs/2026-08-31-docs-adapter-picker-design.md @@ -86,6 +86,8 @@ 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; @@ -93,13 +95,15 @@ export interface DocsLibrary { } ``` -`DocsLibrary.description` is **removed**. It is read in exactly one place today -(`DocsSidebar.tsx:172`, the picker). `DocsSearch` indexes pages, not libraries; -page `metadata.description` is a separate concern. Once the picker stops -rendering it, it is dead data, and seven long strings that nothing reads will -drift unnoticed. +`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. -Group assignment: +Every `description` string stays byte-identical to `main`. Group assignment: | Library | Group | Tagline | | ---------- | ------- | ---------------------------- | @@ -148,14 +152,27 @@ silence, so it must be covered by a test that fails before the fix. min-width: 0; } .docs-sidebar-lib-menu { - max-height: 60vh; /* replaces overflow: hidden */ + max-height: 60vh; /* replaces overflow: hidden — floor only, see below */ overflow-y: auto; } ``` -The redesigned menu measures ~325px, which already fits a 720px viewport — but -by only ~15px. `60vh` guarantees internal scrolling rather than a menu that runs -off the fold at smaller heights. +**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` @@ -164,12 +181,15 @@ Remove the `Library` row from `environmentRows`. `Framework` and ## Testing -Two existing tests in `DocsControlPlane.spec.tsx` will fail and should: +Three existing tests will fail and should: -- `'shows truthful scope and collapsed environment defaults'` — asserts the - removed `Library` row. -- `'supports keyboard entry and dismissal for the library menu'` — assumes - button elements and the `[role="menuitem"]` selector. +- `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: @@ -180,6 +200,12 @@ New coverage: 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