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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,6 @@ examples/chat/angular/src/environments/generated-keys.local.ts

# Local service-account keys (GSC, etc). Never commit these.
keys/

# Playwright demo-recording output (large binaries; see apps/website/scripts/upload-demo-media.md)
**/.record-output/
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.
53 changes: 53 additions & 0 deletions apps/website/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EcosystemStrip } from '../components/landing/EcosystemStrip';
import { Differentiator } from '../components/landing/Differentiator';
import { FeatureBlock } from '../components/landing/FeatureBlock';
import { BrowserFrame } from '../components/ui/BrowserFrame';
import { HITL_CLIP } from '../lib/demo-media';
import { DemoShowcase } from '../components/landing/DemoShowcase';
import { PilotBlock } from '../components/landing/PilotBlock';
import { WhitePaperBlock } from '../components/landing/WhitePaperBlock';
Expand Down Expand Up @@ -134,6 +135,58 @@ export default async function HomePage() {
}
/>

{/*
The homepage claims human-in-the-loop in the Differentiator table and
mentions interrupts in the Stream block, but nothing here showed it.
This is the only section whose heading the approval clip actually
illustrates — the same rule the solutions pages follow.
*/}
<FeatureBlock
id="approve"
eyebrow="Approve"
headline="Nothing irreversible happens without a human."
body={
<>
<code style={{ fontFamily: tokens.typography.fontMono }}>interrupt()</code> freezes the graph
mid-run and the pause lives in the checkpoint, not in component state. Your UI renders the
proposal, the human answers, and{' '}
<code style={{ fontFamily: tokens.typography.fontMono }}>submit({'{ resume }'})</code> continues
the run — with the decision written back beside the action it gated.
</>
}
bullets={[
'interrupt() pauses mid-run; submit({ resume }) continues it',
'<chat-interrupt-panel> renders the proposal',
'The pause is a checkpoint, not a modal',
'Decision and proposal land in one thread record',
]}
supportingCards={[
{ title: 'interrupt()', description: 'Freezes the graph before the action runs.' },
{ title: 'resume', description: 'Carries the human decision back into the run.' },
{ title: 'checkpoint', description: 'The pause survives; it is not UI state.' },
]}
cta={{ label: 'Interrupt patterns', href: '/docs/langgraph/guides/interrupts' }}
visualLeft
visual={
<BrowserFrame url={HITL_CLIP.url} elevation="lg">
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
<video
autoPlay
muted
loop
playsInline
poster={HITL_CLIP.poster}
aria-label={HITL_CLIP.caption}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
>
<source src={HITL_CLIP.videoWebm} type="video/webm" />
<source src={HITL_CLIP.videoMp4} type="video/mp4" />
</video>
</div>
</BrowserFrame>
}
/>

<PilotBlock />
<WhitePaperBlock />
<Promises />
Expand Down
4 changes: 4 additions & 0 deletions apps/website/src/app/solutions/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from '../../../lib/solutions-data';
import { Container } from '../../../components/ui/Container';
import { Section } from '../../../components/ui/Section';
import { SolutionCodeBlock } from '../../../components/solutions/SolutionCodeBlock';
import { SolutionDemoBlock } from '../../../components/solutions/SolutionDemoBlock';
import { Eyebrow } from '../../../components/ui/Eyebrow';
import { Button } from '../../../components/ui/Button';
import { Pill } from '../../../components/ui/Pill';
Expand Down Expand Up @@ -333,6 +335,8 @@ export default async function SolutionPage({ params }: PageProps) {
<PainPoints items={solution.painPoints} accent={solution.color} />
<Architecture intro={solution.architectureIntro} layers={solution.architectureLayers} accent={solution.color} />
<Capabilities items={solution.proofPoints} accent={solution.color} />
<SolutionCodeBlock code={solution.code} accent={solution.color} />
{solution.demo && <SolutionDemoBlock clip={solution.demo} accent={solution.color} />}
<WhitePaperBlock />
<FinalCTA
headline={solution.ctaHeadline}
Expand Down
6 changes: 1 addition & 5 deletions apps/website/src/components/landing/DemoShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DemoCtaPair } from './DemoCtaPair';
import { DemoModal } from './DemoModal';
import { trackCtaClick } from '../../lib/analytics/client';
import { DEMOS } from '../../lib/demos';
import { DEMO_CDN } from '../../lib/demo-media';

type TabKey = (typeof DEMOS)[number]['key'];

Expand All @@ -20,11 +21,6 @@ interface DemoMedia {
href: string;
}

// Demo media is hosted on Vercel Blob (store: ngaf-website-assets) rather than
// committed to the repo — these clips are large binaries that would bloat git
// history on every recut. Re-uploading with the same pathnames keeps these URLs
// stable. See apps/website/scripts/upload-demo-media.md for the upload steps.
const DEMO_CDN = 'https://elgkdaxpsvqcrns1.public.blob.vercel-storage.com/demo';

const MEDIA: DemoMedia[] = [
{ key: 'langgraph', tabLabel: 'LangGraph', url: 'demo.threadplane.ai', videoMp4: `${DEMO_CDN}/langgraph-demo.mp4`, videoWebm: `${DEMO_CDN}/langgraph-demo.webm`, poster: `${DEMO_CDN}/langgraph-demo-poster.webp`, href: DEMOS.find((d) => d.key === 'langgraph')!.href },
Expand Down
88 changes: 88 additions & 0 deletions apps/website/src/components/solutions/SolutionCodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT
import { codeToHtml } from 'shiki';
import { tokens } from '@threadplane/design-tokens';
import { Container } from '../ui/Container';
import { Section } from '../ui/Section';
import { Eyebrow } from '../ui/Eyebrow';
import type { SolutionCode, SolutionCodeBlocks } from '../../lib/solutions-data';

/**
* The `code` block on a solutions page.
*
* Highlighted with Shiki directly rather than through `rehype-pretty-code`:
* that plugin only runs over MDX, and these pages are TSX. The theme matches
* `MdxRenderer`'s (`tokyo-night`) so a snippet here reads the same as one in
* the docs.
*
* This is an async Server Component, so highlighting happens at build time and
* ships no Shiki payload to the browser.
*/
async function highlight(block: SolutionCode) {
return codeToHtml(block.source, { lang: block.language, theme: 'tokyo-night' });
}

export async function SolutionCodeBlock({ code, accent }: { code: SolutionCodeBlocks; accent: string }) {
// Highlight every block up front: an async map inside JSX would give React
// promises to render rather than markup.
const rendered = await Promise.all(
code.map(async (block) => ({ ...block, html: await highlight(block) })),
);

return (
<Section surface="canvas" ariaLabelledBy="solution-code-heading">
<Container>
<div style={{ maxWidth: 820, margin: '0 auto' }}>
<Eyebrow style={{ color: accent, marginBottom: 12 }}>In practice</Eyebrow>
<h2
id="solution-code-heading"
style={{
fontFamily: tokens.typography.h2.family,
fontSize: tokens.typography.h2.size,
lineHeight: tokens.typography.h2.line,
fontWeight: 700,
color: tokens.colors.textPrimary,
margin: 0,
marginBottom: 12,
letterSpacing: '-0.015em',
}}
>
What it looks like in your codebase
</h2>
{rendered.map((block, index) => (
<div key={block.label} style={{ marginTop: index === 0 ? 0 : 24 }}>
<p
style={{
fontFamily: tokens.typography.fontMono,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.04em',
color: tokens.colors.textMuted,
margin: '0 0 10px',
}}
>
{block.label}
</p>
{/*
Shiki emits a complete <pre> that already carries its own background,
padding, and `overflow-x: auto`, so this wrapper owns only the frame.
`overflow: hidden` is what makes the radius clip that background — it
must not be `auto`, which would nest a second scroll container around
a element that already scrolls and can show two scrollbars.
*/}
<div
className="solution-code"
style={{
borderRadius: 12,
overflow: 'hidden',
border: `1px solid ${tokens.surfaces.border}`,
fontSize: 14,
}}
dangerouslySetInnerHTML={{ __html: block.html }}
/>
</div>
))}
</div>
</Container>
</Section>
);
}
101 changes: 101 additions & 0 deletions apps/website/src/components/solutions/SolutionDemoBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MIT
import { tokens } from '@threadplane/design-tokens';
import { Container } from '../ui/Container';
import { Section } from '../ui/Section';
import { Eyebrow } from '../ui/Eyebrow';
import { BrowserFrame } from '../ui/BrowserFrame';
import type { DemoClip } from '../../lib/demo-media';

/**
* A recorded clip on a solutions page, shown after the code.
*
* Deliberately NOT the homepage `DemoShowcase`: that one is a tabbed switcher
* with a play overlay that opens the live demo in a modal. Neither fits here —
* there is one clip and no second runtime to switch between, and the live demo
* opens on an empty thread rather than on the flow this clip is about, so a
* "Launch live demo" button would promise something the destination does not
* deliver. A link under the frame says the same thing honestly.
*
* No client JS: `autoPlay muted loop playsInline` is the whole behaviour, so
* this stays a Server Component.
*/
export function SolutionDemoBlock({ clip, accent }: { clip: DemoClip; accent: string }) {
return (
<Section surface="tinted" ariaLabelledBy="solution-demo-heading">
<Container>
<div style={{ maxWidth: 820, margin: '0 auto' }}>
<Eyebrow style={{ color: accent, marginBottom: 12 }}>See it running</Eyebrow>
<h2
id="solution-demo-heading"
style={{
fontFamily: tokens.typography.h2.family,
fontSize: tokens.typography.h2.size,
lineHeight: tokens.typography.h2.line,
fontWeight: 700,
color: tokens.colors.textPrimary,
margin: 0,
marginBottom: 12,
letterSpacing: '-0.015em',
}}
>
The approval gate, in the product
</h2>
<p
style={{
fontFamily: tokens.typography.bodyLg.family,
fontSize: tokens.typography.bodyLg.size,
lineHeight: tokens.typography.bodyLg.line,
color: tokens.colors.textSecondary,
margin: '0 0 20px',
maxWidth: '62ch',
}}
>
{clip.caption}
</p>

<BrowserFrame url={clip.url} elevation="lg">
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
{/*
Silent, decorative loop. `aria-label` rather than captions: there
is no audio track and no narration to caption, and the prose
above already states what the clip shows.
*/}
<video
autoPlay
muted
loop
playsInline
poster={clip.poster}
aria-label={clip.caption}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
>
<source src={clip.videoWebm} type="video/webm" />
<source src={clip.videoMp4} type="video/mp4" />
</video>
</div>
</BrowserFrame>

<p
style={{
fontFamily: tokens.typography.body.family,
fontSize: tokens.typography.body.size,
color: tokens.colors.textMuted,
margin: '14px 0 0',
}}
>
Recorded from the{' '}
<a
href="https://demo.threadplane.ai"
target="_blank"
rel="noopener noreferrer"
style={{ color: tokens.colors.accent }}
>
live demo
</a>
, which you can drive yourself.
</p>
</div>
</Container>
</Section>
);
}
7 changes: 7 additions & 0 deletions apps/website/src/lib/blog-authors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ export interface Author {
* with docs and code in this repository.
*/
knowsAbout?: readonly string[];
/**
* Profile handles, not URLs. Each is opt-in: `sameAs` is an identity claim, so
* a handle the record does not name must never be synthesized from another.
*/
twitter?: string;
linkedin?: string;
github?: string;
avatar?: string;
}
Expand All @@ -21,6 +26,8 @@ export const blogAuthors: Record<string, Author> = {
bio: 'Agentic software architect building developer tooling for fullstack AI-powered web applications.',
knowsAbout: ['Angular', 'TypeScript', 'LangGraph', 'AG-UI', 'Generative UI', 'Agent user interfaces'],
github: 'blove',
twitter: 'blovedev',
linkedin: 'blove',
},
};

Expand Down
40 changes: 40 additions & 0 deletions apps/website/src/lib/demo-media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: MIT
/**
* Recorded demo clips, hosted on Vercel Blob (store `ngaf-website-assets`)
* rather than committed to the repo — they are large binaries that would bloat
* git history on every recut. Re-uploading with the same pathname keeps these
* URLs stable, so a recut needs no code change.
*
* Shared by the homepage `DemoShowcase` and the solutions pages so one base URL
* is stated once; a second copy would silently drift on the next store move.
*
* See apps/website/scripts/upload-demo-media.md for producing and uploading.
*/
export const DEMO_CDN = 'https://elgkdaxpsvqcrns1.public.blob.vercel-storage.com/demo';

export interface DemoClip {
/** What the clip shows, for the caption under the frame. */
caption: string;
/** Faux address-bar text on the surrounding browser frame. */
url: string;
videoMp4: string;
videoWebm: string;
poster: string;
}

/**
* The human-in-the-loop approval loop, recorded on the canonical demo shell:
* an agent proposing to delete old backups, pausing for sign-off, and resuming
* once approved.
*
* Recorded by `examples/chat/angular/e2e/record-demo.record.ts` against aimock
* fixtures, so a recut is one command and reproduces frame-for-frame.
*/
export const HITL_CLIP: DemoClip = {
caption:
'The agent proposes a destructive action, the graph pauses, and nothing runs until a human approves it.',
url: 'demo.threadplane.ai',
videoMp4: `${DEMO_CDN}/hitl-demo.mp4`,
videoWebm: `${DEMO_CDN}/hitl-demo.webm`,
poster: `${DEMO_CDN}/hitl-demo-poster.webp`,
};
Loading
Loading