From 82e62e76953b7276fd17a3b1bb3aacebcfcbfb95 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 24 Aug 2026 17:39:20 -0700 Subject: [PATCH 1/3] 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/3] 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/3] 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',