diff --git a/apps/website/public/whitepaper-preview.html b/apps/website/public/whitepaper-preview.html index 02d79f170..6c057351c 100644 --- a/apps/website/public/whitepaper-preview.html +++ b/apps/website/public/whitepaper-preview.html @@ -3,154 +3,162 @@ - + + -
-
Threadplane · Open source · Angular
-

Threadplane

-

Production-ready chat, threads, and generative UI for AI agents

-
threadplane.ai · 2026
+
+
Threadplane · Open source · Angular
+ +

Threadplane

+
+

Production-ready chat, threads, and generative UI for AI agents

+
threadplane.ai · 2026
-

Contents

- -
- 01 +

Contents

+
+
+
+ 01 Streaming State Management
-
- 02 +
+ 02 Thread Persistence
-
- 03 +
+ 03 Tool-Call Rendering
-
- 04 +
+ 04 Human Approval Flows
-
- 05 +
+ 05 Generative UI
-
- 06 +
+ 06 Deterministic Testing -
+
-
Chapter 1
-

Streaming State Management

-

# Streaming State Management

-

Server-sent events don't play nicely with Angular's change detection. Zone.js patches `EventSource`, but the resulting microtask scheduling creates timing issues—tokens arrive faster than digest cycles complete, leading to dropped renders or, worse, accumulated state that suddenly flushes in a visual stutter. Teams typically respond by wrapping streams in `NgZone.run()`, manually calling `detectChanges()`, or building elaborate buffer-and-flush mechanisms. All of these approaches share a common failure mode: they work in development and break under production load.

+
Chapter 01
+

Streaming State Management

+
+

Server-sent events don't play nicely with Angular's change detection. Zone.js patches EventSource, but the resulting microtask scheduling creates timing issues—tokens arrive faster than digest cycles complete, leading to dropped renders or, worse, accumulated state that suddenly flushes in a visual stutter. Teams typically respond by wrapping streams in NgZone.run(), manually calling detectChanges(), or building elaborate buffer-and-flush mechanisms. All of these approaches share a common failure mode: they work in development and break under production load.

The root issue isn't Zone.js itself—it's the impedance mismatch between push-based streaming and Angular's pull-based change detection model. When your LangGraph agent streams 50 tokens per second, you need state primitives that coalesce updates intelligently while remaining reactive enough to drive smooth UI. Custom solutions invariably choose wrong: either they batch too aggressively (laggy typing effect) or too little (CPU saturation from excess renders).

Signals as the Synchronization Primitive

-

The `agent()` function returns an Angular signals-based interface that sidesteps these problems entirely. Rather than exposing raw event streams that require manual subscription management, it provides computed signals that update atomically as tokens arrive:

+

The agent() function returns an Angular signals-based interface that sidesteps these problems entirely. Rather than exposing raw event streams that require manual subscription management, it provides computed signals that update atomically as tokens arrive:

@Component({
   selector: 'app-chat',
   template: `
-    
-    
+    <chat-message-list [messages]="chat.messages()" />
+    <chat-input (send)="chat.submit($event)" [disabled]="chat.isLoading()" />
   `,
   changeDetection: ChangeDetectionStrategy.OnPush
 })
 export class ChatComponent {
-  private readonly threadId = signal(undefined);
+  private readonly threadId = signal<string | undefined>(undefined);
   
   readonly chat = agent({
     assistantId: 'support_agent',
     threadId: this.threadId,
-    onThreadId: id => this.threadId.set(id)
+    onThreadId: id => this.threadId.set(id)
   });
 }
 
-

The `messages()` signal returns `Message[]`—a runtime-neutral representation that updates as the stream progresses. Internally, the framework handles token accumulation, message boundary detection, and state reconciliation. Your component simply reads the signal; Angular's signal-based reactivity handles the rest.

-

The `isLoading()` signal deserves specific attention. It returns `true` from the moment you call `submit()` until the stream completes or errors. This eliminates the polling patterns teams often implement—checking message array lengths, tracking "last update" timestamps, or maintaining parallel loading flags that drift out of sync with actual stream state.

+

The messages() signal returns Message[]—a runtime-neutral representation that updates as the stream progresses. Internally, the framework handles token accumulation, message boundary detection, and state reconciliation. Your component simply reads the signal; Angular's signal-based reactivity handles the rest.

+

The isLoading() signal deserves specific attention. It returns true from the moment you call submit() until the stream completes or errors. This eliminates the polling patterns teams often implement—checking message array lengths, tracking "last update" timestamps, or maintaining parallel loading flags that drift out of sync with actual stream state.

OnPush Compatibility

-

Signals and `OnPush` change detection are natural partners, but the pairing requires attention. When `messages()` updates, Angular marks the component dirty through signal dependencies, not through Zone.js event interception. This means your streaming UI actually predates OnPush—it *requires* it for correct behavior under load.

-

The production checklist question—"Are your message signals OnPush-compatible?"—is really asking whether your component tree properly propagates signal reads. If a parent component reads `messages()` and passes the array to a child via `@Input()`, the child must also use `OnPush` or it won't re-render when the array reference changes. The fix is straightforward: either pass the signal itself (`[messages]="chat.messages"`) or ensure `OnPush` propagates down your component tree.

+

Signals and OnPush change detection are natural partners, but the pairing requires attention. When messages() updates, Angular marks the component dirty through signal dependencies, not through Zone.js event interception. This means your streaming UI actually predates OnPush—it *requires* it for correct behavior under load.

+

The production checklist question—"Are your message signals OnPush-compatible?"—is really asking whether your component tree properly propagates signal reads. If a parent component reads messages() and passes the array to a child via @Input(), the child must also use OnPush or it won't re-render when the array reference changes. The fix is straightforward: either pass the signal itself ([messages]="chat.messages") or ensure OnPush propagates down your component tree.

Streaming state management in Angular isn't inherently difficult. It becomes difficult when you fight the framework's reactivity model instead of leveraging it. Signals provide the coalescing, the timing, and the change detection integration. Your job is to read them.

-
Chapter 2
-

Thread Persistence

-

# Thread Persistence

-

Demos work with ephemeral state. Production agents need conversation history that survives page refreshes, tab switches, and navigation—wired to LangGraph's MemorySaver backend.

+
Chapter 02
+

Thread Persistence

+
+

Demos work with ephemeral state. Production agents need conversation history that survives page refreshes, tab switches, and navigation—wired to LangGraph's MemorySaver backend.

Why Stateless Agent UIs Fail in Production

Every agent demo you've seen starts fresh on page load. That's fine for a conference talk. In production, users expect continuity. They start a conversation, close their laptop, resume tomorrow, and pick up where they left off. Without thread persistence, you're forcing users to re-explain context every session. Worse, you're wasting LLM tokens reconstructing state the backend already has.

LangGraph's MemorySaver stores complete conversation history server-side, keyed by thread ID. Your frontend's job is simple: remember which thread the user was talking to and reconnect on mount.

The threadId Signal and onThreadId Callback

-

The `agent()` function accepts a reactive `threadId` signal and an `onThreadId` callback. When `threadId` is undefined, the backend creates a new thread and fires `onThreadId` with the generated ID. Your callback persists it. On subsequent loads, you initialize the signal from storage, and the agent resumes the existing conversation.

+

The agent() function accepts a reactive threadId signal and an onThreadId callback. When threadId is undefined, the backend creates a new thread and fires onThreadId with the generated ID. Your callback persists it. On subsequent loads, you initialize the signal from storage, and the agent resumes the existing conversation.

This pattern keeps thread lifecycle management declarative. You don't manually coordinate thread creation with message sending. The agent handles it.

Persisting to localStorage

The implementation is straightforward:

@Component({
   selector: 'app-chat',
   template: `
-    
-      
-      
-    
+    <chat [agent]="chat">
+      <chat-message-list />
+      <chat-input />
+    </chat>
   `
 })
 export class ChatComponent {
-  private readonly threadId = signal(
+  private readonly threadId = signal<string | undefined>(
     localStorage.getItem('chat_thread') ?? undefined
   );
-

readonly chat = agent({ +readonly chat = agent({ assistantId: 'support_agent', threadId: this.threadId, - onThreadId: id => { + onThreadId: id => { this.threadId.set(id); localStorage.setItem('chat_thread', id); } }); } -

-

On first visit, `threadId` is undefined. The backend creates a thread, `onThreadId` fires, and you persist. On refresh, you read from localStorage, pass the existing ID, and the agent loads history from MemorySaver.

+ +

On first visit, threadId is undefined. The backend creates a thread, onThreadId fires, and you persist. On refresh, you read from localStorage, pass the existing ID, and the agent loads history from MemorySaver.

Thread List UI and Conversation Switching

Production apps typically need multiple conversations. A sidebar shows thread history; clicking switches context. The pattern extends naturally:

-
readonly threads = signal(
+
readonly threads = signal<string[]>(
   JSON.parse(localStorage.getItem('thread_list') ?? '[]')
 );
-

readonly activeThreadId = signal(this.threads()[0]);

-

readonly chat = agent({ +readonly activeThreadId = signal<string | undefined>(this.threads()[0]); +readonly chat = agent({ assistantId: 'support_agent', threadId: this.activeThreadId, - onThreadId: id => { + onThreadId: id => { this.activeThreadId.set(id); - this.threads.update(list => [id, ...list]); + this.threads.update(list => [id, ...list]); localStorage.setItem('thread_list', JSON.stringify(this.threads())); } -});

-

newConversation() { +}); +newConversation() { this.activeThreadId.set(undefined); -}

-

switchThread(id: string) { +} +switchThread(id: string) { this.activeThreadId.set(id); } -

-

When `activeThreadId` changes, the agent reconnects to that thread and `messages()` reflects the restored history. No manual fetching. The reactive binding handles it.

+
+

When activeThreadId changes, the agent reconnects to that thread and messages() reflects the restored history. No manual fetching. The reactive binding handles it.

For production, you'll likely move thread metadata to an API—titles, timestamps, archival status. The pattern remains identical: reactive signal in, persistence callback out.

Production Checklist

Before shipping, verify thread persistence end-to-end:

@@ -162,33 +170,33 @@

Production Checklist

Thread persistence is table stakes for production agent UIs. The framework gives you the primitives. Wire them correctly, and users get the continuity they expect.
-
Chapter 3
-

Tool-Call Rendering

-

# Tool-Call Rendering

-

LangGraph agents don't just generate text—they invoke tools mid-stream, and your UI needs to reflect that execution state in real time. This means showing steps as they appear, displaying final results, and collapsing completed calls into browsable history. Getting this wrong creates a UI that feels broken during the most interesting parts of agent behavior.

+
Chapter 03
+

Tool-Call Rendering

+
+

LangGraph agents don't just generate text—they invoke tools mid-stream, and your UI needs to reflect that execution state in real time. This means showing steps as they appear, displaying final results, and collapsing completed calls into browsable history. Getting this wrong creates a UI that feels broken during the most interesting parts of agent behavior.

The Raw Stream Problem

Tool call events arrive as discrete chunks in the SSE stream. A single tool invocation might produce five or six events: an initial call with arguments, multiple intermediate steps as the tool executes, and a final result. The raw payload includes nested metadata, partial JSON for arguments that stream incrementally, and status fields that change meaning depending on tool type.

Hand-parsing these events is fragile. You end up maintaining state machines to track which call is active, handling out-of-order delivery, and writing defensive code for malformed chunks. Testing becomes painful because you need to simulate realistic streaming sequences. Every edge case—interrupted calls, parallel tool execution, retry logic—adds branching complexity.

-

The framework solves this by exposing `toolCalls()` as a normalized signal on the agent surface. Each tool call object includes its current status, accumulated steps, and final result. The stream parsing happens once, correctly, inside the transport layer.

+

The framework solves this by exposing toolCalls() as a normalized signal on the agent surface. Each tool call object includes its current status, accumulated steps, and final result. The stream parsing happens once, correctly, inside the transport layer.

Headless and Prebuilt Options

-

`@threadplane/chat` provides two components for tool call rendering. `` is the headless primitive—it manages the structural rendering of multiple concurrent calls but leaves visual presentation to you. `` is the prebuilt option that handles common patterns: status indicators, step lists, collapsible sections, and error states.

+

@threadplane/chat provides two components for tool call rendering. <chat-tool-calls> is the headless primitive—it manages the structural rendering of multiple concurrent calls but leaves visual presentation to you. <chat-tool-call-card> is the prebuilt option that handles common patterns: status indicators, step lists, collapsible sections, and error states.

For most production apps, start with the prebuilt card and customize from there:

-

-  
+
<chat-message-list [messages]="chat.messages()">
+  <ng-template #toolCalls let-calls>
     @for (call of calls; track call.id) {
-      
-      
+        (stepClick)="inspectStep($event)">
+      </chat-tool-call-card>
     }
-  
-
+  </ng-template>
+</chat-message-list>
 

The card component reads status from each tool call object and adjusts its presentation accordingly. Running calls show a live step feed. Completed calls collapse to a summary with expandable history. Failed calls surface error details without disrupting the message flow.

Progressive Disclosure

Real-time tool execution benefits from progressive disclosure. Users want to see that something is happening—steps appearing as the tool runs—but they don't want permanent visual clutter once the call completes.

-

The `expandedByDefault` binding above handles this: calls expand while running, then collapse automatically on completion. Users can still click to expand history, but the default state keeps the conversation readable.

+

The expandedByDefault binding above handles this: calls expand while running, then collapse automatically on completion. Users can still click to expand history, but the default state keeps the conversation readable.

This pattern matters more than it seems. Agents that invoke multiple tools per response can generate substantial step output. Without automatic collapsing, the chat becomes a wall of tool metadata instead of a conversation.

Production Checklist

Before shipping, verify this behavior:

@@ -196,26 +204,26 @@

Production Checklist

Steps arrive incrementally. A step might appear with an initial status, then update moments later with results. Your rendering logic should handle these transitions without flicker or layout shift. Test with slow network simulation to catch timing-dependent bugs that don't surface on localhost.

-
Chapter 4
-

Human Approval Flows

-

# Human Approval Flows (Interrupts)

-

Production agents that modify external state—sending emails, initiating payments, deploying infrastructure—require human oversight before execution. LangGraph provides the `interrupt()` primitive for this purpose: a mechanism that pauses graph execution at designated checkpoints and waits for explicit human authorization before proceeding.

+
Chapter 04
+

Human Approval Flows

+
+

Production agents that modify external state—sending emails, initiating payments, deploying infrastructure—require human oversight before execution. LangGraph provides the interrupt() primitive for this purpose: a mechanism that pauses graph execution at designated checkpoints and waits for explicit human authorization before proceeding.

The LangGraph Interrupt Pattern

-

When a LangGraph node calls `interrupt()`, execution halts and the graph emits an interrupt event containing the pending action's metadata. The graph remains suspended until it receives a `Command.RESUME` with one of three directives: proceed with the original action, proceed with modified parameters, or abort entirely. This checkpoint-based approach ensures that no consequential action executes without explicit human consent.

+

When a LangGraph node calls interrupt(), execution halts and the graph emits an interrupt event containing the pending action's metadata. The graph remains suspended until it receives a Command.RESUME with one of three directives: proceed with the original action, proceed with modified parameters, or abort entirely. This checkpoint-based approach ensures that no consequential action executes without explicit human consent.

The challenge lies in surfacing this interrupt state to users and capturing their response without introducing fragile infrastructure. Polling-based solutions waste resources and introduce latency. Custom WebSocket implementations require maintaining connection state, handling reconnections, and synchronizing interrupt lifecycle across multiple browser tabs. Both approaches scatter interrupt logic across services, components, and connection handlers.

-

The `interrupt()` Signal

-

The `agent()` function exposes interrupt state through a dedicated signal:

+

The interrupt() Signal

+

The agent() function exposes interrupt state through a dedicated signal:

readonly chat = agent({
   assistantId: 'deployment_agent',
   threadId: this.threadId,
-  onThreadId: id => this.threadId.set(id)
+  onThreadId: id => this.threadId.set(id)
 });
-

// chat.interrupt() returns AgentInterrupt | undefined -

-

When `interrupt()` returns a defined value, the agent is paused and awaiting human input. The `AgentInterrupt` object contains the action metadata emitted by the graph—typically a description of the pending operation and any parameters the user might modify. When `interrupt()` returns `undefined`, no approval is pending.

+// chat.interrupt() returns AgentInterrupt | undefined +
+

When interrupt() returns a defined value, the agent is paused and awaiting human input. The AgentInterrupt object contains the action metadata emitted by the graph—typically a description of the pending operation and any parameters the user might modify. When interrupt() returns undefined, no approval is pending.

This signal-based approach eliminates the need for manual subscription management or imperative state tracking. Angular's reactivity system propagates interrupt state changes automatically, and the signal remains consistent across component re-renders.

UI Components for Interrupt Handling

-

The `@threadplane/chat` package provides two components for rendering interrupt flows. `` offers a prebuilt approval interface with sensible defaults. For custom designs, the headless `` component exposes the interrupt state and action handlers without imposing markup or styling.

+

The @threadplane/chat package provides two components for rendering interrupt flows. <chat-interrupt-panel> offers a prebuilt approval interface with sensible defaults. For custom designs, the headless <chat-interrupt> component exposes the interrupt state and action handlers without imposing markup or styling.

Both components support three user actions that map directly to resume commands:

  • Approve: Resume execution with the original parameters
  • Edit: Resume execution with user-modified parameters
  • @@ -224,11 +232,11 @@

    UI Components for Interrupt Handling

    @Component({
       template: `
         @if (chat.interrupt(); as interrupt) {
    -      
    +        (cancel)="chat.interrupt()?.cancel()" />
         }
       `
     })
    @@ -236,21 +244,21 @@ 

    UI Components for Interrupt Handling

    readonly chat = agent({ assistantId: 'deployment_agent', threadId: this.threadId, - onThreadId: id => this.threadId.set(id) + onThreadId: id => this.threadId.set(id) }); }
    -

    The edit flow passes modified parameters through the `$event` payload, allowing users to adjust action details before approval. The cancel flow terminates the pending action and allows the conversation to continue without executing the interrupted operation.

    +

    The edit flow passes modified parameters through the $event payload, allowing users to adjust action details before approval. The cancel flow terminates the pending action and allows the conversation to continue without executing the interrupted operation.

    Production Considerations

    Interrupt flows introduce a class of edge cases that prototype implementations often ignore. Users close browser tabs. Sessions expire. Network connections drop mid-approval.

    Production checklist item: *Can your agent UI recover gracefully if a user cancels an interrupt?*

    Cancellation should not leave the agent in an undefined state. The graph must handle abort commands cleanly, the UI must reflect the cancellation immediately, and subsequent user messages should resume normal conversation flow. Test this path explicitly—it executes more frequently in production than most teams anticipate.

-
Chapter 5
-

Generative UI

-

# Generative UI

-

Text responses hit a ceiling. When your data analysis agent returns a markdown table, users copy-paste into spreadsheets. When your booking agent describes available slots, users re-enter the same information into a form. The gap between agent output and user action creates friction that compounds across every interaction.

+
Chapter 05
+

Generative UI

+
+

Text responses hit a ceiling. When your data analysis agent returns a markdown table, users copy-paste into spreadsheets. When your booking agent describes available slots, users re-enter the same information into a form. The gap between agent output and user action creates friction that compounds across every interaction.

Production agents close this gap by emitting structured UI specifications alongside their responses. The agent doesn't return "Here are your results in a table" — it returns a render spec that becomes a live, interactive table component.

The Custom Event Pattern

LangGraph agents emit structured data through custom events during stream execution. Your agent code decides when to emit UI specifications:

@@ -261,57 +269,57 @@

The Custom Event Pattern

"rows": rows_so_far }) -

On the Angular side, `@threadplane/langgraph` surfaces these through the agent's event stream. The `@threadplane/render` package consumes these specs and resolves them to Angular components at runtime.

+

On the Angular side, @threadplane/langgraph surfaces these through the agent's event stream. The @threadplane/render package consumes these specs and resolves them to Angular components at runtime.

Registry-Based Resolution

The registry pattern decouples agent output from component implementation. Your agent emits a type identifier. Your frontend maps that identifier to a component. Neither side knows implementation details of the other.

import { defineAngularRegistry } from '@threadplane/render';
 import { DataTableComponent } from './components/data-table.component';
 import { ReservationFormComponent } from './components/reservation-form.component';
 import { ChartComponent } from './components/chart.component';
-

export const uiRegistry = defineAngularRegistry({ +export const uiRegistry = defineAngularRegistry({ data_table: DataTableComponent, reservation_form: ReservationFormComponent, chart: ChartComponent, // Add components without touching agent code }); -

-

Components receive the spec's data through a standardized input contract. Your `DataTableComponent` receives `columns` and `rows` — it doesn't know or care that a Python agent emitted them.

+ +

Components receive the spec's data through a standardized input contract. Your DataTableComponent receives columns and rows — it doesn't know or care that a Python agent emitted them.

Template usage is direct:

-

+
<render-spec [spec]="currentSpec()" [registry]="uiRegistry" />
 
-

Or configure the registry at the provider level with `provideRender({ registry: uiRegistry })` and omit it from individual templates.

+

Or configure the registry at the provider level with provideRender({ registry: uiRegistry }) and omit it from individual templates.

Progressive Updates Through JSON Patch

Static specs work for complete data. Streaming scenarios require progressive updates. When your agent processes a large dataset, users shouldn't wait for completion before seeing results.

-

`@threadplane/render` supports JSON Patch streaming for incremental UI updates. The agent emits patches as data arrives:

+

@threadplane/render supports JSON Patch streaming for incremental UI updates. The agent emits patches as data arrives:

# Initial spec
 await writer.write({"type": "data_table", "columns": [...], "rows": []})
-

# Patches as rows arrive +# Patches as rows arrive for row in process_rows(): await writer.write({"op": "add", "path": "/rows/-", "value": row}) -

+

The frontend applies patches to the live spec. Rows appear as they're processed. Charts animate as data points arrive. Users see progress, not loading spinners.

The Decoupling Advantage

Tight coupling between agent and frontend creates deployment dependencies. Changing a table column requires coordinated releases. Adding a new visualization blocks on frontend implementation.

-

The registry pattern inverts this. Agents emit specs against a stable contract. Frontend teams add components independently. You can ship a new `heatmap` type in your registry without redeploying agents — they'll use it when ready.

+

The registry pattern inverts this. Agents emit specs against a stable contract. Frontend teams add components independently. You can ship a new heatmap type in your registry without redeploying agents — they'll use it when ready.

This also enables A/B testing component implementations, graceful degradation for unknown types, and environment-specific registries (richer components in desktop, simplified in mobile).

---

Production checkpoint: Can your agent emit UI components without tight coupling to the frontend codebase? If adding a new visualization requires changes to both agent and frontend in lockstep, the integration is too brittle for production iteration speed.

-
Chapter 6
-

Deterministic Testing

-

# Deterministic Testing

-

Agent UIs are notoriously difficult to test. Every call to a live LLM introduces variability—different token sequences, timing variations, occasional model updates that subtly change output format. The result is flaky tests, slow CI pipelines, and an inability to reproduce the exact edge case a user reported. Teams ship agent features with low confidence because their test suites can't verify behavior deterministically.

+
Chapter 06
+

Deterministic Testing

+
+

Agent UIs are notoriously difficult to test. Every call to a live LLM introduces variability—different token sequences, timing variations, occasional model updates that subtly change output format. The result is flaky tests, slow CI pipelines, and an inability to reproduce the exact edge case a user reported. Teams ship agent features with low confidence because their test suites can't verify behavior deterministically.

Why Live LLM Testing Fails

Testing against real LLM APIs introduces three fundamental problems. First, response content varies between runs. The same prompt might yield slightly different phrasing, breaking snapshot tests or exact-match assertions. Second, latency compounds. A single agent interaction might take 2-5 seconds; a test suite with 50 agent tests becomes a 4-minute bottleneck. Third, you can't manufacture edge cases on demand. How do you test interrupt handling if the model decides not to request human input? How do you verify your tool call UI when the model skips the tool entirely?

Deterministic testing requires control over the event stream itself.

MockAgentTransport: Scripted Event Sequences

-

`MockAgentTransport` replaces the network layer entirely. You provide a scripted sequence of events, and the transport emits them on demand. No server, no network, no variability.

+

MockAgentTransport replaces the network layer entirely. You provide a scripted sequence of events, and the transport emits them on demand. No server, no network, no variability.

This approach lets you test streaming behavior by controlling exactly when each token arrives. You can simulate interrupts at precise moments, inject tool calls with specific payloads, and verify error handling by emitting failure events. Your tests become reproducible scenarios rather than probabilistic hopes.

mockLangGraphAgent(): Writable Signal Control

-

For component-level testing, `mockLangGraphAgent()` provides an even more direct approach. It returns an agent surface where every signal is writable—you set the state, and your component reacts.

-
describe('ChatComponent', () => {
-  it('displays interrupt panel when interrupt is pending', () => {
+

For component-level testing, mockLangGraphAgent() provides an even more direct approach. It returns an agent surface where every signal is writable—you set the state, and your component reacts.

+
describe('ChatComponent', () => {
+  it('displays interrupt panel when interrupt is pending', () => {
     const agent = mockLangGraphAgent();
     const fixture = TestBed.createComponent(ChatComponent);
     fixture.componentRef.setInput('agent', agent);
@@ -329,12 +337,12 @@ 

mockLangGraphAgent(): Writable Signal Control

This pattern isolates component behavior from streaming mechanics. You're testing how your UI responds to state—not whether the transport correctly parses SSE frames.

Testing in Isolation

-

Each agent capability becomes independently testable. For streaming, set `isLoading` to true and progressively update `messages` to verify your typing indicators and incremental rendering. For tool calls, populate `toolCalls` with specific payloads and assert your `ChatToolCallCardComponent` renders the expected UI. For generative UI via render-spec, test your registered components against static specs without involving the agent layer at all.

-

Interrupts deserve particular attention. Set `interrupt` to various payloads and verify your `ChatInterruptPanelComponent` handles each type—multiple choice, free text, confirmation dialogs. Call the resume function and assert `interrupt` clears correctly.

+

Each agent capability becomes independently testable. For streaming, set isLoading to true and progressively update messages to verify your typing indicators and incremental rendering. For tool calls, populate toolCalls with specific payloads and assert your ChatToolCallCardComponent renders the expected UI. For generative UI via render-spec, test your registered components against static specs without involving the agent layer at all.

+

Interrupts deserve particular attention. Set interrupt to various payloads and verify your ChatInterruptPanelComponent handles each type—multiple choice, free text, confirmation dialogs. Call the resume function and assert interrupt clears correctly.

Production Checklist

Before shipping agent features, verify this: Do your agent component tests run offline and complete in under 100ms each?

If not, you're either hitting real APIs or your test setup carries unnecessary overhead. Deterministic agent testing should feel like testing any other Angular component—fast, reliable, and completely under your control.

- + \ No newline at end of file diff --git a/apps/website/public/whitepaper.pdf b/apps/website/public/whitepaper.pdf index 732d06587..4f5877877 100644 Binary files a/apps/website/public/whitepaper.pdf and b/apps/website/public/whitepaper.pdf differ diff --git a/apps/website/public/whitepapers/angular-preview.html b/apps/website/public/whitepapers/angular-preview.html index aa6c74be5..8c8340c9b 100644 --- a/apps/website/public/whitepapers/angular-preview.html +++ b/apps/website/public/whitepapers/angular-preview.html @@ -3,71 +3,79 @@ - + + -
-
Threadplane · Angular Agent UI Guide
-

The
Enterprise
Guide
to
Agent
UI
in
Angular

-

Ship LangGraph and AG-UI-compatible agents without building the plumbing

-
threadplane.ai · 2026
+
+
Threadplane · Angular Agent UI Guide
+ +

The Enterprise Guide to Agent UI in Angular

+
+

Ship LangGraph and AG-UI-compatible agents without building the plumbing

+
threadplane.ai · 2026
-

Contents

- -
- 01 +

Contents

+
+
+
+ 01 The Last-Mile Problem
-
- 02 +
+ 02 The agent() API
-
- 03 +
+ 03 Thread Persistence & Memory
-
- 04 +
+ 04 Interrupt & Approval Flows
-
- 05 +
+ 05 Full LangGraph Feature Coverage
-
- 06 +
+ 06 Deterministic Testing -
+
-
Chapter 1
-

The Last-Mile Problem

-

# The Last-Mile Problem

-

You've built the backend. The LangGraph agent handles multi-step reasoning, calls tools, maintains conversation memory, and streams responses token by token. You've tested it with curl, watched it work in LangGraph Studio, maybe even built a quick React prototype. The agent architecture is solid.

+
Chapter 01
+

The Last-Mile Problem

+
+

You've built the backend. The LangGraph agent handles multi-step reasoning, calls tools, maintains conversation memory, and streams responses token by token. You've tested it with curl, watched it work in LangGraph Studio, maybe even built a quick React prototype. The agent architecture is solid.

Then you integrate it with your Angular application.

Zone Pollution Is Architectural, Not Configurable

-

The first symptom appears quickly: performance degradation during streaming. Every SSE event triggers zone.js change detection. A typical LLM response generates hundreds of token events over several seconds. Each event runs through `Zone.wrap()`, schedules a microtask, and triggers a full change detection cycle. Your application becomes unresponsive while the agent is responding.

-

The instinctive fix—running the EventSource outside the zone—creates new problems. Updates don't reach templates. Manual `ChangeDetectorRef.detectChanges()` calls scatter through your codebase. You're now maintaining zone-aware and zone-unaware code paths for the same data flow.

-

This isn't a configuration problem you can solve with `NgZone.runOutsideAngular()`. It's a fundamental mismatch between SSE's event model and Angular's zone-based change detection architecture.

+

The first symptom appears quickly: performance degradation during streaming. Every SSE event triggers zone.js change detection. A typical LLM response generates hundreds of token events over several seconds. Each event runs through Zone.wrap(), schedules a microtask, and triggers a full change detection cycle. Your application becomes unresponsive while the agent is responding.

+

The instinctive fix—running the EventSource outside the zone—creates new problems. Updates don't reach templates. Manual ChangeDetectorRef.detectChanges() calls scatter through your codebase. You're now maintaining zone-aware and zone-unaware code paths for the same data flow.

+

This isn't a configuration problem you can solve with NgZone.runOutsideAngular(). It's a fundamental mismatch between SSE's event model and Angular's zone-based change detection architecture.

Synchronous Templates, Asynchronous Tokens

Angular's template binding model expects synchronous state reads. Signals improved this, but the core assumption remains: when a template renders, it reads current values and completes. LLM token streams don't work this way. Tokens arrive continuously, accumulate into partial content, and may include control events (tool calls, interrupts) interleaved with text.

The naive implementation—updating a signal on every token—violates Angular's expectation of stable reads during change detection. You get ExpressionChangedAfterItHasBeenChecked errors, visual flickering, or worse: dropped tokens during rapid updates.

@@ -75,58 +83,58 @@

Synchronous Templates, Asynchronous Tokens

Push vs. Pull: The Reactivity Mismatch

RxJS Observables are push-based. Angular signals are pull-based. LLM streams are push-based with ordering guarantees. Bridging these models requires careful coordination.

Your REST-era patterns don't transfer. An HTTP response completes atomically; you handle loading, success, or error states. A streaming agent response is loading *and* partially successful *and* potentially errored, simultaneously. Tool calls arrive mid-stream. Human interrupts pause processing indefinitely. Partial content is valid content.

-

The standard `toSignal()` approach gives you the latest emission but loses the accumulated message history. Building that accumulation logic—correctly handling message append vs. replace semantics, tool call lifecycle states, and interrupt coordination—requires understanding LangGraph's event protocol, not just Angular's reactivity model.

+

The standard toSignal() approach gives you the latest emission but loses the accumulated message history. Building that accumulation logic—correctly handling message append vs. replace semantics, tool call lifecycle states, and interrupt coordination—requires understanding LangGraph's event protocol, not just Angular's reactivity model.

The Real Cost

Teams solve these problems. They build zone-patch utilities, token accumulator services, retry-with-backoff wrappers, and error boundary components. They write tests for partial stream failure, reconnection logic, and concurrent stream management.

Then the next project starts, and they build it again. Or they copy the code, discover edge cases the original didn't handle, and fork into divergent implementations.

The gap between a working demo and production-safe Angular integration is measured in weeks of engineering time, repeated across every team building agent-powered features. The backend streaming problem was solved. The frontend streaming problem keeps getting re-solved.

-
Chapter 2
-

The agent() API

-

# The agent() API

-

The `agent()` function is the primary interface for streaming LangGraph agents into Angular components. It returns a `LangGraphAgent` instance containing reactive signals that update automatically as the agent stream progresses. No subscriptions. No cleanup. No zone gymnastics.

+
Chapter 02
+

The agent() API

+
+

The agent() function is the primary interface for streaming LangGraph agents into Angular components. It returns a LangGraphAgent instance containing reactive signals that update automatically as the agent stream progresses. No subscriptions. No cleanup. No zone gymnastics.

Signal Architecture

-

Calling `agent()` returns an object with typed signals covering the full agent lifecycle:

-
  • `messages()` — The accumulated message history as `Message[]`, updated with each stream chunk
  • -
  • `isLoading()` — Boolean signal indicating active stream processing
  • -
  • `error()` — The current error state, or `undefined` when healthy
  • -
  • `interrupt()` — `AgentInterrupt | undefined`, populated when the agent yields control for human input
  • -
  • `status()` — Runtime lifecycle state: `'idle'` | `'running'` | `'error'`; use `isLoading()` for loading UI
  • -
  • `toolCalls()` — Active tool invocations extracted from the message stream
  • -
  • `state()` — The current agent state object from the LangGraph thread
  • +

    Calling agent() returns an object with typed signals covering the full agent lifecycle:

    +
    • messages() — The accumulated message history as Message[], updated with each stream chunk
    • +
    • isLoading() — Boolean signal indicating active stream processing
    • +
    • error() — The current error state, or undefined when healthy
    • +
    • interrupt()AgentInterrupt | undefined, populated when the agent yields control for human input
    • +
    • status() — Runtime lifecycle state: 'idle' | 'running' | 'error'; use isLoading() for loading UI
    • +
    • toolCalls() — Active tool invocations extracted from the message stream
    • +
    • state() — The current agent state object from the LangGraph thread
    -For cases requiring access to the raw LangGraph protocol, additional signals like `langGraphMessages()` expose the unprocessed message format. +For cases requiring access to the raw LangGraph protocol, additional signals like langGraphMessages() expose the unprocessed message format.

    Provider Configuration

    -

    Before `agent()` can connect, configure the transport layer with `provideAgent()`:

    +

    Before agent() can connect, configure the transport layer with provideAgent():

    provideAgent({
       transport: new FetchStreamTransport()
     })
     
    -

    This registers the stream transport globally. Individual `agent()` calls then specify their endpoint:

    +

    This registers the stream transport globally. Individual agent() calls then specify their endpoint:

    readonly chat = agent({
       assistantId: 'support_agent',
       apiUrl: 'https://api.example.com/langgraph',
       threadId: this.threadId,
    -  onThreadId: id => this.threadId.set(id)
    +  onThreadId: id => this.threadId.set(id)
     });
     
    -

    The `assistantId` identifies the deployed agent. The `apiUrl` points to your LangGraph API endpoint. Thread management is handled through the `threadId` input and `onThreadId` callback.

    +

    The assistantId identifies the deployed agent. The apiUrl points to your LangGraph API endpoint. Thread management is handled through the threadId input and onThreadId callback.

    Why Signals Work with OnPush

    -

    Angular's `OnPush` change detection strategy only triggers updates when input references change or when signals read in the template emit new values. Because `agent()` returns signals—not observables requiring `async` pipes—the framework detects changes automatically when stream chunks arrive.

    -

    No `markForCheck()`. No `ChangeDetectorRef` injection. The signals integrate with Angular's reactivity system at the primitive level.

    +

    Angular's OnPush change detection strategy only triggers updates when input references change or when signals read in the template emit new values. Because agent() returns signals—not observables requiring async pipes—the framework detects changes automatically when stream chunks arrive.

    +

    No markForCheck(). No ChangeDetectorRef injection. The signals integrate with Angular's reactivity system at the primitive level.

    Template Binding

    Bind agent state directly in templates without ceremony:

    @Component({
       template: `
         @if (chat.isLoading()) {
    -      
    +      <loading-indicator />
         }
         @for (message of chat.messages(); track message.id) {
    -      
    +      <message-bubble [message]="message" />
         }
         @if (chat.error(); as error) {
    -      
    +      <error-banner [error]="error" />
         }
       `,
       changeDetection: ChangeDetectionStrategy.OnPush
    @@ -137,39 +145,39 @@ 

    Template Binding

    Ten lines. The stream connects, messages accumulate, loading state toggles, errors surface—all reactive, all type-safe.

    The Alternative

    -

    Without `agent()`, the equivalent implementation requires manual stream handling:

    +

    Without agent(), the equivalent implementation requires manual stream handling:

    // Manual approach: ~60 lines of subscription management,
     // token accumulation, error handling, cleanup logic,
     // and change detection triggers
     
    -

    With `agent()`:

    +

    With agent():

    readonly chat = agent({ assistantId: 'chat_agent' });
     
    -

    The Angular signals-based design eliminates the subscription lifecycle entirely. When the component destroys, the signals become inert. No `takeUntilDestroyed()`. No `ngOnDestroy`. The framework handles it.

+

The Angular signals-based design eliminates the subscription lifecycle entirely. When the component destroys, the signals become inert. No takeUntilDestroyed(). No ngOnDestroy. The framework handles it.

-
Chapter 3
-

Thread Persistence & Memory

-

# Thread Persistence & Memory

-

Production agent applications are stateful. Users expect to close a browser tab, return hours later, and resume exactly where they left off. This requires tight coordination between LangGraph's checkpoint system and your Angular frontend's thread management.

+
Chapter 03
+

Thread Persistence & Memory

+
+

Production agent applications are stateful. Users expect to close a browser tab, return hours later, and resume exactly where they left off. This requires tight coordination between LangGraph's checkpoint system and your Angular frontend's thread management.

The Thread Lifecycle

-

LangGraph's `MemorySaver` backend persists conversation state against a `threadId`. Every message, tool call, and state mutation is checkpointed. The frontend's job is simple: track which `threadId` the user is working with and ensure it survives page reloads.

-

The `agent()` surface exposes this through two mechanisms. First, `threadId` accepts a signal containing the current thread identifier—pass `undefined` to create a new conversation. Second, `onThreadId` fires when LangGraph assigns an ID to a newly created thread.

+

LangGraph's MemorySaver backend persists conversation state against a threadId. Every message, tool call, and state mutation is checkpointed. The frontend's job is simple: track which threadId the user is working with and ensure it survives page reloads.

+

The agent() surface exposes this through two mechanisms. First, threadId accepts a signal containing the current thread identifier—pass undefined to create a new conversation. Second, onThreadId fires when LangGraph assigns an ID to a newly created thread.

@Component({
-  template: ``,
+  template: <code><chat [agent]="chat" /></code>,
   providers: [provideAgent()]
 })
 export class ChatPage {
   private readonly storage = inject(ThreadStorageService);
   
-  readonly threadId = signal(
+  readonly threadId = signal<string | undefined>(
     this.storage.getActiveThreadId()
   );
   
   readonly chat = agent({
     assistantId: 'support_agent',
     threadId: this.threadId,
-    onThreadId: id => {
+    onThreadId: id => {
       this.storage.setActiveThreadId(id);
       this.storage.addToThreadList(id);
       this.threadId.set(id);
@@ -177,28 +185,28 @@ 

The Thread Lifecycle

}); }
-

When `threadId` is `undefined`, the first `submit()` call triggers thread creation on the backend. LangGraph responds with the assigned ID, which flows through `onThreadId`. You persist it, update your signal, and subsequent messages automatically route to the correct checkpoint.

+

When threadId is undefined, the first submit() call triggers thread creation on the backend. LangGraph responds with the assigned ID, which flows through onThreadId. You persist it, update your signal, and subsequent messages automatically route to the correct checkpoint.

Restoring State on Mount

-

When a user returns with an existing `threadId`, LangGraph's checkpoint system handles restoration automatically. The backend loads the conversation history from `MemorySaver`, and the frontend receives the full message stream during the initial connection handshake.

-

This means your `messages()` signal populates with historical content without additional API calls. The `langGraphCheckpoint()` signal exposes metadata about the restored state—useful for debugging or displaying "last active" timestamps.

+

When a user returns with an existing threadId, LangGraph's checkpoint system handles restoration automatically. The backend loads the conversation history from MemorySaver, and the frontend receives the full message stream during the initial connection handshake.

+

This means your messages() signal populates with historical content without additional API calls. The langGraphCheckpoint() signal exposes metadata about the restored state—useful for debugging or displaying "last active" timestamps.

Building a Thread List

Most applications need more than single-thread persistence. Users expect to manage multiple conversations:

@Component({
   template: `
-    
-    
+      <button (click)="newThread()">New Conversation</button>
+    </aside>
+    <chat [agent]="chat" />
   `
 })
 export class MultiThreadChat {
-  readonly threadIds = signal(this.storage.getAllThreadIds());
-  readonly threadId = signal(this.storage.getActiveThreadId());
+  readonly threadIds = signal<string[]>(this.storage.getAllThreadIds());
+  readonly threadId = signal<string | undefined>(this.storage.getActiveThreadId());
   
   switchThread(id: string) {
     this.threadId.set(id);
@@ -210,33 +218,33 @@ 

Building a Thread List

} }
-

Switching threads is a signal update. The `agent()` reactive system handles reconnection, state restoration, and UI synchronization.

+

Switching threads is a signal update. The agent() reactive system handles reconnection, state restoration, and UI synchronization.

Production Considerations

-

Server-side thread expiration creates a failure mode your UI must handle. `MemorySaver` configurations often include TTLs—threads expire after periods of inactivity. When a user selects a stale `threadId`, the backend returns an error rather than conversation history.

-

Watch the `error()` signal for thread-not-found conditions. Your recovery logic should remove the invalid ID from local storage, notify the user, and optionally create a fresh thread automatically.

+

Server-side thread expiration creates a failure mode your UI must handle. MemorySaver configurations often include TTLs—threads expire after periods of inactivity. When a user selects a stale threadId, the backend returns an error rather than conversation history.

+

Watch the error() signal for thread-not-found conditions. Your recovery logic should remove the invalid ID from local storage, notify the user, and optionally create a fresh thread automatically.

Production checklist:

  • Does your thread list handle deleted or expired server-side threads gracefully?
  • Are you cleaning up localStorage when threads fail to load?
  • Do you display meaningful state when restoration is in progress versus complete?
  • Have you considered thread metadata (titles, timestamps) beyond raw IDs?
-The `MemorySaver` backend and Angular's signal-based reactivity create a clean separation of concerns. The backend owns durability; the frontend owns navigation. Keep that boundary crisp.
+The MemorySaver backend and Angular's signal-based reactivity create a clean separation of concerns. The backend owns durability; the frontend owns navigation. Keep that boundary crisp.
-
Chapter 4
-

Interrupt & Approval Flows

-

# Interrupt & Approval Flows

-

Agents that modify external systems—sending emails, executing database writes, triggering deployments—require human oversight. Autonomous execution without checkpoints creates liability, compliance violations, and irreversible mistakes. LangGraph's `interrupt()` primitive solves this at the graph level, pausing execution mid-stream until a human provides explicit authorization. `@threadplane/langgraph` surfaces this as a reactive signal, making approval workflows native to Angular's change detection without polling, websockets, or custom resume endpoints.

+
Chapter 04
+

Interrupt & Approval Flows

+
+

Agents that modify external systems—sending emails, executing database writes, triggering deployments—require human oversight. Autonomous execution without checkpoints creates liability, compliance violations, and irreversible mistakes. LangGraph's interrupt() primitive solves this at the graph level, pausing execution mid-stream until a human provides explicit authorization. @threadplane/langgraph surfaces this as a reactive signal, making approval workflows native to Angular's change detection without polling, websockets, or custom resume endpoints.

How LangGraph Interrupt Works

-

When a LangGraph node calls `interrupt()`, the graph halts execution and persists its current state to the configured checkpointer. The interrupt payload—containing context about the pending action—is sent to the client as part of the stream. Execution remains suspended until the client sends a resume command with one of three directives: approve the action as-is, provide edited parameters, or cancel entirely.

+

When a LangGraph node calls interrupt(), the graph halts execution and persists its current state to the configured checkpointer. The interrupt payload—containing context about the pending action—is sent to the client as part of the stream. Execution remains suspended until the client sends a resume command with one of three directives: approve the action as-is, provide edited parameters, or cancel entirely.

The resume payload structure is straightforward:

{ action: 'approve' }                    // Proceed with original parameters
 { action: 'edit', args: { ... } }        // Proceed with modified parameters  
 { action: 'cancel', reason?: string }    // Abort the pending action
 
-

LangGraph's `Command.RESUME` handles the routing. The graph receives the payload and either continues execution, re-executes with new arguments, or terminates gracefully.

+

LangGraph's Command.RESUME handles the routing. The graph receives the payload and either continues execution, re-executes with new arguments, or terminates gracefully.

The interrupt() Signal

-

The agent surface exposes `interrupt()` as a signal that transitions from `undefined` to an `AgentInterrupt` object when the graph pauses:

+

The agent surface exposes interrupt() as a signal that transitions from undefined to an AgentInterrupt object when the graph pauses:

interface AgentInterrupt {
   id: string;
   type: string;
@@ -244,14 +252,14 @@ 

The interrupt() Signal

timestamp: number; }
-

This signal integrates directly with Angular's reactivity model. Components re-render automatically when an interrupt arrives—no subscription management, no manual change detection triggers. When the user responds and execution resumes, the signal returns to `undefined`.

+

This signal integrates directly with Angular's reactivity model. Components re-render automatically when an interrupt arrives—no subscription management, no manual change detection triggers. When the user responds and execution resumes, the signal returns to undefined.

Prebuilt Approval UI

-

`@threadplane/chat` provides ``, a ready-to-use approval interface:

+

@threadplane/chat provides <chat-interrupt-panel>, a ready-to-use approval interface:

@Component({
   selector: 'app-agent',
   template: `
-    
-    
+    <chat [agent]="chat" />
+    <chat-interrupt-panel [agent]="chat" />
   `
 })
 export class AgentComponent {
@@ -262,11 +270,11 @@ 

Prebuilt Approval UI

@Component({
   template: `
     @if (chat.interrupt(); as int) {
-      
-

{{ int.payload.description }}

- - -
+ <div class="approval-modal"> + {{ int.payload.description }} + <button (click)="chat.interrupt({ action: 'approve' })">Approve</button> + <button (click)="chat.interrupt({ action: 'cancel' })">Cancel</button> + </div> } ` }) @@ -274,33 +282,33 @@

Prebuilt Approval UI

readonly chat = agent({ assistantId: 'ops_agent' }); }
-

Calling `interrupt()` with a resume payload sends the command and clears the signal.

+

Calling interrupt() with a resume payload sends the command and clears the signal.

Edge Cases

-

Navigation during interrupt: LangGraph persists interrupt state server-side. If the user navigates away, the interrupt remains active. Re-initializing the agent with the same `threadId` restores the pending interrupt automatically.

+

Navigation during interrupt: LangGraph persists interrupt state server-side. If the user navigates away, the interrupt remains active. Re-initializing the agent with the same threadId restores the pending interrupt automatically.

Session expiration: Checkpointed state survives session boundaries. The interrupt signal repopulates on reconnection, though your application should handle re-authentication before allowing resume actions.

Cancel with partial state: Cancellation doesn't roll back prior node executions. If three nodes completed before the interrupt, those effects persist. Design graphs with compensation logic for actions that require atomicity, or structure interrupts to occur before side effects rather than after.

-

Multiple pending interrupts: LangGraph supports sequential interrupts within a single run. The `interrupt()` signal reflects the current pending interrupt; each resume advances to the next pause point or completion.

-

Human-in-the-loop isn't optional for production agents. `@threadplane/langgraph` makes it reactive, type-safe, and compatible with Angular's rendering model—approval flows become UI state, not infrastructure problems.

+

Multiple pending interrupts: LangGraph supports sequential interrupts within a single run. The interrupt() signal reflects the current pending interrupt; each resume advances to the next pause point or completion.

+

Human-in-the-loop isn't optional for production agents. @threadplane/langgraph makes it reactive, type-safe, and compatible with Angular's rendering model—approval flows become UI state, not infrastructure problems.

-
Chapter 5
-

Full LangGraph Feature Coverage

-

# Full LangGraph Feature Coverage

-

Most Angular LLM integrations handle the basics: send a message, stream tokens, render a response. The moment you need tool calls, subgraphs, or multi-agent coordination, you're writing raw SSE parsers and manually reconciling state. @threadplane/langgraph exists specifically to avoid that cliff—every LangGraph feature surfaces through the same reactive signals your components already consume.

+
Chapter 05
+

Full LangGraph Feature Coverage

+
+

Most Angular LLM integrations handle the basics: send a message, stream tokens, render a response. The moment you need tool calls, subgraphs, or multi-agent coordination, you're writing raw SSE parsers and manually reconciling state. @threadplane/langgraph exists specifically to avoid that cliff—every LangGraph feature surfaces through the same reactive signals your components already consume.

Tool Call Streaming

-

LangGraph emits tool invocations as structured events mid-stream. Rather than parsing `tool_call` chunks yourself, the agent ref exposes them directly:

+

LangGraph emits tool invocations as structured events mid-stream. Rather than parsing tool_call chunks yourself, the agent ref exposes them directly:

readonly chat = agent({
   assistantId: 'research_agent',
   apiUrl: 'https://api.smith.langchain.com'
 });
-

// In your template +// In your template @for (call of chat.toolCalls(); track call.id) { - + <chat-tool-call-card [toolCall]="call" /> } -

-

The `toolCalls()` signal updates as invocations arrive, complete as the agent processes results, and clear when the turn ends. No manual event filtering. Tool call arguments stream incrementally—useful for showing users what data the agent is requesting before results return.

+
+

The toolCalls() signal updates as invocations arrive, complete as the agent processes results, and clear when the turn ends. No manual event filtering. Tool call arguments stream incrementally—useful for showing users what data the agent is requesting before results return.

Subgraph Support

-

Nested graphs emit events with their own namespaces. @threadplane/langgraph flattens these into the primary stream while preserving hierarchy through the `subagents()` signal:

+

Nested graphs emit events with their own namespaces. @threadplane/langgraph flattens these into the primary stream while preserving hierarchy through the subagents() signal:

// Parent agent spawns child graphs for specialized tasks
 const activeSubagents = this.chat.subagents();
 // Returns SubagentInfo[] with id, name, status for each active subgraph
@@ -314,22 +322,22 @@ 

Time Travel

action: 'rewind' });
-

The `langGraphCheckpoints()` signal exposes available restore points. After rewinding, `messages()` reflects the restored state and streaming continues from that node. This enables "undo" flows, A/B comparison of agent paths, and debugging without replaying the entire conversation.

+

The langGraphCheckpoints() signal exposes available restore points. After rewinding, messages() reflects the restored state and streaming continues from that node. This enables "undo" flows, A/B comparison of agent paths, and debugging without replaying the entire conversation.

DeepAgent Multi-Agent Coordination

DeepAgent orchestrates multiple specialized agents through a supervisor pattern. At the stream level, this means interleaved events from distinct agents with coordination metadata. The agent ref normalizes this:

// Each agent's output tagged with origin
 const messages = this.chat.messages();
 // Message.metadata.agent identifies the source agent
-

// Coordination state available through +// Coordination state available through const graphState = this.chat.langGraphState(); // Includes active_agent, delegation_history, shared_context -

+

Your UI can render agent-specific styling, show delegation chains, or visualize the coordination graph—all from signals that update as events arrive.

The onCustomEvent Hook

-

Agents emit structured events beyond messages: progress indicators, analytics payloads, generative UI specs. The `onCustomEvent` callback captures these without polluting the message stream:

+

Agents emit structured events beyond messages: progress indicators, analytics payloads, generative UI specs. The onCustomEvent callback captures these without polluting the message stream:

readonly chat = agent({
   assistantId: 'ui_agent',
-  onCustomEvent: (event) => {
+  onCustomEvent: (event) => {
     if (event.type === 'render_component') {
       this.dynamicUI.set(event.payload);
     }
@@ -339,21 +347,21 @@ 

The onCustomEvent Hook

This separates concerns cleanly: messages render in the chat, custom events drive application-specific behavior.

Why Full Coverage Matters

The pattern we've seen repeatedly: teams adopt a library for basic chat, then bypass it entirely when requirements expand. They end up maintaining parallel implementations—the library for simple flows, raw SSE handling for everything else. That's two mental models, two bug surfaces, two upgrade paths.

-

Full feature coverage eliminates that bifurcation. Tool calls, subgraphs, time travel, and multi-agent coordination all flow through the same `agent()` call. When LangGraph adds capabilities, they surface through existing signals rather than requiring new integration code. Your components stay declarative. Your state stays reactive. The complexity lives in the library, not your application.

+

Full feature coverage eliminates that bifurcation. Tool calls, subgraphs, time travel, and multi-agent coordination all flow through the same agent() call. When LangGraph adds capabilities, they surface through existing signals rather than requiring new integration code. Your components stay declarative. Your state stays reactive. The complexity lives in the library, not your application.

-
Chapter 6
-

Deterministic Testing

-

# Deterministic Testing

-

Agent UIs are notoriously difficult to test. Real LLM calls introduce latency measured in seconds, non-deterministic outputs, rate limits, and network dependencies that make CI pipelines slow and flaky. A test that passes locally might fail in CI because the model returned a slightly different response, or the API throttled your request, or the stream took longer than your timeout.

-

The solution is deterministic mocking at the transport layer. Threadplane provides two complementary approaches: `MockAgentTransport` for scripting realistic SSE event sequences, and `mockLangGraphAgent()` for direct signal manipulation when you need fine-grained control.

+
Chapter 06
+

Deterministic Testing

+
+

Agent UIs are notoriously difficult to test. Real LLM calls introduce latency measured in seconds, non-deterministic outputs, rate limits, and network dependencies that make CI pipelines slow and flaky. A test that passes locally might fail in CI because the model returned a slightly different response, or the API throttled your request, or the stream took longer than your timeout.

+

The solution is deterministic mocking at the transport layer. Threadplane provides two complementary approaches: MockAgentTransport for scripting realistic SSE event sequences, and mockLangGraphAgent() for direct signal manipulation when you need fine-grained control.

MockAgentTransport: Scripted Event Sequences

-

`MockAgentTransport` replaces `FetchStreamTransport` in tests, emitting a predetermined sequence of SSE events without any network calls. You script exactly what the agent receives—message chunks, tool calls, interrupts, errors—and the transport replays them synchronously or with configurable delays.

+

MockAgentTransport replaces FetchStreamTransport in tests, emitting a predetermined sequence of SSE events without any network calls. You script exactly what the agent receives—message chunks, tool calls, interrupts, errors—and the transport replays them synchronously or with configurable delays.

import { TestBed } from '@angular/core/testing';
 import { MockAgentTransport, provideAgent } from '@threadplane/langgraph';
-

describe('ChatComponent', () => { - let transport: MockAgentTransport;

-

beforeEach(() => { +describe('ChatComponent', () => { + let transport: MockAgentTransport; +beforeEach(() => { transport = new MockAgentTransport(); TestBed.configureTestingModule({ imports: [ChatComponent], @@ -361,16 +369,16 @@

MockAgentTransport: Scripted Event Sequences

}); }); }); -

+

This setup runs entirely offline. No HTTP interceptors, no mock servers, no environment configuration. The transport is synchronous by default, meaning your test assertions execute immediately after triggering a submit.

Testing Agent States

-

Every agent state your UI handles needs a corresponding test. `MockAgentTransport` lets you script each scenario explicitly:

-

Streaming in progress: Emit partial message events without a completion event. Assert that `isLoading()` returns true and the message list shows the partial content.

-

Stream complete: Emit the full event sequence including the completion marker. Assert that `isLoading()` returns false and messages contain the final content.

-

Interrupt pending: Script an interrupt event mid-stream. Assert that `interrupt()` returns the interrupt payload and your interrupt panel renders the expected options.

-

Error state: Emit an error event. Assert that `error()` contains the error details and your error UI appears.

+

Every agent state your UI handles needs a corresponding test. MockAgentTransport lets you script each scenario explicitly:

+

Streaming in progress: Emit partial message events without a completion event. Assert that isLoading() returns true and the message list shows the partial content.

+

Stream complete: Emit the full event sequence including the completion marker. Assert that isLoading() returns false and messages contain the final content.

+

Interrupt pending: Script an interrupt event mid-stream. Assert that interrupt() returns the interrupt payload and your interrupt panel renders the expected options.

+

Error state: Emit an error event. Assert that error() contains the error details and your error UI appears.

Direct Signal Control with mockLangGraphAgent

-

When testing component rendering in isolation—without exercising the transport layer—use `mockLangGraphAgent()` to create an agent instance with directly controllable signals:

+

When testing component rendering in isolation—without exercising the transport layer—use mockLangGraphAgent() to create an agent instance with directly controllable signals:

const mockAgent = mockLangGraphAgent({
   messages: signal([{ role: 'assistant', content: 'Test response' }]),
   status: signal('complete'),
@@ -379,11 +387,11 @@ 

Direct Signal Control with mockLangGraphAgent

Pass this mock to components that accept an agent input. You control exactly what signals return, making it trivial to test tool call rendering, generative UI output, and edge cases like empty states or malformed data.

Testing Thread Switching

-

Thread switching tests verify that your component correctly handles `threadId` changes and `onThreadId` callbacks. Script a sequence where the transport emits a new thread ID, then assert that your `onThreadId` handler persisted the value and subsequent messages associate with the correct thread.

+

Thread switching tests verify that your component correctly handles threadId changes and onThreadId callbacks. Script a sequence where the transport emits a new thread ID, then assert that your onThreadId handler persisted the value and subsequent messages associate with the correct thread.

The Benchmark

Agent component tests should run offline and complete in under 100ms each. This isn't aspirational—it's achievable when you eliminate network calls and async delays. A test suite with 50 agent UI tests should finish in under 5 seconds, run identically on developer machines and CI, and produce the same results every time.

If your agent tests take longer or exhibit flakiness, you're either hitting real infrastructure or introducing unnecessary async delays in your mocks. Fix the mocking strategy, not the timeout thresholds.

- + \ No newline at end of file diff --git a/apps/website/public/whitepapers/angular.pdf b/apps/website/public/whitepapers/angular.pdf index 7c44548ad..20e1a505f 100644 Binary files a/apps/website/public/whitepapers/angular.pdf and b/apps/website/public/whitepapers/angular.pdf differ diff --git a/apps/website/public/whitepapers/chat-preview.html b/apps/website/public/whitepapers/chat-preview.html index 7f961b602..a1750bd96 100644 --- a/apps/website/public/whitepapers/chat-preview.html +++ b/apps/website/public/whitepapers/chat-preview.html @@ -3,62 +3,70 @@ - + + -
-
@threadplane/chat · Enterprise Guide
-

The
Enterprise
Guide
to
Agent
Chat
Interfaces
in
Angular

-

Production agent chat UI in days, not sprints

-
threadplane.ai · 2026
+
+
@threadplane/chat · Enterprise Guide
+ +

The Enterprise Guide to Agent Chat Interfaces in Angular

+
+

Production agent chat UI in days, not sprints

+
threadplane.ai · 2026
-

Contents

- -
- 01 +

Contents

+
+
+
+ 01 The Sprint Tax
-
- 02 +
+ 02 Batteries-Included Components
-
- 03 +
+ 03 Theming & Design System Integration
-
- 04 +
+ 04 Generative UI in Chat
-
- 05 +
+ 05 Debug Tooling -
+
-
Chapter 1
-

The Sprint Tax

-

# The Sprint Tax

-

Every team building an Angular agent application eventually builds the same chat UI from scratch. Message list, input box, streaming token display, auto-scroll, loading states, error handling. It takes 4-6 weeks. Then they iterate on it for another 4-6 weeks. Meanwhile, the agent backend is ready and waiting.

+
Chapter 01
+

The Sprint Tax

+
+

Every team building an Angular agent application eventually builds the same chat UI from scratch. Message list, input box, streaming token display, auto-scroll, loading states, error handling. It takes 4-6 weeks. Then they iterate on it for another 4-6 weeks. Meanwhile, the agent backend is ready and waiting.

This isn't a skill gap. It's a structural inefficiency baked into how we approach agent interfaces.

The Inventory

Here's what every production chat UI actually needs:

@@ -81,26 +89,26 @@

The Opportunity Cost

Here's the actual problem: you're paying senior Angular engineers to solve problems that have already been solved. Problems that have nothing to do with what makes your agent application valuable.

While your team is debugging auto-scroll edge cases, your agent backend is ready and waiting. While they're implementing tool call state machines, your differentiating features aren't getting built. While they're fixing accessibility audit failures, your competitors are shipping.

The @threadplane/chat Thesis

-

`@threadplane/chat` ships the complete inventory: ``, ``, ``, ``, ``. Production-grade. Accessible. Mobile-ready. Streaming-optimized.

+

@threadplane/chat ships the complete inventory: <chat-message-list>, <chat-input>, <chat-tool-calls>, <chat-tool-call-card>, <chat-interrupt-panel>. Production-grade. Accessible. Mobile-ready. Streaming-optimized.

The thesis is simple: ship the chat UI on day one. Spend the sprints on what differentiates your product—the agent logic, the tool integrations, the domain-specific features that your competitors can't copy.

The sprint tax is optional. Stop paying it.

-
Chapter 2
-

Batteries-Included Components

-

# Batteries-Included Components

-

@threadplane/chat ships two component tiers: headless primitives that encapsulate behavior without styling opinions, and prebuilt compositions that deliver production-ready interfaces with minimal configuration. The separation lets teams adopt complete solutions immediately while preserving escape hatches for custom requirements.

+
Chapter 02
+

Batteries-Included Components

+
+

@threadplane/chat ships two component tiers: headless primitives that encapsulate behavior without styling opinions, and prebuilt compositions that deliver production-ready interfaces with minimal configuration. The separation lets teams adopt complete solutions immediately while preserving escape hatches for custom requirements.

The Headless Tier

Headless components own behavior and state management but emit no styled markup. They expose content projection slots and structural directives for complete template control.

-

`` manages scroll position, virtualization hints, and message grouping logic. It consumes `Message[]` from the agent surface and handles the complexity of streaming message updates—partial content, role transitions, and optimistic UI states. Your templates define how each message renders.

-

`` handles submit semantics, keyboard shortcuts, disabled states during streaming, and multiline expansion. It exposes form control bindings without prescribing input styling.

-

`` and `` manage tool invocation display, including pending states, execution results, and error handling. `` surfaces human-in-the-loop decision points when the agent requires input to proceed.

+

<chat-message-list> manages scroll position, virtualization hints, and message grouping logic. It consumes Message[] from the agent surface and handles the complexity of streaming message updates—partial content, role transitions, and optimistic UI states. Your templates define how each message renders.

+

<chat-input> handles submit semantics, keyboard shortcuts, disabled states during streaming, and multiline expansion. It exposes form control bindings without prescribing input styling.

+

<chat-tool-calls> and <chat-tool-call-card> manage tool invocation display, including pending states, execution results, and error handling. <chat-interrupt-panel> surfaces human-in-the-loop decision points when the agent requires input to proceed.

These primitives compose freely. Use them when your design system mandates specific markup structures or when accessibility requirements demand particular ARIA patterns.

The Prebuilt Composition Tier

-

The `` component assembles headless primitives with production styling and sensible defaults. It accepts an agent instance and renders a complete interface:

+

The <chat> component assembles headless primitives with production styling and sensible defaults. It accepts an agent instance and renders a complete interface:

@Component({
   selector: 'app-support',
-  template: ``,
+  template: <code><chat [agent]="supportAgent" /></code>,
 })
 export class SupportComponent {
   supportAgent = agent({
@@ -111,26 +119,26 @@ 

The Prebuilt Composition Tier

}

Six lines deliver a functional chat interface with message history, streaming indicators, input handling, and tool call display. The component handles loading states, error presentation, and responsive layout without additional configuration.

-

Companion components—`ChatMessageListComponent`, `ChatInputComponent`, `ChatToolCallsComponent`, `ChatInterruptPanelComponent`, `ChatDebugComponent`—can be used independently when you need prebuilt styling for specific sections while customizing others.

+

Companion components—ChatMessageListComponent, ChatInputComponent, ChatToolCallsComponent, ChatInterruptPanelComponent, ChatDebugComponent—can be used independently when you need prebuilt styling for specific sections while customizing others.

Composing Tiers

The practical pattern: use prebuilt components for standard sections, drop to headless for custom requirements. A typical enterprise implementation might use the prebuilt message list and input while providing a custom tool call renderer that integrates with internal component libraries.

-

The `` component accepts content projection for this purpose. Override specific slots while retaining default behavior elsewhere. When projection proves insufficient, decompose to individual prebuilt components, then to headless primitives as customization needs escalate.

+

The <chat> component accepts content projection for this purpose. Override specific slots while retaining default behavior elsewhere. When projection proves insufficient, decompose to individual prebuilt components, then to headless primitives as customization needs escalate.

The Agent Contract

-

Both tiers consume the runtime-neutral `Agent` contract returned by `agent()` from @threadplane/langgraph. This contract exposes signals: `messages()` returns `Message[]` representing the conversation, `status()` indicates connection state, `isLoading()` reflects pending operations, `toolCalls()` surfaces invocations, and `state()` provides LangGraph checkpoint data.

-

Components bind directly to these signals. When the agent streams a response, `messages()` updates reactively. Components re-render affected sections without manual change detection. The `Message` type from @threadplane/chat provides the runtime-neutral representation—role, content, metadata—that components consume regardless of the underlying LangGraph message format.

+

Both tiers consume the runtime-neutral Agent contract returned by agent() from @threadplane/langgraph. This contract exposes signals: messages() returns Message[] representing the conversation, status() indicates connection state, isLoading() reflects pending operations, toolCalls() surfaces invocations, and state() provides LangGraph checkpoint data.

+

Components bind directly to these signals. When the agent streams a response, messages() updates reactively. Components re-render affected sections without manual change detection. The Message type from @threadplane/chat provides the runtime-neutral representation—role, content, metadata—that components consume regardless of the underlying LangGraph message format.

Choosing Your Tier

-

Start with ``. It covers the common case and establishes baseline functionality in minutes. Drop to prebuilt companions when you need to rearrange layout or inject custom sections between standard elements. Move to headless primitives when your design system requires specific DOM structures or when you're building novel interaction patterns.

+

Start with <chat>. It covers the common case and establishes baseline functionality in minutes. Drop to prebuilt companions when you need to rearrange layout or inject custom sections between standard elements. Move to headless primitives when your design system requires specific DOM structures or when you're building novel interaction patterns.

Migration between tiers is mechanical: prebuilt components are compositions of headless primitives with styling applied. Extracting customization points means identifying which primitive to expose and which styling to preserve. The agent contract remains constant across tiers—your backend integration stays unchanged regardless of which component tier renders the interface.

-
Chapter 3
-

Theming & Design System Integration

-

# Theming & Design System Integration

-

A chat interface that looks like a demo is a liability in production. Users notice when components don't match the rest of the application—inconsistent border radius, wrong font stack, off-brand colors. These details erode trust in the product and in the AI features you're shipping.

+
Chapter 03
+

Theming & Design System Integration

+
+

A chat interface that looks like a demo is a liability in production. Users notice when components don't match the rest of the application—inconsistent border radius, wrong font stack, off-brand colors. These details erode trust in the product and in the AI features you're shipping.

@threadplane/chat exposes its visual design decisions through CSS custom properties, giving you control over appearance without touching component internals or maintaining forks.

The CSS Custom Property API

Every visual decision in @threadplane/chat maps to a custom property. The components read these values at runtime, so overriding them in your stylesheet changes the rendered output immediately.

-

The naming convention follows a predictable pattern: `--chat-{component}-{property}`. Component-level tokens reference global tokens, which reference your design system tokens. This layering lets you override at whatever granularity makes sense—change one button's border radius or change every border radius in the chat interface with a single line.

+

The naming convention follows a predictable pattern: --chat-{component}-{property}. Component-level tokens reference global tokens, which reference your design system tokens. This layering lets you override at whatever granularity makes sense—change one button's border radius or change every border radius in the chat interface with a single line.

Design Token Mapping

If your team already maintains design tokens, integration is direct assignment. Map your existing tokens to the chat component tokens in a single stylesheet:

:root {
@@ -138,20 +146,20 @@ 

Design Token Mapping

--chat-font-family: var(--ds-font-family-body); --chat-font-size-base: var(--ds-font-size-md); --chat-line-height: var(--ds-line-height-normal); -

/* Colors */ +/* Colors */ --chat-surface-primary: var(--ds-color-surface-elevated); --chat-surface-secondary: var(--ds-color-surface-sunken); --chat-text-primary: var(--ds-color-text-primary); --chat-text-secondary: var(--ds-color-text-muted); - --chat-accent: var(--ds-color-brand-primary);

-

/* Shape */ + --chat-accent: var(--ds-color-brand-primary); +/* Shape */ --chat-border-radius: var(--ds-radius-md); - --chat-spacing-unit: var(--ds-spacing-base);

-

/* State colors */ + --chat-spacing-unit: var(--ds-spacing-base); +/* State colors */ --chat-color-error: var(--ds-color-feedback-error); --chat-color-success: var(--ds-color-feedback-success); } -

+

This mapping becomes your single source of truth. When design updates the brand's border radius, the chat components update automatically because they reference your tokens, not hardcoded values.

Typography Integration

Chat interfaces are text-heavy. Typography consistency matters more here than in most UI contexts.

@@ -169,22 +177,22 @@

Dark Mode Support

Components don't contain theme-switching logic. They read current token values. Your application controls when those values change.

Limitations of Token-Based Theming

CSS custom properties control visual properties—colors, spacing, typography, borders. They don't control structure.

-

If you need different DOM layout, custom animations, or component composition that diverges from the default, tokens won't help. This is where the headless pattern applies: use `agent()` directly with your own components, keeping the streaming infrastructure while owning the entire render layer.

+

If you need different DOM layout, custom animations, or component composition that diverges from the default, tokens won't help. This is where the headless pattern applies: use agent() directly with your own components, keeping the streaming infrastructure while owning the entire render layer.

Tokens handle 80% of enterprise theming needs. The headless tier handles the rest.

-
Chapter 4
-

Generative UI in Chat

-

# Generative UI in Chat

-

Text is a bottleneck. When a financial agent needs to present quarterly results, streaming prose about revenue figures wastes cognitive load. When a scheduling agent confirms a booking, a wall of text obscures the actionable details. The most capable agent interfaces solve this by rendering structured UI directly in the message stream—tables, forms, approval cards—alongside conversational text.

+
Chapter 04
+

Generative UI in Chat

+
+

Text is a bottleneck. When a financial agent needs to present quarterly results, streaming prose about revenue figures wastes cognitive load. When a scheduling agent confirms a booking, a wall of text obscures the actionable details. The most capable agent interfaces solve this by rendering structured UI directly in the message stream—tables, forms, approval cards—alongside conversational text.

Structured UI in the Message Stream

@threadplane/chat treats generative UI as a first-class message type. When an agent emits a UI specification instead of text, the chat renders it inline using @threadplane/render. From the user's perspective, the conversation flows naturally: the agent explains context in prose, then presents a rendered component for interaction.

This works because the Agent contract exposes messages as a heterogeneous stream. Text messages render as text. UI messages resolve to Angular components through a registry. The chat component handles both transparently.

The json-render Spec

-

The json-render specification defines a minimal contract for declarative UI. An agent emits a JSON object with a `type` field identifying the component and a `props` field containing its inputs:

+

The json-render specification defines a minimal contract for declarative UI. An agent emits a JSON object with a type field identifying the component and a props field containing its inputs:

{"type": "data-table", "props": {"columns": ["Quarter", "Revenue"], "rows": [...]}}
 
-

The renderer resolves `data-table` to an Angular component, binds `props` to its inputs, and inserts it into the DOM. No custom parsing, no message-type switches in templates. The specification stays minimal intentionally—agents describe *what* to render, not *how*.

+

The renderer resolves data-table to an Angular component, binds props to its inputs, and inserts it into the DOM. No custom parsing, no message-type switches in templates. The specification stays minimal intentionally—agents describe *what* to render, not *how*.

A2UI: Agent-Specific Patterns

Google's A2UI specification extends json-render with patterns specific to agent interactions: approval workflows, structured actions, and rich data displays. Where json-render handles arbitrary components, A2UI codifies the common cases—confirmation dialogs, multi-step forms, action buttons with pending states.

@threadplane/render supports both specifications. You can mix A2UI's structured patterns with custom json-render components in the same message stream.

@@ -194,14 +202,14 @@

Registry Integration

import { DataTableComponent } from './data-table.component'; import { BookingFormComponent } from './booking-form.component'; import { ApprovalCardComponent } from './approval-card.component'; -

const registry = defineAngularRegistry({ +const registry = defineAngularRegistry({ 'data-table': DataTableComponent, 'booking-form': BookingFormComponent, 'approval-card': ApprovalCardComponent, -});

-

// In your providers array +}); +// In your providers array provideRender({ registry }) -

+

The chat component picks up the registry through dependency injection. When a message contains a render spec, it resolves and instantiates the component automatically.

Streaming Patches

Agents rarely emit complete UI specifications in one shot. A data table streams rows as they arrive. A form populates fields progressively. @threadplane/render handles this through JSON Patch streaming—the agent emits an initial skeleton, then streams RFC 6902 patches that mutate the specification incrementally.

@@ -209,26 +217,26 @@

Streaming Patches

This matters for perceived latency. Users see structure immediately, then watch it fill in—progress feels continuous rather than blocked on complete responses.

-
Chapter 5
-

Debug Tooling

-

# Debug Tooling

-

Debugging agent chat is hard. The message stream is opaque, tool call state transitions happen in milliseconds, and interrupt flows have timing edge cases that only surface under load. You can't step through a streaming conversation the way you step through synchronous code.

-

`` is @threadplane/chat's built-in debug panel—a developer overlay that surfaces agent state, raw message events, tool call history, and interrupt state in real time. One component, zero configuration.

+
Chapter 05
+

Debug Tooling

+
+

Debugging agent chat is hard. The message stream is opaque, tool call state transitions happen in milliseconds, and interrupt flows have timing edge cases that only surface under load. You can't step through a streaming conversation the way you step through synchronous code.

+

<chat-debug> is @threadplane/chat's built-in debug panel—a developer overlay that surfaces agent state, raw message events, tool call history, and interrupt state in real time. One component, zero configuration.

What chat-debug Shows

The debug panel exposes four primary views:

-

Message State. The current `Message[]` array, rendered as expandable JSON. You see every message the agent has processed—user inputs, assistant responses, tool results—with full metadata intact.

+

Message State. The current Message[] array, rendered as expandable JSON. You see every message the agent has processed—user inputs, assistant responses, tool results—with full metadata intact.

Streaming Event Log. A chronological log of every event received from the transport layer. This includes partial tokens during streaming, state updates, tool call initiations, and completion signals. The log timestamps each event to the millisecond.

Tool Call State Machine. Each tool call passes through distinct states: pending, executing, completed, or failed. The debug panel visualizes this state machine for every tool call in the current conversation, showing transitions as they happen.

Interrupt Payload. When the agent requests human intervention, the full interrupt payload appears in the panel—the interrupt type, the data the agent is requesting, and any context it provided. After user action, you see both the original payload and the response.

Adding chat-debug

Drop it into any chat interface:

import { ChatDebugComponent } from '@threadplane/chat';
-

@Component({ +@Component({ selector: 'app-support-chat', imports: [ChatComponent, ChatDebugComponent], template: ` - - + <chat [agent]="agent" /> + <chat-debug [agent]="agent" /> ` }) export class SupportChatComponent { @@ -238,7 +246,7 @@

Adding chat-debug

apiUrl: '/api/langgraph' }); } -

+

No configuration required. The panel renders in the bottom-right corner with a toggle to expand and collapse.

Inspecting Individual Messages

Click any message in the Message State view to expand it. You'll see:

@@ -260,12 +268,12 @@

Interrupt State Inspection

Interrupts are the hardest flow to debug without tooling. The agent pauses, waits for user input, then resumes—but what exactly is it waiting for?

The interrupt view shows the full payload the agent sent when requesting the interrupt. After the user responds, you see both sides: what was asked and what was provided. This catches mismatches between what your interrupt UI collects and what the agent expects.

Integration with Angular DevTools

-

Agent signals created by `agent()` appear in Angular DevTools like any other signal. In the component tree, you'll see `messages`, `status`, `isLoading`, `toolCalls`, and `state` as inspectable signals with their current values.

+

Agent signals created by agent() appear in Angular DevTools like any other signal. In the component tree, you'll see messages, status, isLoading, toolCalls, and state as inspectable signals with their current values.

This means standard Angular debugging workflows apply. Set a breakpoint, inspect signal values, trace reactivity—the agent surface behaves like any other signal-based state.

Production Safety

-

`` uses Angular's `isDevMode()` check internally. In production builds, the component renders nothing—no DOM nodes, no event listeners, no performance overhead. Leave it in your templates; the build process handles the rest.

-

For teams that need debug capabilities in staging environments, pass `[forceEnable]="true"` to override the dev mode check. Use this sparingly and gate it behind feature flags.

+

<chat-debug> uses Angular's isDevMode() check internally. In production builds, the component renders nothing—no DOM nodes, no event listeners, no performance overhead. Leave it in your templates; the build process handles the rest.

+

For teams that need debug capabilities in staging environments, pass [forceEnable]="true" to override the dev mode check. Use this sparingly and gate it behind feature flags.

- + \ No newline at end of file diff --git a/apps/website/public/whitepapers/chat.pdf b/apps/website/public/whitepapers/chat.pdf index ae438d20c..4a70df273 100644 Binary files a/apps/website/public/whitepapers/chat.pdf and b/apps/website/public/whitepapers/chat.pdf differ diff --git a/apps/website/public/whitepapers/render-preview.html b/apps/website/public/whitepapers/render-preview.html index 395d97d5a..ea822cabc 100644 --- a/apps/website/public/whitepapers/render-preview.html +++ b/apps/website/public/whitepapers/render-preview.html @@ -3,68 +3,76 @@ - + + -
-
@threadplane/render · Enterprise Guide
-

The
Enterprise
Guide
to
Generative
UI
in
Angular

-

Agents that render UI — without coupling to your frontend

-
threadplane.ai · 2026
+
+
@threadplane/render · Enterprise Guide
+ +

The Enterprise Guide to Generative UI in Angular

+
+

Agents that render UI — without coupling to your frontend

+
threadplane.ai · 2026
-

Contents

- -
- 01 +

Contents

+
+
+
+ 01 The Coupling Problem
-
- 02 +
+ 02 Declarative UI Specs & the json-render Standard
-
- 03 +
+ 03 The Component Registry
-
- 04 +
+ 04 Streaming JSON Patches
-
- 05 +
+ 05 State Management & Computed Functions -
+
-
Chapter 1
-

The Coupling Problem

-

# The Coupling Problem

-

Every Angular developer who has integrated an LLM agent hits the same wall. The agent returns structured output—maybe a product recommendation, a data visualization, or a multi-step form. You need to render it. The obvious solution:

+
Chapter 01
+

The Coupling Problem

+
+

Every Angular developer who has integrated an LLM agent hits the same wall. The agent returns structured output—maybe a product recommendation, a data visualization, or a multi-step form. You need to render it. The obvious solution:

@Component({
   template: `
     @switch (output.type) {
-      @case ('product') {  }
-      @case ('chart') {  }
-      @case ('form') {  }
+      @case ('product') { <product-card [data]="output" /> }
+      @case ('chart') { <data-viz [config]="output" /> }
+      @case ('form') { <dynamic-form [schema]="output" /> }
     }
   `
 })
@@ -73,7 +81,7 @@ 

// Frontend interprets via registry +// Frontend interprets via registry const registry = defineAngularRegistry({ 'data-table': DataTableComponent, 'product-card': ProductCardComponent, // ... }); -

+

New capability? Agent emits it. If the registry has a mapping, it renders. If not, fallback behavior. No frontend deploy required for agent changes. No agent prompt changes required for component refactors.

The registry becomes the contract. Version it. Document it. Let teams evolve independently.

The Standard Problem

-

A registry per frontend doesn't solve coordination—it moves it. You still need agreement on what `"component": "data-table"` means, what props it accepts, how deeply specs can nest.

+

A registry per frontend doesn't solve coordination—it moves it. You still need agreement on what "component": "data-table" means, what props it accepts, how deeply specs can nest.

Without a shared specification, you'll build three proprietary formats. Your agents will need frontend-specific prompt branches. Your component libraries will drift.

This is why the approach demands an open spec—a grammar for describing UI that agents can target and any frontend can interpret. Not a component library. Not a design system. A protocol.

The next chapter introduces that protocol.

-
Chapter 2
-

Declarative UI Specs & the json-render Standard

-

# Declarative UI Specs & the json-render Standard

-

The Problem with Ad-Hoc UI Generation

+
Chapter 02
+

Declarative UI Specs & the json-render Standard

+
+

The Problem with Ad-Hoc UI Generation

When LLMs generate UI, the output format matters as much as the content. Without a formal specification, teams end up with brittle prompt engineering, framework-specific JSON schemas, and UI descriptions that break when models update or requirements change. The json-render specification solves this by defining a framework-agnostic standard for describing component trees as structured JSON.

Anatomy of a json-render Document

A json-render document describes a component tree using three primitives: component name, props, and children.

@@ -159,7 +167,7 @@

Control Flow in the Spec

"children": [{ "component": "admin-panel", "props": {} }] } -

Iteration uses `$for`:

+

Iteration uses $for:

{
   "component": "$for",
   "props": { "each": "{{ items }}", "as": "item" },
@@ -174,68 +182,68 @@ 

Control Flow in the Spec

Computed properties use template expressions. The renderer evaluates these against a provided context object, keeping the spec declarative while supporting dynamic data binding.

Google's A2UI Extension

Google's Agent-to-UI (A2UI) specification extends json-render with agent-specific patterns. It adds constructs for streaming updates, tool call visualization, and interrupt handling—concepts that don't exist in static UI rendering but are fundamental to agent interactions.

-

A2UI defines how partial UI updates arrive during generation, how tool invocations surface to users, and how human-in-the-loop checkpoints integrate with component trees. The `@threadplane/render` package implements both specifications.

+

A2UI defines how partial UI updates arrive during generation, how tool invocations surface to users, and how human-in-the-loop checkpoints integrate with component trees. The @threadplane/render package implements both specifications.

The @threadplane/render Implementation

-

The `` directive consumes json-render documents and instantiates Angular components:

+

The <render-spec> directive consumes json-render documents and instantiates Angular components:

import { Component, signal } from '@angular/core';
 import { RenderSpecComponent, defineAngularRegistry } from '@threadplane/render';
 import { CardComponent, MetricComponent, SparklineComponent } from './components';
-

const registry = defineAngularRegistry({ +const registry = defineAngularRegistry({ card: CardComponent, metric: MetricComponent, sparkline: SparklineComponent -});

-

@Component({ +}); +@Component({ selector: 'app-dashboard', imports: [RenderSpecComponent], - template: `` + template: <code><render-spec [spec]="uiSpec()" [registry]="registry" /></code> }) export class DashboardComponent { registry = registry; - uiSpec = signal(null); + uiSpec = signal<object | null>(null); } -

+

The registry maps component names to Angular components. Unknown components throw at render time—fail fast rather than silent degradation.

Prompting for Valid Output

LLMs generate spec-compliant JSON when prompts include the schema and examples:

Generate a json-render document for a user profile card.
-

Schema: { component: string, props: object, children?: array } -Available components: card, avatar, text, badge

-

Example output: +Schema: { component: string, props: object, children?: array } +Available components: card, avatar, text, badge +Example output: {"component": "card", "props": {"variant": "outlined"}, "children": [...]} -

+

Structured output modes (JSON mode, function calling) enforce syntactic validity. Schema validation catches semantic errors—referencing undefined components or invalid prop types.

The specification creates a stable contract. Agents emit it. Renderers consume it. Neither side knows how the other works.

-
Chapter 3
-

The Component Registry

-

# The Component Registry

-

A render-spec document references components by string name. The registry resolves those names to Angular component classes at render time. Without this mapping layer, the open standard would be theoretical—the registry makes it executable.

+
Chapter 03
+

The Component Registry

+
+

A render-spec document references components by string name. The registry resolves those names to Angular component classes at render time. Without this mapping layer, the open standard would be theoretical—the registry makes it executable.

Defining the Registry

-

`defineAngularRegistry()` accepts a record of component names to Angular component classes:

+

defineAngularRegistry() accepts a record of component names to Angular component classes:

import { defineAngularRegistry } from '@threadplane/render';
 import { CardComponent } from './card.component';
 import { ButtonComponent } from './button.component';
 import { DataTableComponent } from './data-table.component';
 import { AlertComponent } from './alert.component';
-

export const uiRegistry = defineAngularRegistry({ +export const uiRegistry = defineAngularRegistry({ 'Card': CardComponent, 'Button': ButtonComponent, 'DataTable': DataTableComponent, 'Alert': AlertComponent }); -

-

The keys are the exact strings that appear in your render-spec `component` fields. The values are the component classes themselves—not selectors, not factory functions. This directness means the registry is statically analyzable and tree-shakeable.

+ +

The keys are the exact strings that appear in your render-spec component fields. The values are the component classes themselves—not selectors, not factory functions. This directness means the registry is statically analyzable and tree-shakeable.

Providing the Registry

Two patterns, depending on your architecture.

Direct binding works for isolated cases where a single component owns the rendering context:

@Component({
-  template: ``
+  template: <code><render-spec [spec]="spec" [registry]="registry" /></code>
 })
 export class PreviewComponent {
   registry = uiRegistry;
-  spec = input.required();
+  spec = input.required<RenderSpec>();
 }
 

Dependency injection suits applications where multiple components render specs against a shared registry:

@@ -246,21 +254,21 @@

Providing the Registry

] }; -

When both are present, the direct `[registry]` binding takes precedence. This lets you override the global registry for specific rendering contexts—useful for sandboxed previews or A/B testing component implementations.

+

When both are present, the direct [registry] binding takes precedence. This lets you override the global registry for specific rendering contexts—useful for sandboxed previews or A/B testing component implementations.

Resolution and Input Mapping

-

`` walks the spec tree, resolving each `component` string against the registry. For each node, it instantiates the corresponding Angular component and maps the spec's `props` object to `@Input()` bindings.

-

The mapping is direct: a prop named `title` binds to an input named `title`. No transformation, no case conversion. If your component expects `@Input() headerText` but the spec sends `header_text`, the binding fails silently—Angular's standard behavior for unknown inputs.

+

<render-spec> walks the spec tree, resolving each component string against the registry. For each node, it instantiates the corresponding Angular component and maps the spec's props object to @Input() bindings.

+

The mapping is direct: a prop named title binds to an input named title. No transformation, no case conversion. If your component expects @Input() headerText but the spec sends header_text, the binding fails silently—Angular's standard behavior for unknown inputs.

This is intentional. The registry defines *which* components exist; your component contracts define *what* they accept. Keep those contracts stable or version them explicitly.

Unknown Components

-

When a spec references a component name not present in the registry, `` renders nothing for that node by default. No error thrown, no console warning—just a gap in the output.

-

For development, enable strict mode through `provideRender({ strict: true })`. This throws on unknown component names, surfacing mismatches immediately.

+

When a spec references a component name not present in the registry, <render-spec> renders nothing for that node by default. No error thrown, no console warning—just a gap in the output.

+

For development, enable strict mode through provideRender({ strict: true }). This throws on unknown component names, surfacing mismatches immediately.

For production, consider a fallback component:

export const uiRegistry = defineAngularRegistry({
   // ... your components
   '__fallback__': UnknownComponentPlaceholder
 });
 
-

The `__fallback__` key is a convention, not a framework feature. Your error boundary strategy depends on your domain—some applications should fail visibly, others should degrade gracefully.

+

The __fallback__ key is a convention, not a framework feature. Your error boundary strategy depends on your domain—some applications should fail visibly, others should degrade gracefully.

Registry Versioning

Specs persist. Registries evolve. The mismatch creates a versioning problem.

The cleanest solution: never remove component names from the registry. Deprecate by redirecting old names to new implementations:

@@ -269,13 +277,13 @@

Registry Versioning

'DataTable': DataGridV2Component, // legacy alias }); -

For breaking changes in prop shape, version the component name itself (`CardV2`) or handle transformation inside the component. The registry stays stable; the component absorbs the complexity.

+

For breaking changes in prop shape, version the component name itself (CardV2) or handle transformation inside the component. The registry stays stable; the component absorbs the complexity.

-
Chapter 4
-

Streaming JSON Patches

-

# Streaming JSON Patches

-

Generative UI collapses the moment you wait for complete responses. A data table with 50 rows, each containing nested product details, might produce 15KB of JSON. Sending the full document on every update—when a single cell changes—creates unnecessary latency and forces the UI to block until the entire payload arrives. Users stare at spinners while the agent has already produced usable content.

+
Chapter 04
+

Streaming JSON Patches

+
+

Generative UI collapses the moment you wait for complete responses. A data table with 50 rows, each containing nested product details, might produce 15KB of JSON. Sending the full document on every update—when a single cell changes—creates unnecessary latency and forces the UI to block until the entire payload arrives. Users stare at spinners while the agent has already produced usable content.

@threadplane/render solves this with JSON Patch (RFC 6902), streaming incremental operations that mutate the UI spec in place as the agent generates it.

The Problem with Full-Document Streaming

Consider an agent building a dashboard. The initial render spec might be 8KB. Adding a chart adds 2KB. Traditional approaches either:

@@ -284,11 +292,11 @@

The Problem with Full-Document Streaming

Both approaches scale poorly. A spec that grows through 20 incremental updates would transmit 20 full copies—potentially hundreds of kilobytes for what amounts to a few patch operations.

JSON Patch RFC 6902

JSON Patch defines three core operations for mutating JSON documents:

-
  • add: Insert a value at a path (`/dashboard/widgets/3`)
  • +
    • add: Insert a value at a path (/dashboard/widgets/3)
    • replace: Swap a value at an existing path
    • remove: Delete a value at a path
    -Each operation targets a specific JSON Pointer location. The patch `[{"op": "add", "path": "/rows/-", "value": {"id": 42, "name": "Widget"}}]` appends a single row without touching the rest of the document. +Each operation targets a specific JSON Pointer location. The patch [{"op": "add", "path": "/rows/-", "value": {"id": 42, "name": "Widget"}}] appends a single row without touching the rest of the document.

    Patch-Based Agent Output

    Instead of emitting complete specs, the agent streams patch operations as it generates content:

    {"op": "add", "path": "/rows/0", "value": {"id": 1, "status": "pending"}}
    @@ -300,42 +308,42 @@ 

    Partial-JSON Parsing and Skeleton States

    Real streams don't arrive in neat lines. TCP chunks split mid-token. @threadplane/render handles incomplete JSON by maintaining parse state across chunks, rendering valid portions while buffering incomplete fragments.

    Skeleton states emerge naturally from this model. The agent can emit structural placeholders first—empty arrays, loading indicators—then fill them progressively:

    @Component({
    -  template: ``,
    +  template: <code><render-spec [spec]="spec()" [registry]="registry" /></code>,
     })
     export class DashboardComponent {
    -  private store = signalStateStore({ rows: [] });
    +  private store = signalStateStore<DashboardSpec>({ rows: [] });
       spec = this.store.state;
       registry = defineAngularRegistry({ DataTable, SkeletonRow });
    -

    constructor() { - this.streamPatches().subscribe(patch => this.store.applyPatch(patch)); +constructor() { + this.streamPatches().subscribe(patch => this.store.applyPatch(patch)); } } -

    -

    The `signalStateStore` from @threadplane/render manages immutable state updates. Each `applyPatch` call triggers fine-grained Angular signals, re-rendering only components bound to changed paths.

    +
    +

    The signalStateStore from @threadplane/render manages immutable state updates. Each applyPatch call triggers fine-grained Angular signals, re-rendering only components bound to changed paths.

    Performance Characteristics

    Patch-based updates are O(change), not O(spec size). Appending one row to a 500-row table touches one array slot. The differ doesn't walk the entire spec; it applies the operation directly to the target path.

    This matters at scale. A real-time monitoring dashboard receiving 10 updates per second would choke on full-document replacement. With patches, each update carries only the delta—typically under 200 bytes—and applies in microseconds.

    The tradeoff is complexity at the agent layer. Your backend must track spec state and emit valid patches. But the rendering performance gains compound: faster time-to-first-paint, lower bandwidth, and UI that feels alive as the agent thinks.

-
Chapter 5
-

State Management & Computed Functions

-

# State Management & Computed Functions

-

Static UI specs hit a wall fast. The moment you need a total that updates when line items change, or a button that disables based on form state, you're beyond what static JSON can express. Production generative UI requires computed properties and collection rendering—capabilities that @threadplane/render delivers through `signalStateStore()` and spec-level computed functions.

+
Chapter 05
+

State Management & Computed Functions

+
+

Static UI specs hit a wall fast. The moment you need a total that updates when line items change, or a button that disables based on form state, you're beyond what static JSON can express. Production generative UI requires computed properties and collection rendering—capabilities that @threadplane/render delivers through signalStateStore() and spec-level computed functions.

Agent-Managed State with signalStateStore()

-

`signalStateStore()` creates a reactive state container that both agents and components can manipulate. The agent initializes state through the spec, components update it via user interaction, and computed properties derive new values automatically.

+

signalStateStore() creates a reactive state container that both agents and components can manipulate. The agent initializes state through the spec, components update it via user interaction, and computed properties derive new values automatically.

import { signalStateStore } from '@threadplane/render';
-

const store = signalStateStore({ +const store = signalStateStore({ items: [ { name: 'Widget', price: 25, quantity: 2 }, { name: 'Gadget', price: 40, quantity: 1 } ], taxRate: 0.08 -});

-

const spec = { +}); +const spec = { type: 'invoice', state: store, - subtotal: { $compute: 'items.reduce((sum, i) => sum + i.price * i.quantity, 0)' }, + subtotal: { $compute: 'items.reduce((sum, i) => sum + i.price * i.quantity, 0)' }, tax: { $compute: 'subtotal * taxRate' }, total: { $compute: 'subtotal + tax' }, lineItems: { @@ -345,19 +353,19 @@

Agent-Managed State with signalStateStore()

amount: { $compute: '$item.price * $item.quantity' } } }; -

-

The store exposes signals. When `items` changes—whether from agent streaming or user input—`subtotal`, `tax`, and `total` recompute. No imperative wiring required.

+ +

The store exposes signals. When items changes—whether from agent streaming or user input—subtotal, tax, and total recompute. No imperative wiring required.

Computed Properties: Declarative Derived State

-

Computed properties use the `$compute` key to define expressions evaluated at render time. These expressions access the state store's current values and can reference other computed properties, enabling dependency chains.

+

Computed properties use the $compute key to define expressions evaluated at render time. These expressions access the state store's current values and can reference other computed properties, enabling dependency chains.

The expression syntax is intentionally constrained JavaScript. It supports property access, arithmetic, array methods, and ternary operators—enough for UI logic, not enough to become a security liability. The renderer evaluates expressions in a sandboxed context with access only to the state store and iteration variables.

Repeat Loops for Collections

-

The `$repeat` directive iterates over arrays in state, rendering a component instance for each element. Within the repeated block, `$item` references the current element and `$index` provides the iteration index. This handles the common case of rendering lists, tables, and card grids without requiring the agent to enumerate every instance.

+

The $repeat directive iterates over arrays in state, rendering a component instance for each element. Within the repeated block, $item references the current element and $index provides the iteration index. This handles the common case of rendering lists, tables, and card grids without requiring the agent to enumerate every instance.

Drawing the Line: Spec Logic vs. Component Logic

Computed functions handle derived *data*. Components handle derived *behavior*. If you're calculating a display value—totals, formatted dates, conditional text—that belongs in the spec. If you're managing focus, coordinating animations, or handling complex validation workflows, that belongs in the component.

The heuristic: if an agent could reasonably want to change the logic, put it in the spec. If the logic is intrinsic to how the component works, keep it in the component.

Testing Computed Behavior

Test computed properties by manipulating the state store and asserting against the rendered output:

-
it('should recompute total when items change', () => {
+
it('should recompute total when items change', () => {
   const store = signalStateStore({ items: [{ price: 10, quantity: 1 }], taxRate: 0.1 });
   const spec = { type: 'invoice', state: store, total: { $compute: 'items[0].price * items[0].quantity * (1 + taxRate)' } };
   
@@ -367,7 +375,7 @@ 

Testing Computed Behavior

expect(fixture.nativeElement.textContent).toContain('11'); // 10 * 1.1 - store.update(s => ({ ...s, items: [{ price: 20, quantity: 2 }] })); + store.update(s => ({ ...s, items: [{ price: 20, quantity: 2 }] })); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('44'); // 40 * 1.1 @@ -377,4 +385,4 @@

Testing Computed Behavior

- + \ No newline at end of file diff --git a/apps/website/public/whitepapers/render.pdf b/apps/website/public/whitepapers/render.pdf index daea95f2c..809c112ae 100644 Binary files a/apps/website/public/whitepapers/render.pdf and b/apps/website/public/whitepapers/render.pdf differ diff --git a/apps/website/scripts/build-card-fonts.py b/apps/website/scripts/build-card-fonts.py index 38dd581d2..5bc79d832 100644 --- a/apps/website/scripts/build-card-fonts.py +++ b/apps/website/scripts/build-card-fonts.py @@ -6,15 +6,21 @@ Satori (the engine behind Next.js ImageResponse) cannot decode woff2, which is the only format Google Fonts serves, and it crashes on variable-weight TTFs with "Cannot read properties of undefined (reading '256')". So every face a -card uses has to be instanced to a single weight, stripped of its variable -tables, and committed. - -Until now only Garamond was bundled (see instance-garamond.py, which this -script supersedes). Inter and JetBrains Mono were scraped from the Google -Fonts CSS API on every card render. That is a network round trip inside an -image render, and when it fails there is no error — the card silently falls -back to whatever loaded, which is how a card whose eyebrow and pills are -specified in mono came out set in serif. Bundling removes the dependency. +card uses has to reach the renderer as a single static weight, stripped of its +variable tables, and committed. + +Every face a card uses is bundled here rather than scraped from the Google +Fonts CSS API at render time. That was a network round trip inside an image +render, and when it failed there was no error — the card silently fell back to +whatever loaded, which is how a card whose eyebrow and pills are specified in +mono came out set in serif. Bundling removes the dependency. + +The faces are the site's own: Archivo Black for display type, Archivo for +body, JetBrains Mono for the eyebrow and pills. Archivo Black is shipped by +Google as a *static* font — it has no `fvar` — so instancing is conditional; +running the instancer over it would fail rather than no-op. Archivo's variable +source carries a `wdth` axis alongside `wght`, which has to be pinned too, or +variable tables survive into the output and Satori chokes on them. The fonts are subsetted to Latin plus the punctuation the site actually uses, which is what keeps four faces under 150KB total rather than well over 1MB. @@ -22,7 +28,7 @@ range falls back to Satori's bundled Noto Sans rather than failing. Usage: - pip install --user fonttools brotli + pip install --user fonttools # no brotli: this reads and writes TTF python3 apps/website/scripts/build-card-fonts.py Re-run if an upstream font is updated, and commit the result. @@ -45,18 +51,19 @@ FACES = [ { - "name": "EBGaramond-Bold.ttf", - "url": "https://github.com/google/fonts/raw/main/ofl/ebgaramond/EBGaramond%5Bwght%5D.ttf", - "weight": 700, + # Static — no `fvar`, so `build()` skips instancing for this one. + "name": "ArchivoBlack-Regular.ttf", + "url": "https://github.com/google/fonts/raw/main/ofl/archivoblack/ArchivoBlack-Regular.ttf", + "weight": 400, }, { - "name": "Inter-Regular.ttf", - "url": "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf", + "name": "Archivo-Regular.ttf", + "url": "https://github.com/google/fonts/raw/main/ofl/archivo/Archivo%5Bwdth,wght%5D.ttf", "weight": 400, }, { - "name": "Inter-SemiBold.ttf", - "url": "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf", + "name": "Archivo-SemiBold.ttf", + "url": "https://github.com/google/fonts/raw/main/ofl/archivo/Archivo%5Bwdth,wght%5D.ttf", "weight": 600, }, { @@ -75,12 +82,20 @@ def build(face: dict) -> None: raw = tmp.name font = TTFont(raw) - axes = {"wght": face["weight"]} - # Inter carries an optical-size axis as well; pin it to its text setting so - # instancing leaves no variable tables behind for Satori to trip over. - if "fvar" in font and any(a.axisTag == "opsz" for a in font["fvar"].axes): - axes["opsz"] = 14 - font = instantiateVariableFont(font, axes, updateFontNames=False, inplace=True) + # Archivo Black ships static, with no `fvar`. Running the instancer over a + # font with no axes is not a harmless no-op, so only instance when there is + # something to instance. Every *other* axis the source carries has to be + # pinned as well, or its variation tables survive for Satori to trip over. + # Archivo carries `wdth` (pinned to the normal width); `opsz` is handled + # too, since Google ships several text faces with an optical-size axis. + if "fvar" in font: + tags = {a.axisTag for a in font["fvar"].axes} + axes = {"wght": face["weight"]} + if "wdth" in tags: + axes["wdth"] = 100 + if "opsz" in tags: + axes["opsz"] = 14 + font = instantiateVariableFont(font, axes, updateFontNames=False, inplace=True) for table in ("fvar", "STAT", "MVAR", "HVAR", "VVAR", "gvar", "cvar", "avar"): if table in font: del font[table] diff --git a/apps/website/scripts/generate-whitepaper.ts b/apps/website/scripts/generate-whitepaper.ts index dda7a2d7c..627f38a86 100644 --- a/apps/website/scripts/generate-whitepaper.ts +++ b/apps/website/scripts/generate-whitepaper.ts @@ -2,13 +2,24 @@ import Anthropic from '@anthropic-ai/sdk'; import fs from 'fs'; import path from 'path'; import puppeteer from 'puppeteer'; +import { mdToHTML, escapeHtml } from './whitepaper-markdown'; const loadEnvFile = (process as typeof process & { loadEnvFile?: (path?: string) => void }).loadEnvFile; if (!process.env['ANTHROPIC_API_KEY'] && loadEnvFile && fs.existsSync('.env')) { loadEnvFile('.env'); } -const client = new Anthropic(); +/** + * Constructed on first use rather than at import time. The module also exports + * pure helpers (mdToHTML, escapeHtml) that a unit spec imports, and building a + * client at import throws in a browser-like test environment — which is why + * those helpers had no spec while shipping three text-deleting bugs. + */ +let client: Anthropic | undefined; +function anthropic(): Anthropic { + client ??= new Anthropic(); + return client; +} const MODEL = process.env['ANTHROPIC_MODEL'] ?? 'claude-opus-4-5'; const CURRENT_API_CONTEXT = `You are writing public technical whitepapers for Threadplane Threadplane. @@ -70,7 +81,7 @@ const WHITEPAPERS: Record = { title: 'Threadplane', subtitle: 'Production-ready chat, threads, and generative UI for AI agents', eyebrow: 'Threadplane · Open source · Angular', - coverGradient: 'linear-gradient(135deg, #fafbfc 0%, #eaf3ff 100%)', + coverGradient: 'linear-gradient(160deg, #FFFFFF 0%, #F2F2F0 100%)', outputPdf: 'apps/website/public/whitepaper.pdf', outputHtml: 'apps/website/public/whitepaper-preview.html', chapters: [ @@ -196,7 +207,7 @@ Tone: Direct, technical, peer-to-peer. No fluff. Audience is senior Angular engi title: 'The Enterprise Guide to Agent UI in Angular', subtitle: 'Ship LangGraph and AG-UI-compatible agents without building the plumbing', eyebrow: 'Threadplane · Angular Agent UI Guide', - coverGradient: 'linear-gradient(135deg, #fafbfc 0%, #eaf3ff 100%)', + coverGradient: 'linear-gradient(160deg, #FFFFFF 0%, #F2F2F0 100%)', outputPdf: 'apps/website/public/whitepapers/angular.pdf', outputHtml: 'apps/website/public/whitepapers/angular-preview.html', chapters: [ @@ -323,7 +334,7 @@ Tone: Direct, technical, peer-to-peer. No fluff. Audience is senior Angular engi title: 'The Enterprise Guide to Generative UI in Angular', subtitle: 'Agents that render UI — without coupling to your frontend', eyebrow: '@threadplane/render · Enterprise Guide', - coverGradient: 'linear-gradient(135deg, #fafbfc 0%, #e8f5e9 100%)', + coverGradient: 'linear-gradient(160deg, #FFFFFF 0%, #F2F2F0 100%)', outputPdf: 'apps/website/public/whitepapers/render.pdf', outputHtml: 'apps/website/public/whitepapers/render-preview.html', chapters: [ @@ -433,7 +444,7 @@ Tone: Direct, technical, peer-to-peer. No fluff. Audience is senior Angular engi title: 'The Enterprise Guide to Agent Chat Interfaces in Angular', subtitle: 'Production agent chat UI in days, not sprints', eyebrow: '@threadplane/chat · Enterprise Guide', - coverGradient: 'linear-gradient(135deg, #fafbfc 0%, #f3e8ff 100%)', + coverGradient: 'linear-gradient(160deg, #FFFFFF 0%, #F2F2F0 100%)', outputPdf: 'apps/website/public/whitepapers/chat.pdf', outputHtml: 'apps/website/public/whitepapers/chat-preview.html', chapters: [ @@ -540,40 +551,66 @@ Tone: Direct, technical, peer-to-peer. No fluff. Audience is senior Angular engi }, }; +// ── Brand ──────────────────────────────────────────────────────────────── +/** + * The ATC palette, as the website resolves it (libs/design-tokens light theme). + * + * The one hard rule: `SIGNAL` is aviation yellow at 1.84:1 on white, so it is + * a FILL and never type — no labels, no thin rules anyone has to read a word + * off. Emphasis ink is `ACCENT` (scope navy, 15.37:1 on white). + * + * `DISPLAY` is Archivo Black, which ships a SINGLE weight (400) and has no + * italic. Never pair it with `font-weight` or `font-style`: the browser + * synthesizes both and smears an already-black face. `BODY` (Archivo) is the + * family that owns weight and a real italic. + */ +const BRAND = { + DISPLAY: `'Archivo Black','Archivo',sans-serif`, + BODY: `'Archivo',sans-serif`, + MONO: `'JetBrains Mono',monospace`, + INK: '#0A0A0A', + INK_SECONDARY: '#464646', + INK_MUTED: '#737373', + ACCENT: '#15253E', + SIGNAL: '#FFAF00', + BORDER: '#E5E5E5', +} as const; + +/** + * The one yellow device in the document: a short fill sitting under a + * page-opening heading. Repeated on the cover, the contents page, and every + * chapter opener, so the brand signs each page break without ever becoming + * type or a wall of colour. + */ +const signalRule = (width: number) => + `
`; + // ── Markdown to HTML converter ─────────────────────────────────────────── -function mdToHTML(md: string): string { - return md - .replace(/```[\w]*\n([\s\S]*?)```/g, '
$1
') - .replace(/^### (.+)$/gm, '

$1

') - .replace(/^## (.+)$/gm, '

$1

') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/^- (.+)$/gm, '
  • $1
  • ') - .replace(/(
  • [^\n]+<\/li>\n?)+/g, match => `
      ${match}
    `) - .split('\n\n') - .map(block => { - if (block.startsWith('${trimmed}

    ` : ''; - }) - .join('\n'); -} // ── HTML builder ───────────────────────────────────────────────────────── -function buildHTML( - chapters: Array<{ title: string; content: string }>, - config: WhitepaperConfig, -): string { +/** + * A chapter carries either the model's markdown (`content`) or body HTML that + * has already been converted (`bodyHTML`, from `--rerender`). + */ +interface RenderedChapter { + title: string; + content?: string; + bodyHTML?: string; +} + +function buildHTML(chapters: RenderedChapter[], config: WhitepaperConfig): string { const tocHTML = chapters.map((ch, i) => ` -
    - ${String(i + 1).padStart(2, '0')} +
    + ${String(i + 1).padStart(2, '0')} ${ch.title}
    `).join(''); const chaptersHTML = chapters.map((ch, i) => `
    -
    Chapter ${i + 1}
    -

    ${ch.title}

    -
    ${mdToHTML(ch.content)}
    +
    Chapter ${String(i + 1).padStart(2, '0')}
    +

    ${ch.title}

    + ${signalRule(72)} +
    ${ch.bodyHTML ?? mdToHTML(ch.content ?? '')}
    `).join(''); return ` @@ -581,33 +618,41 @@ function buildHTML( - + +
    -
    ${config.eyebrow}
    -

    ${config.title.replace(/ /g, '
    ')}

    -

    ${config.subtitle}

    -
    threadplane.ai · ${new Date().getFullYear()}
    +
    ${config.eyebrow}
    + +

    ${config.title}

    + ${signalRule(132)} +

    ${config.subtitle}

    +
    threadplane.ai · ${new Date().getFullYear()}
    -

    Contents

    - ${tocHTML} +

    Contents

    + ${signalRule(72)} +
    ${tocHTML}
    @@ -637,7 +682,7 @@ async function generateChapter( chapter: WhitepaperConfig['chapters'][0], ): Promise { console.log(` Generating: ${chapter.title}...`); - const message = await client.messages.create({ + const message = await anthropic().messages.create({ model: MODEL, max_tokens: 1500, system: CURRENT_API_CONTEXT, @@ -652,6 +697,79 @@ async function generateChapter( return content.text; } +// ── Re-render (no model calls) ─────────────────────────────────────────── +/** + * Pull the chapter titles and body HTML back out of a committed preview. + * + * `--rerender` exists because the document's design and its prose change on + * different clocks. When the brand moves, the artifact a lead downloads has to + * move with it — but the chapters were already written and reviewed, so paying + * the model to write new ones would swap a design change for a content change + * nobody asked for. This reads the committed HTML and pours the same words + * into the current template. + */ +function readCommittedChapters(htmlPath: string): RenderedChapter[] { + const html = fs.readFileSync(htmlPath, 'utf8'); + const sections = html.match(/
    ]*page-break-before:always[^>]*>[\s\S]*?<\/section>/g) ?? []; + + return sections.map(section => { + const title = /]*>([\s\S]*?)<\/h2>/.exec(section)?.[1]?.trim(); + const body = /
    ]*>([\s\S]*)<\/div>\s*<\/section>/.exec(section)?.[1]; + if (!title || body == null) { + throw new Error(`Could not parse a chapter out of ${htmlPath}`); + } + // Older runs leaked the model's restated `# Title` into the body as literal + // text, directly beneath the heading that already says it. Drop it. + const cleaned = body.replace(/^\s*

    #\s[^<]*<\/p>\s*/, ''); + // Older runs had no inline-code rule, so markdown spans survived as literal + // backticks AND their angle-bracketed contents were parsed as unknown + // elements, which render as nothing. `` therefore + // reached the reader as an empty pair of backticks. The source text is + // still here, so repair it on the way through rather than leaving a + // published document with words missing from it. + const withCodeSpans = cleaned.replace( + /`([^`\n]+)`/g, + (_match, code: string) => `${escapeHtml(code)}`, + ); + // Older runs split paragraphs AFTER building fenced blocks, so any code + // sample containing a blank line was torn in two and its second half + // wrapped in a

    — which also left the sample's own markup unescaped, so + // lines like `` were parsed as unknown elements and + // vanished from the page. Stitch those blocks back together and escape + // them. Unescaping first keeps this idempotent across re-runs. + const withRepairedFences = withCodeSpans.replace( + /

    ([\s\S]*?)<\/code><\/pre>(?:\s*<\/p>)?/g,
    +      (_match, code: string) => {
    +        const stitched = code.replace(/<\/?p>/g, '');
    +        const raw = stitched
    +          .replace(/</g, '<')
    +          .replace(/>/g, '>')
    +          .replace(/&/g, '&');
    +        return `
    ${escapeHtml(raw)}
    `; + }, + ); + return { title, bodyHTML: withRepairedFences.trim() }; + }); +} + +async function rerenderWhitepaper(config: WhitepaperConfig): Promise { + console.log(`\n── ${config.id} (re-render) ──────────────────────────`); + if (!fs.existsSync(config.outputHtml)) { + throw new Error(`No committed preview to re-render at ${config.outputHtml}`); + } + + const chapters = readCommittedChapters(config.outputHtml); + console.log(` Recovered ${chapters.length} chapters from ${config.outputHtml}`); + + const html = buildHTML(chapters, config); + fs.writeFileSync(config.outputHtml, html, 'utf8'); + console.log(` HTML preview: ${config.outputHtml}`); + + await renderPDF(html, config.outputPdf); + const stat = fs.statSync(config.outputPdf); + console.log(` PDF saved to ${config.outputPdf} (${Math.round(stat.size / 1024)}KB)`); +} + // ── Single whitepaper runner ───────────────────────────────────────────── async function generateWhitepaper(config: WhitepaperConfig): Promise { console.log(`\n── ${config.id} ─────────────────────────────────────`); @@ -681,8 +799,12 @@ async function generateWhitepaper(config: WhitepaperConfig): Promise { // ── Main ───────────────────────────────────────────────────────────────── async function main() { + const rerender = process.argv.includes('--rerender'); + console.log('Threadplane White Paper Generator\n'); - console.log(`Model: ${MODEL}`); + console.log(rerender ? 'Mode: re-render committed prose (no model calls)' : `Model: ${MODEL}`); + + const run = rerender ? rerenderWhitepaper : generateWhitepaper; const paperArg = process.argv.find(a => a.startsWith('--paper='))?.split('=')[1] ?? (process.argv.includes('--paper') ? process.argv[process.argv.indexOf('--paper') + 1] : undefined); @@ -693,11 +815,11 @@ async function main() { console.error(`Unknown whitepaper: "${paperArg}". Available: ${Object.keys(WHITEPAPERS).join(', ')}`); process.exit(1); } - await generateWhitepaper(config); + await run(config); } else { - console.log(`Generating all whitepapers: ${Object.keys(WHITEPAPERS).join(', ')}\n`); + console.log(`Whitepapers: ${Object.keys(WHITEPAPERS).join(', ')}\n`); for (const config of Object.values(WHITEPAPERS)) { - await generateWhitepaper(config); + await run(config); } } diff --git a/apps/website/scripts/whitepaper-markdown.spec.ts b/apps/website/scripts/whitepaper-markdown.spec.ts new file mode 100644 index 000000000..5360d4df1 --- /dev/null +++ b/apps/website/scripts/whitepaper-markdown.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { mdToHTML, escapeHtml } from './whitepaper-markdown'; + +/** + * `mdToHTML` turns the model's markdown into the whitepaper body. It has + * shipped three separate silent defects into published PDFs, each of which + * DELETED text rather than merely misformatting it — so it is pinned here. + * + * 1. No inline-code rule, and no escaping: every `` in the + * prose was parsed as an unknown element and rendered as nothing, leaving + * an empty pair of backticks where a component name should be. + * 2. Paragraphs were split AFTER fenced blocks were built, so a code sample + * containing a blank line was torn in half, its second half wrapped in a + *

    , and its own markup left unescaped. + * 3. The fix for (2) lifted fences out to a sentinel that the paragraph + * wrapper did not recognise, so the restored block landed inside a

    — + * and `

    ` is invalid, so the browser auto-closes the paragraph and
    + *     leaves a stray `

    `. + * + * Every case below is one of those. None of them is hypothetical. + */ +describe('mdToHTML', () => { + it('escapes angle-bracketed names in inline code instead of eating them', () => { + const out = mdToHTML('The `` component manages scroll.'); + expect(out).toContain('<chat-message-list>'); + // The literal name must not survive as parseable markup. + expect(out).not.toMatch(//); + // Nor may the backticks reach the reader. + expect(out).not.toContain('`'); + }); + + it('keeps a fenced block whole when it contains a blank line', () => { + const md = ['```ts', 'const a = 1;', '', 'const b = 2;', '```'].join('\n'); + const out = mdToHTML(md); + expect((out.match(/
    /g) ?? []).length).toBe(1);
    +    expect(out).toMatch(/const a = 1;\n\nconst b = 2;/);
    +  });
    +
    +  it('never nests a fenced block inside a paragraph', () => {
    +    const md = ['Intro.', '', '```ts', 'const a = 1;', '', 'const b = 2;', '```', '', 'Outro.'].join('\n');
    +    const out = mdToHTML(md);
    +    expect(out).not.toMatch(/

    \s*

    '); + }); + + it('escapes markup inside fenced blocks', () => { + const md = ['```ts', 'template: ``', '```'].join('\n'); + const out = mdToHTML(md); + // Quotes are deliberately not escaped: this is text content, not an + // attribute value, so `"` needs no entity and escaping it would just make + // the rendered code sample harder to read. + expect(out).toContain('<chat [agent]="agent" />'); + }); + + it('leaks no sentinel into the output', () => { + const md = ['a', '', '```ts', 'x', '```', '', 'b'].join('\n'); + const out = mdToHTML(md); + expect(out).not.toContain(String.fromCharCode(0xe000)); + expect(out).not.toContain('FENCE'); + }); +}); + +describe('escapeHtml', () => { + it('escapes the characters that would otherwise be parsed as markup', () => { + expect(escapeHtml('')).toBe('<a & b>'); + }); + + it('escapes the ampersand first, so entities are not double-built', () => { + expect(escapeHtml('<')).toBe('&lt;'); + }); +}); diff --git a/apps/website/scripts/whitepaper-markdown.ts b/apps/website/scripts/whitepaper-markdown.ts new file mode 100644 index 000000000..d9bd7757e --- /dev/null +++ b/apps/website/scripts/whitepaper-markdown.ts @@ -0,0 +1,77 @@ +/** + * Markdown → HTML for the whitepaper body, kept apart from the generator. + * + * These are pure string functions, but they lived in `generate-whitepaper.ts`, + * which imports puppeteer and the Anthropic SDK at module scope. That made + * them effectively untestable — importing them dragged a browser-automation + * library into a jsdom worker — which is how three separate text-DELETING + * defects reached published PDFs without a single failing test. Splitting them + * out is what lets `generate-whitepaper.spec.ts` exist at all. + */ +/** + * Escape the characters that would otherwise be parsed as markup. + * + * Load-bearing for code spans: the prose is full of Angular element names like + * ``. Unescaped, the browser parses those as unknown + * elements — which render as NOTHING — so the shipped PDF read "` ` manages + * scroll position" with the component name silently gone. + */ +export function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * Marks a lifted-out fenced block. A Private Use Area character rather than + * NUL: it cannot appear in prose, and unlike a control character it does not + * trip `no-control-regex`. + */ +const FENCE_SENTINEL = '\uE000'; + +export function mdToHTML(md: string): string { + // Fenced blocks are lifted out first so their contents are escaped exactly + // once and never re-processed by the inline rules below. The sentinel is a + // Private Use Area character rather than NUL: it cannot appear in prose, and + // unlike a control character it does not trip `no-control-regex`. + const fenced: string[] = []; + const withoutFences = md.replace( + /```[\w]*\n([\s\S]*?)```/g, + (_match, code: string) => { + fenced.push(`
    ${escapeHtml(code)}
    `); + return `${FENCE_SENTINEL}FENCE${fenced.length - 1}${FENCE_SENTINEL}`; + }, + ); + + return withoutFences + // Inline code. Without this the backticks survived verbatim into the PDF + // and anything angle-bracketed inside them vanished. + .replace(/`([^`\n]+)`/g, (_match, code: string) => `${escapeHtml(code)}`) + // The model restates the chapter title as a top-level heading. The chapter + // opener already renders that title, so an `

    ` here would duplicate it — + // and leaving `# ` unhandled leaked a literal hash into the body text. + .replace(/^# .+$/gm, '') + .replace(/^### (.+)$/gm, '

    $1

    ') + .replace(/^## (.+)$/gm, '

    $1

    ') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/(
  • [^\n]+<\/li>\n?)+/g, match => `
      ${match}
    `) + .split('\n\n') + .map(block => { + // The fence sentinel must count as pre-formatted here. It does not start + // with ` — + // and `

    ` is invalid, so the browser auto-closes the paragraph and
    +      // leaves a stray `

    `, which is the exact artifact this rewrite exists + // to remove. + if ( + block.startsWith(FENCE_SENTINEL) || + block.startsWith('${trimmed}

    ` : ''; + }) + .join('\n') + .replace(/\uE000FENCE(\d+)\uE000/g, (_match, index: string) => fenced[Number(index)]); +} diff --git a/apps/website/src/app/blog/[slug]/opengraph-image.tsx b/apps/website/src/app/blog/[slug]/opengraph-image.tsx index 5d2faf477..258a6c224 100644 --- a/apps/website/src/app/blog/[slug]/opengraph-image.tsx +++ b/apps/website/src/app/blog/[slug]/opengraph-image.tsx @@ -72,15 +72,14 @@ export default async function og({ params }: Params) { padding: 64, background: CARD.ground, color: CARD.ink, - fontFamily: 'Inter, sans-serif', + fontFamily: 'Archivo, sans-serif', }} >
    { * These are read off disk at render time. If one goes missing the card does * not fail — `satoriFonts` drops it and Satori falls back — so a deleted or * unbuilt face is invisible until someone looks at a card and finds the - * mono eyebrow set in serif. Assert they exist instead. + * mono eyebrow set in Satori's fallback. Assert they exist instead. */ it.each([ - 'EBGaramond-Bold.ttf', - 'Inter-Regular.ttf', - 'Inter-SemiBold.ttf', + 'ArchivoBlack-Regular.ttf', + 'Archivo-Regular.ttf', + 'Archivo-SemiBold.ttf', 'JetBrainsMono-Bold.ttf', ])('%s is bundled', (name) => { const stat = statSync(join(__dirname, 'fonts', name)); @@ -66,7 +66,13 @@ describe('card fonts', () => { // Satori throws "Cannot read properties of undefined (reading '256')" on a // variable font, which would 500 the request-time default card. The build // script strips `fvar`; this asserts the tag is absent from the file. - for (const name of ['EBGaramond-Bold.ttf', 'Inter-Regular.ttf', 'JetBrainsMono-Bold.ttf']) { + const names = [ + 'ArchivoBlack-Regular.ttf', + 'Archivo-Regular.ttf', + 'Archivo-SemiBold.ttf', + 'JetBrainsMono-Bold.ttf', + ]; + for (const name of names) { const buf = readFileSync(join(__dirname, 'fonts', name)); expect(buf.subarray(0, 2048).includes(Buffer.from('fvar'))).toBe(false); } diff --git a/apps/website/src/app/card/chrome.tsx b/apps/website/src/app/card/chrome.tsx index 3951da986..8fab3660a 100644 --- a/apps/website/src/app/card/chrome.tsx +++ b/apps/website/src/app/card/chrome.tsx @@ -48,8 +48,10 @@ export function Wordmark({ size = 34, color = CARD.ink }: { size?: number; color display: 'flex', alignItems: 'center', gap: 13, - fontFamily: 'EB Garamond', - fontWeight: 700, + // Archivo Black is a single-weight family, so there is no `fontWeight` + // here: asking for 700 only sends Satori hunting for a bold that the + // bundled face does not contain. + fontFamily: 'Archivo Black', fontSize: size, color, }} diff --git a/apps/website/src/app/card/fonts/Archivo-Regular.ttf b/apps/website/src/app/card/fonts/Archivo-Regular.ttf new file mode 100644 index 000000000..30c4e98e2 Binary files /dev/null and b/apps/website/src/app/card/fonts/Archivo-Regular.ttf differ diff --git a/apps/website/src/app/card/fonts/Archivo-SemiBold.ttf b/apps/website/src/app/card/fonts/Archivo-SemiBold.ttf new file mode 100644 index 000000000..efec933e3 Binary files /dev/null and b/apps/website/src/app/card/fonts/Archivo-SemiBold.ttf differ diff --git a/apps/website/src/app/card/fonts/ArchivoBlack-Regular.ttf b/apps/website/src/app/card/fonts/ArchivoBlack-Regular.ttf new file mode 100644 index 000000000..a581cb4b7 Binary files /dev/null and b/apps/website/src/app/card/fonts/ArchivoBlack-Regular.ttf differ diff --git a/apps/website/src/app/card/fonts/EBGaramond-Bold.ttf b/apps/website/src/app/card/fonts/EBGaramond-Bold.ttf deleted file mode 100644 index e8684b9d4..000000000 Binary files a/apps/website/src/app/card/fonts/EBGaramond-Bold.ttf and /dev/null differ diff --git a/apps/website/src/app/card/fonts/Inter-Regular.ttf b/apps/website/src/app/card/fonts/Inter-Regular.ttf deleted file mode 100644 index c8aaeaf2a..000000000 Binary files a/apps/website/src/app/card/fonts/Inter-Regular.ttf and /dev/null differ diff --git a/apps/website/src/app/card/fonts/Inter-SemiBold.ttf b/apps/website/src/app/card/fonts/Inter-SemiBold.ttf deleted file mode 100644 index 84f5fb075..000000000 Binary files a/apps/website/src/app/card/fonts/Inter-SemiBold.ttf and /dev/null differ diff --git a/apps/website/src/app/card/fonts/index.ts b/apps/website/src/app/card/fonts/index.ts index 4cf1868e4..a67ea9d7b 100644 --- a/apps/website/src/app/card/fonts/index.ts +++ b/apps/website/src/app/card/fonts/index.ts @@ -10,8 +10,8 @@ * with a literal filename rather than through a loop over a list. * * The files are produced by `scripts/build-card-fonts.py`: instanced to a - * single weight, stripped of variable tables Satori cannot parse, and subset - * to Latin plus the punctuation the site uses. + * single weight where the source is variable, stripped of the variable tables + * Satori cannot parse, and subset to Latin plus the punctuation the site uses. */ import type { OgFont, OgFontWeight } from '../../og-font'; @@ -29,9 +29,9 @@ async function readSibling(name: string): Promise { } /* Each call passes a literal, so the tracer sees four concrete filenames. */ -export const readGaramondBold = () => readSibling('EBGaramond-Bold.ttf'); -export const readInterRegular = () => readSibling('Inter-Regular.ttf'); -export const readInterSemiBold = () => readSibling('Inter-SemiBold.ttf'); +export const readArchivoBlack = () => readSibling('ArchivoBlack-Regular.ttf'); +export const readArchivoRegular = () => readSibling('Archivo-Regular.ttf'); +export const readArchivoSemiBold = () => readSibling('Archivo-SemiBold.ttf'); export const readMonoBold = () => readSibling('JetBrainsMono-Bold.ttf'); export function toFont( diff --git a/apps/website/src/app/icon.svg b/apps/website/src/app/icon.svg index 050ec643c..7f07721ed 100644 --- a/apps/website/src/app/icon.svg +++ b/apps/website/src/app/icon.svg @@ -1,6 +1,6 @@ - + - + diff --git a/apps/website/src/app/og-font.spec.ts b/apps/website/src/app/og-font.spec.ts index b7c4ea997..4ba559272 100644 --- a/apps/website/src/app/og-font.spec.ts +++ b/apps/website/src/app/og-font.spec.ts @@ -2,17 +2,17 @@ import { describe, expect, it } from 'vitest'; import { satoriFonts, type OgFont } from './og-font'; const FONT: OgFont = { - name: 'EB Garamond', + name: 'Archivo Black', data: new ArrayBuffer(8), - weight: 700, + weight: 400, style: 'normal', }; describe('satoriFonts', () => { it('drops the fonts that failed to load', () => { - expect(satoriFonts([FONT, null, { ...FONT, name: 'Inter', weight: 400 }])).toEqual([ + expect(satoriFonts([FONT, null, { ...FONT, name: 'JetBrains Mono', weight: 700 }])).toEqual([ FONT, - { ...FONT, name: 'Inter', weight: 400 }, + { ...FONT, name: 'JetBrains Mono', weight: 700 }, ]); }); diff --git a/apps/website/src/app/og-font.ts b/apps/website/src/app/og-font.ts index 355392c1a..51f1d41cd 100644 --- a/apps/website/src/app/og-font.ts +++ b/apps/website/src/app/og-font.ts @@ -53,32 +53,34 @@ export function satoriFonts(candidates: (OgFont | null)[]): OgFont[] | undefined } /** - * Loads the shared card font set: Garamond for display type, Inter for body, - * and JetBrains Mono for the eyebrow and pills. + * Loads the shared card font set, which is the site's own: Archivo Black for + * display type, Archivo for body, and JetBrains Mono for the eyebrow and + * pills. Archivo Black is a single-weight family — there is no bold of it, so + * nothing on a card should ask for one. * * All four are bundled (see `./card/fonts`). They used to be fetched from * Google Fonts on every render, which is a network round trip inside an image * render that fails silently: the card simply came out in whichever faces - * happened to load. A card specified with a mono eyebrow rendered in serif - * that way. `loadGoogleFont` is kept for callers that want a face we do not - * bundle, but no card depends on it. + * happened to load. A card specified with a mono eyebrow came out in Satori's + * own fallback face that way. `loadGoogleFont` is kept for callers that want a + * face we do not bundle, but no card depends on it. * * Returns `undefined` (not `[]`) when nothing loaded — see `satoriFonts`. */ export async function loadCardFonts(options: { mono?: boolean } = {}): Promise { - const { readGaramondBold, readInterRegular, readInterSemiBold, readMonoBold, toFont } = await import( + const { readArchivoBlack, readArchivoRegular, readArchivoSemiBold, readMonoBold, toFont } = await import( './card/fonts' ); - const [garamond, interRegular, interSemiBold, mono] = await Promise.all([ - readGaramondBold(), - readInterRegular(), - readInterSemiBold(), + const [archivoBlack, archivoRegular, archivoSemiBold, mono] = await Promise.all([ + readArchivoBlack(), + readArchivoRegular(), + readArchivoSemiBold(), options.mono ? readMonoBold() : Promise.resolve(null), ]); return satoriFonts([ - toFont('EB Garamond', 700, garamond), - toFont('Inter', 400, interRegular), - toFont('Inter', 600, interSemiBold), + toFont('Archivo Black', 400, archivoBlack), + toFont('Archivo', 400, archivoRegular), + toFont('Archivo', 600, archivoSemiBold), toFont('JetBrains Mono', 700, mono), ]); } diff --git a/apps/website/src/app/opengraph-image.tsx b/apps/website/src/app/opengraph-image.tsx index 613489e01..26a9258c5 100644 --- a/apps/website/src/app/opengraph-image.tsx +++ b/apps/website/src/app/opengraph-image.tsx @@ -44,7 +44,7 @@ export default async function OpenGraphImage() { height: '100%', display: 'flex', background: CARD.ground, - fontFamily: 'Inter, sans-serif', + fontFamily: 'Archivo, sans-serif', position: 'relative', overflow: 'hidden', }} @@ -56,8 +56,7 @@ export default async function OpenGraphImage() { display: 'flex', flexDirection: 'column', marginTop: 22, - fontFamily: 'EB Garamond, Georgia, serif', - fontWeight: 700, + fontFamily: 'Archivo Black, sans-serif', fontSize: 60, lineHeight: 1.04, letterSpacing: '-0.02em', diff --git a/apps/website/src/components/ui/PlaneMark.tsx b/apps/website/src/components/ui/PlaneMark.tsx index 3d3b3d2fa..c5689350c 100644 --- a/apps/website/src/components/ui/PlaneMark.tsx +++ b/apps/website/src/components/ui/PlaneMark.tsx @@ -9,8 +9,9 @@ import type { SVGProps } from 'react'; * card, the nav and the favicon each showed whatever the viewer's font * happened to hold. This ships as a path so all three agree. * - * Filled with `currentColor`, so it takes the wordmark's own color and needs - * no dark-mode variant. The square app-icon form (navy field, knocked-out + * Filled with `currentColor`. ui.css sets that colour explicitly rather than + * letting it inherit: the mark is an ink glyph on a signal-yellow squircle, + * which inverts inside the signal scope. The square app-icon form (yellow field, knocked-out * glyph) lives in `src/app/icon.svg`, which browsers request as the favicon. */ export function PlaneMark(props: SVGProps) { diff --git a/apps/website/src/styles/forms.css b/apps/website/src/styles/forms.css index 96f5ec4e2..5819fdf7b 100644 --- a/apps/website/src/styles/forms.css +++ b/apps/website/src/styles/forms.css @@ -9,7 +9,12 @@ --form-control-height: 44px; --form-control-height-compact: 36px; --form-control-radius: 8px; - --form-focus-ring: 0 0 0 3px var(--color-accent-glow); + /* The focused control's indicator is its navy border (15.37:1); this ring is + the halo around it. --color-accent-glow was retuned to the signal yellow, + which composites to 1.19:1 on white — softer than the navy it replaced, so + the halo had visibly thinned. Mixed at 55% it reads again while staying + the signal colour. Same color-mix idiom as --form-error-ring below. */ + --form-focus-ring: 0 0 0 3px color-mix(in srgb, var(--color-signal) 55%, transparent); --color-status-success: #1a7a40; --color-status-error: var(--color-angular-red); --form-error-ring: 0 0 0 3px color-mix(in srgb, var(--color-status-error) 18%, transparent); diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index e188e6a6f..021c091ee 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -110,12 +110,6 @@ .hero-text-link:focus-visible { text-decoration: underline; } -.hero-trust { - margin: 12px 0 0; - font-family: var(--font-sans); - font-size: 13px; - color: var(--color-text-muted); -} @keyframes blink { to { visibility: hidden; } } /* FeatureBlock — components/landing/FeatureBlock.tsx */ .feature-block-grid { @@ -2203,6 +2197,10 @@ width: 100vw; padding: 10px var(--spacing-container-x); background: var(--color-scope); + /* Literal rather than a token on purpose: the strip sits inside the signal + scope, which re-points --color-text-inverted (and every surface token) to + the yellow. The strip declares its own ground, so it declares its own ink + too. */ color: #ffffff; font-family: var(--font-mono); font-size: 11px; diff --git a/libs/design-tokens/src/lib/base.ts b/libs/design-tokens/src/lib/base.ts index f8eeaa42a..3e63ce322 100644 --- a/libs/design-tokens/src/lib/base.ts +++ b/libs/design-tokens/src/lib/base.ts @@ -36,7 +36,11 @@ export const baseTokens = Object.freeze({ signalStrong: '#FFB700', /** Scope navy — dark grounds and data strips. */ scope: SCOPE_NAVY, - /** LIVE red-orange. 3.68:1, so status fills and large bold text only. */ + /** + * LIVE red-orange. 3.68:1, so status fills and large bold text only. + * Reserved: part of the sampled ATC palette, with no consumer as of + * 2026-09-08. Kept so the palette is complete rather than re-derived. + */ alert: '#FF3200', /** Near-black. Text on yellow, and the light-theme text primary. */ ink: '#0A0A0A',