Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./../../dist/apps/website/.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
85 changes: 85 additions & 0 deletions apps/website/src/components/landing/DemoShowcase.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// 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 { DemoShowcase } from './DemoShowcase';

const trackCtaClickMock = vi.hoisted(() => vi.fn());
vi.mock('../../lib/analytics/client', () => ({
trackCtaClick: trackCtaClickMock,
trackExternalLinkClick: vi.fn(),
track: vi.fn(),
}));

vi.mock('../ui/Container', () => ({
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../ui/Section', () => ({
Section: ({ children }: { children: React.ReactNode }) => <section>{children}</section>,
}));

/**
* This section had no tests and an incomplete ARIA tabs pattern: it announced
* `role="tablist"` while offering no `aria-controls`, no roving tabindex, and no
* keyboard handling. These pin the behaviour the roles promise.
*/
describe('DemoShowcase', () => {
it('offers a tab per runtime', () => {
render(<DemoShowcase />);

const tabs = screen.getAllByRole('tab');
expect(tabs.map((t) => t.textContent)).toEqual(['LangGraph', 'AG-UI']);
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
});

it('pairs each tab with the panel it controls', () => {
render(<DemoShowcase />);

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('moves focus with the selection on arrow keys', () => {
// The defect this section shipped with: selection moved, focus did not, so
// the next Tab press skipped the tablist entirely.
render(<DemoShowcase />);

fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' });

const tabs = screen.getAllByRole('tab');
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
expect(document.activeElement).toBe(tabs[1]);
});

it('mounts only the active runtime clip', () => {
// Two autoplaying videos in one section would fetch both on load.
const { container } = render(<DemoShowcase />);

expect(container.querySelectorAll('video')).toHaveLength(1);
expect(container.querySelector('source')?.getAttribute('src')).toMatch(/langgraph-demo/);
});

it('swaps the clip when the other runtime is selected', () => {
const { container } = render(<DemoShowcase />);

fireEvent.click(screen.getAllByRole('tab')[1]);

expect(container.querySelectorAll('video')).toHaveLength(1);
expect(container.querySelector('source')?.getAttribute('src')).toMatch(/ag-ui-demo/);
});

it('reports the runtime whose demo was launched', () => {
trackCtaClickMock.mockClear();
render(<DemoShowcase />);

fireEvent.click(screen.getAllByRole('tab')[1]);
fireEvent.click(screen.getByRole('button', { name: /launch ag-ui live demo/i }));

expect(trackCtaClickMock).toHaveBeenCalledWith(
expect.objectContaining({ surface: 'home_demo', cta_id: 'home_demo_launch_ag_ui' }),
);
});
});
68 changes: 37 additions & 31 deletions apps/website/src/components/landing/DemoShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useState } from 'react';
import { tokens } from '@threadplane/design-tokens';
import { BrowserFrame } from '../ui/BrowserFrame';
import { TabGroup } from '../ui/TabGroup';
import { Button } from '../ui/Button';
import { DemoCtaPair } from './DemoCtaPair';
import { DemoModal } from './DemoModal';
Expand Down Expand Up @@ -30,9 +31,9 @@ const MEDIA: DemoMedia[] = [
export function DemoShowcase() {
const [active, setActive] = useState<TabKey>('langgraph');
const [modalOpen, setModalOpen] = useState(false);
const media = MEDIA.find((m) => m.key === active)!;
const launch = () => {
trackCtaClick({ surface: 'home_demo', destination_url: media.href, cta_id: `home_demo_launch_${active.replace(/-/g, '_')}`, cta_text: 'Launch live demo' });
const launch = (media: DemoMedia) => {
setActive(media.key);
trackCtaClick({ surface: 'home_demo', destination_url: media.href, cta_id: `home_demo_launch_${media.key.replace(/-/g, '_')}`, cta_text: 'Launch live demo' });
setModalOpen(true);
};

Expand All @@ -46,34 +47,39 @@ export function DemoShowcase() {
The identical Threadplane chat surface, running live against a LangGraph backend and an AG-UI backend. Switch tabs to compare — the front end never changes.
</p>

<div role="tablist" aria-label="Demo backend" style={{ display: 'flex', gap: 6, justifyContent: 'center', marginBottom: 12 }}>
{MEDIA.map((m) => {
const on = m.key === active;
return (
<button key={m.key} role="tab" aria-selected={on} onClick={() => setActive(m.key)}
style={{ fontFamily: 'Inter, sans-serif', fontSize: 13, fontWeight: 600, padding: '9px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: on ? tokens.colors.accent : tokens.colors.accentSurface, color: on ? tokens.colors.textInverted : tokens.colors.textMuted }}>
{m.tabLabel}
</button>
);
})}
</div>

<BrowserFrame url={media.url} elevation="lg">
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
<video key={media.key} autoPlay muted loop playsInline poster={media.poster}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}>
<source src={media.videoWebm} type="video/webm" />
<source src={media.videoMp4} type="video/mp4" />
</video>
<button onClick={launch} aria-label={`Launch ${media.tabLabel} live demo`}
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10,
background: 'linear-gradient(180deg, rgba(16,18,32,.15), rgba(16,18,32,.45))', border: 'none', cursor: 'pointer' }}>
<span style={{ width: 56, height: 56, borderRadius: '50%', background: 'rgba(255,255,255,.95)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#15161f', fontSize: 22 }}>&#9654;</span>
<span style={{ fontFamily: 'Inter, sans-serif', fontWeight: 600, fontSize: 13, color: '#fff', background: 'rgba(0,0,0,.5)', padding: '8px 14px', borderRadius: 8 }}>Launch live demo</span>
</button>
</div>
</BrowserFrame>
{/*
Runtime tabs, not medium tabs — this section's whole argument is that the
SAME front end runs on two backends. `TabGroup` supplies the ARIA tabs
pattern (roving tabindex, arrow/Home/End keys, focus following
selection); previously this rendered tab roles with none of that
behaviour, which promised assistive tech a widget that did not respond.
*/}
<TabGroup
groupId="home-demo"
label="Demo backend"
panes={MEDIA.map((m) => ({
id: m.key,
label: m.tabLabel,
content: (
<BrowserFrame url={m.url} elevation="lg">
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
<video autoPlay muted loop playsInline poster={m.poster}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}>
<source src={m.videoWebm} type="video/webm" />
<source src={m.videoMp4} type="video/mp4" />
</video>
<button onClick={() => launch(m)} aria-label={`Launch ${m.tabLabel} live demo`}
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10,
background: 'linear-gradient(180deg, rgba(16,18,32,.15), rgba(16,18,32,.45))', border: 'none', cursor: 'pointer' }}>
<span style={{ width: 56, height: 56, borderRadius: '50%', background: 'rgba(255,255,255,.95)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#15161f', fontSize: 22 }}>&#9654;</span>
<span style={{ fontFamily: 'Inter, sans-serif', fontWeight: 600, fontSize: 13, color: '#fff', background: 'rgba(0,0,0,.5)', padding: '8px 14px', borderRadius: 8 }}>Launch live demo</span>
</button>
</div>
</BrowserFrame>
),
}))}
onSelect={(pane) => setActive(pane.id as TabKey)}
/>

<div style={{ display: 'flex', gap: 10, justifyContent: 'center', flexWrap: 'wrap', marginTop: 18 }}>
<DemoCtaPair surface="home_demo" size="lg" />
Expand Down
108 changes: 23 additions & 85 deletions apps/website/src/components/landing/MediumSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
'use client';
import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
import { tokens } from '@threadplane/design-tokens';
import { type ReactNode } from 'react';
import { TabGroup, type TabPane } from '../ui/TabGroup';
import { trackCtaClick } from '../../lib/analytics/client';

export interface MediumPane {
Expand All @@ -24,91 +24,29 @@ interface MediumSwitcherProps {
panes: MediumPane[];
}

/**
* A homepage section's medium picker: the same claim as a video, as code, or as
* a live embed.
*
* The tabs pattern itself lives in `TabGroup` — this adds only the medium
* semantics and the analytics, so `DemoShowcase` can share the mechanics
* without inheriting a `cta_id` shape that means nothing for runtime tabs.
*/
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();
};
const byMedium = new Map(panes.map((pane) => [pane.id, pane.key]));

return (
<div>
<div
role="tablist"
aria-label={`Choose how to view the ${sectionId} section`}
onKeyDown={onKeyDown}
style={{ display: 'flex', gap: 6, marginBottom: 12 }}
>
{panes.map((pane, index) => {
const selected = index === active;
return (
<button
key={pane.id}
ref={(el) => {
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}
</button>
);
})}
</div>

<div
id={panelId(panes[active].id)}
role="tabpanel"
aria-labelledby={tabId(panes[active].id)}
>
{panes[active].content}
</div>
</div>
<TabGroup
groupId={sectionId}
label={`Choose how to view the ${sectionId} section`}
panes={panes satisfies TabPane[]}
onSelect={(pane) =>
trackCtaClick({
surface: 'home_medium_switcher',
cta_id: `medium_${sectionId}_${byMedium.get(pane.id)}`,
cta_text: pane.label,
})
}
/>
);
}
Loading
Loading