diff --git a/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx b/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx index 2786074fd..3ae9f0eef 100644 --- a/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx +++ b/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx @@ -14,6 +14,10 @@ Want to see the finished result before you build? + +[Try without a backend](/docs/chat/getting-started/try-without-a-backend) renders the same `` with a fake agent — no server, no key. + + diff --git a/apps/website/content/docs/chat/getting-started/quickstart.mdx b/apps/website/content/docs/chat/getting-started/quickstart.mdx index ebf0492ab..487b4f94b 100644 --- a/apps/website/content/docs/chat/getting-started/quickstart.mdx +++ b/apps/website/content/docs/chat/getting-started/quickstart.mdx @@ -7,7 +7,7 @@ Angular 20–22 project with an agent provider configured. See [Agent Installati -The provider steps below assume a running LangGraph server at `http://localhost:2024`. If you don't have one, jump to [Run with no backend](#run-with-no-backend) — `mockAgent()` drives the UI with canned messages so you can see `` render before wiring a real agent. +[Try without a backend](/docs/chat/getting-started/try-without-a-backend) renders the same `` with a fake agent — no server, no key. The provider steps below assume a running LangGraph server at `http://localhost:2024`. diff --git a/apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx b/apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx new file mode 100644 index 000000000..a5469d281 --- /dev/null +++ b/apps/website/content/docs/chat/getting-started/try-without-a-backend.mdx @@ -0,0 +1,104 @@ +--- +title: Try without a backend +description: Render a real Threadplane chat with provideFakeAgent() — no server, no LLM, no account — then swap in a real adapter. +--- + +# Try without a backend + +Render a real `` in your Angular app with no server, no LLM and no account. `provideFakeAgent()` streams a canned reply in-process through the same components you will ship. When the UI looks right, swap the provider for a real adapter. + + +An Angular 20–22 application. Nothing else. + + + + + +```bash +npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked +``` + +`provideFakeAgent()` ships inside the adapter packages, so install the adapter you expect to use later. The LangChain packages are peers of `@threadplane/langgraph`; `marked` renders assistant markdown. + + +A default Angular application caps the initial bundle at 1 MB, and a chat UI plus the LangGraph SDK exceeds it. Raise `budgets[].maximumError` for the `initial` bundle in `angular.json` if `ng build` reports a budget error. + + + + + +```ts +// app.config.ts +import { ApplicationConfig } from '@angular/core'; +import { provideFakeAgent } from '@threadplane/langgraph'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideFakeAgent({ tokens: ['Hello', ' from', ' Threadplane.'] }), + ], +}; +``` + +`FakeAgentConfig` accepts `tokens`, `reasoningTokens` and `delayMs`. Leave them out and the fake agent streams a default reply. + + + + +```ts +// app.component.ts +import { Component } from '@angular/core'; +import { injectAgent } from '@threadplane/langgraph'; +import { ChatComponent } from '@threadplane/chat'; + +@Component({ + selector: 'app-root', + imports: [ChatComponent], + template: ``, +}) +export class AppComponent { + protected readonly agent = injectAgent(); +} +``` + +Run `ng serve`, type anything, and watch the reply stream in. + + + + +```ts +// app.component.spec.ts +import { TestBed } from '@angular/core/testing'; +import { provideFakeAgent } from '@threadplane/langgraph'; +import { AppComponent } from './app.component'; + +it('streams the fake reply', async () => { + TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [provideFakeAgent({ tokens: ['Hello', ' from', ' Threadplane.'], delayMs: 0 })], + }); + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const textarea = fixture.nativeElement.querySelector('textarea'); + textarea.value = 'hi'; + textarea.dispatchEvent(new Event('input')); + fixture.detectChanges(); + fixture.nativeElement.querySelector('button[aria-label="Send message"]').click(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Hello from Threadplane.'); +}); +``` + +The `detectChanges()` call after the input event matters: the send button stays disabled until change detection runs, so a click before it does nothing. + + + + +## Connect a real adapter + +Replace `provideFakeAgent(...)` with one line and keep every component as it is: + +- **LangGraph**: `provideAgent({ apiUrl: 'http://localhost:2024', assistantId: 'agent' })` — [LangGraph quickstart](/docs/langgraph/getting-started/quickstart) +- **AG-UI**: `provideAgent({ url: 'http://localhost:8000/agent' })` from `@threadplane/ag-ui` — [AG-UI quickstart](/docs/ag-ui/getting-started/quickstart) + +Not every backend emits every capability; see [Choosing an adapter](/docs/choosing-an-adapter). diff --git a/apps/website/content/docs/langgraph/getting-started/quickstart.mdx b/apps/website/content/docs/langgraph/getting-started/quickstart.mdx index 71ae228bf..808a7b931 100644 --- a/apps/website/content/docs/langgraph/getting-started/quickstart.mdx +++ b/apps/website/content/docs/langgraph/getting-started/quickstart.mdx @@ -6,6 +6,10 @@ Build a streaming chat component with `injectAgent()` in 5 minutes. Angular 20–22 project using a Node.js version supported by that Angular major. If you need setup help, see the [Installation](/docs/langgraph/getting-started/installation) guide. + +[Try without a backend](/docs/chat/getting-started/try-without-a-backend) renders the same `` with a fake agent — no server, no key. + + diff --git a/apps/website/e2e/home-hero.spec.ts b/apps/website/e2e/home-hero.spec.ts new file mode 100644 index 000000000..1d4e08e8b --- /dev/null +++ b/apps/website/e2e/home-hero.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; + +test.describe('homepage hero', () => { + test('install dialog opens, is keyboard operable, and copies the visible command', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto('/'); + await page.getByRole('button', { name: 'Install Threadplane' }).click(); + const dialog = page.getByRole('dialog', { name: 'Install Threadplane' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('radio', { name: 'Try without a backend' })).toHaveAttribute('aria-checked', 'true'); + await dialog.getByRole('radio', { name: 'Try without a backend' }).focus(); + await page.keyboard.press('ArrowRight'); + await expect(dialog.getByRole('radio', { name: 'LangGraph' })).toHaveAttribute('aria-checked', 'true'); + const visible = await dialog.getByTestId('install-command').textContent(); + await dialog.getByRole('button', { name: 'Copy install command' }).click(); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toBe(visible); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(page.getByRole('button', { name: 'Install Threadplane' })).toBeFocused(); + }); + + test('poster renders before the frame and the frame mounts on desktop', async ({ page }) => { + await page.goto('/'); + const demo = page.locator('[data-hero-demo]'); + await expect(demo.locator('img')).toHaveAttribute('src', '/screenshots/hero-walkthrough-poster.webp'); + await expect(demo.locator('iframe')).toHaveAttribute('src', 'https://demo.threadplane.ai/hero'); + }); + + test('mobile shows Play walkthrough instead of the frame', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + const demo = page.locator('[data-hero-demo]'); + await demo.scrollIntoViewIfNeeded(); + await expect(demo.getByRole('button', { name: 'Play walkthrough' })).toBeVisible(); + await expect(demo.locator('iframe')).toHaveCount(0); + }); +}); diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 20b456b1c..76bb1f9c8 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -21,6 +21,7 @@ async function expectNoHorizontalOverflow( test('landing page renders hero headline', async ({ page }) => { await page.goto('/'); await expect(page.locator('#hero-heading')).toBeVisible(); + await expect(page.locator('#hero-heading')).toHaveText('The AI agent UI framework for Angular.'); const headline = await page.locator('#hero-heading').textContent(); expect(headline?.toLowerCase()).toContain('angular'); }); @@ -31,11 +32,13 @@ test('landing page renders the dark proof band', async ({ page }) => { await expect(page.locator('#proof[data-surface="dark"]')).toBeVisible(); }); -test('landing page renders feature blocks (Stream/Render/Ship)', async ({ page }) => { +test('landing page renders feature blocks (Stream/Persist/Approve/Render/Test)', async ({ page }) => { await page.goto('/'); await expect(page.locator('#stream-heading')).toBeVisible(); + await expect(page.locator('#persist-heading')).toBeVisible(); + await expect(page.locator('#approve-heading')).toBeVisible(); await expect(page.locator('#render-heading')).toBeVisible(); - await expect(page.locator('#ship-heading')).toBeVisible(); + await expect(page.locator('#test-heading')).toBeVisible(); }); test('landing page no longer carries the retired promises section', async ({ page }) => { diff --git a/apps/website/src/app/opengraph-image.tsx b/apps/website/src/app/opengraph-image.tsx index 16870fa71..25379b007 100644 --- a/apps/website/src/app/opengraph-image.tsx +++ b/apps/website/src/app/opengraph-image.tsx @@ -6,7 +6,7 @@ * file in any route folder. */ import { ImageResponse } from 'next/og'; -import { POSITIONING_PROOF_POINTS, PRIMARY_TAGLINE, SHORT_POSITIONING_DESCRIPTION } from '../lib/positioning'; +import { HERO_H1, POSITIONING_PROOF_POINTS, PRIMARY_TAGLINE, SHORT_POSITIONING_DESCRIPTION } from '../lib/positioning'; import { loadCardFonts } from './og-font'; // Node runtime (not edge) so we can read the bundled Garamond TTF off disk. @@ -61,7 +61,7 @@ export default async function OpenGraphImage() { maxWidth: 980, }} > - Build fullstack agentic Angular apps. + {HERO_H1} {/* Subhead */} diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 0af8e9659..b45091e52 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -1,11 +1,13 @@ import { Hero } from '../components/landing/Hero'; import { LogoRibbon } from '../components/landing/LogoRibbon'; import { ProofStrip } from '../components/landing/ProofStrip'; +import { RuntimeParity } from '../components/landing/RuntimeParity'; +import { ThreeSteps } from '../components/landing/ThreeSteps'; import { FeatureBlock } from '../components/landing/FeatureBlock'; -import { StackDiagramSection } from '../components/landing/StackDiagramSection'; -import { HomeConceptGrid } from '../components/landing/HomeConceptGrid'; import { DemoShowcase } from '../components/landing/DemoShowcase'; import { MediumSwitcher } from '../components/landing/MediumSwitcher'; +import { CodingAgentQuickstart } from '../components/landing/CodingAgentQuickstart'; +import { ScopeTable } from '../components/landing/ScopeTable'; import { SECTION_MEDIA } from '../lib/section-media'; import { buildPanes } from '../lib/build-panes'; import { PilotBlock } from '../components/landing/PilotBlock'; @@ -15,20 +17,26 @@ import { FinalCTA } from '../components/landing/FinalCTA'; import { RecentArticles } from '../components/landing/RecentArticles'; import { Section } from '../components/ui/Section'; import { Container } from '../components/ui/Container'; -import { createPageMetadata, LONG_SUBHEAD, PRIMARY_TAGLINE } from '../lib/site-metadata'; +import { + createPageMetadata, + HERO_SECONDARY_HREF, + HOME_DESCRIPTION, + HOME_TITLE, + INSTALL_OPTIONS, +} from '../lib/site-metadata'; import { getFormPolicy } from '../lib/growth/form-policy'; export const metadata = createPageMetadata({ - title: PRIMARY_TAGLINE, - description: LONG_SUBHEAD, + title: HOME_TITLE, + description: HOME_DESCRIPTION, pathname: '/', type: 'website', }); export default async function HomePage() { const formPolicy = getFormPolicy(); - const [streamPanes, renderPanes, shipPanes, approvePanes] = await Promise.all( - (['stream', 'render', 'ship', 'approve'] as const).map((key) => + const [streamPanes, persistPanes, approvePanes, renderPanes, testPanes] = await Promise.all( + (['stream', 'persist', 'approve', 'render', 'test'] as const).map((key) => buildPanes(SECTION_MEDIA[key], SECTION_MEDIA[key].video?.url ?? ''), ), ); @@ -38,86 +46,55 @@ export default async function HomePage() { - - - - - - {/* Interactive demo showcase */} -
- - - -
+ + {/* Stream */} - provideAgent wires the agent into DI;{' '} - injectAgent() hands back signals — messages(), status(), error() — plus durable threads and tool progress. + injectAgent() hands back signals: messages(), status(), error(), + isLoading(), and tool progress. Nothing to subscribe to, nothing to tear down. } rows={[ { claim: 'Signals, not promises', api: 'injectAgent()' }, - { claim: 'Threads that branch, resume, replay', api: 'threadId' }, - { claim: 'Same contract on LangGraph and AG-UI', api: 'runtime adapters' }, + { claim: 'Tool progress as it happens', api: 'toolProgress()' }, + { claim: 'Same contract on LangGraph and AG-UI', api: 'Agent' }, ]} cta={{ label: 'Read the streaming guide', href: '/docs/langgraph/guides/streaming' }} visual={} /> - {/* Render */} + {/* Persist */} } - /> - - {/* Ship — the live demo */} - } + cta={{ label: 'Persistence patterns', href: '/docs/langgraph/guides/persistence' }} + visualLeft + visual={} /> - {/* - This is the only section whose heading the approval clip actually - illustrates — the same rule the solutions pages follow. - */} + {/* Approve */} - interrupt() freezes the run inside the checkpoint. Your UI renders the proposal;{' '} - submit({'{ resume }'}) continues with the decision on the record. + interrupt() freezes the run inside the checkpoint. Your UI renders the + proposal; submit({'{ resume }'}) continues with the decision on the + record. } rows={[ @@ -126,15 +103,67 @@ export default async function HomePage() { { claim: 'The decision lands beside the action it gated', api: 'submit({ resume })' }, ]} cta={{ label: 'Interrupt patterns', href: '/docs/langgraph/guides/interrupts' }} - visualLeft visual={} /> - + {/* Render */} + } + /> + + {/* Test */} + + provideFakeAgent() streams canned tokens in-process; mock transports + script tool calls and interrupts. Your component specs stay deterministic and fast. + + } + rows={[ + { claim: 'No key, no server, no network', api: 'provideFakeAgent()' }, + { claim: 'Script tool calls and interrupts', api: 'mockLangGraphAgent()' }, + { claim: 'Same UI code in test and production', api: 'Agent' }, + ]} + cta={{ label: 'Try without a backend', href: INSTALL_OPTIONS[0].quickstartHref }} + visual={} + /> + + {/* Interactive demo showcase */} +
+ + + +
+ + + + + - ); } diff --git a/apps/website/src/components/landing/AdapterGuideLink.spec.tsx b/apps/website/src/components/landing/AdapterGuideLink.spec.tsx new file mode 100644 index 000000000..0f726f3db --- /dev/null +++ b/apps/website/src/components/landing/AdapterGuideLink.spec.tsx @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const trackCtaClickMock = vi.hoisted(() => vi.fn()); +vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: trackCtaClickMock, track: vi.fn() })); + +beforeEach(() => trackCtaClickMock.mockClear()); + +describe('AdapterGuideLink', () => { + it('links to the adapter guide and fires home_adapter_guide on click', async () => { + const { AdapterGuideLink } = await import('./AdapterGuideLink'); + render(); + const link = screen.getByRole('link', { name: 'Choose an adapter →' }); + expect(link.getAttribute('href')).toBe('/docs/choosing-an-adapter'); + fireEvent.click(link); + expect(trackCtaClickMock).toHaveBeenCalledWith( + expect.objectContaining({ + cta_id: 'home_adapter_guide', + track: 'developer', + surface: 'home', + destination_url: '/docs/choosing-an-adapter', + }), + ); + }); +}); diff --git a/apps/website/src/components/landing/AdapterGuideLink.tsx b/apps/website/src/components/landing/AdapterGuideLink.tsx new file mode 100644 index 000000000..48da48623 --- /dev/null +++ b/apps/website/src/components/landing/AdapterGuideLink.tsx @@ -0,0 +1,30 @@ +'use client'; + +import Link from 'next/link'; +import { trackCtaClick } from '../../lib/analytics/client'; + +/** + * Runtime parity's "Choose an adapter" link. Split out of RuntimeParity (a + * server component) so the click can fire `home_adapter_guide` — a bare + * `data-cta` attribute on a server-rendered `` has no handler wired to + * it and never tracks. + */ +export function AdapterGuideLink() { + return ( + + trackCtaClick({ + cta_id: 'home_adapter_guide', + track: 'developer', + surface: 'home', + destination_url: '/docs/choosing-an-adapter', + }) + } + > + Choose an adapter → + + ); +} diff --git a/apps/website/src/components/landing/CodingAgentQuickstart.spec.tsx b/apps/website/src/components/landing/CodingAgentQuickstart.spec.tsx new file mode 100644 index 000000000..26aa410a1 --- /dev/null +++ b/apps/website/src/components/landing/CodingAgentQuickstart.spec.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { CODING_AGENT_PROMPT } from '../../lib/positioning'; + +const trackCtaClickMock = vi.hoisted(() => vi.fn()); +const writeTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock('../../lib/analytics/client', () => ({ + trackCtaClick: trackCtaClickMock, + track: vi.fn(), +})); +vi.mock('../ui/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../ui/Section', () => ({ + Section: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../ui/SectionHeader', () => ({ + SectionHeader: ({ heading }: { heading: React.ReactNode }) =>

{heading}

, +})); +vi.mock('../ui/Button', () => ({ + Button: ({ + children, + href, + onClick, + }: { + children: React.ReactNode; + href?: string; + onClick?: () => void; + }) => + href ? ( + + {children} + + ) : ( + + ), +})); + +beforeEach(() => { + trackCtaClickMock.mockClear(); + writeTextMock.mockClear(); + Object.assign(navigator, { clipboard: { writeText: writeTextMock } }); +}); + +describe('CodingAgentQuickstart', () => { + it('renders the maintained prompt verbatim and the four links', async () => { + const { CodingAgentQuickstart } = await import('./CodingAgentQuickstart'); + render(); + expect(screen.getByTestId('coding-agent-prompt').textContent).toBe(CODING_AGENT_PROMPT); + expect(screen.getByRole('link', { name: /Read AGENTS.md/ }).getAttribute('href')).toBe('/AGENTS.md'); + expect(screen.getByRole('link', { name: /full agent reference/ }).getAttribute('href')).toBe('/llms-full.txt'); + expect(screen.getByRole('link', { name: /human quickstart/ }).getAttribute('href')).toBe( + '/docs/chat/getting-started/try-without-a-backend', + ); + }); + + it('copy writes the prompt and tracks without sending the text', async () => { + const { CodingAgentQuickstart } = await import('./CodingAgentQuickstart'); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Copy setup prompt' })); + expect(writeTextMock).toHaveBeenCalledWith(CODING_AGENT_PROMPT); + const call = trackCtaClickMock.mock.calls.find((c) => c[0].cta_id === 'home_coding_agent_prompt')?.[0]; + expect(call).toBeTruthy(); + expect(JSON.stringify(call)).not.toContain('Add Threadplane to this Angular application'); + }); +}); diff --git a/apps/website/src/components/landing/CodingAgentQuickstart.tsx b/apps/website/src/components/landing/CodingAgentQuickstart.tsx new file mode 100644 index 000000000..eb23e4f15 --- /dev/null +++ b/apps/website/src/components/landing/CodingAgentQuickstart.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useState } from 'react'; +import { Container } from '../ui/Container'; +import { Section } from '../ui/Section'; +import { SectionHeader } from '../ui/SectionHeader'; +import { Button } from '../ui/Button'; +import { trackCtaClick } from '../../lib/analytics/client'; +import { CODING_AGENT_PROMPT, INSTALL_OPTIONS } from '../../lib/positioning'; + +const LINKS = [ + { label: 'Read AGENTS.md', href: '/AGENTS.md' }, + { label: 'Open the full agent reference', href: '/llms-full.txt' }, + { label: 'Start the human quickstart', href: INSTALL_OPTIONS[0].quickstartHref }, +]; + +export function CodingAgentQuickstart() { + const [copied, setCopied] = useState(false); + + const copy = async () => { + trackCtaClick({ cta_id: 'home_coding_agent_prompt', track: 'developer', surface: 'home' }); + try { + await navigator.clipboard?.writeText(CODING_AGENT_PROMPT); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* the prompt is visible on the page; the reader can select it */ + } + }; + + return ( +
+ + +
+          {CODING_AGENT_PROMPT}
+        
+
+ + {LINKS.map((link) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/website/src/components/landing/DemoModal.tsx b/apps/website/src/components/landing/DemoModal.tsx index ef53861d1..f53f0d3f5 100644 --- a/apps/website/src/components/landing/DemoModal.tsx +++ b/apps/website/src/components/landing/DemoModal.tsx @@ -1,7 +1,7 @@ // apps/website/src/components/landing/DemoModal.tsx 'use client'; -import { useEffect, useRef } from 'react'; import { trackExternalLinkClick } from '../../lib/analytics/client'; +import { Modal } from '../ui/Modal'; type TabKey = 'langgraph' | 'ag-ui'; @@ -21,81 +21,39 @@ interface DemoModalProps { } export function DemoModal({ open, onClose, tabs, active, onActive }: DemoModalProps) { - const frameRef = useRef(null); - const closeBtnRef = useRef(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( - '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 ( -
{ if (e.target === e.currentTarget) onClose(); }} - className="demo-modal" - > -
-
- -
- {tabs.map((t) => { - const on = t.key === active; - return ( - - ); - })} -
- {tab.url} - + +
+ - -
-