From 82e62e76953b7276fd17a3b1bb3aacebcfcbfb95 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 24 Aug 2026 17:39:20 -0700 Subject: [PATCH 1/6] feat(website): link Brian's X and LinkedIn profiles from the Person node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- apps/website/next-env.d.ts | 2 +- apps/website/src/lib/blog-authors.ts | 7 +++++++ apps/website/src/lib/structured-data.spec.ts | 19 ++++++++++++++++--- apps/website/src/lib/structured-data.ts | 18 ++++++++++++++++-- 4 files changed, 40 insertions(+), 6 deletions(-) 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/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/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 }, }; From 7a54dffc2e3fd0026f3be02b039870c48d831da0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 25 Aug 2026 10:49:28 -0700 Subject: [PATCH 2/6] feat(website): give each solutions page real code, and split the duplicated proof point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` or `
` 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()` + `` 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 `
`
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 
---
 .../website/src/app/solutions/[slug]/page.tsx |  2 +
 .../solutions/SolutionCodeBlock.tsx           | 78 +++++++++++++++++++
 apps/website/src/lib/solutions-data.spec.ts   | 65 ++++++++++++++++
 apps/website/src/lib/solutions-data.ts        | 70 ++++++++++++++++-
 4 files changed, 213 insertions(+), 2 deletions(-)
 create mode 100644 apps/website/src/components/solutions/SolutionCodeBlock.tsx
 create mode 100644 apps/website/src/lib/solutions-data.spec.ts

diff --git a/apps/website/src/app/solutions/[slug]/page.tsx b/apps/website/src/app/solutions/[slug]/page.tsx
index 4a352f1b9..b7583fddc 100644
--- a/apps/website/src/app/solutions/[slug]/page.tsx
+++ b/apps/website/src/app/solutions/[slug]/page.tsx
@@ -10,6 +10,7 @@ import {
 } from '../../../lib/solutions-data';
 import { Container } from '../../../components/ui/Container';
 import { Section } from '../../../components/ui/Section';
+import { SolutionCodeBlock } from '../../../components/solutions/SolutionCodeBlock';
 import { Eyebrow } from '../../../components/ui/Eyebrow';
 import { Button } from '../../../components/ui/Button';
 import { Pill } from '../../../components/ui/Pill';
@@ -333,6 +334,7 @@ export default async function SolutionPage({ params }: PageProps) {
       
       
       
+      
       
       
+      
+        
+ In practice +

+ What it looks like in your codebase +

+

+ {code.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/lib/solutions-data.spec.ts b/apps/website/src/lib/solutions-data.spec.ts new file mode 100644 index 000000000..4a7790981 --- /dev/null +++ b/apps/website/src/lib/solutions-data.spec.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { SOLUTIONS, getSolutionBySlug } from './solutions-data'; + +/** + * 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.source.trim().length, solution.slug).toBeGreaterThan(80); + expect(solution.code.label.trim().length, solution.slug).toBeGreaterThan(0); + expect(solution.code.source, solution.slug).not.toMatch(/TODO|FIXME|\.\.\.$/); + } + }); + + it('shows a different part of the stack in each entry', () => { + // Not just "is the text different" — the snippets must not collapse to the + // same call. Compare the identifiers each one actually exercises. + // + // Framework entry points appear in every Angular snippet and carry no + // information about WHICH part of the stack is on show, so they are + // excluded. Keep this list to genuine boilerplate: adding a meaningful API + // here (`interrupt`, `history`, `submit`) would silence exactly the + // duplication this test exists to catch. + const UBIQUITOUS = new Set(['injectAgent(', 'computed(']); + const apiSurface = (source: string) => + new Set( + (source.match(/\b[a-zA-Z_][a-zA-Z0-9_]{4,}\s*\(/g) ?? []).filter( + (call) => !UBIQUITOUS.has(call), + ), + ); + + for (const a of SOLUTIONS) { + for (const b of SOLUTIONS) { + if (a.slug >= b.slug) continue; + const [sa, sb] = [apiSurface(a.code.source), apiSurface(b.code.source)]; + const shared = [...sa].filter((call) => sb.has(call)); + expect(shared, `${a.slug} vs ${b.slug} call the same API`).toEqual([]); + } + } + }); + + 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..9e2326298 100644 --- a/apps/website/src/lib/solutions-data.ts +++ b/apps/website/src/lib/solutions-data.ts @@ -13,6 +13,16 @@ * 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 @@ -39,6 +49,17 @@ 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; +} + export interface SolutionConfig { slug: string; color: string; @@ -50,6 +71,7 @@ export interface SolutionConfig { architectureIntro: string; architectureLayers: ArchitectureLayer[]; proofPoints: ProofPoint[]; + code: SolutionCode; ctaHeadline: string; ctaSubtext: string; metaTitle: string; @@ -98,9 +120,22 @@ 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 — replaying a thread', + language: 'typescript', + source: `export class AuditTrailComponent { + private readonly agent = injectAgent(REVIEW_AGENT); + + // Every checkpoint the thread passed through, oldest first. + readonly checkpoints = computed(() => this.agent.history()); + + // Raw LangGraph metadata, for the fields an auditor asks about. + readonly rawCheckpoints = computed(() => this.agent.langGraphHistory()); +}`, + }, 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 +185,19 @@ 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 registry', + language: 'typescript', + source: `// The agent emits a json-render spec; your own components render it. +const registry = defineAngularRegistry({ + BarChart: BarChartComponent, + DataTable: DataTableComponent, + KpiCard: KpiCardComponent, +}); + +// In the template — the spec streams in and the view updates itself: +// `, + }, 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 +244,27 @@ 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 — escalation', + language: 'typescript', + source: `export class SupportChatComponent { + private readonly agent = injectAgent(SUPPORT_AGENT); + + // Populated when the graph pauses; null the rest of the time. + readonly pendingRefund = computed(() => this.agent.interrupt()); + + approveRefund(approver: string) { + this.agent.submit({ resume: { approved: true, approver } }); + } + + denyRefund(reason: string) { + this.agent.submit({ resume: { approved: false, reason } }); + } +}`, + }, 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', From 8c570f1a0f57e693641a50bd6d76e2eef6751ca4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 25 Aug 2026 13:12:30 -0700 Subject: [PATCH 3/6] feat(website): expand the solutions code blocks to component + template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` directly. The real generative-UI path is `views()` plus ``; `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 `` yourself — a different path, and the wrong one for a chat-based analytics surface. - The support template rendered a bare ``; 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 --- .../solutions/SolutionCodeBlock.tsx | 64 +++++---- apps/website/src/lib/solutions-data.spec.ts | 44 +++--- apps/website/src/lib/solutions-data.ts | 131 ++++++++++++++---- 3 files changed, 166 insertions(+), 73 deletions(-) diff --git a/apps/website/src/components/solutions/SolutionCodeBlock.tsx b/apps/website/src/components/solutions/SolutionCodeBlock.tsx index 8339ed9fd..68961e44f 100644 --- a/apps/website/src/components/solutions/SolutionCodeBlock.tsx +++ b/apps/website/src/components/solutions/SolutionCodeBlock.tsx @@ -4,7 +4,7 @@ import { tokens } from '@threadplane/design-tokens'; import { Container } from '../ui/Container'; import { Section } from '../ui/Section'; import { Eyebrow } from '../ui/Eyebrow'; -import type { SolutionCode } from '../../lib/solutions-data'; +import type { SolutionCode, SolutionCodeBlocks } from '../../lib/solutions-data'; /** * The `code` block on a solutions page. @@ -17,11 +17,16 @@ import type { SolutionCode } from '../../lib/solutions-data'; * This is an async Server Component, so highlighting happens at build time and * ships no Shiki payload to the browser. */ -export async function SolutionCodeBlock({ code, accent }: { code: SolutionCode; accent: string }) { - const html = await codeToHtml(code.source, { - lang: code.language, - theme: 'tokyo-night', - }); +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 (
@@ -43,17 +48,20 @@ export async function SolutionCodeBlock({ code, accent }: { code: SolutionCode; > What it looks like in your codebase -

- {code.label} -

+ {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.
@@ -61,16 +69,18 @@ export async function SolutionCodeBlock({ code, accent }: { code: SolutionCode;
             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/lib/solutions-data.spec.ts b/apps/website/src/lib/solutions-data.spec.ts index 4a7790981..64f5589f9 100644 --- a/apps/website/src/lib/solutions-data.spec.ts +++ b/apps/website/src/lib/solutions-data.spec.ts @@ -25,35 +25,41 @@ describe('SOLUTIONS', () => { it('gives every entry real code, not a placeholder', () => { for (const solution of SOLUTIONS) { - expect(solution.code.source.trim().length, solution.slug).toBeGreaterThan(80); - expect(solution.code.label.trim().length, solution.slug).toBeGreaterThan(0); - expect(solution.code.source, solution.slug).not.toMatch(/TODO|FIXME|\.\.\.$/); + 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', () => { - // Not just "is the text different" — the snippets must not collapse to the - // same call. Compare the identifiers each one actually exercises. + // 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. // - // Framework entry points appear in every Angular snippet and carry no - // information about WHICH part of the stack is on show, so they are - // excluded. Keep this list to genuine boilerplate: adding a meaningful API - // here (`interrupt`, `history`, `submit`) would silence exactly the - // duplication this test exists to catch. - const UBIQUITOUS = new Set(['injectAgent(', 'computed(']); - const apiSurface = (source: string) => - new Set( - (source.match(/\b[a-zA-Z_][a-zA-Z0-9_]{4,}\s*\(/g) ?? []).filter( - (call) => !UBIQUITOUS.has(call), - ), - ); + // `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] = [apiSurface(a.code.source), apiSurface(b.code.source)]; + 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} call the same API`).toEqual([]); + expect(shared, `${a.slug} vs ${b.slug} exercise the same API`).toEqual([]); } } }); diff --git a/apps/website/src/lib/solutions-data.ts b/apps/website/src/lib/solutions-data.ts index 9e2326298..4e7dfd022 100644 --- a/apps/website/src/lib/solutions-data.ts +++ b/apps/website/src/lib/solutions-data.ts @@ -60,6 +60,13 @@ export interface SolutionCode { 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; @@ -71,7 +78,7 @@ export interface SolutionConfig { architectureIntro: string; architectureLayers: ArchitectureLayer[]; proofPoints: ProofPoint[]; - code: SolutionCode; + code: SolutionCodeBlocks; ctaHeadline: string; ctaSubtext: string; metaTitle: string; @@ -123,19 +130,49 @@ export const SOLUTIONS: SolutionConfig[] = [ { 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 — replaying a thread', - language: 'typescript', - source: `export class AuditTrailComponent { + code: [ + { + label: 'audit-trail.component.ts — reading the thread record', + language: 'typescript', + source: `export class AuditTrailComponent { private readonly agent = injectAgent(REVIEW_AGENT); - // Every checkpoint the thread passed through, oldest first. + // Runtime-neutral timeline: every checkpoint the thread passed through. readonly checkpoints = computed(() => this.agent.history()); - // Raw LangGraph metadata, for the fields an auditor asks about. - readonly rawCheckpoints = computed(() => this.agent.langGraphHistory()); + // 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.

`, + }, + ], 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', @@ -185,19 +222,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 registry', - language: 'typescript', - source: `// The agent emits a json-render spec; your own components render it. -const registry = defineAngularRegistry({ - BarChart: BarChartComponent, - DataTable: DataTableComponent, - KpiCard: KpiCardComponent, + 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, }); -// In the template — the spec streams in and the view updates itself: -// `, - }, +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', @@ -247,15 +300,19 @@ const registry = defineAngularRegistry({ { 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 — escalation', - language: 'typescript', - source: `export class SupportChatComponent { - private readonly agent = injectAgent(SUPPORT_AGENT); + code: [ + { + label: 'support-chat.component.ts — the escalation gate', + language: 'typescript', + source: `export class SupportChatComponent { + protected readonly agent = injectAgent(SUPPORT_AGENT); - // Populated when the graph pauses; null the rest of the time. + // 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 } }); } @@ -263,8 +320,28 @@ const registry = defineAngularRegistry({ 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) { + +}`, + }, + ], 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', From 9aa02752c7cb66c06660dba6488da11b57a49593 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 25 Aug 2026 18:28:08 -0700 Subject: [PATCH 4/6] feat(website): show the HITL approval clip on the solutions pages it applies to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + .../website/src/app/solutions/[slug]/page.tsx | 2 + .../src/components/landing/DemoShowcase.tsx | 6 +- .../solutions/SolutionDemoBlock.tsx | 101 ++++++++++++++++++ apps/website/src/lib/demo-media.ts | 40 +++++++ apps/website/src/lib/solutions-data.spec.ts | 20 ++++ apps/website/src/lib/solutions-data.ts | 11 ++ .../angular/e2e/record-demo.config.ts | 36 +++++++ .../angular/e2e/record-demo.record.ts | 46 ++++++++ .../chat/angular/e2e/record-demo.config.ts | 33 ++++++ .../chat/angular/e2e/record-demo.record.ts | 53 +++++++++ 11 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 apps/website/src/components/solutions/SolutionDemoBlock.tsx create mode 100644 apps/website/src/lib/demo-media.ts create mode 100644 cockpit/chat/interrupts/angular/e2e/record-demo.config.ts create mode 100644 cockpit/chat/interrupts/angular/e2e/record-demo.record.ts create mode 100644 examples/chat/angular/e2e/record-demo.config.ts create mode 100644 examples/chat/angular/e2e/record-demo.record.ts 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/src/app/solutions/[slug]/page.tsx b/apps/website/src/app/solutions/[slug]/page.tsx index b7583fddc..790ad2a9d 100644 --- a/apps/website/src/app/solutions/[slug]/page.tsx +++ b/apps/website/src/app/solutions/[slug]/page.tsx @@ -11,6 +11,7 @@ import { 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'; @@ -335,6 +336,7 @@ export default async function SolutionPage({ params }: PageProps) { + {solution.demo && } d.key === 'langgraph')!.href }, 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/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 index 64f5589f9..43c747e03 100644 --- a/apps/website/src/lib/solutions-data.spec.ts +++ b/apps/website/src/lib/solutions-data.spec.ts @@ -1,6 +1,7 @@ // 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 @@ -64,6 +65,25 @@ describe('SOLUTIONS', () => { } }); + 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 4e7dfd022..ef5035cbd 100644 --- a/apps/website/src/lib/solutions-data.ts +++ b/apps/website/src/lib/solutions-data.ts @@ -27,6 +27,8 @@ * * 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; @@ -79,6 +81,13 @@ export interface SolutionConfig { 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; @@ -173,6 +182,7 @@ export const SOLUTIONS: SolutionConfig[] = [

{{ 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', @@ -342,6 +352,7 @@ export class DashboardComponent { }`, }, ], + 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/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/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); +}); From 41a69aaa51f036a402b2c4992ec996f7679d24db Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 25 Aug 2026 18:43:58 -0700 Subject: [PATCH 5/6] feat(website): add an Approve section to the homepage, showing the HITL clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/website/src/app/page.tsx | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) 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={ + +
+ +
+
+ } + /> + From 86252e2034c911beb8a1486d9efc228853b43da2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 25 Aug 2026 19:17:48 -0700 Subject: [PATCH 6/6] 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 --- ...6-08-25-homepage-medium-switcher-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-homepage-medium-switcher-design.md 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`.