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 (