From ab9a2dff9e6347decb47b06dbfd49d9d6f0e5d82 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 26 Aug 2026 09:30:48 -0700 Subject: [PATCH 01/10] feat(website): declare per-section media for the homepage switcher --- apps/website/src/lib/demo-media.ts | 13 +++++ apps/website/src/lib/section-media.spec.ts | 32 +++++++++++ apps/website/src/lib/section-media.ts | 62 ++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 apps/website/src/lib/section-media.spec.ts create mode 100644 apps/website/src/lib/section-media.ts diff --git a/apps/website/src/lib/demo-media.ts b/apps/website/src/lib/demo-media.ts index 290df04ec..d3b03f626 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 + * rather than re-declared per consumer so the homepage showcase and the section + * switcher cannot drift apart. + */ +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 } }); + } +}`, + }, + ], + }, +}; From 0bfd48e1b448836707fe54c83a8df3f0e8227d44 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 26 Aug 2026 09:37:02 -0700 Subject: [PATCH 02/10] feat(website): MediumSwitcher renders a lone medium without a tablist --- .../landing/MediumSwitcher.spec.tsx | 22 +++++++++++++ .../src/components/landing/MediumSwitcher.tsx | 31 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 apps/website/src/components/landing/MediumSwitcher.spec.tsx create mode 100644 apps/website/src/components/landing/MediumSwitcher.tsx 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..b60f0e911 --- /dev/null +++ b/apps/website/src/components/landing/MediumSwitcher.spec.tsx @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MediumSwitcher } from './MediumSwitcher'; + +vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: vi.fn() })); + +describe('MediumSwitcher', () => { + it('renders a lone medium with no tablist', () => { + render( + the clip

}]} + />, + ); + + expect(screen.getByText('the clip')).toBeTruthy(); + expect(screen.queryByRole('tablist')).toBeNull(); + }); +}); diff --git a/apps/website/src/components/landing/MediumSwitcher.tsx b/apps/website/src/components/landing/MediumSwitcher.tsx new file mode 100644 index 000000000..ea141e9a6 --- /dev/null +++ b/apps/website/src/components/landing/MediumSwitcher.tsx @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +'use client'; +import { useState, type ReactNode } from 'react'; + +export interface MediumPane { + 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) { + const [active, setActive] = useState(0); + + // One medium needs no control surface; chrome around a single option is noise. + if (panes.length <= 1) { + return <>{panes[0]?.content ?? null}; + } + + return <>{panes[active].content}; +} From 1d8be51f7e5b23823ba53a1f1adb1ca06dc86da2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 26 Aug 2026 09:37:35 -0700 Subject: [PATCH 03/10] feat(website): give MediumSwitcher a real tablist --- .../landing/MediumSwitcher.spec.tsx | 22 +++++++++ .../src/components/landing/MediumSwitcher.tsx | 47 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/website/src/components/landing/MediumSwitcher.spec.tsx b/apps/website/src/components/landing/MediumSwitcher.spec.tsx index b60f0e911..99df1e732 100644 --- a/apps/website/src/components/landing/MediumSwitcher.spec.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.spec.tsx @@ -19,4 +19,26 @@ describe('MediumSwitcher', () => { expect(screen.getByText('the clip')).toBeTruthy(); expect(screen.queryByRole('tablist')).toBeNull(); }); + const twoPanes = [ + { key: 'video' as const, label: 'Video', content:

the clip

}, + { 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')); + }); }); diff --git a/apps/website/src/components/landing/MediumSwitcher.tsx b/apps/website/src/components/landing/MediumSwitcher.tsx index ea141e9a6..cfb5bfddf 100644 --- a/apps/website/src/components/landing/MediumSwitcher.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT 'use client'; import { useState, type ReactNode } from 'react'; +import { tokens } from '@threadplane/design-tokens'; export interface MediumPane { key: 'video' | 'code' | 'live'; @@ -27,5 +28,49 @@ export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) { return <>{panes[0]?.content ?? null}; } - return <>{panes[active].content}; + const tabId = (key: string) => `${sectionId}-tab-${key}`; + const panelId = (key: string) => `${sectionId}-panel-${key}`; + + return ( +
+
+ {panes.map((pane, index) => { + const selected = index === active; + return ( + + ); + })} +
+ +
+ {panes[active].content} +
+
+ ); } From dcff7d63a28463b9180ce936b097dfd3a02b205f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 26 Aug 2026 09:38:07 -0700 Subject: [PATCH 04/10] test(website): pin active-pane-only mounting in MediumSwitcher --- .../landing/MediumSwitcher.spec.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/website/src/components/landing/MediumSwitcher.spec.tsx b/apps/website/src/components/landing/MediumSwitcher.spec.tsx index 99df1e732..c70b5e537 100644 --- a/apps/website/src/components/landing/MediumSwitcher.spec.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.spec.tsx @@ -2,7 +2,7 @@ // @vitest-environment jsdom import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MediumSwitcher } from './MediumSwitcher'; vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: vi.fn() })); @@ -41,4 +41,22 @@ describe('MediumSwitcher', () => { 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(); + }); }); From dee21f64a557ee9d9f15cc320b43be60b8ee8685 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 26 Aug 2026 09:38:31 -0700 Subject: [PATCH 05/10] feat(website): arrow-key navigation for MediumSwitcher tabs --- .../components/landing/MediumSwitcher.spec.tsx | 14 ++++++++++++++ .../src/components/landing/MediumSwitcher.tsx | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/website/src/components/landing/MediumSwitcher.spec.tsx b/apps/website/src/components/landing/MediumSwitcher.spec.tsx index c70b5e537..f2e1ffef9 100644 --- a/apps/website/src/components/landing/MediumSwitcher.spec.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.spec.tsx @@ -59,4 +59,18 @@ describe('MediumSwitcher', () => { 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'); + + 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'); + }); }); diff --git a/apps/website/src/components/landing/MediumSwitcher.tsx b/apps/website/src/components/landing/MediumSwitcher.tsx index cfb5bfddf..685e59051 100644 --- a/apps/website/src/components/landing/MediumSwitcher.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT 'use client'; -import { useState, type ReactNode } from 'react'; +import { useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'; import { tokens } from '@threadplane/design-tokens'; export interface MediumPane { @@ -31,9 +31,21 @@ export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) { const tabId = (key: string) => `${sectionId}-tab-${key}`; const panelId = (key: string) => `${sectionId}-panel-${key}`; + const onKeyDown = (event: ReactKeyboardEvent) => { + if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return; + event.preventDefault(); + const delta = event.key === 'ArrowRight' ? 1 : -1; + setActive((current) => (current + delta + panes.length) % panes.length); + }; + return (
-
+
{panes.map((pane, index) => { const selected = index === active; return ( From 8e4847488b5276a285df03a4e3fded76baed24e5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 26 Aug 2026 09:39:12 -0700 Subject: [PATCH 06/10] feat(website): track which medium readers choose --- .../landing/MediumSwitcher.spec.tsx | 22 ++++++++++++++++++- .../src/components/landing/MediumSwitcher.tsx | 15 +++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/website/src/components/landing/MediumSwitcher.spec.tsx b/apps/website/src/components/landing/MediumSwitcher.spec.tsx index f2e1ffef9..671a5ed26 100644 --- a/apps/website/src/components/landing/MediumSwitcher.spec.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.spec.tsx @@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { MediumSwitcher } from './MediumSwitcher'; -vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: vi.fn() })); +const trackCtaClickMock = vi.hoisted(() => vi.fn()); +vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: trackCtaClickMock })); describe('MediumSwitcher', () => { it('renders a lone medium with no tablist', () => { @@ -73,4 +74,23 @@ describe('MediumSwitcher', () => { fireEvent.keyDown(tablist, { key: 'ArrowLeft' }); expect(screen.getAllByRole('tab')[1].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: '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(); + }); }); diff --git a/apps/website/src/components/landing/MediumSwitcher.tsx b/apps/website/src/components/landing/MediumSwitcher.tsx index 685e59051..ceba99756 100644 --- a/apps/website/src/components/landing/MediumSwitcher.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.tsx @@ -2,6 +2,7 @@ 'use client'; import { useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'; import { tokens } from '@threadplane/design-tokens'; +import { trackCtaClick } from '../../lib/analytics/client'; export interface MediumPane { key: 'video' | 'code' | 'live'; @@ -31,11 +32,21 @@ export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) { const tabId = (key: string) => `${sectionId}-tab-${key}`; const panelId = (key: string) => `${sectionId}-panel-${key}`; + const select = (index: number) => { + setActive(index); + trackCtaClick({ + surface: 'home_medium_switcher', + cta_id: `${sectionId}_${panes[index].key}`, + cta_text: panes[index].label, + }); + }; + const onKeyDown = (event: ReactKeyboardEvent) => { if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return; event.preventDefault(); const delta = event.key === 'ArrowRight' ? 1 : -1; - setActive((current) => (current + delta + panes.length) % panes.length); + const next = (active + delta + panes.length) % panes.length; + select(next); }; return ( @@ -57,7 +68,7 @@ export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) { aria-selected={selected} aria-controls={panelId(pane.key)} tabIndex={selected ? 0 : -1} - onClick={() => setActive(index)} + onClick={() => select(index)} style={{ fontFamily: 'Inter, sans-serif', fontSize: 13, From 8343a8b6e332e9cf3dde019202d6ee5ab965441b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 26 Aug 2026 09:42:55 -0700 Subject: [PATCH 07/10] fix(website): move focus with arrow-key tab selection --- .../landing/MediumSwitcher.spec.tsx | 12 +++++++++++ .../src/components/landing/MediumSwitcher.tsx | 20 +++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/website/src/components/landing/MediumSwitcher.spec.tsx b/apps/website/src/components/landing/MediumSwitcher.spec.tsx index 671a5ed26..01263b943 100644 --- a/apps/website/src/components/landing/MediumSwitcher.spec.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.spec.tsx @@ -67,6 +67,7 @@ describe('MediumSwitcher', () => { 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'); @@ -75,6 +76,17 @@ describe('MediumSwitcher', () => { 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(); diff --git a/apps/website/src/components/landing/MediumSwitcher.tsx b/apps/website/src/components/landing/MediumSwitcher.tsx index ceba99756..da6463c18 100644 --- a/apps/website/src/components/landing/MediumSwitcher.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT 'use client'; -import { useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'; +import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'; import { tokens } from '@threadplane/design-tokens'; import { trackCtaClick } from '../../lib/analytics/client'; @@ -22,7 +22,10 @@ interface MediumSwitcherProps { } 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) { @@ -42,11 +45,17 @@ export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) { }; const onKeyDown = (event: ReactKeyboardEvent) => { - if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return; + 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(); - const delta = event.key === 'ArrowRight' ? 1 : -1; - const next = (active + delta + panes.length) % panes.length; select(next); + tabRefs.current[next]?.focus(); }; return ( @@ -62,6 +71,9 @@ export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) { return (
{panes[active].content}
diff --git a/apps/website/src/lib/demo-media.ts b/apps/website/src/lib/demo-media.ts index d3b03f626..7230d9349 100644 --- a/apps/website/src/lib/demo-media.ts +++ b/apps/website/src/lib/demo-media.ts @@ -41,8 +41,8 @@ export const HITL_CLIP: DemoClip = { /** * The LangGraph streaming demo, recorded on the canonical demo shell. Exported - * rather than re-declared per consumer so the homepage showcase and the section - * switcher cannot drift apart. + * 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.',