Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
2 changes: 2 additions & 0 deletions apps/website/src/app/solutions/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -333,6 +334,7 @@ export default async function SolutionPage({ params }: PageProps) {
<PainPoints items={solution.painPoints} accent={solution.color} />
<Architecture intro={solution.architectureIntro} layers={solution.architectureLayers} accent={solution.color} />
<Capabilities items={solution.proofPoints} accent={solution.color} />
<SolutionCodeBlock code={solution.code} accent={solution.color} />
<WhitePaperBlock />
<FinalCTA
headline={solution.ctaHeadline}
Expand Down
88 changes: 88 additions & 0 deletions apps/website/src/components/solutions/SolutionCodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT
import { codeToHtml } from 'shiki';
import { tokens } from '@threadplane/design-tokens';
import { Container } from '../ui/Container';
import { Section } from '../ui/Section';
import { Eyebrow } from '../ui/Eyebrow';
import type { SolutionCode, SolutionCodeBlocks } from '../../lib/solutions-data';

/**
* The `code` block on a solutions page.
*
* Highlighted with Shiki directly rather than through `rehype-pretty-code`:
* that plugin only runs over MDX, and these pages are TSX. The theme matches
* `MdxRenderer`'s (`tokyo-night`) so a snippet here reads the same as one in
* the docs.
*
* This is an async Server Component, so highlighting happens at build time and
* ships no Shiki payload to the browser.
*/
async function highlight(block: SolutionCode) {
return codeToHtml(block.source, { lang: block.language, theme: 'tokyo-night' });
}

export async function SolutionCodeBlock({ code, accent }: { code: SolutionCodeBlocks; accent: string }) {
// Highlight every block up front: an async map inside JSX would give React
// promises to render rather than markup.
const rendered = await Promise.all(
code.map(async (block) => ({ ...block, html: await highlight(block) })),
);

return (
<Section surface="canvas" ariaLabelledBy="solution-code-heading">
<Container>
<div style={{ maxWidth: 820, margin: '0 auto' }}>
<Eyebrow style={{ color: accent, marginBottom: 12 }}>In practice</Eyebrow>
<h2
id="solution-code-heading"
style={{
fontFamily: tokens.typography.h2.family,
fontSize: tokens.typography.h2.size,
lineHeight: tokens.typography.h2.line,
fontWeight: 700,
color: tokens.colors.textPrimary,
margin: 0,
marginBottom: 12,
letterSpacing: '-0.015em',
}}
>
What it looks like in your codebase
</h2>
{rendered.map((block, index) => (
<div key={block.label} style={{ marginTop: index === 0 ? 0 : 24 }}>
<p
style={{
fontFamily: tokens.typography.fontMono,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.04em',
color: tokens.colors.textMuted,
margin: '0 0 10px',
}}
>
{block.label}
</p>
{/*
Shiki emits a complete <pre> 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.
*/}
<div
className="solution-code"
style={{
borderRadius: 12,
overflow: 'hidden',
border: `1px solid ${tokens.surfaces.border}`,
fontSize: 14,
}}
dangerouslySetInnerHTML={{ __html: block.html }}
/>
</div>
))}
</div>
</Container>
</Section>
);
}
7 changes: 7 additions & 0 deletions apps/website/src/lib/blog-authors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -21,6 +26,8 @@ export const blogAuthors: Record<string, Author> = {
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',
},
};

Expand Down
71 changes: 71 additions & 0 deletions apps/website/src/lib/solutions-data.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
147 changes: 145 additions & 2 deletions apps/website/src/lib/solutions-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -50,6 +78,7 @@ export interface SolutionConfig {
architectureIntro: string;
architectureLayers: ArchitectureLayer[];
proofPoints: ProofPoint[];
code: SolutionCodeBlocks;
ctaHeadline: string;
ctaSubtext: string;
metaTitle: string;
Expand Down Expand Up @@ -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: `<table class="audit">
@for (row of approvals(); track row.at) {
<tr>
<td>{{ row.at | date: 'medium' }}</td>
<td>{{ row.action.description }}</td>
<td>{{ row.decision.approved ? 'Approved' : 'Rejected' }}</td>
<td>{{ row.decision.reason }}</td>
</tr>
}
</table>

<p class="muted">{{ checkpoints().length }} checkpoints on this thread.</p>`,
},
],
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',
Expand Down Expand Up @@ -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: `<!-- ChatComponent detects a JSON spec in the AI message and renders it
through the catalog. Partial specs render as they stream, so the first
chart appears before the query has finished. -->
<chat [agent]="agent" [views]="analyticsViews" />`,
},
],
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',
Expand Down Expand Up @@ -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: `<chat [agent]="agent" />

@if (pendingRefund(); as pending) {
<aside class="approval">
<h3>{{ pending.value.action }} — {{ pending.value.amount | currency }}</h3>
<p>{{ pending.value.reason }}</p>

<button (click)="approveRefund(currentAgentName())">Approve</button>
<button (click)="denyRefund('Outside policy')">Deny</button>
</aside>
}`,
},
],
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',
Expand Down
Loading
Loading