diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx
index 763af60e6..ec04a486f 100644
--- a/apps/website/src/app/page.tsx
+++ b/apps/website/src/app/page.tsx
@@ -3,8 +3,12 @@ import { EcosystemStrip } from '../components/landing/EcosystemStrip';
import { Differentiator } from '../components/landing/Differentiator';
import { FeatureBlock } from '../components/landing/FeatureBlock';
import { BrowserFrame } from '../components/ui/BrowserFrame';
-import { HITL_CLIP } from '../lib/demo-media';
import { DemoShowcase } from '../components/landing/DemoShowcase';
+import { MediumSwitcher } from '../components/landing/MediumSwitcher';
+import type { MediumPane } from '../components/landing/MediumSwitcher';
+import { HighlightedCode } from '../components/landing/HighlightedCode';
+import { SECTION_MEDIA } from '../lib/section-media';
+import type { SectionMedia } from '../lib/section-media';
import { PilotBlock } from '../components/landing/PilotBlock';
import { WhitePaperBlock } from '../components/landing/WhitePaperBlock';
import { Promises } from '../components/landing/Promises';
@@ -23,7 +27,66 @@ export const metadata = createPageMetadata({
type: 'website',
});
+/**
+ * Builds the panes for a section on the SERVER.
+ *
+ * `HighlightedCode` is an async Server Component, so it cannot be rendered from
+ * inside the client `MediumSwitcher`. Highlighting here and passing the result
+ * as a prop is what makes the code tab possible at all.
+ */
+async function buildPanes(media: SectionMedia, clipUrl: string): Promise {
+ // Typed, not inferred: `const panes = []` is `any[]` under this tsconfig and
+ // fails the production build's type check.
+ const panes: MediumPane[] = [];
+
+ if (media.video) {
+ const clip = media.video;
+ panes.push({
+ id: 'video',
+ key: 'video',
+ label: 'Video',
+ content: (
+
+
+
+
+
+
+
+
+ ),
+ });
+ }
+
+ const codeBlocks = media.code ?? [];
+ codeBlocks.forEach((block, index) => {
+ panes.push({
+ id: `code-${index}`,
+ key: 'code',
+ label: codeBlocks.length > 1 ? block.label : 'Code',
+ content: (
+
+
+
+ ),
+ });
+ });
+
+ return panes;
+}
+
export default async function HomePage() {
+ const streamPanes = await buildPanes(SECTION_MEDIA.stream, SECTION_MEDIA.stream.video?.url ?? '');
+ const approvePanes = await buildPanes(SECTION_MEDIA.approve, SECTION_MEDIA.approve.video?.url ?? '');
+
return (
<>
@@ -59,17 +122,7 @@ export default async function HomePage() {
{ title: '@threadplane/langgraph', description: 'Native LangGraph streaming.' },
]}
cta={{ label: 'Read the streaming guide', href: '/docs/langgraph/guides/streaming' }}
- visual={
-
-
-
- }
+ visual={ }
/>
{/* Render */}
@@ -167,24 +220,7 @@ export default async function HomePage() {
]}
cta={{ label: 'Interrupt patterns', href: '/docs/langgraph/guides/interrupts' }}
visualLeft
- visual={
-
-
-
-
-
-
-
-
- }
+ visual={ }
/>
diff --git a/apps/website/src/components/landing/MediumSwitcher.spec.tsx b/apps/website/src/components/landing/MediumSwitcher.spec.tsx
new file mode 100644
index 000000000..94091dafe
--- /dev/null
+++ b/apps/website/src/components/landing/MediumSwitcher.spec.tsx
@@ -0,0 +1,123 @@
+// SPDX-License-Identifier: MIT
+// @vitest-environment jsdom
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MediumSwitcher } from './MediumSwitcher';
+
+const trackCtaClickMock = vi.hoisted(() => vi.fn());
+vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: trackCtaClickMock }));
+
+describe('MediumSwitcher', () => {
+ it('renders a lone medium with no tablist', () => {
+ render(
+ the clip
}]}
+ />,
+ );
+
+ expect(screen.getByText('the clip')).toBeTruthy();
+ expect(screen.queryByRole('tablist')).toBeNull();
+ });
+ const twoPanes = [
+ { id: 'video', key: 'video' as const, label: 'Video', content: the clip
},
+ { id: 'code', key: 'code' as const, label: 'Code', content: the snippet
},
+ ];
+
+ it('exposes a tab per medium with the first selected', () => {
+ render( );
+
+ const tabs = screen.getAllByRole('tab');
+ expect(tabs).toHaveLength(2);
+ expect(tabs[0].getAttribute('aria-selected')).toBe('true');
+ expect(tabs[1].getAttribute('aria-selected')).toBe('false');
+ });
+
+ it('points each tab at the panel it controls', () => {
+ render( );
+
+ const tab = screen.getAllByRole('tab')[0];
+ const panel = screen.getByRole('tabpanel');
+ expect(tab.getAttribute('aria-controls')).toBe(panel.getAttribute('id'));
+ expect(panel.getAttribute('aria-labelledby')).toBe(tab.getAttribute('id'));
+ });
+
+ it('mounts only the active pane', () => {
+ render( );
+
+ // Not "hidden" — absent. A CSS-toggled implementation would still fetch the
+ // video and the iframe on page load, which is the cost this avoids.
+ expect(screen.getByText('the clip')).toBeTruthy();
+ expect(screen.queryByText('the snippet')).toBeNull();
+ });
+
+ it('swaps which pane is mounted when a tab is clicked', () => {
+ render( );
+
+ fireEvent.click(screen.getAllByRole('tab')[1]);
+
+ expect(screen.queryByText('the clip')).toBeNull();
+ expect(screen.getByText('the snippet')).toBeTruthy();
+ });
+
+ it('moves between tabs with arrow keys, wrapping at the ends', () => {
+ render( );
+ const tablist = screen.getByRole('tablist');
+
+ fireEvent.keyDown(tablist, { key: 'ArrowRight' });
+ expect(screen.getAllByRole('tab')[1].getAttribute('aria-selected')).toBe('true');
+ expect(document.activeElement).toBe(screen.getAllByRole('tab')[1]);
+
+ fireEvent.keyDown(tablist, { key: 'ArrowRight' });
+ expect(screen.getAllByRole('tab')[0].getAttribute('aria-selected')).toBe('true');
+
+ fireEvent.keyDown(tablist, { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('tab')[1].getAttribute('aria-selected')).toBe('true');
+ });
+
+ it('jumps to the first and last tab with Home and End', () => {
+ render( );
+ const tablist = screen.getByRole('tablist');
+
+ fireEvent.keyDown(tablist, { key: 'End' });
+ expect(screen.getAllByRole('tab')[1].getAttribute('aria-selected')).toBe('true');
+
+ fireEvent.keyDown(tablist, { key: 'Home' });
+ expect(screen.getAllByRole('tab')[0].getAttribute('aria-selected')).toBe('true');
+ });
+
+ it('reports the medium a reader switches to', () => {
+ trackCtaClickMock.mockClear();
+ render( );
+
+ fireEvent.click(screen.getAllByRole('tab')[1]);
+
+ expect(trackCtaClickMock).toHaveBeenCalledWith(
+ expect.objectContaining({ surface: 'home_medium_switcher', cta_id: 'medium_stream_code' }),
+ );
+ });
+
+ it('does not report the medium a reader never chose', () => {
+ trackCtaClickMock.mockClear();
+ render( );
+
+ // Rendering is not a choice; only an explicit switch is.
+ expect(trackCtaClickMock).not.toHaveBeenCalled();
+ });
+
+ it('keeps ids unique when a section has two code panes', () => {
+ render(
+ first },
+ { id: 'code-1', key: 'code', label: 'Template', content: second
},
+ ]}
+ />,
+ );
+
+ const ids = screen.getAllByRole('tab').map((t) => t.getAttribute('id'));
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
diff --git a/apps/website/src/components/landing/MediumSwitcher.tsx b/apps/website/src/components/landing/MediumSwitcher.tsx
new file mode 100644
index 000000000..36111dc25
--- /dev/null
+++ b/apps/website/src/components/landing/MediumSwitcher.tsx
@@ -0,0 +1,114 @@
+// SPDX-License-Identifier: MIT
+'use client';
+import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
+import { tokens } from '@threadplane/design-tokens';
+import { trackCtaClick } from '../../lib/analytics/client';
+
+export interface MediumPane {
+ /** Unique within a switcher — used for React keys and DOM ids. */
+ id: string;
+ /** Which medium this is; drives analytics, not identity. */
+ key: 'video' | 'code' | 'live';
+ label: string;
+ /**
+ * Pre-rendered content. Code panes are highlighted on the server and passed
+ * in, because `HighlightedCode` is an async Server Component and a client
+ * component cannot render one as a child.
+ */
+ content: ReactNode;
+}
+
+interface MediumSwitcherProps {
+ /** Used for tab/panel ids and the analytics `cta_id`. */
+ sectionId: string;
+ panes: MediumPane[];
+}
+
+export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) {
+ // Call sites pass a static `panes` array, so the index cannot go stale. If a
+ // caller ever makes a medium conditional, this needs a clamp.
+ const [active, setActive] = useState(0);
+ const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
+
+ // One medium needs no control surface; chrome around a single option is noise.
+ if (panes.length <= 1) {
+ return <>{panes[0]?.content ?? null}>;
+ }
+
+ const tabId = (id: string) => `${sectionId}-tab-${id}`;
+ const panelId = (id: string) => `${sectionId}-panel-${id}`;
+
+ const select = (index: number) => {
+ setActive(index);
+ trackCtaClick({
+ surface: 'home_medium_switcher',
+ cta_id: `medium_${sectionId}_${panes[index].key}`,
+ cta_text: panes[index].label,
+ });
+ };
+
+ const onKeyDown = (event: ReactKeyboardEvent) => {
+ const last = panes.length - 1;
+ let next: number;
+ if (event.key === 'ArrowRight') next = (active + 1) % panes.length;
+ else if (event.key === 'ArrowLeft') next = (active - 1 + panes.length) % panes.length;
+ else if (event.key === 'Home') next = 0;
+ else if (event.key === 'End') next = last;
+ else return;
+
+ event.preventDefault();
+ select(next);
+ tabRefs.current[next]?.focus();
+ };
+
+ return (
+
+
+ {panes.map((pane, index) => {
+ const selected = index === active;
+ return (
+ {
+ tabRefs.current[index] = el;
+ }}
+ id={tabId(pane.id)}
+ role="tab"
+ type="button"
+ aria-selected={selected}
+ aria-controls={panelId(pane.id)}
+ tabIndex={selected ? 0 : -1}
+ onClick={() => select(index)}
+ style={{
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 13,
+ fontWeight: 600,
+ padding: '8px 14px',
+ borderRadius: 8,
+ border: 'none',
+ cursor: 'pointer',
+ background: selected ? tokens.colors.accent : tokens.colors.accentSurface,
+ color: selected ? tokens.colors.textInverted : tokens.colors.textMuted,
+ }}
+ >
+ {pane.label}
+
+ );
+ })}
+
+
+
+ {panes[active].content}
+
+
+ );
+}
diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts
index 26621fde9..b390c50a9 100644
--- a/apps/website/src/lib/analytics/events.ts
+++ b/apps/website/src/lib/analytics/events.ts
@@ -35,6 +35,7 @@ export type AnalyticsSurface =
| 'home'
| 'home_demo'
| 'home_whitepaper'
+ | 'home_medium_switcher'
| 'pricing'
| 'docs'
| 'blog'
@@ -87,7 +88,9 @@ export type CtaId =
| `footer_${string}`
// Landing section CTAs derive ids from surface + demo key at runtime
| `final_cta_${string}`
- | `home_demo_${string}`;
+ | `home_demo_${string}`
+ // MediumSwitcher derives ids from section id + medium key at runtime
+ | `medium_${string}`;
export type AnalyticsLibrary = 'langgraph' | 'render' | 'chat' | 'ag-ui' | 'unknown';
diff --git a/apps/website/src/lib/demo-media.ts b/apps/website/src/lib/demo-media.ts
index 290df04ec..7230d9349 100644
--- a/apps/website/src/lib/demo-media.ts
+++ b/apps/website/src/lib/demo-media.ts
@@ -38,3 +38,16 @@ export const HITL_CLIP: DemoClip = {
videoWebm: `${DEMO_CDN}/hitl-demo.webm`,
poster: `${DEMO_CDN}/hitl-demo-poster.webp`,
};
+
+/**
+ * The LangGraph streaming demo, recorded on the canonical demo shell. Exported
+ * so the section switcher does not add another hardcoded copy of these URLs.
+ * `DemoShowcase` still declares its own; consolidating it is tracked separately.
+ */
+export const LANGGRAPH_CLIP: DemoClip = {
+ caption: 'Tokens stream into the Angular surface as the agent produces them.',
+ url: 'demo.threadplane.ai',
+ videoMp4: `${DEMO_CDN}/langgraph-demo.mp4`,
+ videoWebm: `${DEMO_CDN}/langgraph-demo.webm`,
+ poster: `${DEMO_CDN}/langgraph-demo-poster.webp`,
+};
diff --git a/apps/website/src/lib/section-media.spec.ts b/apps/website/src/lib/section-media.spec.ts
new file mode 100644
index 000000000..94f33b064
--- /dev/null
+++ b/apps/website/src/lib/section-media.spec.ts
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: MIT
+import { describe, expect, it } from 'vitest';
+import { SECTION_MEDIA } from './section-media';
+import { DEMO_CDN } from './demo-media';
+
+describe('SECTION_MEDIA', () => {
+ it('declares at least one medium for every section', () => {
+ for (const [key, media] of Object.entries(SECTION_MEDIA)) {
+ const count = [media.video, media.code, media.live].filter(Boolean).length;
+ expect(count, key).toBeGreaterThan(0);
+ }
+ });
+
+ it('serves every video through the shared blob base', () => {
+ for (const [key, media] of Object.entries(SECTION_MEDIA)) {
+ if (!media.video) continue;
+ for (const url of [media.video.videoMp4, media.video.videoWebm, media.video.poster]) {
+ expect(url, key).toContain(DEMO_CDN);
+ }
+ }
+ });
+
+ it('gives every code pane a label and real source', () => {
+ for (const [key, media] of Object.entries(SECTION_MEDIA)) {
+ for (const block of media.code ?? []) {
+ expect(block.label.trim().length, key).toBeGreaterThan(0);
+ expect(block.source.trim().length, key).toBeGreaterThan(40);
+ expect(block.source, key).not.toMatch(/TODO|FIXME/);
+ }
+ }
+ });
+});
diff --git a/apps/website/src/lib/section-media.ts b/apps/website/src/lib/section-media.ts
new file mode 100644
index 000000000..177cded3d
--- /dev/null
+++ b/apps/website/src/lib/section-media.ts
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: MIT
+import { HITL_CLIP, LANGGRAPH_CLIP, type DemoClip } from './demo-media';
+import type { SolutionCodeBlocks } from './solutions-data';
+
+/**
+ * What a homepage section can show. Every medium is optional and that is
+ * load-bearing: a section with one medium renders bare, with no tablist, and
+ * sections gain tabs as media is produced rather than blocking on a recording
+ * that does not exist yet.
+ */
+export interface SectionMedia {
+ video?: DemoClip;
+ code?: SolutionCodeBlocks;
+ /** Phase 2. Declared now so the switcher's shape does not change later. */
+ live?: { prompt: string; mode?: 'embed' | 'popup' | 'sidebar' };
+}
+
+export const SECTION_MEDIA: Record<'stream' | 'approve', SectionMedia> = {
+ stream: {
+ video: LANGGRAPH_CLIP,
+ code: [
+ {
+ label: 'chat.component.ts — headless streaming',
+ language: 'typescript',
+ source: `export class ChatPageComponent {
+ protected readonly agent = injectAgent();
+
+ // Signals, not callbacks: the template re-renders as tokens arrive.
+ readonly messages = computed(() => this.agent.messages());
+ readonly isLoading = computed(() => this.agent.isLoading());
+
+ send(message: string) {
+ this.agent.submit({ message });
+ }
+}`,
+ },
+ ],
+ },
+ approve: {
+ video: HITL_CLIP,
+ code: [
+ {
+ label: 'approval.component.ts — the gate',
+ language: 'typescript',
+ source: `export class ApprovalComponent {
+ protected readonly agent = injectAgent(APPROVAL_AGENT);
+
+ // Non-null only while the graph is paused on an interrupt.
+ readonly pendingApproval = computed(() => this.agent.interrupt());
+
+ approve() {
+ this.agent.submit({ resume: { approved: true } });
+ }
+
+ reject(reason: string) {
+ this.agent.submit({ resume: { approved: false, reason } });
+ }
+}`,
+ },
+ ],
+ },
+};