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={ - - Cockpit reference app — Angular streaming guide with provideAgent setup - - } + 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 ( + + ); + })} +
+ +
+ {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 } }); + } +}`, + }, + ], + }, +};