diff --git a/apps/website/content/docs/ag-ui/getting-started/introduction.mdx b/apps/website/content/docs/ag-ui/getting-started/introduction.mdx index c3306c20c..5e18aef07 100644 --- a/apps/website/content/docs/ag-ui/getting-started/introduction.mdx +++ b/apps/website/content/docs/ag-ui/getting-started/introduction.mdx @@ -9,7 +9,12 @@ AG-UI is an open agent-to-UI protocol. It standardizes how agent runtimes stream -The [AG-UI demo](https://ag-ui.threadplane.ai) runs this exact chat surface against an AG-UI backend — streaming, tool calls, and generative UI included. Compare it side by side with the [LangGraph demo](https://demo.threadplane.ai). +The AG-UI demo runs this exact chat surface against an AG-UI backend — streaming, tool calls, and generative UI included. + + + Run AG-UI demo + Compare LangGraph demo + ## How it fits diff --git a/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx b/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx index 773d76916..2786074fd 100644 --- a/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx +++ b/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx @@ -7,7 +7,11 @@ Angular 20–22 project using a Node.js version supported by that Angular major. -Want to see the finished result before you build? Open the live [AG-UI demo](https://ag-ui.threadplane.ai). +Want to see the finished result before you build? + + + Run AG-UI demo + diff --git a/apps/website/content/docs/langgraph/getting-started/introduction.mdx b/apps/website/content/docs/langgraph/getting-started/introduction.mdx index ae9fb1ed7..7749e1020 100644 --- a/apps/website/content/docs/langgraph/getting-started/introduction.mdx +++ b/apps/website/content/docs/langgraph/getting-started/introduction.mdx @@ -8,6 +8,14 @@ This guide walks you through the complete workflow: build a LangGraph agent in Python, run it locally, connect it to an Angular app with `injectAgent()`, and deploy to production. + +The LangGraph demo runs this chat surface against a LangGraph backend — streaming, threads, interrupts, and tool calls included. + + + Run LangGraph demo + + + ## How it fits -The hosted example runs at [examples.threadplane.ai/runtimes/aws-strands](https://examples.threadplane.ai/runtimes/aws-strands/). The source is [`cockpit/runtimes/aws-strands`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/aws-strands). +The hosted example runs the AWS Strands integration end to end. + + + Run the example + View source + ## What the integration demonstrates diff --git a/apps/website/content/docs/runtimes/mastra/overview.mdx b/apps/website/content/docs/runtimes/mastra/overview.mdx index 2f1a50613..0129c064d 100644 --- a/apps/website/content/docs/runtimes/mastra/overview.mdx +++ b/apps/website/content/docs/runtimes/mastra/overview.mdx @@ -10,7 +10,12 @@ description: What the Mastra integration demonstrates through @threadplane/ag-ui The Threadplane example is a camping trip planner. It streams messages, calls a backend tool, keeps a packing list in shared state through Mastra's working memory, and suspends a run for human approval before reserving a campsite. -The hosted example runs at [examples.threadplane.ai/runtimes/mastra](https://examples.threadplane.ai/runtimes/mastra/). The Angular source is [`cockpit/runtimes/mastra`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/mastra), and its backend is [`deployments/ag-ui-mastra`](https://github.com/cacheplane/angular-agent-framework/tree/main/deployments/ag-ui-mastra) rather than the FastAPI deployment the two Python runtimes share. +The hosted example runs the Mastra integration end to end. Its backend is [`deployments/ag-ui-mastra`](https://github.com/cacheplane/angular-agent-framework/tree/main/deployments/ag-ui-mastra) rather than the FastAPI deployment the two Python runtimes share. + + + Run Mastra example + View source + ## What the integration demonstrates diff --git a/apps/website/content/docs/runtimes/microsoft-agent-framework/overview.mdx b/apps/website/content/docs/runtimes/microsoft-agent-framework/overview.mdx index 96333c180..0c10269ef 100644 --- a/apps/website/content/docs/runtimes/microsoft-agent-framework/overview.mdx +++ b/apps/website/content/docs/runtimes/microsoft-agent-framework/overview.mdx @@ -10,7 +10,12 @@ description: What the Microsoft Agent Framework integration demonstrates through The Threadplane example is an expense assistant. It looks up policy through a server-side tool, streams a proposed expense into frontend state while the model is still writing it, and requires human approval before submitting. -The hosted example runs at [examples.threadplane.ai/runtimes/microsoft-agent-framework](https://examples.threadplane.ai/runtimes/microsoft-agent-framework/). The source is [`cockpit/runtimes/microsoft-agent-framework`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/microsoft-agent-framework). +The hosted example runs the Microsoft Agent Framework integration end to end. + + + Run the example + View source + ## What the integration demonstrates diff --git a/apps/website/e2e/docs-shell.spec.ts b/apps/website/e2e/docs-shell.spec.ts index d026230bc..3e5e4a973 100644 --- a/apps/website/e2e/docs-shell.spec.ts +++ b/apps/website/e2e/docs-shell.spec.ts @@ -33,9 +33,32 @@ test.describe('DocsTOC rail', () => { // Nothing is active at the top: the first heading is below the reading line. await expect(page.locator('.docs-toc-link[data-active]')).toHaveCount(0); + // The reading line lands a heading as "active" once its top crosses + // scrollRoot.top + scrollRoot.clientHeight * 0.25 (see DocsTOC.tsx). A + // hard-coded `scrollTop: 4000` broke the last time this page's content + // grew (a callout pushed everything below it further down the article), + // so compute the scroll offset that puts a specific heading right at + // that line instead of relying on a pixel constant that content edits + // keep invalidating. + const targetHeadingId = 'connect-with-angular'; const articleScroller = page.locator('.docs-workspace-article'); - await articleScroller.evaluate((element) => - element.scrollTo({ top: 4000, behavior: 'instant' }), + const scrollTop = await articleScroller.evaluate((scrollRoot, headingId) => { + const heading = document.getElementById(headingId); + if (!heading) throw new Error(`missing heading #${headingId}`); + // Heading's distance from the top of the scroller's content, i.e. + // where its rect top would be if scrollTop were 0. + const headingTopAtZero = + heading.getBoundingClientRect().top - + scrollRoot.getBoundingClientRect().top + + scrollRoot.scrollTop; + const line = scrollRoot.clientHeight * 0.25; + // A couple of extra pixels so the heading's top is unambiguously at + // or above the line, matching DocsTOC's `<=` comparison. + return Math.max(0, Math.round(headingTopAtZero - line + 2)); + }, targetHeadingId); + await articleScroller.evaluate( + (element, top) => element.scrollTo({ top, behavior: 'instant' }), + scrollTop, ); await expect .poll(() => @@ -43,7 +66,7 @@ test.describe('DocsTOC rail', () => { .locator('.docs-toc-link[data-active]') .evaluateAll((els) => els.map((e) => e.getAttribute('href'))), ) - .toEqual(['#connect-with-angular']); + .toEqual([`#${targetHeadingId}`]); // ...and it follows the scroll rather than latching on the first match. await articleScroller.evaluate((element) => diff --git a/apps/website/e2e/docs.spec.ts b/apps/website/e2e/docs.spec.ts index bd3ad5f32..1863a293d 100644 --- a/apps/website/e2e/docs.spec.ts +++ b/apps/website/e2e/docs.spec.ts @@ -48,7 +48,7 @@ test.describe('Docs landing page', () => { test.describe('Docs slug page', () => { const route = '/docs/langgraph/getting-started/introduction'; - test('keeps page scroll fixed when focusing the bottom control-plane action', async ({ + test('keeps page scroll fixed when focusing the last control-plane nav link', async ({ page, }) => { await page.setViewportSize({ width: 1024, height: 900 }); @@ -62,14 +62,21 @@ test.describe('Docs slug page', () => { const pane = page.locator( '[data-cockpit-desktop-navigation] [data-control-plane-pane]', ); - const search = pane.getByRole('button', { name: 'Search docs' }); + // Search moved to the top of the pane (was the bottom Actions-bar item + // this test used to focus), so it can no longer stand in for "something + // near the bottom of the scrollable pane". The last link in the Learn + // section tree still sits at the bottom of the pane's content and is + // rendered on every docs page, so it exercises the same bug class: focus + // deep in the pane must scroll the pane, not the window. + const lastNavLink = pane.locator('.docs-sidebar-section-link').last(); await expect(pane).toBeVisible(); + await expect(lastNavLink).toBeVisible(); await pane.evaluate((element) => { element.scrollTop = 0; }); await page.evaluate(() => window.scrollTo(0, 0)); - await search.focus(); + await lastNavLink.focus(); await expect.poll(() => pane.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); expect(await page.evaluate(() => window.scrollY)).toBe(0); @@ -82,12 +89,14 @@ test.describe('Docs slug page', () => { await expect(page.locator('article').first()).toBeVisible(); }); - test('renders the branded chrome (sidebar mark, page-header eyebrow, prev/next direction)', async ({ page }) => { + test('renders the branded chrome (sidebar mark, breadcrumb trail, prev/next direction)', async ({ page }) => { await page.goto(route); // Sidebar shows the active library's logo mark await expect(page.locator('aside img[src="/logos/langgraph.svg"]').first()).toBeVisible(); - // Branded page header eyebrow - await expect(page.getByText(/LangGraph\s+·\s+Getting Started/i).first()).toBeVisible(); + // The lib · section label moved into the shell's breadcrumb trail. + const breadcrumb = page.locator('nav[aria-label="Breadcrumb"]').first(); + await expect(breadcrumb).toContainText('LangGraph'); + await expect(breadcrumb).toContainText('Getting Started'); // Prev/Next: introduction is the first page, so a "Next →" card is present await expect(page.getByText('Next →').first()).toBeVisible(); // Per-page LLM actions trigger diff --git a/apps/website/e2e/workspace-shell.spec.ts b/apps/website/e2e/workspace-shell.spec.ts index cf55ee6aa..4728b70f0 100644 --- a/apps/website/e2e/workspace-shell.spec.ts +++ b/apps/website/e2e/workspace-shell.spec.ts @@ -331,39 +331,83 @@ test.describe('workspace shell', () => { } }); - for (const path of ['/docs', '/docs/choosing-an-adapter']) { - test(`docs-only ${path} keeps operational modes focusable and local`, async ({ - page, - }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto(path); + test('docs-only /docs/choosing-an-adapter keeps operational modes focusable and local', async ({ + page, + }) => { + const path = '/docs/choosing-an-adapter'; + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(path); - const controlPlane = page.locator('[data-docs-control-plane]'); - await expect(controlPlane).toBeVisible(); - for (const mode of ['Run', 'Code', 'API'] as const) { - const control = controlPlane.getByRole('button', { - name: mode, - exact: true, - }); - await expect(control).toHaveAttribute('aria-disabled', 'true'); - await expect(control).toHaveAccessibleDescription( - new RegExp( - `${mode} is unavailable because this page has no workspace capability`, - 'i' - ) - ); - await expect(control).not.toHaveAttribute('href', /.+/); - await expect(control).not.toHaveAttribute('target', /.+/); - await control.focus(); - await expect(control).toBeFocused(); - await control.click({ force: true }); - await expect(page).toHaveURL(path); - } - await expect( - controlPlane.getByRole('button', { name: 'Search docs' }) - ).toBeVisible(); - }); - } + const controlPlane = page.locator('[data-docs-control-plane]'); + await expect(controlPlane).toBeVisible(); + for (const mode of ['Run', 'Code', 'API'] as const) { + const control = controlPlane.getByRole('button', { + name: mode, + exact: true, + }); + await expect(control).toHaveAttribute('aria-disabled', 'true'); + await expect(control).toHaveAccessibleDescription( + new RegExp( + `${mode} is unavailable because this page has no workspace capability`, + 'i' + ) + ); + await expect(control).not.toHaveAttribute('href', /.+/); + await expect(control).not.toHaveAttribute('target', /.+/); + await control.focus(); + await expect(control).toBeFocused(); + await control.click({ force: true }); + await expect(page).toHaveURL(path); + } + await expect( + controlPlane.getByRole('button', { name: 'Search docs' }) + ).toBeVisible(); + }); + + test('docs-only /docs keeps Code and API disabled but Run is a live link to the canonical example', async ({ + page, + }) => { + const path = '/docs'; + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(path); + + const controlPlane = page.locator('[data-docs-control-plane]'); + await expect(controlPlane).toBeVisible(); + + for (const mode of ['Code', 'API'] as const) { + const control = controlPlane.getByRole('button', { + name: mode, + exact: true, + }); + await expect(control).toHaveAttribute('aria-disabled', 'true'); + await expect(control).toHaveAccessibleDescription( + new RegExp( + `${mode} is unavailable because this page has no workspace capability`, + 'i' + ) + ); + await expect(control).not.toHaveAttribute('href', /.+/); + await expect(control).not.toHaveAttribute('target', /.+/); + } + + await expect( + controlPlane.getByRole('button', { name: 'Search docs' }) + ).toBeVisible(); + + const run = controlPlane.getByRole('link', { name: 'Run', exact: true }); + await expect(run).toHaveAttribute( + 'href', + '/docs/langgraph/guides/streaming?mode=run' + ); + // Prove the destination actually resolves rather than 404ing. + const [response] = await Promise.all([ + page.waitForNavigation(), + run.click(), + ]); + expect(response?.status()).toBe(200); + await expect(page).toHaveURL('/docs/langgraph/guides/streaming?mode=run'); + await expect(page.locator('[data-workspace-shell]')).toBeVisible(); + }); test('uses workspace fallbacks only when a shared Docs path would lose identity', async ({ page, diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx index 25f9cd321..e526b004f 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx @@ -1,7 +1,8 @@ import { isValidElement, type ComponentType, type ReactNode } from 'react'; import { describe, expect, it } from 'vitest'; -import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb'; import { DocsPageHeader } from '../../../../../components/docs/DocsPageHeader'; +import { LibraryMark } from '../../../../../components/docs/LibraryMark'; +import { DocsSearchFooter } from '../../../../../components/docs/DocsSearchFooter'; import { DocsTOC } from '../../../../../components/docs/DocsTOC'; import { MdxRenderer } from '../../../../../components/docs/MdxRenderer'; import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; @@ -13,6 +14,7 @@ interface ElementProps { requestedMode?: string | null; resolution?: { kind?: string; identity?: { availableModes?: string[] } }; contentBundle?: { runtimeUrl?: string | null }; + contextTrail?: readonly { label: string; href?: string; icon?: ReactNode }[]; } function findElement( @@ -60,7 +62,6 @@ describe('unified docs workspace route', () => { activeLibrary: 'langgraph', activeSection: 'guides', activeSlug: 'streaming', - pageTitle: 'Streaming', }); }); @@ -73,9 +74,6 @@ describe('unified docs workspace route', () => { const slot = workspace?.props.docsSlot; expect(workspace?.props.resolution).toMatchObject({ kind: 'docs-only' }); - expect( - findElement(slot, DocsBreadcrumb as ComponentType) - ).toBeTruthy(); expect( findElement(slot, DocsPageHeader as ComponentType) ).toBeTruthy(); @@ -83,6 +81,49 @@ describe('unified docs workspace route', () => { expect(findElement(slot, DocsTOC as ComponentType)).toBeTruthy(); }); + it('invites a search at the foot of a content page', async () => { + const tree = await route('langgraph', 'guides', 'testing'); + const workspace = findElement( + tree, + WebsiteWorkspace as ComponentType + ); + + expect( + findElement( + workspace?.props.docsSlot, + DocsSearchFooter as ComponentType + ) + ).toBeTruthy(); + }); + + it('hands the shell one accurate trail instead of four renditions', async () => { + const tree = await route('ag-ui', 'getting-started', 'introduction'); + const workspace = findElement( + tree, + WebsiteWorkspace as ComponentType + ); + + // Docs titles, not manifest identity: the derived label read + // "Ag Ui / Getting Started / Overview". + const trail = workspace?.props.contextTrail ?? []; + expect(trail.map(({ label, href }) => ({ label, href }))).toEqual([ + { label: 'Docs', href: '/docs' }, + { label: 'AG-UI', href: '/docs/ag-ui/getting-started/introduction' }, + { label: 'Getting Started', href: undefined }, + { label: 'Introduction', href: undefined }, + ]); + + // Only the library rung carries the mark; the rest are plain labels. + trail.forEach((crumb, index) => { + if (index === 1) { + expect(isValidElement(crumb.icon)).toBe(true); + expect((crumb.icon as React.ReactElement).type).toBe(LibraryMark); + } else { + expect(crumb.icon).toBeUndefined(); + } + }); + }); + it('keeps canonical metadata independent of the workspace mode query', async () => { const metadata = await generateMetadata({ params: Promise.resolve({ diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index 5f3c9ae1f..fe2dddf8d 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -2,10 +2,11 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { MdxRenderer } from '../../../../../components/docs/MdxRenderer'; import { DocsSearch } from '../../../../../components/docs/DocsSearch'; -import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb'; import { DocsPageHeader } from '../../../../../components/docs/DocsPageHeader'; +import { LibraryMark } from '../../../../../components/docs/LibraryMark'; import { PageActions } from '../../../../../components/docs/PageActions'; import { DocsPrevNext } from '../../../../../components/docs/DocsPrevNext'; +import { DocsSearchFooter } from '../../../../../components/docs/DocsSearchFooter'; import { DEFAULT_DOCS_DESCRIPTION, getAllDocSlugs, @@ -27,6 +28,7 @@ import { DocsTOC } from '../../../../../components/docs/DocsTOC'; import { extractHeadings } from '../../../../../lib/extract-headings'; import { findDocsPage, + getDocsSection, getLibraryConfig, libraryIntroPath, type LibraryId, @@ -111,9 +113,9 @@ export default async function DocsPage({ params }: DocsRouteProps) { dateModified: getDocLastModified(pathname)?.toISOString(), }); - // Mirrors the visible , which links the library rung through - // the same `libraryIntroPath()` — there is no /docs/ route, so a - // crumb pointing there would 404. + // Mirrors the visible trail the shell header renders from `contextTrail`, + // which links the library rung through the same `libraryIntroPath()` — there + // is no /docs/ route, so a crumb pointing there would 404. // // The section rung the visible trail shows between library and page is // deliberately absent: it is plain text there because no section index route @@ -125,6 +127,24 @@ export default async function DocsPage({ params }: DocsRouteProps) { { name: doc.title, pathname }, ]); + // The one visible trail on this page. The shell header renders it; nothing + // else on the page restates it. Section titles come from docs-config, and + // the section rung carries no href because there is no section index route. + const contextTrail = [ + { label: 'Docs', href: '/docs' }, + { + label: libConfig.title, + href: libraryIntroPath(library), + // 20, not the component's default 24: the trail's line box is ~20px + // (13px text at 1.5), so a larger chip would grow the header row. The + // logo inside is 60% of the chip, and at size 16 that left a 10px mark + // in a 14px box, which read as a smudge rather than a logo. + icon: , + }, + { label: getDocsSection(library, section)?.title ?? section }, + { label: doc.title }, + ]; + const docsSlot = (
@@ -136,15 +156,7 @@ export default async function DocsPage({ params }: DocsRouteProps) { * ~500px right of the prose it belongs to (1272px vs 768px at * 1920). */}
-
+ {/* Sibling to docs-article-layout, not nested inside its article column: + * that column excludes the TOC rail's width, so a full-width band placed + * inside it would be narrower than the scrollable docs-workspace-article + * area it should span. This is as wide as that area gets without + * restructuring the layout further. */} +
); @@ -212,11 +230,11 @@ export default async function DocsPage({ params }: DocsRouteProps) { navigationTree={workspacePage.navigationTree} routePath={pathname} docsSlot={docsSlot} + contextTrail={contextTrail} docsContext={{ activeLibrary: library as LibraryId, activeSection: section, activeSlug: slug, - pageTitle: doc.title, }} /> diff --git a/apps/website/src/app/docs/choosing-an-adapter/page.tsx b/apps/website/src/app/docs/choosing-an-adapter/page.tsx index 4291042c9..fd17cfbef 100644 --- a/apps/website/src/app/docs/choosing-an-adapter/page.tsx +++ b/apps/website/src/app/docs/choosing-an-adapter/page.tsx @@ -45,7 +45,6 @@ export default function ChoosingAnAdapterPage() { activeLibrary={null} activeSection="" activeSlug="" - pageTitle={PAGE_TITLE} />
diff --git a/apps/website/src/app/docs/docs-index-shell.spec.tsx b/apps/website/src/app/docs/docs-index-shell.spec.tsx index 567672a05..0af20aa6f 100644 --- a/apps/website/src/app/docs/docs-index-shell.spec.tsx +++ b/apps/website/src/app/docs/docs-index-shell.spec.tsx @@ -17,11 +17,8 @@ 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.queryByRole('heading', { name: 'Scope' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Search docs' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'Choose a library' })).toBeTruthy(); }); @@ -53,4 +50,14 @@ describe('docs index', () => { expect(pickerNames).toContain('json-render'); expect(pickerNames).not.toContain('Render'); }); + + it('wires Run to the canonical default example route', () => { + render(); + + // Registry-derived, not hardcoded: /workspace/langgraph/streaming 404s + // because that capability's canonical destination is its docs route. + expect( + screen.getByRole('link', { name: 'Run' }).getAttribute('href'), + ).toBe('/docs/langgraph/guides/streaming?mode=run'); + }); }); diff --git a/apps/website/src/app/docs/docs-structured-data.spec.tsx b/apps/website/src/app/docs/docs-structured-data.spec.tsx index f84bfdbb3..1bcdd7472 100644 --- a/apps/website/src/app/docs/docs-structured-data.spec.tsx +++ b/apps/website/src/app/docs/docs-structured-data.spec.tsx @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest'; import { isValidElement, type ReactNode } from 'react'; -import { render, screen } from '@testing-library/react'; import DocsPage, { generateMetadata } from './[library]/[section]/[slug]/page'; -import { DocsBreadcrumb } from '../../components/docs/DocsBreadcrumb'; -import { docsConfig, type LibraryId } from '../../lib/docs-config'; -import { getDocBySlug } from '../../lib/docs'; +import { docsConfig } from '../../lib/docs-config'; import { getSitemapRoutes } from '../../lib/site-metadata'; interface Slug { @@ -51,6 +48,25 @@ function nodeOfType(nodes: Record[], type: string): Record node['@type'] === type); } +function findWorkspaceContextTrail( + node: ReactNode +): { label: string; href?: string }[] | undefined { + if (Array.isArray(node)) { + for (const child of node) { + const found = findWorkspaceContextTrail(child); + if (found) return found; + } + return undefined; + } + if (!isValidElement(node)) return undefined; + const props = node.props as { + contextTrail?: { label: string; href?: string }[]; + children?: ReactNode; + }; + if (props.contextTrail) return props.contextTrail; + return findWorkspaceContextTrail(props.children); +} + describe('docs page structured data', () => { // The tautology this replaces compared `resolveDocDescription` against // `getDocMetadata`, which calls it — both sides were the same function. The @@ -74,28 +90,19 @@ describe('docs page structured data', () => { expect(nodes.map((node) => node['@type'])).toEqual(['TechArticle', 'BreadcrumbList']); }); - // Google expects the breadcrumb markup to correspond to the visible trail, so - // the JSON-LD is checked against what actually renders rather - // than against a second copy of the same string. - it('links the same library URL the visible breadcrumb links', async () => { + it('links the same library URL the visible trail links', async () => { for (const sample of SAMPLES) { - const doc = getDocBySlug(sample.library, sample.section, sample.slug); + const tree = await DocsPage({ params: Promise.resolve(sample) }); const nodes = await renderedJsonLd(sample); const crumbs = nodeOfType(nodes, 'BreadcrumbList')?.itemListElement as | { name: string; item: string }[] | undefined; - const { unmount } = render( - , - ); + // The trail the shell header renders, taken from the route itself, so + // this cannot pass by agreeing with a component the route dropped. + const trail = findWorkspaceContextTrail(tree); const libraryTitle = docsConfig.find((lib) => lib.id === sample.library)?.title ?? ''; - const visibleHref = screen.getByRole('link', { name: libraryTitle }).getAttribute('href'); - unmount(); + const visibleHref = trail?.find((crumb) => crumb.label === libraryTitle)?.href; const label = `${sample.library}/${sample.section}/${sample.slug}`; expect([label, crumbs?.find((crumb) => crumb.name === libraryTitle)?.item]).toEqual([ diff --git a/apps/website/src/app/docs/page.tsx b/apps/website/src/app/docs/page.tsx index d88da5d82..fb9e1f8f7 100644 --- a/apps/website/src/app/docs/page.tsx +++ b/apps/website/src/app/docs/page.tsx @@ -4,12 +4,15 @@ import { Container } from '../../components/ui/Container'; import { Section } from '../../components/ui/Section'; 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 { DocsSearchFooter } from '../../components/docs/DocsSearchFooter'; import { createPageMetadata } from '../../lib/site-metadata'; +import { + getCanonicalWebsiteWorkspaceHref, + resolveWorkspacePath, +} from '@threadplane/cockpit-registry'; export const metadata = createPageMetadata({ title: 'Documentation — Threadplane', @@ -19,6 +22,21 @@ export const metadata = createPageMetadata({ type: 'website', }); +/** + * The example the index's Run rail item opens. + * + * Resolved through the registry rather than written as a path: this + * capability publishes a `docsPath`, so `getWorkspaceDestinationPath()` makes + * its canonical destination the docs route and `/workspace/langgraph/streaming` + * 404s. Today this yields `/docs/langgraph/guides/streaming?mode=run`, and it + * stays correct if that docs path moves. A renamed or removed capability + * resolves to null, and Run falls back to disabled rather than to a dead link. + */ +const DEFAULT_EXAMPLE_RESOLUTION = resolveWorkspacePath('/workspace/langgraph/streaming'); +const DEFAULT_EXAMPLE_RUN_HREF = DEFAULT_EXAMPLE_RESOLUTION + ? getCanonicalWebsiteWorkspaceHref(DEFAULT_EXAMPLE_RESOLUTION, 'Run') + : undefined; + interface Backend { title: string; blurb: string; @@ -156,7 +174,7 @@ export default function DocsLandingPage() { activeLibrary={null} activeSection="" activeSlug="" - pageTitle={DOCS_INDEX_TITLE} + runHref={DEFAULT_EXAMPLE_RUN_HREF} /> {/* Deliberately outside the article measure the [slug] route uses — the * card grids need their own width, and the prose column would flatten @@ -310,18 +328,7 @@ export default function DocsLandingPage() { {/* Search prompt */} -
- -
-

- Looking for something specific? -

-

- Press ⌘K to search the docs. -

-
-
-
+
); diff --git a/apps/website/src/components/docs/DocsBreadcrumb.tsx b/apps/website/src/components/docs/DocsBreadcrumb.tsx deleted file mode 100644 index 9c0993e24..000000000 --- a/apps/website/src/components/docs/DocsBreadcrumb.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import Link from 'next/link'; -import { getLibraryConfig, libraryIntroPath, type LibraryId } from '../../lib/docs-config'; - -interface Props { - library: LibraryId; - section: string; - slug?: string; - title: string; -} - -function humanize(s: string): string { - return s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); -} - -export function DocsBreadcrumb({ library, section, slug: _slug, title }: Props) { - const libConfig = getLibraryConfig(library); - const libraryTitle = libConfig?.title ?? library; - const sectionTitle = libConfig?.sections.find((s) => s.id === section)?.title ?? humanize(section); - - return ( - - ); -} diff --git a/apps/website/src/components/docs/DocsControlPlane.spec.tsx b/apps/website/src/components/docs/DocsControlPlane.spec.tsx index ed0581e08..7de3d6298 100644 --- a/apps/website/src/components/docs/DocsControlPlane.spec.tsx +++ b/apps/website/src/components/docs/DocsControlPlane.spec.tsx @@ -115,7 +115,6 @@ describe('DocsControlPlane', () => { activeLibrary={null} activeSection="" activeSlug="" - pageTitle="Overview" /> ); @@ -144,7 +143,6 @@ describe('DocsControlPlane', () => { activeLibrary="langgraph" activeSection="guides" activeSlug="streaming" - pageTitle="Streaming" /> ); @@ -159,46 +157,44 @@ describe('DocsControlPlane', () => { } }); - it('shows truthful scope without the retired Cockpit Runtime preview', () => { + it('leads with search instead of restating the breadcrumb', () => { + const listener = vi.fn(); + document.addEventListener('keydown', listener); render( ); - const scope = screen - .getByRole('heading', { name: 'Scope' }) - .closest('section'); - if (!scope) throw new Error('Expected Scope section'); - expect(within(scope).getByText('LangGraph')).toBeTruthy(); - expect(within(scope).getByText('Guides')).toBeTruthy(); - expect(within(scope).getByText('Streaming')).toBeTruthy(); + // The trail is the shell header's job now; a Scope card here said the + // same thing a third time. + expect(screen.queryByRole('heading', { name: 'Scope' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Search docs' })); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ key: 'k', metaKey: true }) + ); + document.removeEventListener('keydown', listener); + expect(screen.queryByRole('button', { name: 'Environment' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Runtime' })).toBeNull(); expect(screen.queryByText('Cockpit')).toBeNull(); - expect(screen.queryByRole('link', { name: /Open controls/ })).toBeNull(); }); - it('keeps search as a real icon action', () => { - const listener = vi.fn(); - document.addEventListener('keydown', listener); + it('drops the Actions bar when search was its only member', () => { render( ); - fireEvent.click(screen.getByRole('button', { name: 'Search docs' })); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ key: 'k', metaKey: true }) - ); - document.removeEventListener('keydown', listener); + // LangGraph publishes no demoUrl, so nothing is left to put in Actions. + expect(screen.queryByRole('toolbar', { name: 'Docs actions' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Search docs' })).toBeTruthy(); }); it('connects nested Learn disclosures to their controlled content', () => { @@ -207,7 +203,6 @@ describe('DocsControlPlane', () => { activeLibrary="langgraph" activeSection="guides" activeSlug="streaming" - pageTitle="Streaming" /> ); @@ -225,7 +220,6 @@ describe('DocsControlPlane', () => { activeLibrary="langgraph" activeSection="guides" activeSlug="streaming" - pageTitle="Streaming" /> ); @@ -250,7 +244,6 @@ describe('DocsControlPlane', () => { activeLibrary="langgraph" activeSection="guides" activeSlug="streaming" - pageTitle="Streaming" /> ); @@ -283,7 +276,6 @@ describe('DocsControlPlane', () => { activeLibrary="langgraph" activeSection="guides" activeSlug="streaming" - pageTitle="Streaming" /> ); @@ -307,7 +299,6 @@ describe('DocsControlPlane', () => { activeLibrary="langgraph" activeSection="guides" activeSlug="streaming" - pageTitle="Streaming" /> ); @@ -326,7 +317,6 @@ describe('DocsControlPlane', () => { activeLibrary="langgraph" activeSection="guides" activeSlug="streaming" - pageTitle="Streaming" /> ); @@ -352,19 +342,12 @@ describe('DocsControlPlane', () => { }); describe('DocsControlPlane — library-neutral', () => { - it.each([ - { pageTitle: 'Overview', path: '/docs' }, - { - pageTitle: 'Choosing an adapter', - path: '/docs/choosing-an-adapter', - }, - ])('$path keeps standalone controls disabled and free of Cockpit handoffs', ({ pageTitle }) => { + it('keeps every standalone control disabled on the adapter comparison page', () => { render( , ); @@ -377,24 +360,45 @@ describe('DocsControlPlane — library-neutral', () => { fireEvent.click(control); } expect(track).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Search docs' })).toBeTruthy(); }); - it('states only what it knows in Scope', () => { + it('sends Run to the default example when the index supplies one', () => { + render( + , + ); + + // href turns the rail item into an , so it is a link, not a button. + expect( + screen.getByRole('link', { name: 'Run' }).getAttribute('href'), + ).toBe('/docs/langgraph/guides/streaming?mode=run'); + + // The index still has no Code or API view of its own. + for (const mode of ['Code', 'API'] as const) { + expect( + screen.getByRole('button', { + name: mode, + description: `${mode} is unavailable because this page has no workspace capability.`, + }).getAttribute('href'), + ).toBeNull(); + } + }); + + it('offers search on a library-neutral page too', () => { render( , ); - const scope = screen.getByRole('heading', { name: 'Scope' }).closest('section'); - if (!scope) throw new Error('Expected Scope section'); - expect(within(scope).getByText('Choosing an adapter')).toBeTruthy(); - expect(within(scope).queryByText('LangGraph')).toBeNull(); - expect(within(scope).queryByText('Getting Started')).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Scope' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Search docs' })).toBeTruthy(); }); it('offers an unselected picker and no section tree', () => { @@ -403,7 +407,6 @@ describe('DocsControlPlane — library-neutral', () => { activeLibrary={null} activeSection="" activeSlug="" - pageTitle="Choosing an adapter" />, ); @@ -425,15 +428,15 @@ describe('DocsContextContent', () => { activeLibrary="render" activeSection="guides" activeSlug="specs" - pageTitle="Specs & Elements" mobile /> ); - expect(screen.getByRole('heading', { name: 'Scope' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Search docs' })).toBeTruthy(); + // json-render publishes no demoUrl, so Actions has nothing left to hold. + expect(screen.queryByRole('toolbar', { name: 'Docs actions' })).toBeNull(); expect(screen.getByRole('button', { name: 'Learn' })).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Runtime' })).toBeNull(); - expect(screen.getByRole('toolbar', { name: 'Docs actions' })).toBeTruthy(); }); it('keeps explicit standalone demo actions', () => { @@ -442,7 +445,6 @@ describe('DocsContextContent', () => { activeLibrary="ag-ui" activeSection="getting-started" activeSlug="introduction" - pageTitle="Introduction" />, ); diff --git a/apps/website/src/components/docs/DocsControlPlane.tsx b/apps/website/src/components/docs/DocsControlPlane.tsx index f860cb183..3702245f2 100644 --- a/apps/website/src/components/docs/DocsControlPlane.tsx +++ b/apps/website/src/components/docs/DocsControlPlane.tsx @@ -17,11 +17,7 @@ import { ControlPlaneSection, useControlPlanePreferences, } from '@threadplane/ui-react'; -import { - getDocsSection, - getLibraryConfig, - type LibraryId, -} from '../../lib/docs-config'; +import { getLibraryConfig, type LibraryId } from '../../lib/docs-config'; import { DocsNavigation } from './DocsSidebar'; export interface DocsControlPlaneProps { @@ -29,7 +25,15 @@ export interface DocsControlPlaneProps { activeLibrary: LibraryId | null; activeSection: string; activeSlug: string; - pageTitle: string; + /** + * Where the Run rail item goes on a page that has no example of its own. + * + * Only the docs index supplies this. Run normally means "run the example on + * this page", and a docs-only page correctly has none — but the index is not + * a capability page at all, so the canonical example is the only meaningful + * target. Absent, Run stays disabled. + */ + runHref?: string; } const dispatchSearch = () => @@ -41,7 +45,6 @@ export function DocsContextContent({ activeLibrary, activeSection, activeSlug, - pageTitle, mobile = false, onNavigate, onSearchHandoff, @@ -52,9 +55,6 @@ export function DocsContextContent({ }) { const preferences = useControlPlanePreferences('docs'); const library = activeLibrary ? getLibraryConfig(activeLibrary) : undefined; - const section = activeLibrary - ? getDocsSection(activeLibrary, activeSection) - : undefined; const openSearch = () => { if (!mobile) { dispatchSearch(); @@ -69,16 +69,18 @@ export function DocsContextContent({ }; return (
- -
- {/* A neutral page has no library and no section. Say only what is - * true — inventing them is how the mobile drawer came to claim - * "LangGraph / Getting Started" on the adapter-comparison page. */} - {library?.title ?? 'Docs'} - {library && section ? {section.title} : null} - {pageTitle} -
-
+ - - - + + ) : null}
); } @@ -139,7 +136,8 @@ export function DocsControlPlane(props: DocsControlPlaneProps) {
". + * Both have moved into the shell header's breadcrumb trail, which already + * names the library and section — restating either here was a second + * rendition of the same location. This row now exists only to host + * `actions`. + */ +export function DocsPageHeader({ actions }: Props) { return (
-
- - - {libTitle} · {sectionTitle} - -
{actions ?
{actions}
: null}
); diff --git a/apps/website/src/components/docs/DocsSearchFooter.spec.tsx b/apps/website/src/components/docs/DocsSearchFooter.spec.tsx new file mode 100644 index 000000000..c99d1b3cd --- /dev/null +++ b/apps/website/src/components/docs/DocsSearchFooter.spec.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { DocsSearchFooter } from './DocsSearchFooter'; + +describe('DocsSearchFooter', () => { + it('opens search from a real button, not a keyboard instruction', () => { + const listener = vi.fn(); + document.addEventListener('keydown', listener); + render(); + + // The old copy read "Press ⌘K to search the docs" as static text, which + // is unactionable on a device with no ⌘K. + fireEvent.click(screen.getByRole('button', { name: /Search the docs/ })); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ key: 'k', metaKey: true }) + ); + document.removeEventListener('keydown', listener); + }); + + it('keeps the shortcut as a hint', () => { + const { container } = render(); + const pill = container.querySelector('[data-ui="pill"]'); + expect(pill?.textContent).toBe('⌘K'); + // Decoration, not part of the accessible name — see the exact-name + // assertion below, which would fail if this regressed. + expect(pill?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('keeps the ⌘K pill out of the button accessible name', () => { + render(); + expect( + screen.getByRole('button', { name: 'Search the docs' }) + ).toBeTruthy(); + }); +}); diff --git a/apps/website/src/components/docs/DocsSearchFooter.tsx b/apps/website/src/components/docs/DocsSearchFooter.tsx new file mode 100644 index 000000000..7f3637f9d --- /dev/null +++ b/apps/website/src/components/docs/DocsSearchFooter.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { Pill } from '../ui/Pill'; + +/** + * The invitation to search, at the foot of a docs page. + * + * A button rather than the instruction it used to be: "Press ⌘K" is not an + * affordance on a device with no ⌘K. It dispatches the same synthetic keydown + * the control plane's own search trigger uses (see DocsControlPlane), so + * `DocsSearch` needs no new entry point. + * + * The ⌘K pill is `aria-hidden`, matching the control plane trigger's own + * hint: there is nothing to press on a device with no keyboard, so it is + * decoration, not part of the accessible name. + */ +export function DocsSearchFooter() { + const openSearch = () => + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', metaKey: true }) + ); + + return ( +
+ +
+

+ Looking for something specific? +

+ +
+
+
+ ); +} diff --git a/apps/website/src/components/docs/MdxRenderer.tsx b/apps/website/src/components/docs/MdxRenderer.tsx index 397ff687b..2e8ebc974 100644 --- a/apps/website/src/components/docs/MdxRenderer.tsx +++ b/apps/website/src/components/docs/MdxRenderer.tsx @@ -1,6 +1,6 @@ import { MDXRemote } from 'next-mdx-remote/rsc'; -import { tokens } from '@threadplane/design-tokens'; import { Callout } from './mdx/Callout'; +import { CalloutAction, CalloutActions } from './mdx/CalloutActions'; import { Steps, Step } from './mdx/Steps'; import { Tabs, Tab } from './mdx/Tabs'; import { Card, CardGroup } from './mdx/Card'; @@ -38,6 +38,8 @@ const DIAGRAM_DIMENSIONS: Record = { const mdxComponents = { Callout, + CalloutActions, + CalloutAction, Steps, Step, Tabs, @@ -96,13 +98,7 @@ interface MdxRendererProps { export function MdxRenderer({ source }: MdxRendererProps) { return ( -
+
{ + it('sends absolute hrefs off-site safely', () => { + render( + + Run the AG-UI demo + + ); + + const link = screen.getByRole('link', { name: 'Run the AG-UI demo' }); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('keeps in-site hrefs in the same tab', () => { + render(Quick Start); + + const link = screen.getByRole('link', { name: 'Quick Start' }); + expect(link.getAttribute('target')).toBeNull(); + expect(link.getAttribute('rel')).toBeNull(); + }); + + it('treats a protocol-relative href as external', () => { + render(Off-site); + + const link = screen.getByRole('link', { name: 'Off-site' }); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('treats a mailto href as external', () => { + render(Email us); + + const link = screen.getByRole('link', { name: 'Email us' }); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('keeps a same-document hash href in the same tab', () => { + render(Jump down); + + const link = screen.getByRole('link', { name: 'Jump down' }); + expect(link.getAttribute('target')).toBeNull(); + expect(link.getAttribute('rel')).toBeNull(); + }); + + it('defaults to the primary variant and opts out of the prose link rule', () => { + render(Docs); + + const link = screen.getByRole('link', { name: 'Docs' }); + expect(link.getAttribute('data-variant')).toBe('primary'); + // Without this the .docs-prose underline would corrupt the button. + expect(link.hasAttribute('data-mdx-chrome')).toBe(true); + }); + + it('honours an explicit secondary variant', () => { + render( + + LangGraph demo + + ); + + expect( + screen.getByRole('link', { name: 'LangGraph demo' }).getAttribute('data-variant') + ).toBe('secondary'); + }); +}); + +describe('CalloutActions', () => { + it('groups its actions in one row', () => { + const { container } = render( + + Run + + Compare + + + ); + + const row = container.querySelector('[data-mdx="callout-actions"]'); + expect(row).toBeTruthy(); + expect(within(row as HTMLElement).getAllByRole('link')).toHaveLength(2); + }); +}); diff --git a/apps/website/src/components/docs/mdx/CalloutActions.tsx b/apps/website/src/components/docs/mdx/CalloutActions.tsx new file mode 100644 index 000000000..721a8fe7d --- /dev/null +++ b/apps/website/src/components/docs/mdx/CalloutActions.tsx @@ -0,0 +1,66 @@ +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +/** + * Call-to-action buttons inside a ``. + * + * **A CTA belongs only in a callout whose purpose is to send the reader + * somewhere to run or see something.** Explanatory callouts ("Mental model", + * "Why this matters", "Node return values merge, not replace") and cautions + * ("Never expose API keys") keep prose links: 152 callouts ship in the docs + * and most of them are prose, not doors. A button on an explanation trains + * readers to ignore buttons. + * + * Keep the prose too. The button is the action; the prose is the context that + * says why you would take it. + */ +export function CalloutActions({ children }: { children: ReactNode }) { + return
{children}
; +} + +interface CalloutActionProps { + href: string; + /** `primary` is filled; `secondary` is outlined. Defaults to `primary`. */ + variant?: 'primary' | 'secondary'; + children: ReactNode; +} + +/** + * An href is internal only if it stays on this document or this site: + * same-document (`#section`), site-relative (`/docs/...`, `?query`). + * Everything else — including a protocol-relative `//host/path` (which + * *looks* site-relative but resolves against whatever host the current + * scheme applies to, i.e. leaves the site) and `mailto:`/`tel:` URIs — is + * external. + */ +function isExternalHref(href: string): boolean { + const isSiteRelative = + (href.startsWith('/') && !href.startsWith('//')) || + href.startsWith('#') || + href.startsWith('?'); + return !isSiteRelative; +} + +export function CalloutAction({ + href, + variant = 'primary', + children, +}: CalloutActionProps) { + const isExternal = isExternalHref(href); + + return ( + + {children} + + ); +} diff --git a/apps/website/src/components/docs/mdx/Card.tsx b/apps/website/src/components/docs/mdx/Card.tsx index 0d7d72a41..fdbf3ab46 100644 --- a/apps/website/src/components/docs/mdx/Card.tsx +++ b/apps/website/src/components/docs/mdx/Card.tsx @@ -30,7 +30,7 @@ export function Card({ ? { target: '_blank', rel: 'noopener noreferrer' } : {}; return ( - +
diff --git a/apps/website/src/components/docs/mdx/FeatureChips.tsx b/apps/website/src/components/docs/mdx/FeatureChips.tsx index 08ffafddb..8f76796b0 100644 --- a/apps/website/src/components/docs/mdx/FeatureChips.tsx +++ b/apps/website/src/components/docs/mdx/FeatureChips.tsx @@ -23,7 +23,7 @@ export function FeatureChips() { return (
{CHIPS.map((chip) => ( - +
{chip.icon}
{chip.title}
diff --git a/apps/website/src/components/docs/mdx/headings.tsx b/apps/website/src/components/docs/mdx/headings.tsx index dc0c032d2..03f69e325 100644 --- a/apps/website/src/components/docs/mdx/headings.tsx +++ b/apps/website/src/components/docs/mdx/headings.tsx @@ -33,7 +33,7 @@ function headingText(node: ReactNode): string { */ function HeadingAnchor({ id, children }: { id: string; children: ReactNode }) { const label = headingText(children).replace(/\s+/g, ' ').trim() || id; - return ; + return ; } /** MDX component overrides that add permalink anchors to H2/H3. */ diff --git a/apps/website/src/components/shared/Nav.spec.tsx b/apps/website/src/components/shared/Nav.spec.tsx index 2ee91dccf..9c9f88e9f 100644 --- a/apps/website/src/components/shared/Nav.spec.tsx +++ b/apps/website/src/components/shared/Nav.spec.tsx @@ -26,19 +26,21 @@ describe('Docs mobile navigation', () => { pathnameRef.current = '/docs/langgraph/guides/streaming'; }); - it('names the docs index the same way the page does', () => { + it('mounts the docs control plane, search trigger included, on the library-neutral docs index', () => { pathnameRef.current = '/docs'; render(
+ +``` + +It sits outside the `md:max-w-3xl` measure deliberately: it is a full-width band, like the one on the index. + +- [ ] **Step 8: Assert it reaches the content route** + +Append to `apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx`, inside the existing `describe`: + +```tsx + it('invites a search at the foot of a content page', async () => { + const tree = await route('langgraph', 'guides', 'testing'); + const workspace = findElement( + tree, + WebsiteWorkspace as ComponentType + ); + + expect( + findElement(workspace?.props.docsSlot, DocsSearchFooter as ComponentType) + ).toBeTruthy(); + }); +``` + +and add the import at the top of that file: + +```ts +import { DocsSearchFooter } from '../../../../../components/docs/DocsSearchFooter'; +``` + +- [ ] **Step 9: Run the tests to verify they pass** + +```bash +cd apps/website && GROWTH_FORM_POLICY=growth_v1 npx vitest run --config vite.config.mts src/app/docs src/components/docs +``` + +Expected: PASS. + +- [ ] **Step 10: Verify in the browser** + +Navigate to `http://localhost:3000/docs/langgraph/guides/testing`, scroll the article container to the bottom (the Browser pane suspends scroll events when hidden — use `javascript_tool` with `document.querySelector('[data-workspace-panels]')?.scrollTo(0, 1e6)` or read the DOM instead of scrolling), and confirm the footer renders below the prev/next cards. Click the button and confirm the search dialog opens with `read_page`. Then confirm the index still renders it exactly once: + +```js +JSON.stringify({ footers: document.querySelectorAll('#search-prompt-heading').length }); +``` + +Expected: `1` on both `/docs` and a content page. + +- [ ] **Step 11: Commit** + +```bash +git add apps/website/src/components/docs/DocsSearchFooter.tsx apps/website/src/components/docs/DocsSearchFooter.spec.tsx apps/website/src/app/docs apps/website/src/styles/pages.css apps/website/src/styles/docs.css +git commit -m "feat(docs): invite a search at the foot of every docs page + +Extracts the index's search prompt into DocsSearchFooter and renders it +below prev/next on every content page. The prompt becomes a real button: +'Press ⌘K' was static text and unactionable on a device with no ⌘K, so +the shortcut is now a hint beside the control rather than the only way in. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 9: Full verification + +Per the repo's verification-before-completion discipline: no success claim without command output. `nx test website` is green on `main`, so any red here is this branch's. + +- [ ] **Step 1: Full website suite** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/gallant-clarke-963ed0 && GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx test website --outputStyle=static 2>&1 | tail -30 +``` + +Expected: PASS. + +- [ ] **Step 2: Shared library suites** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/gallant-clarke-963ed0 && NX_DAEMON=false npx nx run-many -t test --projects=workspace-react,ui-react,cockpit-registry,cockpit-shell --outputStyle=static 2>&1 | tail -30 +``` + +Expected: PASS. `workspace-react` is the only one this branch touches; the other three are its consumers and dependencies. + +- [ ] **Step 3: Lint** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/gallant-clarke-963ed0 && GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx run-many -t lint --projects=website,workspace-react --outputStyle=static 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -E "error|Error|warning" | head -30 +``` + +Strip ANSI before grepping or the match silently misses colored output. **Errors** must be zero; pre-existing warnings are acceptable. Unused imports (`Pill`, `within`, `DocsBreadcrumb`, `humanize`, `getDocsSection`) are the likely offenders and are errors, not warnings. + +- [ ] **Step 4: Production build** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/gallant-clarke-963ed0 && GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx build website --outputStyle=static 2>&1 | tail -25 +``` + +Expected: success. If Turbopack panics about the workspace root, a stale dev directory is the cause: `rm -rf apps/website/.next` and re-run. + +- [ ] **Step 5: Website e2e for the docs routes** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/gallant-clarke-963ed0 && GROWTH_FORM_POLICY=growth_v1 NX_DAEMON=false npx nx e2e website --outputStyle=static --grep "docs" 2>&1 | tail -30 +``` + +Stop the preview server first — a running dev server holds port 3000 and the e2e web server will either fail to bind or, worse, silently test the old bundle. Any failure that names a breadcrumb, a Scope card or `⌘K` copy is this branch's and must be fixed in the spec, not by loosening the assertion. + +- [ ] **Step 6: Final visual pass** + +With the preview restarted, screenshot each of these and confirm the change is present and nothing else regressed: + +| URL | What to confirm | +| --- | --- | +| `/docs` | search leads the pane, Run is a live link, footer at the bottom, no empty Actions section | +| `/docs/choosing-an-adapter` | Run still disabled, search present, no Scope | +| `/docs/ag-ui/getting-started/introduction` | one trail, two CTA buttons in the tip callout, underlined prose links, footer | +| `/docs/langgraph/guides/streaming?mode=run` | Run panel loads, header trail correct, cockpit sidebar unaffected | +| `/docs/langgraph/guides/testing` | footer below prev/next on a docs-only page | + +Then check `read_console_messages` with `onlyErrors: true` on each — expected: no errors. + +- [ ] **Step 7: Confirm the diff contains nothing unintended** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/gallant-clarke-963ed0 && git status --short && git diff --stat origin/main...HEAD +``` + +`apps/website/.env.local` must **not** appear (it is gitignored; if it shows, do not add it). Confirm `DocsBreadcrumb.tsx` shows as deleted and no stray scratch files are staged. + +--- + +## Notes for the implementer + +- **Task order matters twice.** Task 1 establishes the `data-mdx-chrome` convention that Task 6's buttons rely on. Task 4 must land before Task 5, which consumes the prop it adds. Tasks 2, 3, 7 and 8 are independent of each other. +- **Do not restore `DocsBreadcrumb`** if a test fails at import after Task 5. Update the test — the component is deliberately gone. +- **Do not "fix" the grey map or a blank reload** if you happen to open an AG-UI itinerary page while verifying. Per the project's recorded history that is never a code bug. +- **The Browser pane suspends `requestAnimationFrame` and scroll events while hidden.** A scroll-spy or intersection-observer UI will look dead and a `computer` scroll action can time out. Prefer `read_page`, `get_page_text` and `javascript_tool` over screenshots and scrolling when the pane is not visible. diff --git a/docs/superpowers/specs/2026-09-03-docs-page-improvements-design.md b/docs/superpowers/specs/2026-09-03-docs-page-improvements-design.md new file mode 100644 index 000000000..cfd0c2d10 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-docs-page-improvements-design.md @@ -0,0 +1,255 @@ +# Docs page improvements — design + +Date: 2026-09-03 +Status: approved, ready for planning + +Six changes to the `/docs` experience: a working Run rail item on the docs +index, search in place of the Scope card, one breadcrumb trail instead of four, +visible links in docs prose, call-to-action buttons in the callouts that exist +to send people somewhere, and the search footer on every docs content page. + +## Findings that shaped the design + +Two things were verified against the running dev server before designing, and +both changed the shape of the work. + +**Docs prose links carry no styling at all.** `MdxRenderer` wraps content in +`docs-prose prose prose-slate` and sets `--tw-prose-links` to the accent color, +but Tailwind Typography is not active in this app: a stylesheet walk in the +loaded page found zero rules matching `.prose`. The custom property is set and +nothing reads it. Computed styles on `/docs/ag-ui/getting-started/introduction` +were `color: rgb(28, 28, 28)`, `text-decoration-line: none`, `font-weight: 400` +for a link in a paragraph, and `rgb(70, 70, 70)` for a link inside a callout. +The reported problem was callout links; the actual problem is every link in +every docs page, and callouts only make it most visible because their body text +is already muted. + +**`/workspace/langgraph/streaming` returns 404.** The capability has a +`docsPath`, so `getWorkspaceDestinationPath()` resolves its canonical +destination to the docs route, and `generateStaticParams()` in +`apps/website/src/app/workspace/[product]/[topic]/page.tsx` deliberately skips +entries whose destination is not their own `workspacePath`. The working Run URL +for that capability is `/docs/langgraph/guides/streaming?mode=run`, which loads +and reports `runtime ready`. The Run href must therefore be derived from the +registry, not written as a literal path. + +A third finding scoped the work rather than changing it. In +`libs/workspace-react/src/lib/components/control-plane/cockpit-control-plane.tsx`, +the host's `renderContextPane` is used only while the active mode is `Docs`; +Run, Code and API render `CockpitSidebar` instead. The "Capability" scope +visible in Run mode belongs to the cockpit sidebar and is not touched here. + +## 1. Run on the docs index points at the default example + +`DocsControlPlane` hardcodes `disabled` on the Run, Code and API rail items, +with a `disabledReason` explaining that the page has no workspace capability. +That is true of a docs page with no example. It is misleading on `/docs`, which +is not a capability page at all: there is no page-specific example to run, so +the canonical example is the only meaningful target. + +On `/docs` only, Run becomes a link. Its href is resolved from the registry at +module scope: + +```ts +const DEFAULT_EXAMPLE = resolveWorkspacePath('/workspace/langgraph/streaming'); +const DEFAULT_EXAMPLE_RUN_HREF = DEFAULT_EXAMPLE + ? getCanonicalWebsiteWorkspaceHref(DEFAULT_EXAMPLE, 'Run') + : null; +``` + +This yields `/docs/langgraph/guides/streaming?mode=run` today and stays correct +if that capability's docs path moves. When the lookup returns `null` — the +capability was renamed or removed — Run falls back to today's disabled state +rather than rendering a dead link. + +Code and API stay disabled on `/docs`. Run semantics on capability doc pages are +unchanged: there, Run means "run the example on this page", and a docs-only page +correctly has none. + +## 2. Scope becomes search + +The `Scope` section at the top of `DocsContextContent` repeats the breadcrumb +trail, and item 3 gives that trail a single owner. It is replaced by a search +trigger in the same position: a full-width button styled as an input, carrying a +magnifier icon, the label `Search docs`, and a `⌘K` hint that is hidden where a +pointer is coarse. + +The trigger calls the existing `openSearch()`, so the mobile-drawer handoff +(`onSearchHandoff`, then `onNavigate` plus a `requestAnimationFrame`-deferred +dispatch) keeps working unchanged. + +The `Search docs` icon button then leaves the `Actions` bar, where it would be a +second control for the same thing. `Actions` renders only when at least one +action remains — otherwise `/docs`, whose only action was search, would show an +empty section with a heading. + +## 3. One breadcrumb trail, owned by the shell header + +A doc page currently renders the same trail four times: + +| Source | Renders | +| --- | --- | +| `workspace-shell.tsx` header | `Ag Ui / Getting Started / Overview` (mono, muted) | +| `DocsBreadcrumb` | `Docs / AG-UI / Getting Started / Introduction` | +| `DocsPageHeader` | `AG-UI · GETTING STARTED` | +| `DocsContextContent` Scope card | `AG-UI / Getting Started / Introduction` | + +The Scope card goes away under item 2. Of the remaining three, the shell header +keeps the trail, because it is the one position that belongs to the shell rather +than to the article, and it is where a reader already looks for location. + +Today that header derives its label from the manifest identity, which is why it +reads `Ag Ui` and `Overview` — the manifest's `toLabel()` casing and its +`page: 'overview'`, neither of which matches what the docs tree calls the +library or the page. It also renders as a muted mono `

`, which is decoration, +not navigation. + +`WorkspaceShell` gains an optional prop: + +```ts +readonly contextTrail?: readonly WorkspaceCrumb[]; +``` + +where `WorkspaceCrumb` is `{ label: string; href?: string }`, declared in +`libs/workspace-react/src/lib/workspace-contracts.ts` and re-exported from the +package index alongside the other shell contracts. When supplied, +the header renders a `

{headerActions ? (
diff --git a/libs/workspace-react/src/styles/workspace.css b/libs/workspace-react/src/styles/workspace.css index 6791ea2eb..6f0cad848 100644 --- a/libs/workspace-react/src/styles/workspace.css +++ b/libs/workspace-react/src/styles/workspace.css @@ -1539,3 +1539,44 @@ animation: none !important; } } + +/* Shell header location trail. Structure lives in workspace-shell.tsx; a host + * may restyle it through these attributes. This is the floor, not the design. */ +[data-workspace-trail] { + /* Replaces the `truncate` the old

carried. Without min-width: 0 a flex + * child cannot shrink below its content size, so a long or many-rung trail + * would force the header row wider instead of yielding space. */ + min-width: 0; +} +[data-workspace-trail] ol[data-workspace-trail-list] { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + row-gap: 4px; + font-size: 0.75rem; + line-height: 1.5; + color: var(--ds-text-muted); +} +[data-workspace-trail-link] { + color: var(--ds-text-muted); + text-decoration: none; +} +[data-workspace-trail-link]:hover { + color: var(--ds-text-primary); +} +[data-workspace-trail-separator] { + margin: 0 8px; +} +[data-workspace-trail-current] { + color: var(--ds-text-primary); + font-weight: 600; +} +[data-workspace-trail-icon] { + display: inline-flex; + align-items: center; + margin-right: 6px; + vertical-align: middle; +}