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. */}
+
+
+```
+
+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