diff --git a/.gitignore b/.gitignore index e6a543dbd..597ce1580 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/apps/website/next-env.d.ts b/apps/website/next-env.d.ts index c4b7818fb..fdbfe5258 100644 --- a/apps/website/next-env.d.ts +++ b/apps/website/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 1537e9f4a..763af60e6 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -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'; @@ -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. + */} + + interrupt() 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{' '} + submit({'{ resume }'}) continues + the run — with the decision written back beside the action it gated. + + } + bullets={[ + 'interrupt() pauses mid-run; submit({ resume }) continues it', + ' 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={ + +
+ +
+
+ } + /> + diff --git a/apps/website/src/app/solutions/[slug]/page.tsx b/apps/website/src/app/solutions/[slug]/page.tsx index 4a352f1b9..790ad2a9d 100644 --- a/apps/website/src/app/solutions/[slug]/page.tsx +++ b/apps/website/src/app/solutions/[slug]/page.tsx @@ -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'; @@ -333,6 +335,8 @@ export default async function SolutionPage({ params }: PageProps) { + + {solution.demo && } d.key === 'langgraph')!.href }, diff --git a/apps/website/src/components/solutions/SolutionCodeBlock.tsx b/apps/website/src/components/solutions/SolutionCodeBlock.tsx new file mode 100644 index 000000000..68961e44f --- /dev/null +++ b/apps/website/src/components/solutions/SolutionCodeBlock.tsx @@ -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 ( +
+ +
+ In practice +

+ What it looks like in your codebase +

+ {rendered.map((block, index) => ( +
+

+ {block.label} +

+ {/* + Shiki emits a complete
 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.
+          */}
+              
+
+ ))} +
+ +
+ ); +} diff --git a/apps/website/src/components/solutions/SolutionDemoBlock.tsx b/apps/website/src/components/solutions/SolutionDemoBlock.tsx new file mode 100644 index 000000000..3fbf1fe2a --- /dev/null +++ b/apps/website/src/components/solutions/SolutionDemoBlock.tsx @@ -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 ( +
+ +
+ See it running +

+ The approval gate, in the product +

+

+ {clip.caption} +

+ + +
+ {/* + 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. + */} + +
+
+ +

+ Recorded from the{' '} + + live demo + + , which you can drive yourself. +

+
+
+
+ ); +} diff --git a/apps/website/src/lib/blog-authors.ts b/apps/website/src/lib/blog-authors.ts index 1093271d0..7b9e12a0a 100644 --- a/apps/website/src/lib/blog-authors.ts +++ b/apps/website/src/lib/blog-authors.ts @@ -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; } @@ -21,6 +26,8 @@ export const blogAuthors: Record = { 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', }, }; diff --git a/apps/website/src/lib/demo-media.ts b/apps/website/src/lib/demo-media.ts new file mode 100644 index 000000000..290df04ec --- /dev/null +++ b/apps/website/src/lib/demo-media.ts @@ -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`, +}; diff --git a/apps/website/src/lib/solutions-data.spec.ts b/apps/website/src/lib/solutions-data.spec.ts new file mode 100644 index 000000000..43c747e03 --- /dev/null +++ b/apps/website/src/lib/solutions-data.spec.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { SOLUTIONS, getSolutionBySlug } from './solutions-data'; +import { DEMO_CDN } from './demo-media'; + +/** + * The file header of `solutions-data.ts` sets an editorial rule: no entry may + * be a find-and-replace of another. Most of that rule needs human judgement. + * These tests pin the parts that do not — the mechanical tells that a new + * entry was cloned from an existing one. + */ +describe('SOLUTIONS', () => { + it('gives every entry a distinct slug', () => { + const slugs = SOLUTIONS.map((s) => s.slug); + expect(new Set(slugs).size).toBe(slugs.length); + }); + + it('never repeats a proof-point marker across entries', () => { + // Two entries sharing a marker is the specific tell that one was cloned: + // `compliance` and `customer-support` both carried `Required` for a claim + // that differed only in its synonyms. + const markers = SOLUTIONS.flatMap((s) => s.proofPoints.map((p) => p.metric)); + const duplicates = markers.filter((m, i) => markers.indexOf(m) !== i); + expect(duplicates).toEqual([]); + }); + + it('gives every entry real code, not a placeholder', () => { + for (const solution of SOLUTIONS) { + expect(solution.code.length, solution.slug).toBeGreaterThan(0); + for (const block of solution.code) { + expect(block.source.trim().length, `${solution.slug}/${block.label}`).toBeGreaterThan(80); + expect(block.label.trim().length, solution.slug).toBeGreaterThan(0); + expect(block.source, `${solution.slug}/${block.label}`).not.toMatch(/TODO|FIXME|\.\.\.$/); + } + } + }); + + it('shows a different part of the stack in each entry', () => { + // Compare the FRAMEWORK surface, not every identifier. An earlier version + // matched any `name(` and broke as soon as the snippets grew — two entries + // both calling `filter()` says nothing about which part of the stack they + // show. What matters is which agent methods and package entry points each + // one reaches for. + // + // `injectAgent` is deliberately absent: every Angular snippet starts there. + const FRAMEWORK_ENTRY = /\b(views|defineAngularRegistry|provideAgent|provideRender|signalStateStore)\s*\(/g; + const AGENT_METHOD = /\bagent\.(\w+)\s*\(/g; + + const surface = (solution: (typeof SOLUTIONS)[number]) => { + const all = solution.code.map((b) => b.source).join('\n'); + return new Set([ + ...(all.match(FRAMEWORK_ENTRY) ?? []).map((c) => c.replace(/\s*\($/, '')), + ...[...all.matchAll(AGENT_METHOD)].map((m) => `agent.${m[1]}`), + ]); + }; + + for (const a of SOLUTIONS) { + for (const b of SOLUTIONS) { + if (a.slug >= b.slug) continue; + const [sa, sb] = [surface(a), surface(b)]; + expect(sa.size, `${a.slug} exercises no framework API`).toBeGreaterThan(0); + const shared = [...sa].filter((call) => sb.has(call)); + expect(shared, `${a.slug} vs ${b.slug} exercise the same API`).toEqual([]); + } + } + }); + + it('only attaches a clip to an entry whose claim it shows', () => { + // The HITL clip illustrates an approval gate. `analytics` has no approval + // story, so reusing the footage there would be padding — the same asset + // under a heading it does not illustrate. + const withDemo = SOLUTIONS.filter((s) => s.demo).map((s) => s.slug).sort(); + + expect(withDemo).toEqual(['compliance', 'customer-support']); + }); + + it('serves every clip from the shared blob base', () => { + // A hardcoded URL here would drift the next time the store moves. + for (const solution of SOLUTIONS) { + if (!solution.demo) continue; + for (const url of [solution.demo.videoMp4, solution.demo.videoWebm, solution.demo.poster]) { + expect(url, solution.slug).toContain(DEMO_CDN); + } + } + }); + + it('resolves a known slug and rejects an unknown one', () => { + expect(getSolutionBySlug('compliance')?.slug).toBe('compliance'); + expect(getSolutionBySlug('not-a-solution')).toBeUndefined(); + }); +}); diff --git a/apps/website/src/lib/solutions-data.ts b/apps/website/src/lib/solutions-data.ts index 8d14ea300..ef5035cbd 100644 --- a/apps/website/src/lib/solutions-data.ts +++ b/apps/website/src/lib/solutions-data.ts @@ -13,10 +13,22 @@ * blog post or a docs guide instead, where the thing you actually have to * say can stand on its own. * + * "Real code" is a required field, not an aspiration: `code` must be a working + * snippet against the published API, and each entry's snippet must show a + * DIFFERENT part of the stack from its siblings. Two entries that both reduce + * to "call interrupt(), then approve it" are the find-and-replace this rule + * exists to prevent, however different their prose is. + * + * `solutions-data.spec.ts` enforces what can be enforced mechanically — + * unique proof-point markers, a distinct code snippet per entry. The + * editorial judgement above is still yours. + * * Adding an entry is an editorial decision, not a data-file edit. * * See https://developers.google.com/search/docs/fundamentals/ai-optimization-guide */ +import { HITL_CLIP, type DemoClip } from './demo-media'; + export interface SolutionPainPoint { title: string; description: string; @@ -39,6 +51,24 @@ export interface ProofPoint { label: string; } +/** + * A working snippet against the published API — see the file header. The + * `language` is a Shiki identifier; `label` names the file or layer it comes + * from so the reader knows where it belongs. + */ +export interface SolutionCode { + label: string; + language: 'typescript' | 'python' | 'html'; + source: string; +} + +/** + * The blocks an entry shows, in reading order. An array because the Angular + * story is rarely one file — a component and the template that drives it say + * more together than either does alone. + */ +export type SolutionCodeBlocks = readonly SolutionCode[]; + export interface SolutionConfig { slug: string; color: string; @@ -50,6 +80,14 @@ export interface SolutionConfig { architectureIntro: string; architectureLayers: ArchitectureLayer[]; proofPoints: ProofPoint[]; + code: SolutionCodeBlocks; + /** + * OPTIONAL, and it must stay optional. A clip belongs on an entry only when + * it shows that entry's actual claim — the same footage under a heading it + * does not illustrate is the padding this file's header warns about. + * `analytics` has no approval story, so it carries no clip. + */ + demo?: DemoClip; ctaHeadline: string; ctaSubtext: string; metaTitle: string; @@ -98,9 +136,53 @@ export const SOLUTIONS: SolutionConfig[] = [ ], proofPoints: [ { metric: 'Every', label: 'Agent action recorded — tool calls, interrupts, and state transitions captured in the thread record' }, - { metric: 'Required', label: 'Human approval before consequential actions — wired into LangGraph interrupts, not bolted on' }, + { metric: 'Evidenced', label: 'Each approval is written into the checkpoint beside the action it gated — the decision and the proposal are one record' }, { metric: 'Replayable', label: 'Thread persistence preserves the full decision path for review by auditors and your compliance team' }, ], + code: [ + { + label: 'audit-trail.component.ts — reading the thread record', + language: 'typescript', + source: `export class AuditTrailComponent { + private readonly agent = injectAgent(REVIEW_AGENT); + + // Runtime-neutral timeline: every checkpoint the thread passed through. + readonly checkpoints = computed(() => this.agent.history()); + + // Raw LangGraph ThreadState[], for the fields an auditor asks about. + private readonly raw = computed(() => this.agent.langGraphHistory()); + + // The decisions themselves, lifted out of the checkpoint values. Each row + // pairs what was proposed with what a human answered, and when. + readonly approvals = computed(() => + this.raw() + .filter((state) => state.values?.['approval_result']) + .map((state) => ({ + at: state.created_at, + action: state.values['proposed_action'], + decision: state.values['approval_result'], + })), + ); +}`, + }, + { + label: 'audit-trail.component.html', + language: 'html', + source: ` + @for (row of approvals(); track row.at) { + + + + + + + } +
{{ row.at | date: 'medium' }}{{ row.action.description }}{{ row.decision.approved ? 'Approved' : 'Rejected' }}{{ row.decision.reason }}
+ +

{{ checkpoints().length }} checkpoints on this thread.

`, + }, + ], + demo: HITL_CLIP, ctaHeadline: 'Ship compliant AI agents — without the compliance tax', ctaSubtext: 'Download the field report or start a pilot. Your compliance team will thank you.', metaTitle: 'Compliance & Audit — Threadplane Solutions', @@ -150,6 +232,35 @@ export const SOLUTIONS: SolutionConfig[] = [ { metric: 'Streaming', label: 'Token-level updates as the agent reasons over your data — first results visible before completion' }, { metric: 'Inline', label: 'Charts, tables, and KPI cards rendered into the conversation as Angular components you already own' }, ], + code: [ + { + label: 'dashboard.component.ts — the view catalog', + language: 'typescript', + source: `import { ChatComponent, views } from '@threadplane/chat'; +import { injectAgent } from '@threadplane/langgraph'; + +// Your components, keyed by the name the agent uses in its spec. +// Nothing here knows what question the user will ask. +const analyticsViews = views({ + bar_chart: BarChartComponent, + data_table: DataTableComponent, + kpi_card: KpiCardComponent, +}); + +export class DashboardComponent { + protected readonly agent = injectAgent(); + protected readonly analyticsViews = analyticsViews; +}`, + }, + { + label: 'dashboard.component.html', + language: 'html', + source: ` +`, + }, + ], ctaHeadline: 'Turn your data into conversations', ctaSubtext: 'Download the field report or start a pilot. Ship a conversational BI experience in weeks, not quarters.', metaTitle: 'Analytics & BI — Threadplane Solutions', @@ -196,9 +307,52 @@ export const SOLUTIONS: SolutionConfig[] = [ ], proofPoints: [ { metric: 'Preserved', label: 'Full conversation history across bot-to-human handoff — no repeating the question, no re-explaining the problem' }, - { metric: 'Required', label: 'Human approval gates on sensitive actions (refunds, account changes, escalations) via LangGraph interrupts' }, + { metric: 'Named', label: 'Refunds and account changes resume only with an identified approver — the agent cannot self-authorize' }, { metric: 'Visible', label: 'Tool-call replay for human agents on escalation — see every step the AI took before the handoff' }, ], + code: [ + { + label: 'support-chat.component.ts — the escalation gate', + language: 'typescript', + source: `export class SupportChatComponent { + protected readonly agent = injectAgent(SUPPORT_AGENT); + + // Populated only while the graph is paused on an interrupt. + readonly pendingRefund = computed(() => this.agent.interrupt()); + readonly awaitingHuman = computed(() => this.pendingRefund() !== null); + + // Resuming carries the approver, so the record shows who authorized it — + // the agent has no path to approve its own refund. + approveRefund(approver: string) { + this.agent.submit({ resume: { approved: true, approver } }); + } + + denyRefund(reason: string) { + this.agent.submit({ resume: { approved: false, reason } }); + } + + send(message: string) { + this.agent.submit({ message }); + } +}`, + }, + { + label: 'support-chat.component.html', + language: 'html', + source: ` + +@if (pendingRefund(); as pending) { + +}`, + }, + ], + demo: HITL_CLIP, ctaHeadline: 'Support agents that make your team better', ctaSubtext: 'Download the field report or start a pilot. Resolve routine tickets, escalate the rest with full context, keep your customers happy.', metaTitle: 'Customer Support — Threadplane Solutions', diff --git a/apps/website/src/lib/structured-data.spec.ts b/apps/website/src/lib/structured-data.spec.ts index b6e0603be..76b87b67f 100644 --- a/apps/website/src/lib/structured-data.spec.ts +++ b/apps/website/src/lib/structured-data.spec.ts @@ -233,6 +233,7 @@ describe('aboutPageJsonLd', () => { expect(person['name']).toBe(AUTHOR.name); expect(person['jobTitle']).toBe(AUTHOR.role); expect(person['description']).toBe(AUTHOR.bio); + // The fixture names only a GitHub handle, so only that profile may appear. expect(person['sameAs']).toEqual(['https://github.com/blove']); expect(person['url']).toBe('https://threadplane.ai/about'); }); @@ -251,12 +252,24 @@ describe('aboutPageJsonLd', () => { expect((person['worksFor'] as JsonLdNode)['@id']).toBe(ORGANIZATION_ID); }); - it('resolves the real site author to a real GitHub profile', () => { + it('omits a profile the author record does not name', () => { + // Each handle is opt-in per field: an author with only a GitHub handle must + // not acquire an invented X or LinkedIn URL. + const graph = aboutPageJsonLd({ name: 'Anon', github: 'anon' })['@graph'] as JsonLdNode[]; + const person = graph.find((node) => node['@type'] === 'Person') as JsonLdNode; + expect(person['sameAs']).toEqual(['https://github.com/anon']); + }); + + it('resolves the real site author to real profiles', () => { // The page passes `blogAuthors['brian']`; `sameAs` is an identity claim, so - // this pins the profile the repo actually knows rather than the fixture's. + // this pins the profiles the repo actually knows rather than the fixture's. const graph = aboutPageJsonLd(blogAuthors['brian'])['@graph'] as JsonLdNode[]; const person = graph.find((node) => node['@type'] === 'Person') as JsonLdNode; - expect(person['sameAs']).toEqual(['https://github.com/blove']); + expect(person['sameAs']).toEqual([ + 'https://github.com/blove', + 'https://x.com/blovedev', + 'https://www.linkedin.com/in/blove', + ]); }); it('serializes to JSON', () => { diff --git a/apps/website/src/lib/structured-data.ts b/apps/website/src/lib/structured-data.ts index e1d1357fe..33f9c9f90 100644 --- a/apps/website/src/lib/structured-data.ts +++ b/apps/website/src/lib/structured-data.ts @@ -127,6 +127,19 @@ export const PERSON_ID = `${getCanonicalUrl(ABOUT_PATH)}#person`; * Every field is derived from the caller's {@link Author} record; nothing about * the person is stated here. */ +/** + * The external profiles an author record actually names, as absolute URLs. + * + * Order is stable so the emitted JSON-LD does not churn between builds. + */ +function personProfiles(author: Author): string[] { + return [ + author.github && `https://github.com/${author.github}`, + author.twitter && `https://x.com/${author.twitter}`, + author.linkedin && `https://www.linkedin.com/in/${author.linkedin}`, + ].filter((url): url is string => Boolean(url)); +} + export function aboutPageJsonLd(author: Author) { const url = getCanonicalUrl(ABOUT_PATH); const person: JsonLdNode = { @@ -137,8 +150,9 @@ export function aboutPageJsonLd(author: Author) { ...(author.role ? { jobTitle: author.role } : {}), ...(author.bio ? { description: author.bio } : {}), // Only profiles the repo actually knows about; `sameAs` is an identity - // claim, so a guessed profile is a false one. - ...(author.github ? { sameAs: [`https://github.com/${author.github}`] } : {}), + // claim, so a guessed profile is a false one. Each handle is a separate + // opt-in field — one is never derived from another. + ...(personProfiles(author).length ? { sameAs: personProfiles(author) } : {}), ...(author.knowsAbout?.length ? { knowsAbout: [...author.knowsAbout] } : {}), worksFor: { '@id': ORGANIZATION_ID }, }; diff --git a/cockpit/chat/interrupts/angular/e2e/record-demo.config.ts b/cockpit/chat/interrupts/angular/e2e/record-demo.config.ts new file mode 100644 index 000000000..4630f81fa --- /dev/null +++ b/cockpit/chat/interrupts/angular/e2e/record-demo.config.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +/** + * Playwright config for recording the HITL demo clip. Mirrors + * `playwright.config.ts` — same aimock-backed global setup — and adds video + * capture at the size the website expects. + * + * Separate from the test config on purpose: `testMatch` here picks up only + * `*.record.ts`, so recording never runs in CI and the e2e suite never records. + * + * npx playwright test --config cockpit/chat/interrupts/angular/e2e/record-demo.config.ts + */ +import { defineConfig, devices } from '@playwright/test'; +import { portsFor } from '../../../../../cockpit/ports.mjs'; + +const { angular: angularPort } = portsFor('cockpit-chat-interrupts-angular'); + +/** 1280x800, matching the existing homepage clips. */ +const FRAME = { width: 1280, height: 800 }; + +export default defineConfig({ + testDir: '.', + testMatch: '**/*.record.ts', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: 'list', + use: { + baseURL: `http://localhost:${angularPort}`, + viewport: FRAME, + video: { mode: 'on', size: FRAME }, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], viewport: FRAME } }], + globalSetup: './global-setup-impl.ts', + globalTeardown: require.resolve('../../../../../libs/e2e-harness/src/global-teardown'), + outputDir: './.record-output', +}); diff --git a/cockpit/chat/interrupts/angular/e2e/record-demo.record.ts b/cockpit/chat/interrupts/angular/e2e/record-demo.record.ts new file mode 100644 index 000000000..ca5a6e879 --- /dev/null +++ b/cockpit/chat/interrupts/angular/e2e/record-demo.record.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +/** + * Records the human-in-the-loop loop as a screen capture for the website. + * + * NOT a test — `.record.ts`, so the normal `**\/*.spec.ts` config never picks it + * up in CI. Run it with `record-demo.config.ts`, which boots the same aimock + * backend the e2e suite uses, so the take is deterministic and needs no API key. + * + * Pacing is deliberate: the clip has to be readable at a glance on a marketing + * page, so it types at human speed and holds on the two beats that matter — + * the approval panel appearing, and the booking confirmed after Accept. + * + * See apps/website/scripts/upload-demo-media.md for encoding and upload. + */ +import { test, expect } from '@playwright/test'; + +// One take, no retries: a retry would leave two videos and the wrong one may win. +test.describe.configure({ retries: 0 }); + +test('hitl approval loop', async ({ page }) => { + await page.goto('/'); + + // Let the shell settle before the first keystroke — the opening frame becomes + // the poster image. + await page.waitForTimeout(1200); + + const input = page.getByRole('textbox', { name: /message|prompt/i }); + await input.click(); + await input.pressSequentially('Book me on UA123.', { delay: 55 }); + await page.waitForTimeout(500); + + await page.getByRole('button', { name: /send/i }).click(); + + // The agent proposes, the graph pauses, the panel renders. This is the beat + // the whole clip exists to show. + const panel = page.locator('chat-interrupt-panel'); + await expect(panel).toBeVisible({ timeout: 60_000 }); + await panel.scrollIntoViewIfNeeded(); + await page.waitForTimeout(2600); + + await page.getByRole('button', { name: 'Accept' }).click(); + + // Resumed: the tool actually runs and the booking is confirmed. + await expect(page.getByText(/booked/i).last()).toBeVisible({ timeout: 60_000 }); + await page.waitForTimeout(2400); +}); diff --git a/docs/superpowers/specs/2026-08-25-homepage-medium-switcher-design.md b/docs/superpowers/specs/2026-08-25-homepage-medium-switcher-design.md new file mode 100644 index 000000000..06140e84a --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-homepage-medium-switcher-design.md @@ -0,0 +1,145 @@ +# Homepage medium switcher — design + +**Date:** 2026-08-25 +**Status:** approved, not yet implemented + +## Problem + +The homepage proves things unevenly. Of five visuals, `DemoShowcase` is a tabbed +video with a live-demo modal, the new `#approve` block is a video, and Stream, +Render, and Ship are **static screenshots**. The page also displays **no code at +all** — `HighlightedCode` exists and is used on the library landing pages +(`/langgraph`, `/render`, `/chat`), but nothing on the homepage shows a line of +it, which is a strange gap for a developer framework. + +Different readers want different evidence. A video proves the thing is real, code +proves it is small, and a live demo proves it is not staged. Today each section +picks one and the reader gets no choice. + +## Goal + +Let a single homepage section offer the same claim as video, as code, and as a +live embed, so a skimming buyer and a skeptical developer can each get the proof +they came for — and so the homepage gains a code surface where it matters. + +## Non-goals + +- Rebuilding `DemoShowcase`. Its tabs are *runtimes* (LangGraph, AG-UI), not + mediums. Mixing the two axes weakens the "One chat UI. Two runtimes. Same + code." comparison that section exists to make. +- Any change to `FeatureBlock`. The switcher drops into its existing `visual` + slot. + +## Component + +`MediumSwitcher`, a client component (tab state), rendered into +`FeatureBlock`'s `visual` slot. + +```ts +interface SectionMedia { + video?: DemoClip; // from lib/demo-media.ts + code?: SolutionCode[]; // reuse the solutions type + live?: { prompt: string; mode?: 'embed' | 'popup' | 'sidebar' }; +} +``` + +Every medium is optional, and that is load-bearing: + +- **One medium renders bare, with no tablist.** Chrome around a single option is + noise. +- Sections gain tabs as media is produced, rather than blocking the whole feature + on a recording that does not exist yet. + +Tab order is fixed — video, code, live — so the control does not reshuffle +between sections. + +## The constraint that shapes the implementation + +The homepage today autoplays **one** video. Four switchers could mean four +autoplaying videos plus four iframes, on a page that already runs thirteen +sections. That is the main technical risk in this design. + +**Only the active pane mounts.** Inactive videos are not in the DOM. The live +iframe mounts only once its tab is selected, never on page load. First paint +therefore stays at one video — Stream's, since `video` is the default tab — +and poster images carry the visual weight of unselected panes. + +This is a correctness requirement, not an optimization. A reviewer should reject +an implementation that renders all three panes and toggles them with CSS. + +## Content + +Four sections × three mediums = twelve panes. + +| Section | Video | Code | Live prompt | +| --- | --- | --- | --- | +| Stream | `langgraph-demo` (exists) | streaming snippet | stream a long answer | +| Render | **needs recording** — generative UI | `views()` + `` | chart request | +| Ship | **needs recording** — reload restores the thread | `error()` / `status()` / `reload()` | any prompt, then reload | +| Approve | `hitl-demo` (exists) | `interrupt()` / `submit({ resume })` | the backups approval scenario | + +Ship was initially judged to have no watchable moment. That was wrong: +**durability is watchable.** Reloading the page and seeing the thread restore, +or killing the backend and seeing the error boundary offer a retry, is exactly +Ship's claim and is recordable deterministically. + +Every code pane must be a working snippet against the published API, sourced +from the docs rather than written from memory. The same rule +`solutions-data.ts` already enforces applies here. + +## `?prompt=` in examples/chat + +The demo app supports `/embed/:threadId`, `/popup/:threadId`, +`/sidebar/:threadId` and an `?appmode=` flag, but has no way to open on a given +scenario. Without one, every section's live tab is the same empty demo under a +different heading — the find-and-replace pattern `solutions-data.ts` exists to +prevent, and the weakest tab in every section. + +Add `?prompt=` to `examples/chat`, which **prefills the composer and never +auto-sends**. Auto-executing text from a URL would let any link run something on +a visitor's behalf; prefilling keeps a human in the loop, which is the framework's +own argument. + +Rejected alternative: deep-linking pre-seeded threads via `/embed/:threadId`. +It needs no app change, but the seeded threads must survive in production +storage, and a checkpoint wipe would silently empty every live tab on the +homepage with no failing test anywhere. + +## Accessibility + +Real `tablist` / `tab` / `tabpanel` roles with `aria-selected`, +`aria-controls`, and arrow-key navigation — not the button-only pattern +`DemoShowcase` uses today. Videos stay `muted` + `playsInline` with an +`aria-label`; they carry no audio and no narration, so captions would have +nothing to caption. + +## Analytics + +Medium switches go through the existing `trackCtaClick` path, so which proof +readers actually reach for is measurable rather than assumed. + +## Testing + +- `MediumSwitcher`: tab roles and `aria-selected`; arrow-key movement; a single + medium renders bare with no tablist; **only the active pane is in the DOM**; + the live iframe is absent until its tab is selected. +- Data: every section declares at least one medium; every live prompt is + non-empty; every video URL resolves through `DEMO_CDN`. +- `?prompt=`: the composer is prefilled and **no run starts** — the important + assertion, since the failure mode is a URL that executes. + +## Delivery + +Two PRs, so neither is unreviewable and half ships without waiting on +recordings. + +1. `MediumSwitcher` + Stream and Approve (their videos already exist), with code + and video tabs. No live tab yet. +2. `?prompt=` in `examples/chat`, the Render and Ship recordings, and the live + tab across all four sections. + +## Open questions + +None blocking. The two recordings are mechanical: write a `.record.ts`, run it +against aimock, encode, upload — the path used for `hitl-demo`, documented in +`apps/website/scripts/upload-demo-media.md`. diff --git a/examples/chat/angular/e2e/record-demo.config.ts b/examples/chat/angular/e2e/record-demo.config.ts new file mode 100644 index 000000000..4014d47c8 --- /dev/null +++ b/examples/chat/angular/e2e/record-demo.config.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +/** + * Playwright config for recording the HITL demo clip from the canonical demo + * shell. Mirrors `playwright.config.ts` — same aimock-backed global setup — + * and adds video capture at the size the website expects. + * + * `testMatch` picks up only `*.record.ts`, so recording never runs in CI and + * the e2e suite never records. + * + * npx playwright test --config examples/chat/angular/e2e/record-demo.config.ts + */ +import { defineConfig } from '@playwright/test'; + +/** 1280x800, matching the existing homepage clips. */ +const FRAME = { width: 1280, height: 800 }; + +export default defineConfig({ + testDir: '.', + testMatch: '**/*.record.ts', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: 'list', + timeout: 180_000, + use: { + baseURL: 'http://localhost:4200', + viewport: FRAME, + video: { mode: 'on', size: FRAME }, + }, + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + outputDir: './.record-output', +}); diff --git a/examples/chat/angular/e2e/record-demo.record.ts b/examples/chat/angular/e2e/record-demo.record.ts new file mode 100644 index 000000000..aa99760cb --- /dev/null +++ b/examples/chat/angular/e2e/record-demo.record.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +/** + * Records the human-in-the-loop approval loop on the canonical demo shell — + * the same surface as demo.threadplane.ai, so the clip matches the homepage + * ones rather than looking like a different product. + * + * NOT a test — `.record.ts`, which the e2e config's `**\/*.spec.ts` never picks + * up. Run it with `record-demo.config.ts`, which reuses the aimock-backed + * global setup, so the take is deterministic and needs no API key. + * + * The scenario is deliberately the destructive-action one from + * `interrupt-approval.spec.ts`: an agent proposing to delete old backups and + * pausing for sign-off is the claim the compliance page actually makes. + * + * See apps/website/scripts/upload-demo-media.md for encoding and upload. + */ +import { test, expect } from '@playwright/test'; +import { openDemo } from './test-helpers'; + +test.describe.configure({ retries: 0 }); + +// VERBATIM from interrupt-approval.spec.ts. aimock fixtures match on the exact +// user message — reword this and the agent never calls request_approval, so the +// graph never pauses and there is nothing to record. +const PROMPT = + 'I want to clean up old database backups older than 90 days. Walk me through ' + + 'what you would delete, and call request_approval before doing anything ' + + 'destructive so I can review your plan.'; + +test('hitl approval loop', async ({ page }) => { + await openDemo(page, '/embed'); + await page.waitForTimeout(1500); + + const input = page.getByRole('textbox', { name: /type a message|message|prompt/i }); + await input.click(); + await input.pressSequentially(PROMPT, { delay: 28 }); + await page.waitForTimeout(600); + await page.getByRole('button', { name: /send/i }).click(); + + // The graph pauses on interrupt() and the panel renders. Hold here — this is + // the beat the clip exists to show. + // `toBeAttached`, not visible: the e2e suite waits the same way — the panel + // is a durable paused-state signal, not a transient popup. + const panel = page.locator('chat-interrupt-panel'); + await expect(panel).toBeAttached({ timeout: 90_000 }); + await expect(panel).toContainText(/agent paused/i, { timeout: 30_000 }); + await panel.scrollIntoViewIfNeeded(); + await page.waitForTimeout(3200); + + // Approve, and let the agent resume and finish. + await panel.getByRole('button', { name: /accept/i }).click(); + await page.waitForTimeout(4000); +});