Skip to content
Open
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
25 changes: 25 additions & 0 deletions apps/website/e2e/demo-modal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test';

test('homepage demo: launch opens a modal, Esc closes it', async ({ page }) => {
await page.goto('/');
const launch = page.getByRole('button', { name: /launch .* live demo/i }).first();
await launch.scrollIntoViewIfNeeded();
await launch.click();

const dialog = page.getByRole('dialog', { name: /live demo/i });
await expect(dialog).toBeVisible();
await expect(dialog.locator('iframe')).toBeVisible();

await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(launch).toBeFocused();
});

test('homepage demo: in-modal tabs switch the runtime', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /launch .* live demo/i }).first().click();
const dialog = page.getByRole('dialog', { name: /live demo/i });
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: 'AG-UI' }).click();
await expect(dialog.getByText('ag-ui.threadplane.ai')).toBeVisible();
});
130 changes: 130 additions & 0 deletions apps/website/src/components/landing/DemoModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// apps/website/src/components/landing/DemoModal.tsx
'use client';
import { useEffect, useRef } from 'react';
import { tokens } from '@threadplane/design-tokens';
import { trackExternalLinkClick } from '../../lib/analytics/client';

type TabKey = 'langgraph' | 'ag-ui';

export interface DemoModalTab {
key: TabKey;
tabLabel: string;
url: string;
href: string;
}

interface DemoModalProps {
open: boolean;
onClose: () => void;
tabs: DemoModalTab[];
active: TabKey;
onActive: (key: TabKey) => void;
}

export function DemoModal({ open, onClose, tabs, active, onActive }: DemoModalProps) {
const frameRef = useRef<HTMLDivElement>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null);
const tab = tabs.find((t) => t.key === active) ?? tabs[0];

// While open: Esc to close, focus trap, body scroll lock, restore focus on close.
useEffect(() => {
if (!open) return;
const prevFocus = document.activeElement as HTMLElement | null;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
closeBtnRef.current?.focus();

const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key !== 'Tab') return;
const f = frameRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])',
);
if (!f || f.length === 0) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
prevFocus?.focus?.();
};
}, [open, onClose]);

if (!open) return null;

return (
<div
role="dialog"
aria-modal="true"
aria-label="Live demo"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: 'fixed', inset: 0, zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(16,18,32,.55)',
animation: 'demoModalBackdrop .16s ease-out',
}}
>
<style>{`
@keyframes demoModalBackdrop { from { opacity: 0 } to { opacity: 1 } }
@keyframes demoModalFrame { from { transform: scale(.96); opacity:.6 } to { transform: scale(1); opacity:1 } }
.demo-modal__frame {
width: min(96vw, calc(90vh * 16 / 10));
background: ${tokens.surfaces.surface};
border-radius: ${tokens.radius.lg};
box-shadow: 0 24px 60px rgba(0,0,0,.45);
overflow: hidden;
display: flex; flex-direction: column;
animation: demoModalFrame .16s ease-out;
}
.demo-modal__body { width: 100%; aspect-ratio: 16 / 10; background: #15161f; }
@media (max-width: 640px) {
.demo-modal__frame { width: 100vw; height: 100dvh; border-radius: 0; }
.demo-modal__body { aspect-ratio: auto; flex: 1 1 auto; }
}
@media (prefers-reduced-motion: reduce) {
[role="dialog"], .demo-modal__frame { animation: none !important; }
}
`}</style>

<div ref={frameRef} className="demo-modal__frame">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: tokens.surfaces.surfaceTinted, borderBottom: `1px solid ${tokens.surfaces.border}` }}>
<div style={{ display: 'flex', gap: 5 }} aria-hidden="true">
{[0, 1, 2].map((i) => <span key={i} style={{ width: 8, height: 8, borderRadius: '50%', background: '#cdd2df' }} />)}
</div>
<div role="tablist" aria-label="Demo backend" style={{ display: 'flex', gap: 5 }}>
{tabs.map((t) => {
const on = t.key === active;
return (
<button key={t.key} role="tab" aria-selected={on} onClick={() => onActive(t.key)}
style={{ fontFamily: 'Inter, sans-serif', fontSize: 12, fontWeight: 600, padding: '6px 12px', borderRadius: 7, border: 'none', cursor: 'pointer',
background: on ? tokens.colors.accent : tokens.colors.accentSurface, color: on ? tokens.colors.textInverted : tokens.colors.textMuted }}>
{t.tabLabel}
</button>
);
})}
</div>
<span style={{ flex: 1, textAlign: 'center', fontFamily: tokens.typography.fontMono, fontSize: 12, fontWeight: 600, color: tokens.colors.textMuted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{tab.url}</span>
<button ref={closeBtnRef} onClick={onClose} aria-label="Close demo"
style={{ width: 28, height: 28, borderRadius: 7, border: 'none', cursor: 'pointer', background: tokens.colors.accentSurface, color: tokens.colors.textSecondary, fontSize: 16, lineHeight: 1 }}>&#215;</button>
</div>

<div className="demo-modal__body">
<iframe src={tab.href} title={`${tab.tabLabel} live demo`}
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }} />
</div>

<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 14px', borderTop: `1px solid ${tokens.surfaces.border}`, background: tokens.surfaces.surface }}>
<span style={{ fontFamily: 'Inter, sans-serif', fontSize: 12, color: tokens.colors.textMuted }}>Esc or click outside to close &middot; MIT &middot; no signup</span>
<a href={tab.href} target="_blank" rel="noopener noreferrer"
onClick={() => trackExternalLinkClick(tab.href, { surface: 'home_demo', cta_id: `home_demo_full_${tab.key.replace(/-/g, '_')}`, cta_text: 'Open the full demo' })}
style={{ fontFamily: 'Inter, sans-serif', fontSize: 12, fontWeight: 600, color: tokens.colors.accent, textDecoration: 'none' }}>Open the full demo &#8599;</a>
</div>
</div>
</div>
);
}
46 changes: 25 additions & 21 deletions apps/website/src/components/landing/DemoShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { tokens } from '@threadplane/design-tokens';
import { BrowserFrame } from '../ui/BrowserFrame';
import { Button } from '../ui/Button';
import { DemoCtaPair } from './DemoCtaPair';
import { DemoModal } from './DemoModal';
import { trackCtaClick } from '../../lib/analytics/client';
import { DEMOS } from '../../lib/demos';

type TabKey = (typeof DEMOS)[number]['key'];
Expand Down Expand Up @@ -31,10 +33,12 @@ const MEDIA: DemoMedia[] = [

export function DemoShowcase() {
const [active, setActive] = useState<TabKey>('langgraph');
const [launched, setLaunched] = useState<Set<TabKey>>(new Set());
const [modalOpen, setModalOpen] = useState(false);
const media = MEDIA.find((m) => m.key === active)!;
const isLaunched = launched.has(active);
const launch = () => setLaunched((prev) => new Set(prev).add(active));
const launch = () => {
trackCtaClick({ surface: 'home_demo', destination_url: media.href, cta_id: `home_demo_launch_${active.replace(/-/g, '_')}`, cta_text: 'Launch live demo' });
setModalOpen(true);
};

return (
<div style={{ maxWidth: 760, margin: '0 auto', textAlign: 'center' }}>
Expand All @@ -61,24 +65,17 @@ export function DemoShowcase() {

<BrowserFrame url={media.url} elevation="lg">
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
{isLaunched ? (
<iframe src={media.href} title={`${media.tabLabel} live demo`} loading="lazy"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }} />
) : (
<>
<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>
</>
)}
<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>

Expand All @@ -91,6 +88,13 @@ export function DemoShowcase() {
<p style={{ fontFamily: tokens.typography.caption.family, fontSize: tokens.typography.caption.size, color: tokens.colors.textMuted, margin: '14px 0 0' }}>
Video loops instantly · click Launch to open the live, interactive demo · MIT · no signup
</p>
<DemoModal
open={modalOpen}
onClose={() => setModalOpen(false)}
tabs={MEDIA}
active={active}
onActive={setActive}
/>
</div>
);
}
Loading
Loading