Skip to content

Commit 9bfeb26

Browse files
bloveclaude
andauthored
feat(website): give each solutions page real code, and split the duplicated proof point (#830)
* feat(website): link Brian's X and LinkedIn profiles from the Person node `sameAs` is how a Person node resolves to a real-world identity, and answer engines lean on it for entity disambiguation — the reason /about carries a Person node at all. It listed only GitHub, so the strongest disambiguating signals were missing. Add the two profiles Brian already links publicly from brianflove.com, verified against that page's raw HTML rather than a summary. (LinkedIn answers 999 to automated requests; that is its anti-bot response, not a dead link.) Keep the existing invariant intact: `sameAs` states only profiles the author record actually names. Each handle is its own opt-in field, so one is never synthesized from another — an author with a GitHub handle does not acquire an invented X URL — and `personProfiles()` emits them in a stable order so the JSON-LD does not churn between builds. A test covers exactly that case. `twitter` was already declared on the Author interface and read by nothing; populating it now feeds only `sameAs`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): give each solutions page real code, and split the duplicated proof point Two findings from auditing `solutions-data.ts` against its own guardrail. The guardrail demanded "real code" the data model could not hold. `SolutionConfig` had no code field, the page rendered none, and the live pages contained zero `<code>` or `<pre>` elements — the clause was unfulfillable, not merely unmet. Add a required `code` field and a snippet per entry, each written against the published API: `agent.history()`/`langGraphHistory()` for compliance, `agent.interrupt()`/`submit({ resume })` for customer support, `defineAngularRegistry()` + `<render-spec>` for analytics. Highlighting uses Shiki directly rather than `rehype-pretty-code`, which only runs over MDX; the theme matches `MdxRenderer` so a snippet here reads like one in the docs. It runs in an async Server Component, so it costs the browser nothing. The wrapper uses `overflow: hidden`, not `auto` — Shiki's `<pre>` already scrolls, and nesting a second scroll container can show two scrollbars. The overlap between `compliance` and `customer-support` was narrower than reported: vocabulary overlap is 26% against a 20% control, and pain points, titles, and CTAs are all distinct. The genuine duplicate was one proof point — both used the marker `Required` for a human-approval claim that differed only in synonyms. Both are rewritten to their own half: compliance to the audit record, support to approver identity. `solutions-data.spec.ts` now enforces mechanically what the header asks for in prose: unique proof-point markers, real code, and — the important one — that no two entries' snippets exercise the same API. That test earned its place: it rejected the first draft of these snippets, where compliance and support both called `interrupt()`, which is precisely the find-and-replace the guardrail exists to prevent. Framework entry points (`injectAgent`, `computed`) are excluded because they appear in any Angular snippet and say nothing about which part of the stack is on show; the exclusion list is commented so it cannot be quietly widened to hide a real clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): expand the solutions code blocks to component + template One five-line snippet per page showed the call but not the shape of the work. Each entry now carries an ordered array of blocks — the component and the template that drives it — because the Angular story is rarely one file. Fixes two API errors in the first pass, both caught by checking the docs rather than the rendered page: - The analytics snippet called `agent.surface()?.spec`, which does not exist, and drove `<render-spec>` directly. The real generative-UI path is `views()` plus `<chat [views]>`; `ChatComponent` detects a JSON spec in the AI message and renders it through the catalog, streaming partial specs as they arrive. `defineAngularRegistry()` is for driving `<render-spec>` yourself — a different path, and the wrong one for a chat-based analytics surface. - The support template rendered a bare `<chat />`; the component takes `[agent]`. The API-surface test needed rewriting, not relaxing. Matching any `name(` worked for five-line snippets and broke immediately at this size: two entries both calling `filter()` says nothing about which part of the stack they show. It now compares framework surface only — agent methods and package entry points — which is what the guardrail actually cares about. Still mutation-tested: pointing support's snippet at `agent.history()` fails with `compliance vs customer-support exercise the same API`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): show the HITL approval clip on the solutions pages it applies to Adds the recorded human-in-the-loop loop after the code section: an agent proposing to delete old backups, the graph pausing, and nothing running until a human approves. `demo` is optional on purpose, and must stay optional. The clip shows an approval gate, so it goes on `compliance` and `customer-support` and NOT on `analytics`, which has no approval story — the same footage under a heading it does not illustrate is exactly the padding this file's header warns about. A test pins the placement rather than trusting the next editor to remember. `SolutionDemoBlock` is deliberately not the homepage `DemoShowcase`. That one is a tabbed switcher with a play overlay opening the live demo in a modal, and neither fits: there is one clip and no second runtime to switch between, and the live demo opens on an empty thread rather than on this flow, so a "Launch live demo" button would promise something the destination does not deliver. A plain link under the frame says the same thing without the false promise. No client JS — autoplay/muted/loop/playsInline is the whole behaviour. `DEMO_CDN` moves to `lib/demo-media.ts` and `DemoShowcase` now imports it. It was previously a private constant there, so the solutions pages would have needed a second copy that silently drifts the next time the store moves. The recording scripts are committed alongside, so a recut is one command instead of rediscovery. They are `.record.ts`, which the e2e `testMatch` never picks up, so they cannot run in CI. Both document the trap that cost me a take: aimock matches the EXACT user message, so rewording the prompt means the agent never calls request_approval, the graph never pauses, and there is nothing to record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): add an Approve section to the homepage, showing the HITL clip The homepage claimed human-in-the-loop without ever showing it: the Differentiator table lists "Resumable interrupts" as a production-readiness dimension and the Stream block mentions interrupts in passing, but every FeatureBlock visual is a static screenshot and none of them is an approval claim. The strongest thing the recorded clip proves was asserted in a table row and never evidenced. A fourth FeatureBlock follows the established pattern and is the only placement whose heading the clip actually illustrates — the same rule the solutions pages follow. `visualLeft` continues the alternation after Ship. Two placements considered and rejected. A third tab in `DemoShowcase` would break a deliberate framing: that section is "One chat UI. Two runtimes. Same code" and its tabs are runtimes, so a capability tab makes the tablist heterogeneous and weakens the comparison it exists to make. Swapping an existing FeatureBlock's screenshot for the clip would put it under Stream, Render, or Ship — none of which is an approval claim. Reuses `HITL_CLIP`, so the homepage and the solutions pages cannot drift, and stays a Server Component: autoplay/muted/loop/playsInline is the whole behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(spec): homepage medium switcher (video / code / live embed) Design for letting one homepage section prove its claim three ways. Records two decisions that constrain implementation: only the active pane may mount (four autoplaying videos plus iframes on an already-long page is the main risk), and the live tab needs a ?prompt= param in examples/chat or it degrades into the same empty demo under four different headings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent fb67c47 commit 9bfeb26

14 files changed

Lines changed: 850 additions & 7 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,6 @@ examples/chat/angular/src/environments/generated-keys.local.ts
7070

7171
# Local service-account keys (GSC, etc). Never commit these.
7272
keys/
73+
74+
# Playwright demo-recording output (large binaries; see apps/website/scripts/upload-demo-media.md)
75+
**/.record-output/

apps/website/src/app/page.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { EcosystemStrip } from '../components/landing/EcosystemStrip';
33
import { Differentiator } from '../components/landing/Differentiator';
44
import { FeatureBlock } from '../components/landing/FeatureBlock';
55
import { BrowserFrame } from '../components/ui/BrowserFrame';
6+
import { HITL_CLIP } from '../lib/demo-media';
67
import { DemoShowcase } from '../components/landing/DemoShowcase';
78
import { PilotBlock } from '../components/landing/PilotBlock';
89
import { WhitePaperBlock } from '../components/landing/WhitePaperBlock';
@@ -134,6 +135,58 @@ export default async function HomePage() {
134135
}
135136
/>
136137

138+
{/*
139+
The homepage claims human-in-the-loop in the Differentiator table and
140+
mentions interrupts in the Stream block, but nothing here showed it.
141+
This is the only section whose heading the approval clip actually
142+
illustrates — the same rule the solutions pages follow.
143+
*/}
144+
<FeatureBlock
145+
id="approve"
146+
eyebrow="Approve"
147+
headline="Nothing irreversible happens without a human."
148+
body={
149+
<>
150+
<code style={{ fontFamily: tokens.typography.fontMono }}>interrupt()</code> freezes the graph
151+
mid-run and the pause lives in the checkpoint, not in component state. Your UI renders the
152+
proposal, the human answers, and{' '}
153+
<code style={{ fontFamily: tokens.typography.fontMono }}>submit({'{ resume }'})</code> continues
154+
the run — with the decision written back beside the action it gated.
155+
</>
156+
}
157+
bullets={[
158+
'interrupt() pauses mid-run; submit({ resume }) continues it',
159+
'<chat-interrupt-panel> renders the proposal',
160+
'The pause is a checkpoint, not a modal',
161+
'Decision and proposal land in one thread record',
162+
]}
163+
supportingCards={[
164+
{ title: 'interrupt()', description: 'Freezes the graph before the action runs.' },
165+
{ title: 'resume', description: 'Carries the human decision back into the run.' },
166+
{ title: 'checkpoint', description: 'The pause survives; it is not UI state.' },
167+
]}
168+
cta={{ label: 'Interrupt patterns', href: '/docs/langgraph/guides/interrupts' }}
169+
visualLeft
170+
visual={
171+
<BrowserFrame url={HITL_CLIP.url} elevation="lg">
172+
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
173+
<video
174+
autoPlay
175+
muted
176+
loop
177+
playsInline
178+
poster={HITL_CLIP.poster}
179+
aria-label={HITL_CLIP.caption}
180+
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
181+
>
182+
<source src={HITL_CLIP.videoWebm} type="video/webm" />
183+
<source src={HITL_CLIP.videoMp4} type="video/mp4" />
184+
</video>
185+
</div>
186+
</BrowserFrame>
187+
}
188+
/>
189+
137190
<PilotBlock />
138191
<WhitePaperBlock />
139192
<Promises />

apps/website/src/app/solutions/[slug]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
} from '../../../lib/solutions-data';
1111
import { Container } from '../../../components/ui/Container';
1212
import { Section } from '../../../components/ui/Section';
13+
import { SolutionCodeBlock } from '../../../components/solutions/SolutionCodeBlock';
14+
import { SolutionDemoBlock } from '../../../components/solutions/SolutionDemoBlock';
1315
import { Eyebrow } from '../../../components/ui/Eyebrow';
1416
import { Button } from '../../../components/ui/Button';
1517
import { Pill } from '../../../components/ui/Pill';
@@ -333,6 +335,8 @@ export default async function SolutionPage({ params }: PageProps) {
333335
<PainPoints items={solution.painPoints} accent={solution.color} />
334336
<Architecture intro={solution.architectureIntro} layers={solution.architectureLayers} accent={solution.color} />
335337
<Capabilities items={solution.proofPoints} accent={solution.color} />
338+
<SolutionCodeBlock code={solution.code} accent={solution.color} />
339+
{solution.demo && <SolutionDemoBlock clip={solution.demo} accent={solution.color} />}
336340
<WhitePaperBlock />
337341
<FinalCTA
338342
headline={solution.ctaHeadline}

apps/website/src/components/landing/DemoShowcase.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DemoCtaPair } from './DemoCtaPair';
77
import { DemoModal } from './DemoModal';
88
import { trackCtaClick } from '../../lib/analytics/client';
99
import { DEMOS } from '../../lib/demos';
10+
import { DEMO_CDN } from '../../lib/demo-media';
1011

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

@@ -20,11 +21,6 @@ interface DemoMedia {
2021
href: string;
2122
}
2223

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

2925
const MEDIA: DemoMedia[] = [
3026
{ 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 },
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-License-Identifier: MIT
2+
import { codeToHtml } from 'shiki';
3+
import { tokens } from '@threadplane/design-tokens';
4+
import { Container } from '../ui/Container';
5+
import { Section } from '../ui/Section';
6+
import { Eyebrow } from '../ui/Eyebrow';
7+
import type { SolutionCode, SolutionCodeBlocks } from '../../lib/solutions-data';
8+
9+
/**
10+
* The `code` block on a solutions page.
11+
*
12+
* Highlighted with Shiki directly rather than through `rehype-pretty-code`:
13+
* that plugin only runs over MDX, and these pages are TSX. The theme matches
14+
* `MdxRenderer`'s (`tokyo-night`) so a snippet here reads the same as one in
15+
* the docs.
16+
*
17+
* This is an async Server Component, so highlighting happens at build time and
18+
* ships no Shiki payload to the browser.
19+
*/
20+
async function highlight(block: SolutionCode) {
21+
return codeToHtml(block.source, { lang: block.language, theme: 'tokyo-night' });
22+
}
23+
24+
export async function SolutionCodeBlock({ code, accent }: { code: SolutionCodeBlocks; accent: string }) {
25+
// Highlight every block up front: an async map inside JSX would give React
26+
// promises to render rather than markup.
27+
const rendered = await Promise.all(
28+
code.map(async (block) => ({ ...block, html: await highlight(block) })),
29+
);
30+
31+
return (
32+
<Section surface="canvas" ariaLabelledBy="solution-code-heading">
33+
<Container>
34+
<div style={{ maxWidth: 820, margin: '0 auto' }}>
35+
<Eyebrow style={{ color: accent, marginBottom: 12 }}>In practice</Eyebrow>
36+
<h2
37+
id="solution-code-heading"
38+
style={{
39+
fontFamily: tokens.typography.h2.family,
40+
fontSize: tokens.typography.h2.size,
41+
lineHeight: tokens.typography.h2.line,
42+
fontWeight: 700,
43+
color: tokens.colors.textPrimary,
44+
margin: 0,
45+
marginBottom: 12,
46+
letterSpacing: '-0.015em',
47+
}}
48+
>
49+
What it looks like in your codebase
50+
</h2>
51+
{rendered.map((block, index) => (
52+
<div key={block.label} style={{ marginTop: index === 0 ? 0 : 24 }}>
53+
<p
54+
style={{
55+
fontFamily: tokens.typography.fontMono,
56+
fontSize: 12,
57+
fontWeight: 700,
58+
letterSpacing: '0.04em',
59+
color: tokens.colors.textMuted,
60+
margin: '0 0 10px',
61+
}}
62+
>
63+
{block.label}
64+
</p>
65+
{/*
66+
Shiki emits a complete <pre> that already carries its own background,
67+
padding, and `overflow-x: auto`, so this wrapper owns only the frame.
68+
`overflow: hidden` is what makes the radius clip that background — it
69+
must not be `auto`, which would nest a second scroll container around
70+
a element that already scrolls and can show two scrollbars.
71+
*/}
72+
<div
73+
className="solution-code"
74+
style={{
75+
borderRadius: 12,
76+
overflow: 'hidden',
77+
border: `1px solid ${tokens.surfaces.border}`,
78+
fontSize: 14,
79+
}}
80+
dangerouslySetInnerHTML={{ __html: block.html }}
81+
/>
82+
</div>
83+
))}
84+
</div>
85+
</Container>
86+
</Section>
87+
);
88+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// SPDX-License-Identifier: MIT
2+
import { tokens } from '@threadplane/design-tokens';
3+
import { Container } from '../ui/Container';
4+
import { Section } from '../ui/Section';
5+
import { Eyebrow } from '../ui/Eyebrow';
6+
import { BrowserFrame } from '../ui/BrowserFrame';
7+
import type { DemoClip } from '../../lib/demo-media';
8+
9+
/**
10+
* A recorded clip on a solutions page, shown after the code.
11+
*
12+
* Deliberately NOT the homepage `DemoShowcase`: that one is a tabbed switcher
13+
* with a play overlay that opens the live demo in a modal. Neither fits here —
14+
* there is one clip and no second runtime to switch between, and the live demo
15+
* opens on an empty thread rather than on the flow this clip is about, so a
16+
* "Launch live demo" button would promise something the destination does not
17+
* deliver. A link under the frame says the same thing honestly.
18+
*
19+
* No client JS: `autoPlay muted loop playsInline` is the whole behaviour, so
20+
* this stays a Server Component.
21+
*/
22+
export function SolutionDemoBlock({ clip, accent }: { clip: DemoClip; accent: string }) {
23+
return (
24+
<Section surface="tinted" ariaLabelledBy="solution-demo-heading">
25+
<Container>
26+
<div style={{ maxWidth: 820, margin: '0 auto' }}>
27+
<Eyebrow style={{ color: accent, marginBottom: 12 }}>See it running</Eyebrow>
28+
<h2
29+
id="solution-demo-heading"
30+
style={{
31+
fontFamily: tokens.typography.h2.family,
32+
fontSize: tokens.typography.h2.size,
33+
lineHeight: tokens.typography.h2.line,
34+
fontWeight: 700,
35+
color: tokens.colors.textPrimary,
36+
margin: 0,
37+
marginBottom: 12,
38+
letterSpacing: '-0.015em',
39+
}}
40+
>
41+
The approval gate, in the product
42+
</h2>
43+
<p
44+
style={{
45+
fontFamily: tokens.typography.bodyLg.family,
46+
fontSize: tokens.typography.bodyLg.size,
47+
lineHeight: tokens.typography.bodyLg.line,
48+
color: tokens.colors.textSecondary,
49+
margin: '0 0 20px',
50+
maxWidth: '62ch',
51+
}}
52+
>
53+
{clip.caption}
54+
</p>
55+
56+
<BrowserFrame url={clip.url} elevation="lg">
57+
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
58+
{/*
59+
Silent, decorative loop. `aria-label` rather than captions: there
60+
is no audio track and no narration to caption, and the prose
61+
above already states what the clip shows.
62+
*/}
63+
<video
64+
autoPlay
65+
muted
66+
loop
67+
playsInline
68+
poster={clip.poster}
69+
aria-label={clip.caption}
70+
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
71+
>
72+
<source src={clip.videoWebm} type="video/webm" />
73+
<source src={clip.videoMp4} type="video/mp4" />
74+
</video>
75+
</div>
76+
</BrowserFrame>
77+
78+
<p
79+
style={{
80+
fontFamily: tokens.typography.body.family,
81+
fontSize: tokens.typography.body.size,
82+
color: tokens.colors.textMuted,
83+
margin: '14px 0 0',
84+
}}
85+
>
86+
Recorded from the{' '}
87+
<a
88+
href="https://demo.threadplane.ai"
89+
target="_blank"
90+
rel="noopener noreferrer"
91+
style={{ color: tokens.colors.accent }}
92+
>
93+
live demo
94+
</a>
95+
, which you can drive yourself.
96+
</p>
97+
</div>
98+
</Container>
99+
</Section>
100+
);
101+
}

apps/website/src/lib/demo-media.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-License-Identifier: MIT
2+
/**
3+
* Recorded demo clips, hosted on Vercel Blob (store `ngaf-website-assets`)
4+
* rather than committed to the repo — they are large binaries that would bloat
5+
* git history on every recut. Re-uploading with the same pathname keeps these
6+
* URLs stable, so a recut needs no code change.
7+
*
8+
* Shared by the homepage `DemoShowcase` and the solutions pages so one base URL
9+
* is stated once; a second copy would silently drift on the next store move.
10+
*
11+
* See apps/website/scripts/upload-demo-media.md for producing and uploading.
12+
*/
13+
export const DEMO_CDN = 'https://elgkdaxpsvqcrns1.public.blob.vercel-storage.com/demo';
14+
15+
export interface DemoClip {
16+
/** What the clip shows, for the caption under the frame. */
17+
caption: string;
18+
/** Faux address-bar text on the surrounding browser frame. */
19+
url: string;
20+
videoMp4: string;
21+
videoWebm: string;
22+
poster: string;
23+
}
24+
25+
/**
26+
* The human-in-the-loop approval loop, recorded on the canonical demo shell:
27+
* an agent proposing to delete old backups, pausing for sign-off, and resuming
28+
* once approved.
29+
*
30+
* Recorded by `examples/chat/angular/e2e/record-demo.record.ts` against aimock
31+
* fixtures, so a recut is one command and reproduces frame-for-frame.
32+
*/
33+
export const HITL_CLIP: DemoClip = {
34+
caption:
35+
'The agent proposes a destructive action, the graph pauses, and nothing runs until a human approves it.',
36+
url: 'demo.threadplane.ai',
37+
videoMp4: `${DEMO_CDN}/hitl-demo.mp4`,
38+
videoWebm: `${DEMO_CDN}/hitl-demo.webm`,
39+
poster: `${DEMO_CDN}/hitl-demo-poster.webp`,
40+
};

0 commit comments

Comments
 (0)