diff --git a/apps/website/next-env.d.ts b/apps/website/next-env.d.ts index c4b7818fb..fdbfe5258 100644 --- a/apps/website/next-env.d.ts +++ b/apps/website/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./../../dist/apps/website/.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/website/src/app/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) { + ({ ...block, html: await highlight(block) })), + ); + + return ( +
+ +
+ In practice +

+ What it looks like in your codebase +

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

+ {block.label} +

+ {/* + Shiki emits a complete
 that already carries its own background,
+            padding, and `overflow-x: auto`, so this wrapper owns only the frame.
+            `overflow: hidden` is what makes the radius clip that background — it
+            must not be `auto`, which would nest a second scroll container around
+            a element that already scrolls and can show two scrollbars.
+          */}
+              
+
+ ))} +
+ +
+ ); +} diff --git a/apps/website/src/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/solutions-data.spec.ts b/apps/website/src/lib/solutions-data.spec.ts new file mode 100644 index 000000000..64f5589f9 --- /dev/null +++ b/apps/website/src/lib/solutions-data.spec.ts @@ -0,0 +1,71 @@ +// 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.length, solution.slug).toBeGreaterThan(0); + for (const block of solution.code) { + expect(block.source.trim().length, `${solution.slug}/${block.label}`).toBeGreaterThan(80); + expect(block.label.trim().length, solution.slug).toBeGreaterThan(0); + expect(block.source, `${solution.slug}/${block.label}`).not.toMatch(/TODO|FIXME|\.\.\.$/); + } + } + }); + + it('shows a different part of the stack in each entry', () => { + // Compare the FRAMEWORK surface, not every identifier. An earlier version + // matched any `name(` and broke as soon as the snippets grew — two entries + // both calling `filter()` says nothing about which part of the stack they + // show. What matters is which agent methods and package entry points each + // one reaches for. + // + // `injectAgent` is deliberately absent: every Angular snippet starts there. + const FRAMEWORK_ENTRY = /\b(views|defineAngularRegistry|provideAgent|provideRender|signalStateStore)\s*\(/g; + const AGENT_METHOD = /\bagent\.(\w+)\s*\(/g; + + const surface = (solution: (typeof SOLUTIONS)[number]) => { + const all = solution.code.map((b) => b.source).join('\n'); + return new Set([ + ...(all.match(FRAMEWORK_ENTRY) ?? []).map((c) => c.replace(/\s*\($/, '')), + ...[...all.matchAll(AGENT_METHOD)].map((m) => `agent.${m[1]}`), + ]); + }; + + for (const a of SOLUTIONS) { + for (const b of SOLUTIONS) { + if (a.slug >= b.slug) continue; + const [sa, sb] = [surface(a), surface(b)]; + expect(sa.size, `${a.slug} exercises no framework API`).toBeGreaterThan(0); + const shared = [...sa].filter((call) => sb.has(call)); + expect(shared, `${a.slug} vs ${b.slug} exercise the same API`).toEqual([]); + } + } + }); + + it('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..4e7dfd022 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,24 @@ export interface ProofPoint { label: string; } +/** + * A working snippet against the published API — see the file header. The + * `language` is a Shiki identifier; `label` names the file or layer it comes + * from so the reader knows where it belongs. + */ +export interface SolutionCode { + label: string; + language: 'typescript' | 'python' | 'html'; + source: string; +} + +/** + * The blocks an entry shows, in reading order. An array because the Angular + * story is rarely one file — a component and the template that drives it say + * more together than either does alone. + */ +export type SolutionCodeBlocks = readonly SolutionCode[]; + export interface SolutionConfig { slug: string; color: string; @@ -50,6 +78,7 @@ export interface SolutionConfig { architectureIntro: string; architectureLayers: ArchitectureLayer[]; proofPoints: ProofPoint[]; + code: SolutionCodeBlocks; ctaHeadline: string; ctaSubtext: string; metaTitle: string; @@ -98,9 +127,52 @@ export const SOLUTIONS: SolutionConfig[] = [ ], proofPoints: [ { metric: 'Every', label: 'Agent action recorded — tool calls, interrupts, and state transitions captured in the thread record' }, - { metric: 'Required', label: 'Human approval before consequential actions — wired into LangGraph interrupts, not bolted on' }, + { metric: 'Evidenced', label: 'Each approval is written into the checkpoint beside the action it gated — the decision and the proposal are one record' }, { metric: 'Replayable', label: 'Thread persistence preserves the full decision path for review by auditors and your compliance team' }, ], + code: [ + { + label: 'audit-trail.component.ts — reading the thread record', + language: 'typescript', + source: `export class AuditTrailComponent { + private readonly agent = injectAgent(REVIEW_AGENT); + + // Runtime-neutral timeline: every checkpoint the thread passed through. + readonly checkpoints = computed(() => this.agent.history()); + + // Raw LangGraph ThreadState[], for the fields an auditor asks about. + private readonly raw = computed(() => this.agent.langGraphHistory()); + + // The decisions themselves, lifted out of the checkpoint values. Each row + // pairs what was proposed with what a human answered, and when. + readonly approvals = computed(() => + this.raw() + .filter((state) => state.values?.['approval_result']) + .map((state) => ({ + at: state.created_at, + action: state.values['proposed_action'], + decision: state.values['approval_result'], + })), + ); +}`, + }, + { + label: 'audit-trail.component.html', + language: 'html', + source: ` + @for (row of approvals(); track row.at) { + + + + + + + } +
{{ row.at | date: 'medium' }}{{ row.action.description }}{{ row.decision.approved ? 'Approved' : 'Rejected' }}{{ row.decision.reason }}
+ +

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

`, + }, + ], 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 +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 catalog', + language: 'typescript', + source: `import { ChatComponent, views } from '@threadplane/chat'; +import { injectAgent } from '@threadplane/langgraph'; + +// Your components, keyed by the name the agent uses in its spec. +// Nothing here knows what question the user will ask. +const analyticsViews = views({ + bar_chart: BarChartComponent, + data_table: DataTableComponent, + kpi_card: KpiCardComponent, +}); + +export class DashboardComponent { + protected readonly agent = injectAgent(); + protected readonly analyticsViews = analyticsViews; +}`, + }, + { + label: 'dashboard.component.html', + language: 'html', + source: ` +`, + }, + ], ctaHeadline: 'Turn your data into conversations', ctaSubtext: 'Download the field report or start a pilot. Ship a conversational BI experience in weeks, not quarters.', metaTitle: 'Analytics & BI — Threadplane Solutions', @@ -196,9 +297,51 @@ export const SOLUTIONS: SolutionConfig[] = [ ], proofPoints: [ { metric: 'Preserved', label: 'Full conversation history across bot-to-human handoff — no repeating the question, no re-explaining the problem' }, - { metric: 'Required', label: 'Human approval gates on sensitive actions (refunds, account changes, escalations) via LangGraph interrupts' }, + { metric: 'Named', label: 'Refunds and account changes resume only with an identified approver — the agent cannot self-authorize' }, { metric: 'Visible', label: 'Tool-call replay for human agents on escalation — see every step the AI took before the handoff' }, ], + code: [ + { + label: 'support-chat.component.ts — the escalation gate', + language: 'typescript', + source: `export class SupportChatComponent { + protected readonly agent = injectAgent(SUPPORT_AGENT); + + // Populated only while the graph is paused on an interrupt. + readonly pendingRefund = computed(() => this.agent.interrupt()); + readonly awaitingHuman = computed(() => this.pendingRefund() !== null); + + // Resuming carries the approver, so the record shows who authorized it — + // the agent has no path to approve its own refund. + approveRefund(approver: string) { + this.agent.submit({ resume: { approved: true, approver } }); + } + + denyRefund(reason: string) { + this.agent.submit({ resume: { approved: false, reason } }); + } + + send(message: string) { + this.agent.submit({ message }); + } +}`, + }, + { + label: 'support-chat.component.html', + language: 'html', + source: ` + +@if (pendingRefund(); as pending) { + +}`, + }, + ], ctaHeadline: 'Support agents that make your team better', ctaSubtext: 'Download the field report or start a pilot. Resolve routine tickets, escalate the rest with full context, keep your customers happy.', metaTitle: 'Customer Support — Threadplane Solutions', diff --git a/apps/website/src/lib/structured-data.spec.ts b/apps/website/src/lib/structured-data.spec.ts index b6e0603be..76b87b67f 100644 --- a/apps/website/src/lib/structured-data.spec.ts +++ b/apps/website/src/lib/structured-data.spec.ts @@ -233,6 +233,7 @@ describe('aboutPageJsonLd', () => { expect(person['name']).toBe(AUTHOR.name); expect(person['jobTitle']).toBe(AUTHOR.role); expect(person['description']).toBe(AUTHOR.bio); + // The fixture names only a GitHub handle, so only that profile may appear. expect(person['sameAs']).toEqual(['https://github.com/blove']); expect(person['url']).toBe('https://threadplane.ai/about'); }); @@ -251,12 +252,24 @@ describe('aboutPageJsonLd', () => { expect((person['worksFor'] as JsonLdNode)['@id']).toBe(ORGANIZATION_ID); }); - it('resolves the real site author to a real GitHub profile', () => { + it('omits a profile the author record does not name', () => { + // Each handle is opt-in per field: an author with only a GitHub handle must + // not acquire an invented X or LinkedIn URL. + const graph = aboutPageJsonLd({ name: 'Anon', github: 'anon' })['@graph'] as JsonLdNode[]; + const person = graph.find((node) => node['@type'] === 'Person') as JsonLdNode; + expect(person['sameAs']).toEqual(['https://github.com/anon']); + }); + + it('resolves the real site author to real profiles', () => { // The page passes `blogAuthors['brian']`; `sameAs` is an identity claim, so - // this pins the profile the repo actually knows rather than the fixture's. + // this pins the profiles the repo actually knows rather than the fixture's. const graph = aboutPageJsonLd(blogAuthors['brian'])['@graph'] as JsonLdNode[]; const person = graph.find((node) => node['@type'] === 'Person') as JsonLdNode; - expect(person['sameAs']).toEqual(['https://github.com/blove']); + expect(person['sameAs']).toEqual([ + 'https://github.com/blove', + 'https://x.com/blovedev', + 'https://www.linkedin.com/in/blove', + ]); }); it('serializes to JSON', () => { diff --git a/apps/website/src/lib/structured-data.ts b/apps/website/src/lib/structured-data.ts index e1d1357fe..33f9c9f90 100644 --- a/apps/website/src/lib/structured-data.ts +++ b/apps/website/src/lib/structured-data.ts @@ -127,6 +127,19 @@ export const PERSON_ID = `${getCanonicalUrl(ABOUT_PATH)}#person`; * Every field is derived from the caller's {@link Author} record; nothing about * the person is stated here. */ +/** + * The external profiles an author record actually names, as absolute URLs. + * + * Order is stable so the emitted JSON-LD does not churn between builds. + */ +function personProfiles(author: Author): string[] { + return [ + author.github && `https://github.com/${author.github}`, + author.twitter && `https://x.com/${author.twitter}`, + author.linkedin && `https://www.linkedin.com/in/${author.linkedin}`, + ].filter((url): url is string => Boolean(url)); +} + export function aboutPageJsonLd(author: Author) { const url = getCanonicalUrl(ABOUT_PATH); const person: JsonLdNode = { @@ -137,8 +150,9 @@ export function aboutPageJsonLd(author: Author) { ...(author.role ? { jobTitle: author.role } : {}), ...(author.bio ? { description: author.bio } : {}), // Only profiles the repo actually knows about; `sameAs` is an identity - // claim, so a guessed profile is a false one. - ...(author.github ? { sameAs: [`https://github.com/${author.github}`] } : {}), + // claim, so a guessed profile is a false one. Each handle is a separate + // opt-in field — one is never derived from another. + ...(personProfiles(author).length ? { sameAs: personProfiles(author) } : {}), ...(author.knowsAbout?.length ? { knowsAbout: [...author.knowsAbout] } : {}), worksFor: { '@id': ORGANIZATION_ID }, };