`. Every route gets the same navbar. The whole of it is `apps/website/src/components/shared/Nav.tsx` (~500 lines) plus presentation in `apps/website/src/styles/chrome.css`.
+
+**Why `chrome.css` rules are unlayered.** Tailwind v4 puts utilities in `@layer utilities`, and unlayered author CSS beats every layer. The rules in `chrome.css` are deliberately outside `@layer` so they keep the precedence the inline styles they replaced had. **Do not wrap anything you add there in `@layer`** — utilities would start winning and rendering would change.
+
+**`--nav-h` is measured, not derived.** It is declared in `chrome.css` at three steps (58px, 66px at `md`, 81px at `lg`) and read by six places: the mobile overlay's `top` (`chrome.css`), the docs shell padding and both sticky rails (`docs.css:79,84,99,1824,1827`), a page offset (`pages.css:277`), and `html`'s `scroll-padding-top` (`app/global.css:31`). The last time it was wrong, phones got 22px of dead space and anchors landed 81px under the nav. jsdom cannot measure layout, so `e2e/nav-height.spec.ts` is the only thing that can hold it honest.
+
+**Colors.** `--ds-signal` (`#FFAF00`) is 1.84:1 on white and is **fill-only** — it never carries text. `--ds-scope` (`#15253E`, navy) is the accent role for text and links. In the nav, yellow does exactly two jobs: the `Talk to Us` fill and the active-link underline.
+
+**Commands.**
+
+```bash
+npx nx test website -- --run src/components/shared/Nav.spec.tsx
+```
+
+**Nx does not forward the path filter to Vitest** — that command runs the whole website suite (140 files, ~1407 tests) and the trailing path is ignored. That is strictly stronger verification, so the commands in this plan keep it, but expect full-suite counts rather than one file's. To actually filter while iterating, bypass Nx:
+
+```bash
+cd apps/website && npx vitest run src/components/shared/Nav.spec.tsx
+```
+
+**Two traps with that second form.** Some specs resolve paths against `process.cwd()`, so running them from `apps/website` rather than through Nx changes their result. `src/lib/cockpit-retirement.spec.ts` fails 5 of 9 that way and passes under Nx — a false alarm that looks exactly like a regression you caused. So: use the filtered form only on the one spec you are iterating on, and **never** run the whole suite from `apps/website`. Every pass/fail claim in a report or a commit message must come from `npx nx test website`.
+
+```bash
+npx nx lint website
+```
+
+```bash
+npx nx build website
+```
+
+**`nx test` and `nx lint` do not typecheck.** Vitest strips types and this ESLint config is not type-aware, so a type error passes both and only `nx build website` (or `tsc --noEmit`) catches it. Task 4 shipped a broken build behind 1411 passing tests and a clean lint that way: a bare `` const id = `${surface}_${item.ctaId}` `` widens to `string`, which is not assignable to the `CtaId` template-literal union. **Run the build at the end of every task that touches `.ts`/`.tsx`, not only at the end of the plan.**
+
+Playwright specs need a dev server; the config starts one. To run a single e2e file — note the `--testFiles=` form, because a bare positional path fails on this Nx/Playwright executor with `unknown option '--_=…'`:
+
+```bash
+npx nx e2e website -- --testFiles=e2e/nav-height.spec.ts
+```
+
+**Playwright `.hover()` does not traverse.** It jumps straight to the target's centre in one step, so it never crosses the space between two elements. Any test whose subject is a *path* — a hover grace period, a dead zone between a trigger and its panel, a drag — is vacuous when written with `.hover()`: it passes identically whether the behaviour works or not. Task 4 shipped exactly such a test and only caught it because the mutation proof failed to fail. Use `page.mouse.move(x, y, { steps: 15 })` when the movement is the thing under test.
+
+**An entrance animation makes geometry assertions flaky.** A test that samples several `boundingBox()` values in sequence can read them mid-interpolation — Task 4's four-item layout test failed roughly one run in five with a ~2px discrepancy after a 140ms transform was added. Await `getAnimations().finished` on the animating element before sampling. Waiting for the animation to settle is legitimate; a fixed `waitForTimeout` or a retry-until-green loop is masking, and the assertions themselves must not change.
+
+**Inverting a condition does not prove it equivalent.** A refactor of the drawer's Escape handler on this branch was "proved" safe by flipping the new condition and observing both Escape tests fail. They did — and the refactor still shipped a real defect, because the two tests only exercised two paths and the rewrite had silently dropped a guard that mattered on a third. Escape became a dead key at the drawer root on docs routes: the pop branch set a level that was already set, React bailed out, and the close was never reached. Three presses, drawer still open.
+
+When you change a condition that governs a state machine, **enumerate the reachable states and check each one**, rather than relying on the existing tests to define the space. For this drawer the states are: root on a marketing route; a pushed panel on a marketing route; the docs opening level; root reached via Back on a docs route; a panel pushed from that root. A test suite that passes tells you only about the paths someone already thought of.
+
+**`npx nx test website` is the only correct unit lane.** `npx vitest run --config apps/website/vite.config.mts` from the repo root collects just 298 tests and excludes `Nav.spec.tsx` entirely; running the suite from `apps/website` makes cwd-sensitive specs fail spuriously. Both look like a test run and neither is one.
+
+**Several worktrees on this machine run this same suite.** Ports 4308 (website) and 4300 (the cockpit runtime webServer) are contended, and a neighbouring worktree can start a server *during* your run — which surfaces as a wall of `net::ERR_CONNECTION_REFUSED` with zero assertion failures, not as a normal test failure. Read the failures before believing them: if every one is a connection error, it is contention, not your change.
+
+Never kill a server whose working directory is a different worktree — another session is using it. Identify a holder with `lsof -ti tcp:4308` then `lsof -a -p
-d cwd -Fn`. To run in isolation regardless of who holds what, start your own server on a free port and point Playwright at it with `BASE_URL` (`playwright.config.ts` skips its own webServers when `BASE_URL` is set). Kill only orphans confirmed to be from this worktree.
+
+**Free the port first.** A previous run's `next-server` can outlive it and hold the Playwright web-server port, and the failure does not say so. If a spec run hangs or the server will not start, find and kill the orphan before debugging anything else — one was found 19 minutes stale on port 4308 during Task 4. A stale server is also perfectly capable of serving an OLD bundle, so a green run against one proves nothing.
+
+**Tests that will break, and which task fixes each.** These exist today in `src/components/shared/Nav.spec.tsx` and assert the old IA. Do not delete them ahead of time — each is rewritten in the task that changes its behavior:
+
+| Test | Breaks because | Fixed in |
+| --- | --- | --- |
+| `retires Examples from desktop navigation without changing primary destinations or demos` | asserts a `Pilot to Prod` top-level link and a `Demo` dropdown button | Task 4 |
+| `uses the existing header trigger for the control-plane Docs drawer` | asserts the `Docs` **tab** button carries `data-active` | Task 8 |
+| `preserves the Site tab alongside the Docs control plane` | the Site tab is deleted | Task 8 |
+| `retires Examples from mobile navigation without changing primary destinations or demos` | asserts a flat mobile link list | Task 8 |
+
+---
+
+## File Structure
+
+| File | Responsibility | Task |
+| --- | --- | --- |
+| `apps/website/src/components/shared/nav-config.ts` (create) | The IA as data: triggers, panel columns, items, hrefs, icons, analytics ids, hero routes | 1 |
+| `apps/website/src/components/shared/nav-config.spec.ts` (create) | Every href resolves to a real route; every analytics id is unique | 1 |
+| `apps/website/src/components/shared/useNavSurface.ts` (create) | Transparent vs. solid, from route plus an IntersectionObserver sentinel | 2 |
+| `apps/website/src/components/shared/useNavSurface.spec.tsx` (create) | Surface states without a layout engine | 2 |
+| `apps/website/src/components/shared/NavDesktop.tsx` (create) | The bar row, the trigger buttons, the shared panel container | 3, 4 |
+| `apps/website/src/components/shared/NavMobile.tsx` (create) | The drawer, its focus trap, and the drill-in stack | 7, 8 |
+| `apps/website/src/components/shared/Nav.tsx` (modify) | Shell: reads the route, renders sentinel + desktop + mobile | 3, 5, 7 |
+| `apps/website/src/components/shared/Nav.spec.tsx` (modify) | Rewritten assertions for the new IA | 4, 8 |
+| `apps/website/src/styles/chrome.css` (modify) | Panel styling, surface states, docs height, drill-in; deletes `nav-demo-*` and `nav-mtabs` | 4, 5, 6, 8, 9 |
+| `apps/website/e2e/nav-height.spec.ts` (modify) | Split into marketing steps on `/` and docs steps on `/docs` | 6 |
+| `apps/website/e2e/nav-surface.spec.ts` (create) | Transparent at scroll 0 on every hero route; solid after scrolling | 5 |
+
+`nav-config.ts` is the seam that matters: both surfaces render from it, so adding a destination is a data change rather than an edit in two components.
+
+---
+
+## Task 1: The IA as data
+
+**Files:**
+- Create: `apps/website/src/components/shared/nav-config.ts`
+- Create: `apps/website/src/components/shared/nav-config.spec.ts`
+
+Nothing consumes this yet. It lands as data plus a test that proves the data is not lying about routes.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `apps/website/src/components/shared/nav-config.spec.ts`:
+
+```ts
+import { existsSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { HERO_ROUTES, NAV_TRIGGERS, navItems } from './nav-config';
+import { docsConfig } from '../../lib/docs-config';
+import { getAllSolutionSlugs } from '../../lib/solutions-data';
+
+const APP_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'app');
+
+/**
+ * `/docs/:library/:section/:slug` is a dynamic route, so a page file cannot
+ * prove it exists. `docsConfig` is what the route renders from, so that is
+ * what a docs href has to be checked against.
+ */
+function docsHrefResolves(href: string): boolean {
+ if (href === '/docs') return true;
+ const [, , library, section, slug] = href.split('/');
+ if (!library) return false;
+ if (!section) return existsSync(join(APP_ROOT, 'docs', library, 'page.tsx'));
+ return docsConfig.some(
+ (entry) =>
+ entry.id === library &&
+ entry.sections.some(
+ (group) =>
+ group.id === section && group.pages.some((page) => page.slug === slug),
+ ),
+ );
+}
+
+/**
+ * `/solutions/:slug` is a dynamic route too. `getAllSolutionSlugs()` is what
+ * `generateStaticParams` renders from, so that is what a solutions href has to
+ * be checked against.
+ */
+function solutionsHrefResolves(href: string): boolean {
+ if (href === '/solutions') return true;
+ const [, , slug] = href.split('/');
+ return Boolean(slug) && getAllSolutionSlugs().includes(slug);
+}
+
+function staticHrefResolves(href: string): boolean {
+ return existsSync(join(APP_ROOT, ...href.split('/').filter(Boolean), 'page.tsx'));
+}
+
+describe('nav-config', () => {
+ it('points every internal link at a route that exists', () => {
+ const unresolved = navItems()
+ .filter((item) => !item.external)
+ .filter((item) => {
+ if (item.href.startsWith('/docs')) return !docsHrefResolves(item.href);
+ if (item.href.startsWith('/solutions')) return !solutionsHrefResolves(item.href);
+ return !staticHrefResolves(item.href);
+ })
+ .map((item) => `${item.label} → ${item.href}`);
+
+ expect(unresolved).toEqual([]);
+ });
+
+ it('sends every external link somewhere over https', () => {
+ const bad = navItems()
+ .filter((item) => item.external)
+ .filter((item) => !item.href.startsWith('https://'))
+ .map((item) => item.label);
+
+ expect(bad).toEqual([]);
+ });
+
+ it('gives every destination a unique analytics id', () => {
+ const ids = navItems().map((item) => item.ctaId);
+ expect(ids.length).toBeGreaterThan(0);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('gives every panel item a label and a description', () => {
+ const thin = navItems()
+ .filter((item) => !item.label.trim() || !item.description.trim())
+ .map((item) => item.ctaId);
+
+ expect(thin).toEqual([]);
+ });
+
+ it('names four triggers, in order', () => {
+ expect(NAV_TRIGGERS.map((trigger) => trigger.label)).toEqual([
+ 'Libraries',
+ 'Docs',
+ 'Solutions',
+ 'Pricing',
+ ]);
+ });
+
+ it('lists only routes that actually render a hero', () => {
+ // Landing pages join this list in the change that gives each one a hero.
+ // Listing a white page here renders navy links over nothing.
+ expect(HERO_ROUTES).toEqual(['/']);
+ });
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `npx nx test website -- --run src/components/shared/nav-config.spec.ts`
+
+Expected: FAIL — `Failed to resolve import "./nav-config"`.
+
+- [ ] **Step 3: Write the config**
+
+Create `apps/website/src/components/shared/nav-config.ts`:
+
+```ts
+import type { ComponentType } from 'react';
+import {
+ BarChart3,
+ BookOpen,
+ Braces,
+ Building2,
+ Compass,
+ Lightbulb,
+ Map as MapIcon,
+ MessageSquare,
+ Newspaper,
+ Play,
+ Rocket,
+ ShieldCheck,
+ Users,
+} from 'lucide-react';
+import type { LibraryId } from '../../lib/docs-config';
+import { DEMOS, demoCtaSuffix } from '../../lib/demos';
+
+export type NavIcon = ComponentType<{ size?: number; 'aria-hidden'?: boolean }>;
+
+export interface NavItem {
+ readonly label: string;
+ /** One whole literal — never assembled from interpolated fragments, so the
+ * rendered-copy scan in lib/public-copy.spec.ts can see all of it. */
+ readonly description: string;
+ readonly href: string;
+ readonly external?: true;
+ /** Renders the library's own mark instead of `icon`. */
+ readonly library?: LibraryId;
+ readonly icon?: NavIcon;
+ /** Analytics suffix. Prefixed with `nav_` or `mobile_nav_` at the call site. */
+ readonly ctaId: string;
+}
+
+export interface NavColumn {
+ readonly heading?: string;
+ readonly items: readonly NavItem[];
+}
+
+export interface NavPanel {
+ readonly columns: readonly NavColumn[];
+ readonly footer?: NavItem & { readonly lead: string };
+}
+
+export type NavTrigger =
+ | { readonly kind: 'link'; readonly id: string; readonly label: string; readonly href: string; readonly ctaId: string }
+ | { readonly kind: 'panel'; readonly id: string; readonly label: string; readonly panel: NavPanel };
+
+const LIBRARIES: NavPanel = {
+ columns: [
+ {
+ items: [
+ {
+ label: '@threadplane/langgraph',
+ description: 'LangGraph and LangChain agents in Angular',
+ href: '/langgraph',
+ library: 'langgraph',
+ ctaId: 'libraries_langgraph',
+ },
+ {
+ label: '@threadplane/ag-ui',
+ description: 'The AG-UI protocol — CrewAI, Mastra, MAF',
+ href: '/ag-ui',
+ library: 'ag-ui',
+ ctaId: 'libraries_ag_ui',
+ },
+ {
+ label: '@threadplane/chat',
+ description: 'Chat, timeline, and thread primitives',
+ href: '/chat',
+ library: 'chat',
+ ctaId: 'libraries_chat',
+ },
+ {
+ label: '@threadplane/render',
+ description: 'Generative UI from agent output',
+ href: '/render',
+ library: 'render',
+ ctaId: 'libraries_render',
+ },
+ ],
+ },
+ ],
+ footer: {
+ lead: 'Not sure which one?',
+ label: 'Choosing an adapter',
+ description: 'The four libraries side by side',
+ href: '/docs/choosing-an-adapter',
+ icon: Compass,
+ ctaId: 'libraries_choosing_an_adapter',
+ },
+};
+
+const DOCS: NavPanel = {
+ columns: [
+ {
+ heading: 'Start here',
+ items: [
+ {
+ label: 'Documentation',
+ description: 'Every library, one shell',
+ href: '/docs',
+ icon: BookOpen,
+ ctaId: 'docs_documentation',
+ },
+ {
+ label: 'Quick start',
+ description: 'An agent on screen in ten minutes',
+ href: '/docs/langgraph/getting-started/quickstart',
+ icon: Rocket,
+ ctaId: 'docs_quick_start',
+ },
+ {
+ label: 'Choosing an adapter',
+ description: 'The four libraries side by side',
+ href: '/docs/choosing-an-adapter',
+ icon: Compass,
+ ctaId: 'docs_choosing_an_adapter',
+ },
+ ],
+ },
+ {
+ heading: 'Go deeper',
+ items: [
+ {
+ label: 'Guides',
+ description: 'Streaming, persistence, interrupts, memory',
+ href: '/docs/langgraph/guides/streaming',
+ icon: MapIcon,
+ ctaId: 'docs_guides',
+ },
+ {
+ label: 'Concepts',
+ description: 'The agent contract, signals, and state',
+ href: '/docs/langgraph/concepts/agent-contract',
+ icon: Lightbulb,
+ ctaId: 'docs_concepts',
+ },
+ {
+ label: 'API reference',
+ description: 'injectAgent, provideAgent, transports',
+ href: '/docs/langgraph/api/inject-agent',
+ icon: Braces,
+ ctaId: 'docs_api_reference',
+ },
+ ],
+ },
+ {
+ heading: 'See it running',
+ // Derived from DEMOS so the nav, the footer, and the mobile stack cannot
+ // disagree about where a demo lives.
+ items: DEMOS.map((demo) => ({
+ label: demo.label,
+ description: new URL(demo.href).host,
+ href: demo.href,
+ external: true as const,
+ icon: Play,
+ ctaId: `docs_demo_${demoCtaSuffix(demo.key)}`,
+ })),
+ },
+ ],
+};
+
+const SOLUTIONS: NavPanel = {
+ columns: [
+ {
+ heading: 'Use cases',
+ items: [
+ {
+ label: 'Customer support',
+ description: 'Deflection with a human in the loop',
+ href: '/solutions/customer-support',
+ icon: MessageSquare,
+ ctaId: 'solutions_customer_support',
+ },
+ {
+ label: 'Analytics',
+ description: 'Conversational data exploration',
+ href: '/solutions/analytics',
+ icon: BarChart3,
+ ctaId: 'solutions_analytics',
+ },
+ {
+ label: 'Compliance',
+ description: 'Auditable, approval-gated agents',
+ href: '/solutions/compliance',
+ icon: ShieldCheck,
+ ctaId: 'solutions_compliance',
+ },
+ ],
+ },
+ {
+ heading: 'Company',
+ items: [
+ {
+ label: 'Pilot to Prod',
+ description: 'How a pilot becomes a shipped surface',
+ href: '/pilot-to-prod',
+ icon: Building2,
+ ctaId: 'solutions_pilot_to_prod',
+ },
+ {
+ label: 'Blog',
+ description: 'Engineering notes and release write-ups',
+ href: '/blog',
+ icon: Newspaper,
+ ctaId: 'solutions_blog',
+ },
+ {
+ label: 'About',
+ description: 'Who is behind Threadplane',
+ href: '/about',
+ icon: Users,
+ ctaId: 'solutions_about',
+ },
+ ],
+ },
+ ],
+};
+
+export const NAV_TRIGGERS: readonly NavTrigger[] = [
+ { kind: 'panel', id: 'libraries', label: 'Libraries', panel: LIBRARIES },
+ { kind: 'panel', id: 'docs', label: 'Docs', panel: DOCS },
+ { kind: 'panel', id: 'solutions', label: 'Solutions', panel: SOLUTIONS },
+ { kind: 'link', id: 'pricing', label: 'Pricing', href: '/pricing', ctaId: 'pricing' },
+];
+
+/** Every destination in the nav, flattened — for tests and for analytics audits. */
+export function navItems(): readonly NavItem[] {
+ return NAV_TRIGGERS.flatMap((trigger) => {
+ if (trigger.kind === 'link') {
+ return [
+ {
+ label: trigger.label,
+ description: trigger.label,
+ href: trigger.href,
+ ctaId: trigger.ctaId,
+ } satisfies NavItem,
+ ];
+ }
+ const items = trigger.panel.columns.flatMap((column) => column.items);
+ return trigger.panel.footer ? [...items, trigger.panel.footer] : items;
+ });
+}
+
+/**
+ * Routes whose page opens on a colored hero, where the bar renders transparent
+ * at scroll 0.
+ *
+ * `/` is the only one today. The library landing pages open on white; listing
+ * one before it has a hero renders navy links over a white page with no bar
+ * behind them. Each page joins this list in the change that gives it a hero.
+ */
+export const HERO_ROUTES: readonly string[] = ['/'];
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `npx nx test website -- --run src/components/shared/nav-config.spec.ts`
+
+Expected: PASS, 6 tests.
+
+If `points every internal link at a route that exists` fails, check the slug against `src/lib/docs-config.ts` or `src/lib/solutions-data.ts` rather than loosening the assertion.
+
+**The one exception, and it bit the first run of this task:** a failure that names *every* href in a family means the family is a dynamic route the test has no resolver for — not that the hrefs are wrong. `/docs/*` and `/solutions/*` both have resolvers above. Repointing such links at a static hub to make the test pass silently destroys the IA; add the resolver instead.
+
+- [ ] **Step 4b: Prove each dynamic-route resolver is not vacuous**
+
+A resolver that returns `true` for everything passes this test while catching nothing. For each of `docsHrefResolves` and `solutionsHrefResolves`, temporarily repoint one href at a slug that does not exist (`/solutions/does-not-exist`), confirm the test FAILS naming that href, then restore it and confirm it passes.
+
+- [ ] **Step 5: Confirm the new copy clears the public-copy contract**
+
+Run: `npx nx test website -- --run src/lib/public-copy.spec.ts`
+
+Expected: PASS. This spec's `renderedCopyFiles` scan walks every non-spec `.ts`/`.tsx`/`.mjs` under `src/`, so it reads the descriptions you just wrote. A failure names the banned phrase — reword the description.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/website/src/components/shared/nav-config.ts apps/website/src/components/shared/nav-config.spec.ts
+git commit -m "feat(website): describe the navbar IA as data"
+```
+
+---
+
+## Task 2: The bar's surface state
+
+**Files:**
+- Create: `apps/website/src/components/shared/useNavSurface.ts`
+- Create: `apps/website/src/components/shared/useNavSurface.spec.tsx`
+
+A hook returning `'transparent' | 'solid'` plus the ref for the sentinel element that decides it. An IntersectionObserver, not a scroll listener: it is cheaper, and the in-app Browser pane suspends scroll events, which would make a listener-based version look broken during local verification when it is not.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `apps/website/src/components/shared/useNavSurface.spec.tsx`:
+
+```tsx
+// @vitest-environment jsdom
+import React from 'react';
+import { act, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useNavSurface } from './useNavSurface';
+
+let observerCallback: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
+const disconnect = vi.fn();
+
+function Probe({ pathname }: { pathname: string }) {
+ const { surface, sentinelRef } = useNavSurface(pathname);
+ return (
+ <>
+
+ {surface}
+ >
+ );
+}
+
+describe('useNavSurface', () => {
+ beforeEach(() => {
+ observerCallback = null;
+ disconnect.mockClear();
+ vi.stubGlobal(
+ 'IntersectionObserver',
+ class {
+ constructor(callback: (entries: { isIntersecting: boolean }[]) => void) {
+ observerCallback = callback;
+ }
+ observe() {}
+ disconnect = disconnect;
+ },
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('is transparent at rest on a hero route', () => {
+ render();
+ expect(screen.getByTestId('surface').textContent).toBe('transparent');
+ });
+
+ it('is solid on a route with no hero, and observes nothing there', () => {
+ render();
+ expect(screen.getByTestId('surface').textContent).toBe('solid');
+ expect(observerCallback).toBeNull();
+ });
+
+ it('solidifies once the sentinel scrolls out of view', () => {
+ render();
+ act(() => observerCallback?.([{ isIntersecting: false }]));
+ expect(screen.getByTestId('surface').textContent).toBe('solid');
+ });
+
+ it('goes transparent again when the sentinel returns', () => {
+ render();
+ act(() => observerCallback?.([{ isIntersecting: false }]));
+ act(() => observerCallback?.([{ isIntersecting: true }]));
+ expect(screen.getByTestId('surface').textContent).toBe('transparent');
+ });
+
+ it('stays solid without an IntersectionObserver rather than flashing transparent', () => {
+ vi.stubGlobal('IntersectionObserver', undefined);
+ render();
+ // Server render and very old browsers land here. A hero route with no way
+ // to detect scrolling must not sit transparent forever once scrolled.
+ expect(screen.getByTestId('surface').textContent).toBe('solid');
+ });
+
+ it('disconnects on unmount', () => {
+ const view = render();
+ view.unmount();
+ expect(disconnect).toHaveBeenCalledOnce();
+ });
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `npx nx test website -- --run src/components/shared/useNavSurface.spec.tsx`
+
+Expected: FAIL — `Failed to resolve import "./useNavSurface"`.
+
+- [ ] **Step 3: Write the hook**
+
+Create `apps/website/src/components/shared/useNavSurface.ts`:
+
+```ts
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { HERO_ROUTES } from './nav-config';
+
+export type NavSurface = 'transparent' | 'solid';
+
+/**
+ * Whether the bar renders over the page or on its own white ground.
+ *
+ * The trigger is an IntersectionObserver on a sentinel at the top of the
+ * document rather than a scroll listener: it is cheaper, and the in-app
+ * Browser pane suspends scroll events, so a listener-based version looks
+ * broken during local verification when it is not. Either way this state has
+ * to be confirmed in a real browser window.
+ */
+export function useNavSurface(pathname: string): {
+ surface: NavSurface;
+ sentinelRef: React.RefObject;
+} {
+ const isHeroRoute = HERO_ROUTES.includes(pathname);
+ const sentinelRef = useRef(null);
+ // Starts false so a hero route with no observer renders solid rather than
+ // sitting transparent over scrolled content forever.
+ const [atTop, setAtTop] = useState(false);
+
+ useEffect(() => {
+ if (!isHeroRoute) {
+ setAtTop(false);
+ return undefined;
+ }
+ const sentinel = sentinelRef.current;
+ if (!sentinel || typeof IntersectionObserver !== 'function') return undefined;
+
+ setAtTop(true);
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries.at(-1);
+ if (entry) setAtTop(entry.isIntersecting);
+ },
+ { threshold: 0 },
+ );
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [isHeroRoute, pathname]);
+
+ return { surface: isHeroRoute && atTop ? 'transparent' : 'solid', sentinelRef };
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `npx nx test website -- --run src/components/shared/useNavSurface.spec.tsx`
+
+Expected: PASS, 6 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/website/src/components/shared/useNavSurface.ts apps/website/src/components/shared/useNavSurface.spec.tsx
+git commit -m "feat(website): derive the nav bar surface from route and scroll"
+```
+
+---
+
+## Task 3: Extract the desktop bar, unchanged
+
+**Files:**
+- Create: `apps/website/src/components/shared/NavDesktop.tsx`
+- Modify: `apps/website/src/components/shared/Nav.tsx`
+
+A pure move. No behavior changes, so every existing test must still pass — that is what proves the move was clean. Task 4 changes what it renders.
+
+- [ ] **Step 1: Move the desktop markup into a new component**
+
+Create `apps/website/src/components/shared/NavDesktop.tsx`. Move these exact ranges out of `Nav.tsx` **without editing a character of them** — this task's only proof of correctness is that the existing tests still pass, so any "while I'm here" change destroys that proof:
+
+| Lines in `Nav.tsx` | What |
+| --- | --- |
+| 18–22 | the `links` array |
+| 69–118 | `function DemoDropdown()` |
+| 247–270 | `const trackNavLink = …` (becomes an exported `function`) |
+| 285–345 | the `` block under `{/* Desktop links */}` |
+
+`MenuIcon` (37–51) and `CloseIcon` (53–67) stay put for now — Task 7 moves them with the drawer.
+
+```tsx
+'use client';
+
+import Link from 'next/link';
+import { useEffect, useRef, useState } from 'react';
+import {
+ trackCtaClick,
+ trackExternalLinkClick,
+} from '../../lib/analytics/client';
+import { Button } from '../ui/Button';
+import { GitHubIcon } from '../ui/GitHubIcon';
+import { GITHUB_REPO_URL } from '../../lib/positioning';
+import { DEMOS, demoCtaSuffix } from '../../lib/demos';
+
+export const links = [
+ { label: 'Pilot to Prod', href: '/pilot-to-prod', external: false },
+ { label: 'Docs', href: '/docs', external: false },
+ { label: 'Pricing', href: '/pricing', external: false },
+];
+
+export function trackNavLink(
+ label: string,
+ href: string,
+ external: boolean,
+ surface: 'nav' | 'mobile_nav',
+) {
+ const slug = label
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_|_$/g, '');
+ const ctaId: `nav_${string}` | `mobile_nav_${string}` =
+ surface === 'nav' ? `nav_${slug}` : `mobile_nav_${slug}`;
+ if (external) {
+ trackExternalLinkClick(href, { surface, cta_id: ctaId, cta_text: label });
+ return;
+ }
+ trackCtaClick({
+ surface,
+ destination_url: href,
+ cta_id: ctaId,
+ cta_text: label,
+ });
+}
+
+// ... DemoDropdown moved here verbatim from Nav.tsx ...
+
+export function NavDesktop() {
+ // The
block from Nav.tsx,
+ // moved verbatim.
+}
+```
+
+- [ ] **Step 2: Consume it from `Nav.tsx`**
+
+In `Nav.tsx`, delete the moved code and replace the desktop `
` with `
`, importing `links` and `trackNavLink` from `./NavDesktop` for the mobile list that still uses them.
+
+- [ ] **Step 3: Run the existing suite to verify nothing changed**
+
+Run: `npx nx test website -- --run src/components/shared/Nav.spec.tsx`
+
+Expected: PASS, unchanged. A failure here means the move was not verbatim — fix the move, do not edit the test.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add apps/website/src/components/shared/NavDesktop.tsx apps/website/src/components/shared/Nav.tsx
+git commit -m "refactor(website): extract the desktop nav row into its own component"
+```
+
+---
+
+## Task 4: Desktop triggers and panels
+
+**Files:**
+- Modify: `apps/website/src/components/shared/NavDesktop.tsx`
+- Modify: `apps/website/src/components/shared/Nav.spec.tsx:106-152` (the `retires Examples from desktop navigation…` test)
+- Modify: `apps/website/src/styles/chrome.css`
+
+- [ ] **Step 1: Write the failing test**
+
+In `apps/website/src/components/shared/Nav.spec.tsx`, **replace** `retires Examples from desktop navigation without changing primary destinations or demos` with:
+
+```tsx
+ it('opens a panel per trigger and links each library from it', () => {
+ pathnameRef.current = '/';
+ render(
);
+ const navigation = screen.getByRole('navigation');
+
+ const libraries = within(navigation).getByRole('button', { name: 'Libraries' });
+ expect(libraries.getAttribute('aria-expanded')).toBe('false');
+ fireEvent.click(libraries);
+ expect(libraries.getAttribute('aria-expanded')).toBe('true');
+
+ const panel = document.getElementById(
+ libraries.getAttribute('aria-controls') ?? '',
+ );
+ if (!panel) throw new Error('Expected the trigger to control a panel');
+ expect(
+ within(panel).getByRole('link', { name: /@threadplane\/langgraph/ }).getAttribute('href'),
+ ).toBe('/langgraph');
+ expect(
+ within(panel).getByRole('link', { name: /@threadplane\/render/ }).getAttribute('href'),
+ ).toBe('/render');
+ expect(
+ within(panel).getByRole('link', { name: /Choosing an adapter/ }).getAttribute('href'),
+ ).toBe('/docs/choosing-an-adapter');
+ });
+
+ it('keeps Pricing a plain link and retires the Demo dropdown', () => {
+ pathnameRef.current = '/';
+ render(
);
+ const navigation = screen.getByRole('navigation');
+
+ expect(
+ within(navigation).getByRole('link', { name: 'Pricing' }).getAttribute('href'),
+ ).toBe('/pricing');
+ expect(within(navigation).queryByRole('button', { name: /^Demo/ })).toBeNull();
+
+ fireEvent.click(within(navigation).getByRole('button', { name: 'Docs' }));
+ expect(
+ screen.getByRole('link', { name: /LangGraph demo/ }).getAttribute('href'),
+ ).toBe('https://demo.threadplane.ai');
+ expect(
+ screen.getByRole('link', { name: /AG-UI demo/ }).getAttribute('href'),
+ ).toBe('https://ag-ui.threadplane.ai');
+ });
+
+ it('shows one panel at a time and closes on Escape, restoring trigger focus', () => {
+ pathnameRef.current = '/';
+ render(
);
+ const navigation = screen.getByRole('navigation');
+ const libraries = within(navigation).getByRole('button', { name: 'Libraries' });
+ const solutions = within(navigation).getByRole('button', { name: 'Solutions' });
+
+ fireEvent.click(libraries);
+ fireEvent.click(solutions);
+ expect(libraries.getAttribute('aria-expanded')).toBe('false');
+ expect(solutions.getAttribute('aria-expanded')).toBe('true');
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(solutions.getAttribute('aria-expanded')).toBe('false');
+ expect(document.activeElement).toBe(solutions);
+ });
+
+ it('tags panel link analytics with the trigger it came from', () => {
+ pathnameRef.current = '/';
+ render(
);
+ const navigation = screen.getByRole('navigation');
+ fireEvent.click(within(navigation).getByRole('button', { name: 'Solutions' }));
+ fireEvent.click(screen.getByRole('link', { name: /Blog/ }));
+
+ expect(trackCtaClick).toHaveBeenCalledWith({
+ surface: 'nav',
+ destination_url: '/blog',
+ cta_id: 'nav_solutions_blog',
+ cta_text: 'Blog',
+ });
+ });
+
+ it('still links the repository from the bar', () => {
+ pathnameRef.current = '/';
+ render(
);
+ expect(
+ within(screen.getByRole('navigation'))
+ .getByRole('link', { name: 'GitHub repository' })
+ .getAttribute('href'),
+ ).toBe('https://github.com/cacheplane/angular-agent-framework');
+ });
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `npx nx test website -- --run src/components/shared/Nav.spec.tsx`
+
+Expected: FAIL — `Unable to find role="button" and name "Libraries"`.
+
+- [ ] **Step 3: Rewrite `NavDesktop.tsx` to render from the config**
+
+In `apps/website/src/components/shared/NavDesktop.tsx`, **delete `DemoDropdown`** (nothing references it after this task, and an unused non-exported function fails lint) and replace the `NavDesktop` component. **Keep the exported `links` array and `trackNavLink`** — the mobile drawer still imports them until Task 8, and Task 9 deletes them.
+
+```tsx
+'use client';
+
+import Link from 'next/link';
+import { useCallback, useEffect, useId, useRef, useState } from 'react';
+import { ChevronDown } from 'lucide-react';
+import {
+ trackCtaClick,
+ trackExternalLinkClick,
+} from '../../lib/analytics/client';
+import { Button } from '../ui/Button';
+import { GitHubIcon } from '../ui/GitHubIcon';
+import { GITHUB_REPO_URL } from '../../lib/positioning';
+import { LibraryMark } from '../docs/LibraryMark';
+import { NAV_TRIGGERS, type NavItem, type NavPanel } from './nav-config';
+
+/** Matches the docs sidebar's grace: long enough to cross the gap diagonally. */
+const OPEN_DELAY_MS = 100;
+const CLOSE_DELAY_MS = 150;
+
+export function trackNavItem(item: NavItem, surface: 'nav' | 'mobile_nav') {
+ // Annotated, not inferred: a bare template literal widens to `string`, which
+ // is not assignable to CtaId (`nav_${string}` | `mobile_nav_${string}`).
+ // Nothing but `nx build website` catches that.
+ const ctaId: `nav_${string}` | `mobile_nav_${string}` =
+ surface === 'nav' ? `nav_${item.ctaId}` : `mobile_nav_${item.ctaId}`;
+ if (item.external) {
+ trackExternalLinkClick(item.href, {
+ surface,
+ cta_id: ctaId,
+ cta_text: item.label,
+ });
+ return;
+ }
+ trackCtaClick({
+ surface,
+ destination_url: item.href,
+ cta_id: ctaId,
+ cta_text: item.label,
+ });
+}
+
+export function NavPanelItem({
+ item,
+ surface,
+ onNavigate,
+}: {
+ item: NavItem;
+ surface: 'nav' | 'mobile_nav';
+ onNavigate?: () => void;
+}) {
+ const Icon = item.icon;
+ const body = (
+ <>
+
+ {item.library ? (
+
+ ) : Icon ? (
+
+ ) : null}
+
+
+ {item.label}
+ {item.description}
+
+ >
+ );
+ const onClick = () => {
+ trackNavItem(item, surface);
+ onNavigate?.();
+ };
+
+ if (item.external) {
+ return (
+
+ {body}
+
+ );
+ }
+ return (
+
+ {body}
+
+ );
+}
+
+function Panel({ panel, id }: { panel: NavPanel; id: string }) {
+ return (
+
+
+ {panel.columns.map((column, index) => (
+
+ {column.heading ? (
+ {column.heading}
+ ) : null}
+ {column.items.map((item) => (
+
+ ))}
+
+ ))}
+
+ {panel.footer ? (
+
+ {panel.footer.lead}
+
+
+ ) : null}
+
+ );
+}
+
+export function NavDesktop() {
+ const [openId, setOpenId] = useState
(null);
+ const panelPrefix = useId();
+ const triggerRefs = useRef(new Map());
+ const openTimer = useRef(null);
+ const closeTimer = useRef(null);
+
+ const clearTimers = useCallback(() => {
+ if (openTimer.current !== null) window.clearTimeout(openTimer.current);
+ if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
+ openTimer.current = null;
+ closeTimer.current = null;
+ }, []);
+
+ useEffect(() => clearTimers, [clearTimers]);
+
+ useEffect(() => {
+ if (!openId) return undefined;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return;
+ event.preventDefault();
+ clearTimers();
+ triggerRefs.current.get(openId)?.focus();
+ setOpenId(null);
+ };
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [clearTimers, openId]);
+
+ const scheduleOpen = (id: string) => {
+ clearTimers();
+ openTimer.current = window.setTimeout(() => setOpenId(id), OPEN_DELAY_MS);
+ };
+ const scheduleClose = () => {
+ clearTimers();
+ closeTimer.current = window.setTimeout(() => setOpenId(null), CLOSE_DELAY_MS);
+ };
+
+ const panelId = (id: string) => `${panelPrefix}-${id}`;
+
+ return (
+
+ {NAV_TRIGGERS.map((trigger) =>
+ trigger.kind === 'link' ? (
+
{
+ clearTimers();
+ setOpenId(null);
+ }}
+ onClick={() =>
+ trackCtaClick({
+ surface: 'nav',
+ destination_url: trigger.href,
+ cta_id: `nav_${trigger.ctaId}`,
+ cta_text: trigger.label,
+ })
+ }
+ className="text-sm font-mono transition-colors nav-link"
+ >
+ {trigger.label}
+
+ ) : (
+
+ ),
+ )}
+
+
+ trackExternalLinkClick(GITHUB_REPO_URL, {
+ surface: 'nav',
+ cta_id: 'nav_github',
+ cta_text: 'GitHub',
+ })
+ }
+ className="transition-colors nav-link"
+ aria-label="GitHub repository"
+ >
+
+
+
+
+ {NAV_TRIGGERS.filter((trigger) => trigger.kind === 'panel').map((trigger) =>
+ trigger.kind === 'panel' && openId === trigger.id ? (
+
+ ) : null,
+ )}
+
+ );
+}
+```
+
+Note that `NavPanelItem` and `trackNavItem` are **exported**: Task 8's mobile levels render the same items through the same component, which is what keeps the two surfaces from drifting.
+
+- [ ] **Step 4: Add the panel styling**
+
+Append to `apps/website/src/styles/chrome.css`, **outside any `@layer`**:
+
+```css
+/* Nav panels
+ *
+ * No borders and no dividers anywhere: the hovered item separates itself with a
+ * soft shadow on white. Yellow is fill-only (1.84:1 on white) so it appears
+ * here only as the footer arrow chip — never as text, never as a panel wash. */
+.nav-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+.nav-trigger-caret {
+ color: var(--color-text-muted);
+ transition: transform 0.18s;
+}
+.nav-trigger-caret[data-open] {
+ transform: rotate(180deg);
+}
+.nav-panel-shell {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ z-index: 60;
+}
+.nav-panel {
+ background: var(--color-surface-tinted, #fafafa);
+ border-radius: 12px;
+ box-shadow: 0 1px 3px rgba(10, 10, 10, 0.08), 0 8px 24px rgba(10, 10, 10, 0.06);
+ padding: 28px 24px 24px;
+}
+.nav-panel-cols {
+ display: grid;
+ gap: 26px;
+}
+.nav-panel[data-columns='2'] .nav-panel-cols {
+ grid-template-columns: 1fr 1fr;
+}
+.nav-panel[data-columns='3'] .nav-panel-cols {
+ grid-template-columns: repeat(3, 1fr);
+}
+.nav-panel[data-columns='1'] .nav-panel-cols {
+ grid-template-columns: repeat(4, 1fr);
+ gap: 6px;
+}
+.nav-panel-col-head {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+ margin: 0 0 12px 13px;
+}
+.nav-panel-item {
+ display: flex;
+ gap: 11px;
+ align-items: flex-start;
+ padding: 12px 13px;
+ border-radius: 10px;
+ text-decoration: none;
+ transition: background 0.15s, box-shadow 0.15s;
+}
+.nav-panel-item:hover,
+.nav-panel-item:focus-visible {
+ background: var(--color-surface);
+ box-shadow: 0 1px 2px rgba(10, 10, 10, 0.06), 0 6px 16px rgba(10, 10, 10, 0.05);
+}
+.nav-panel-item-chip {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ background: rgba(21, 37, 62, 0.07);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-accent);
+ flex: none;
+}
+.nav-panel-item-label {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.3;
+ color: var(--color-accent);
+}
+.nav-panel-item-desc {
+ display: block;
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--color-text-muted);
+ margin-top: 4px;
+}
+.nav-panel-footer {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 18px;
+}
+.nav-panel-footer-lead {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--color-text-muted);
+ padding-left: 13px;
+}
+.nav-panel-footer .nav-panel-item-chip {
+ background: var(--color-signal, #ffaf00);
+ color: var(--color-text-primary);
+}
+```
+
+The panel shell is absolutely positioned against the nav row, so give the row a positioning context:
+
+```css
+.nav-desktop {
+ position: static;
+}
+.nav-bar > div {
+ position: relative;
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `npx nx test website -- --run src/components/shared/Nav.spec.tsx`
+
+Expected: PASS. The four mobile tests listed in the orientation table still fail if you touched the mobile half — you should not have.
+
+- [ ] **Step 6: Verify in a real browser**
+
+Start the preview and check the panels open on hover, close on mouse-out, and that the caret rotates:
+
+```bash
+npx nx serve website
+```
+
+Visit `http://localhost:3000`, hover `Libraries`, `Docs`, and `Solutions`. Confirm no borders or dividers inside the panel and that the only yellow is the footer arrow chip and the `Talk to Us` button.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/website/src/components/shared/NavDesktop.tsx apps/website/src/components/shared/Nav.spec.tsx apps/website/src/styles/chrome.css
+git commit -m "feat(website): render the desktop nav as four triggers with hover panels"
+```
+
+---
+
+## Task 5: The transparent bar
+
+**Files:**
+- Modify: `apps/website/src/components/shared/Nav.tsx`
+- Modify: `apps/website/src/styles/chrome.css`
+- Create: `apps/website/e2e/nav-surface.spec.ts`
+
+- [ ] **Step 1: Wire the hook and render the sentinel**
+
+In `apps/website/src/components/shared/Nav.tsx`, call the hook and stamp the result on the bar. The sentinel is rendered **outside** the fixed nav so that it scrolls; `body` is its containing block, so `top: 0` means the top of the document.
+
+```tsx
+const { surface, sentinelRef } = useNavSurface(pathname);
+```
+
+```tsx
+
+