From 3eaa278f3ba7832f73d442b146ac3a28a2b0a38f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 22 May 2026 09:29:18 -0700 Subject: [PATCH 01/14] feat(website): polish blog template + reusable AG-UI arch diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable infrastructure changes ahead of publishing the AG-UI tutorial post. - MdxRenderer is now layout-agnostic: docs and blog each own their outer chrome (sidebar padding lived in MdxRenderer and was fighting the blog page's article wrapper). Docs page absorbs the chrome it relied on; root element switched from
to
to avoid nested-article hydration mismatches. - Tabs body wrapper no longer paints its own border/background — the inner rehype-pretty-code
already owns its surface, and the wrapper was creating a visible "extending border" below short code blocks. - global.css: restore disc/decimal list markers + padding on .docs-prose ul/ol (Tailwind preflight had stripped them), drop the duplicate inset box-shadow ring on code-block
, and add responsive rules for the
  new arch diagram so it collapses to a single column under 720px.
- New AgUiArchDiagram component: three brand-styled boxes (Backend →
  @ngaf/ag-ui adapter → @ngaf/chat UI) with labeled SSE / Agent-contract
  arrows. Replaces hand-drawn ASCII art that drifted out of alignment
  due to rehype-pretty-code line padding + JetBrains Mono fallbacks for
  box-drawing glyphs.
- Blog post template (apps/website/src/app/blog/[slug]/page.tsx): proper
  paddingTop offset so the date no longer clips behind the fixed nav,
  brand  with category/date/reading-time, large Garamond H1
  matching the homepage hero, description as bodyLg subhead, tag chips
  driven by frontmatter.tags, and a right-column DocsTOC at ≥xl widths.
  Adds a UTC-anchored formatDate to avoid "2026-05-21" rendering as
  "May 20" west of UTC.
- .claude/launch.json: add ag-ui-streaming entry pointing at the AG-UI
  cockpit demo for an upcoming follow-up that will swap in a real hero
  screenshot once libs/render tsconfig is unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 .claude/launch.json                           |   7 +
 apps/website/src/app/blog/[slug]/page.tsx     | 120 ++++++++++----
 .../docs/[library]/[section]/[slug]/page.tsx  |  16 +-
 apps/website/src/app/global.css               |  25 ++-
 .../src/components/docs/AgUiArchDiagram.tsx   | 148 ++++++++++++++++++
 .../src/components/docs/MdxRenderer.tsx       |  40 ++---
 apps/website/src/components/docs/mdx/Tabs.tsx |   9 +-
 7 files changed, 300 insertions(+), 65 deletions(-)
 create mode 100644 apps/website/src/components/docs/AgUiArchDiagram.tsx

diff --git a/.claude/launch.json b/.claude/launch.json
index 9c61833b2..479a9a848 100644
--- a/.claude/launch.json
+++ b/.claude/launch.json
@@ -20,6 +20,13 @@
       "runtimeArgs": ["-c", "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && npx nx serve cockpit-langgraph-streaming-angular --port 4300"],
       "port": 4300
     },
+    {
+      "name": "ag-ui-streaming",
+      "runtimeExecutable": "/bin/bash",
+      "runtimeArgs": ["-c", "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && npx nx serve cockpit-ag-ui-streaming-angular --port $PORT"],
+      "port": 4350,
+      "autoPort": true
+    },
     {
       "name": "examples-chat",
       "runtimeExecutable": "/bin/bash",
diff --git a/apps/website/src/app/blog/[slug]/page.tsx b/apps/website/src/app/blog/[slug]/page.tsx
index 0c2b9e5e3..48b6c9375 100644
--- a/apps/website/src/app/blog/[slug]/page.tsx
+++ b/apps/website/src/app/blog/[slug]/page.tsx
@@ -1,9 +1,14 @@
 import type { Metadata } from 'next';
 import { notFound } from 'next/navigation';
+import { tokens } from '@ngaf/design-tokens';
 import { MdxRenderer } from '../../../components/docs/MdxRenderer';
+import { DocsTOC } from '../../../components/docs/DocsTOC';
 import { AuthorByline } from '../../../components/blog/AuthorByline';
+import { TagChips } from '../../../components/blog/TagChips';
+import { Eyebrow } from '../../../components/ui/Eyebrow';
 import { getAllPosts, getPostBySlug } from '../../../lib/blog';
 import { getAuthor } from '../../../lib/blog-authors';
+import { extractHeadings } from '../../../lib/extract-headings';
 import { createPageMetadata } from '../../../lib/site-metadata';
 
 interface Params {
@@ -28,40 +33,97 @@ export async function generateMetadata({ params }: Params): Promise {
   });
 }
 
+function formatDate(iso: string): string {
+  // Parse YYYY-MM-DD as a date in UTC, then format using UTC parts to avoid
+  // timezone shifts (e.g. "2026-05-21" rendering as "May 20" west of UTC).
+  const d = new Date(`${iso}T00:00:00Z`);
+  return d.toLocaleDateString('en-US', {
+    month: 'long',
+    day: 'numeric',
+    year: 'numeric',
+    timeZone: 'UTC',
+  });
+}
+
+function readingTimeMin(markdown: string): number {
+  const words = markdown
+    .replace(/```[\s\S]*?```/g, '') // strip code fences (not real reading)
+    .replace(/[#*_`>\-]/g, ' ')
+    .split(/\s+/)
+    .filter(Boolean).length;
+  return Math.max(1, Math.round(words / 220));
+}
+
 export default async function BlogPostPage({ params }: Params) {
   const { slug } = await params;
   const post = getPostBySlug(slug);
   if (!post || post.frontmatter.draft) notFound();
   const author = getAuthor(post.frontmatter.author);
+  const minutes = readingTimeMin(post.content);
+  const primaryTag = post.frontmatter.tags?.[0]
+    ? post.frontmatter.tags[0].toUpperCase()
+    : 'POST';
+  const headings = extractHeadings(post.content);
+
   return (
-    
-
- -

- {post.frontmatter.title} -

- -
- -
+
+
+
+
+ + {primaryTag} · {formatDate(post.frontmatter.date)} · {minutes} min read + +

+ {post.frontmatter.title} +

+

+ {post.frontmatter.description} +

+
+ +
+ {post.frontmatter.tags && post.frontmatter.tags.length > 0 ? ( + + ) : null} +
+ +
+ +
+
); } diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index 5a0a9e5f0..739a386d0 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -74,13 +74,15 @@ export default async function DocsPage({ params }: DocsRouteProps) {
- +
+ +
{section === 'api' && (() => { const entries = loadApiDocs(library); const nameMap = API_NAME_MAP[library] ?? {}; diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index a712b3bc6..112e18fc0 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -53,7 +53,7 @@ html { padding: 1.25rem 1.5rem; border-radius: 0.75rem; border: 1px solid rgba(0, 0, 0, 0.1); - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); overflow-x: auto; font-size: 0.8rem; line-height: 1.7; @@ -102,8 +102,11 @@ html { .docs-prose h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 1rem; font-family: var(--font-garamond); } .docs-prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.75rem; font-family: var(--font-garamond); } .docs-prose p { line-height: 1.75; margin-bottom: 1.25rem; } -.docs-prose ul, .docs-prose ol { margin-bottom: 1.25rem; } +.docs-prose ul, .docs-prose ol { margin-bottom: 1.25rem; padding-left: 1.5rem; } +.docs-prose ul { list-style: disc; } +.docs-prose ol { list-style: decimal; } .docs-prose li { margin-bottom: 0.25rem; } +.docs-prose li::marker { color: var(--color-text-muted, #555770); } .docs-table-scroll { max-width: 100%; overflow-x: auto; margin: 1.5rem 0; } .docs-prose table { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0; } @@ -139,6 +142,24 @@ html { border-radius: var(--radius-sm); } +/* AG-UI architecture diagram */ +.ag-ui-arch-grid { + display: grid; + grid-template-columns: 1fr auto 1fr auto 1fr; + align-items: stretch; + gap: 0; +} +@media (max-width: 720px) { + .ag-ui-arch-grid { + grid-template-columns: 1fr; + } + .ag-ui-arch-arrow { + transform: rotate(90deg); + padding: 12px 0; + margin: 4px auto; + } +} + /* Docs — readable column max-width */ .docs-prose { max-width: 70ch; diff --git a/apps/website/src/components/docs/AgUiArchDiagram.tsx b/apps/website/src/components/docs/AgUiArchDiagram.tsx new file mode 100644 index 000000000..7148f1940 --- /dev/null +++ b/apps/website/src/components/docs/AgUiArchDiagram.tsx @@ -0,0 +1,148 @@ +import { tokens } from '@ngaf/design-tokens'; + +interface BoxProps { + eyebrow: string; + title: string; + meta: string; + tone?: 'neutral' | 'accent'; +} + +function Box({ eyebrow, title, meta, tone = 'neutral' }: BoxProps) { + const isAccent = tone === 'accent'; + return ( +
+ + {eyebrow} + + + {title} + + + {meta} + +
+ ); +} + +function ArrowLabel({ label, sub }: { label: string; sub: string }) { + return ( +
+ + {label} + + + + + + {sub} + +
+ ); +} + +export function AgUiArchDiagram() { + return ( +
+
+ + + + + +
+
+ Backend speaks AG-UI over SSE → adapter exposes a signal-shaped Agent contract → chat UI renders. +
+
+ ); +} diff --git a/apps/website/src/components/docs/MdxRenderer.tsx b/apps/website/src/components/docs/MdxRenderer.tsx index df306bb2f..33fafa032 100644 --- a/apps/website/src/components/docs/MdxRenderer.tsx +++ b/apps/website/src/components/docs/MdxRenderer.tsx @@ -8,6 +8,7 @@ import { CodeGroup } from './mdx/CodeGroup'; import { Pre } from './mdx/CodeBlock'; import { FeatureChips } from './mdx/FeatureChips'; import { ArchFlowDiagram } from './ArchFlowDiagram'; +import { AgUiArchDiagram } from './AgUiArchDiagram'; import { type LibraryId } from '../../lib/docs-config'; import rehypePrettyCode from 'rehype-pretty-code'; import rehypeSlug from 'rehype-slug'; @@ -23,6 +24,7 @@ const mdxComponents = { CardGroup, CodeGroup, ArchFlowDiagram, + AgUiArchDiagram, FeatureChips, pre: Pre, table: ({ children, ...rest }: React.HTMLAttributes) => ( @@ -59,26 +61,24 @@ interface MdxRendererProps { export function MdxRenderer({ source, library, section, slug, title }: MdxRendererProps) { return ( -
-
- -
+
+
); } diff --git a/apps/website/src/components/docs/mdx/Tabs.tsx b/apps/website/src/components/docs/mdx/Tabs.tsx index 684f51a6d..699f2add2 100644 --- a/apps/website/src/components/docs/mdx/Tabs.tsx +++ b/apps/website/src/components/docs/mdx/Tabs.tsx @@ -56,13 +56,8 @@ export function Tabs({ items, children }: { items?: string[]; children: React.Re ))}
- {/* Tab body */} -
+ {/* Tab body — no wrapper border/background; the inner code block owns its surface */} +
{tabs[active]}
From 2e9265384dd505c2ca8644201d06a4e8b35ee4fd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 22 May 2026 09:30:31 -0700 Subject: [PATCH 02/14] docs(blog): "Build Fullstack Agentic Angular Apps Using AG-UI" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New long-form tutorial post (~2,700 words) targeted at Angular devs new to the agent stack, covering the AG-UI protocol, the @ngaf/ag-ui adapter, and a worked example using @ngaf/chat. - Personal proof-of-source in the lede (Head of Ecosystem and Partnerships at CopilotKit; AG-UI protocol contributor) so readers know the recommendations come from inside the work. - Featured-post frontmatter (featured: true) so it surfaces on /blog. - Hero screenshot deferred — libs/render tsconfig is blocking the AG-UI cockpit demo build; tracked as a follow-up that will swap in a real streaming-mid-frame shot once unblocked. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...stack-agentic-angular-apps-using-ag-ui.mdx | 327 ++++++++++++++++++ .../hero.png | Bin 0 -> 72694 bytes 2 files changed, 327 insertions(+) create mode 100644 apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx create mode 100644 apps/website/public/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui/hero.png diff --git a/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx b/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx new file mode 100644 index 000000000..f3ba33a33 --- /dev/null +++ b/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx @@ -0,0 +1,327 @@ +--- +title: "Build Fullstack Agentic Angular Apps Using AG-UI" +description: "A practical, signal-native walkthrough for wiring any AG-UI backend (LangGraph, CrewAI, Mastra, Pydantic AI, Microsoft Agent Framework) to a production Angular chat UI." +date: 2026-05-21 +tags: [tutorial, ag-ui, angular, agents, langgraph] +author: brian +featured: true +--- + +Learn how to build a fullstack agentic Angular app using the AG-UI protocol, from the backend event stream all the way to a signal-driven chat surface in your component. + +A quick note on where I'm coming from. I was Head of Ecosystem and Partnerships at CopilotKit, where AG-UI was born, and I'm a contributor to the protocol. I've watched it evolve inside CopilotKit into the open, framework-agnostic protocol it is today. So when I say "this is the right shape," I'm saying it from the inside. + +I'll also say this plainly: I love AG-UI. Markus and the CopilotKit team did an *excellent* job designing and building it. The protocol is small, the event model is honest, and the schema is the kind of thing you can hold in your head over a coffee. That's rare, and it matters more than people give it credit for. + +I've been shipping agentic features in Angular for the last 3 years (well, really since gpt-3.5 dropped), and one piece that matters more than people give it credit for is the *protocol* between the agent and the UI. The model and the framework get the attention, but having an open, shared wire format is what makes the work portable. AG-UI is the first one that really feels right for that, and the event model maps onto Angular signals like it was designed for them. (It wasn't. That's just a happy accident.) + +Let's wire one up. + +## Goals + +- Understand what AG-UI is and why it matters for Angular devs. +- Wire an AG-UI-compatible backend to an Angular 20+ app using `@ngaf/ag-ui` and `@ngaf/chat`. +- See how 17 protocol events become a handful of signals you can read from a template. +- Handle the parts that turn a demo into a product: tool calls, interrupts, threads, fallbacks. +- Have fun! + +## What is AG-UI? + +AG-UI is the **Agent-User Interaction Protocol**. It's an open, event-based protocol for streaming an agent's state to a user-facing app over plain HTTP. + +The official one-liner from the docs: + +> An open, lightweight, event-based protocol that standardizes how AI agents connect to user-facing applications. + +That's the whole pitch. It standardizes the wire so the agent doesn't care what frontend you use, and the frontend doesn't care what backend you wrote. + +It was introduced by [CopilotKit](https://www.copilotkit.ai/blog/introducing-ag-ui-the-protocol-where-agents-meet-users) in 2025. Markus and the team open-sourced it after years of integrating LangGraph and CrewAI agents into frontend apps and realizing the wire format was the durable piece. It's now the third member of an emerging triad of agent protocols: + +- **MCP**: agent ↔ tools. +- **A2A**: agent ↔ other agents. +- **AG-UI**: agent ↔ user. + +A real production agent typically speaks all three. The interesting one for us is AG-UI, because it's the one your *users* see. + +### Why Angular devs should care + +For the last two years, every interesting agentic UI library has been React-first. CopilotKit, assistant-ui, Vercel AI SDK UI. All React. If you were building an Angular app, your options were: + +1. Reach for `EventSource`, hand-roll the SSE parsing, and reinvent message lists, status, tool cards, and interrupts for the fifth time. +2. Iframe a React app into your Angular app. (Please don't.) +3. Wait. + +AG-UI changes the math. The protocol is framework-agnostic, the official `@ag-ui/client` SDK is plain TypeScript with RxJS, and the event model is a sequence of small writes to a growing list. Which is *exactly* what Angular signals are. + +For me, the moment it clicked was reading the event schema. Seventeen events. That's it. You can hold the whole protocol in your head. + +## The fullstack picture + +Before any code, let's draw the seams. + + + +Three boxes. Two seams. + +**The backend.** Whatever you want, as long as it can emit AG-UI events. LangGraph, CrewAI, Mastra, Microsoft Agent Framework, Pydantic AI, AG2, AWS Strands, Agno. They all have first-party or partnership adapters. If your backend isn't on that list, you write a small middleware that yields AG-UI events. The [middleware guide](https://docs.ag-ui.com/quickstart/middleware) walks through it. + +**The wire.** Server-Sent Events. Plain HTTP, no WebSocket gymnastics, no custom binary framing. Your firewall, load balancer, and reverse proxy already know what to do with it. + +**The Angular side.** This is what ThreadPlane provides. `@ngaf/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@ngaf/chat` is the UI. It reads from that contract and renders. The two are decoupled on purpose. We'll get to why. + +## Let's wire it up + +We'll start with a fresh Angular 20+ app. I'll assume you already have one, or you can spin one up with `ng new`. + +### Install the packages + + + + +```bash +npm install @ngaf/ag-ui @ngaf/chat marked +``` + + + + +```bash +pnpm add @ngaf/ag-ui @ngaf/chat marked +``` + + + + +```bash +yarn add @ngaf/ag-ui @ngaf/chat marked +``` + + + + +`marked` is the markdown renderer the chat uses for assistant messages. It's a peer dep so you can swap it. + +### Provide the agent + +```ts +// app.config.ts +import { ApplicationConfig } from '@angular/core'; +import { provideAgUiAgent } from '@ngaf/ag-ui'; +import { provideChat } from '@ngaf/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgUiAgent({ url: 'http://localhost:8000/agent' }), + provideChat({ assistantName: 'Astra' }), + ], +}; +``` + +That's the whole bootstrap. `provideAgUiAgent` is the AG-UI transport. It wraps the official `@ag-ui/client` `HttpAgent` and exposes the signal-shaped contract via DI. `provideChat` is the chat UI's configuration. + +Notice they're independent. `@ngaf/chat` doesn't know it's talking to an AG-UI backend. It just reads from the `Agent` contract. We'll lean on that boundary later. + +### Render the chat + +```ts +// chat-page.component.ts +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { AG_UI_AGENT } from '@ngaf/ag-ui'; +import { ChatComponent } from '@ngaf/chat'; + +@Component({ + selector: 'app-chat-page', + standalone: true, + imports: [ChatComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ `, +}) +export class ChatPageComponent { + protected readonly agent = inject(AG_UI_AGENT); +} +``` + +That's it. Three files, maybe twenty lines of code, and you have a working streaming chat backed by any AG-UI-compatible agent. + +
+ The AG-UI streaming demo running in the browser. A user message reads 'What is Angular Agent Framework?' and the assistant has streamed back a multi-sentence response with regenerate, copy, and feedback controls underneath. +
The AG-UI streaming demo running on @ngaf/chat with the @ngaf/ag-ui adapter — FakeAgent provides the events, but the same code drives real LangGraph/CrewAI/Mastra backends.
+
+ +No `EventSource`. No reducer. No manual subscribe-and-render plumbing. No store. + +Spin up your agent backend, point `url` at it, and the chat just works. + +## How AG-UI events become signals + +This is the part I find genuinely *freakin' cool*, so indulge me for a moment. + +The AG-UI protocol has seventeen event types, grouped into five families: + +- **Lifecycle**: `RUN_STARTED`, `RUN_FINISHED`, `RUN_ERROR`, `STEP_STARTED`, `STEP_FINISHED`. +- **Text messages**: `TEXT_MESSAGE_START`, `TEXT_MESSAGE_CONTENT`, `TEXT_MESSAGE_END`, `TEXT_MESSAGE_CHUNK`. +- **Tool calls**: `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, `TOOL_CALL_RESULT`, `TOOL_CALL_CHUNK`. +- **State sync**: `STATE_SNAPSHOT`, `STATE_DELTA`, `MESSAGES_SNAPSHOT`. +- **Reasoning**: for thinking-style models like o1 and Claude with extended thinking. + +Having worked on the protocol from the inside, I'll tell you the families are doing real work. They're not arbitrary. Lifecycle is for "is something happening?" Text messages are the streaming triad you already know from chat UIs. Tool calls are deliberately incremental so you can render the *intent* before the arguments are fully formed. State sync uses RFC 6902 JSON Patch so the wire stays small even when the agent's state is large. Every event earns its keep. + +ThreadPlane's `@ngaf/ag-ui` runs each event through a small reducer that updates a handful of signals on the `Agent` contract: + +- `messages()`: `Message[]`, the chat history. `TEXT_MESSAGE_CONTENT` appends a delta to the in-flight assistant message. +- `status()`: `'idle' | 'running' | 'error' | 'paused'`. Driven by the `RUN_*` events. +- `toolCalls()`: `ToolCall[]`. `TOOL_CALL_START` appends, `TOOL_CALL_ARGS` streams JSON into the args, `TOOL_CALL_END` marks complete, `TOOL_CALL_RESULT` populates the result. +- `interrupt()`: the current human-in-the-loop pause, if any. +- `error()`: populated by `RUN_ERROR`. + +Your template reads these directly: + +```html +

Status: {{ agent.status() }}

+ +@for (message of agent.messages(); track message.id) { + +} + +@if (agent.interrupt(); as pause) { + +} +``` + +No `async` pipe gymnastics. No `OnDestroy` to clean up a subscription. No manual change-detection plumbing. + +The reason this works is that streaming, at the data-shape level, is just a sequence of small writes to a growing list. And signals are a sequence of small writes to a growing list. The mental model and the runtime model are the same shape. + +In my opinion, that's the *real* reason Angular is a good fit for agentic UIs. Not the templates. Not standalone components. The shape of the state. + +## Tool calls and interrupts + +Two surfaces that separate a "chat with a model" from a "real agent" are tool calls and interrupts. Let's look at both. + +### Tool calls + +When the agent decides to call a tool, you get a stream of events: + +```text +TOOL_CALL_START { id: "1", name: "search_repo" } +TOOL_CALL_ARGS { id: "1", delta: "{\"quer" } +TOOL_CALL_ARGS { id: "1", delta: "y\": \"a" } +TOOL_CALL_ARGS { id: "1", delta: "uth flow\"}" } +TOOL_CALL_END { id: "1" } +TOOL_CALL_RESULT { id: "1", result: { files: [...] } } +``` + +The `` component renders this as a tool-call card by default. A small block in the message list showing the tool name, the args (formatted, even mid-stream), a spinner while it's running, and the result when it returns. + +You almost never want the default forever. You want *your* card for your most important tools: branded, with a click-through to the data, an approve/reject button, whatever your product needs. + +```html + + + + + +``` + +The slot pattern is intentional. The chat ships defaults so you can demo in a day, and it gets out of your way the moment you have a real design system to hit. + +### Interrupts + +An interrupt is the agent saying *"I need a human before I do this thing."* + +You see them in production agents constantly: approve this database write, confirm this email send, choose between these three branches, fill in this missing field. AG-UI surfaces interrupts as state on the agent, and `@ngaf/chat` renders them inline in the message list with whatever resume affordance you give it. + +```html +@if (agent.interrupt(); as pause) { +
+

{{ pause.message }}

+ + +
+} +``` + +The agent stays paused until you call `resume()`. On the backend, your graph picks up exactly where it left off. From a user's perspective, the chat feels *collaborative* instead of *autonomous-and-scary*, which is the entire point of human-in-the-loop in the first place. + +## Threads, persistence, and the things demos skip + +A single-thread chat is a demo. A real product remembers conversations across sessions and across devices. + +AG-UI itself is stateless on the wire. Every run carries a `threadId`, and the backend is responsible for persistence. Most adapters expose thread CRUD as a small API: + +- list threads +- create a thread +- switch threads +- delete a thread + +The Angular-side pattern is to bind the `threadId` to a signal (usually from your router or a sidebar selection) and the chat re-reads the new thread automatically. No manual unsubscribe. No race conditions. + +```ts +protected readonly threadId = signal(null); + +constructor() { + effect(() => { + const id = this.threadId(); + if (id) this.agent.switchThread(id); + }); +} +``` + +What you *do* with that capability is a product decision, not a framework one. Scope threads per project, per task, per user session. There's no right answer. From my experience, the wrong answer is to ignore the question entirely and ship a chat where every refresh starts from zero. + +If you want a starting point, `@ngaf/chat` exposes a `` primitive that handles the layout without locking you into a persistence model. + +## Swap the backend without changing the UI + +This is the part that pays off the protocol bet. + +Say you started with a Python LangGraph backend, shipped to production, and a quarter later you decide you want to migrate one graph to Mastra because the team writing it lives in TypeScript. With AG-UI, that's a backend change. Your Angular code does not move. + +```ts +// before +provideAgUiAgent({ url: '/agents/langgraph' }), + +// after +provideAgUiAgent({ url: '/agents/mastra' }), +``` + +That's the diff. + +The same applies in reverse. If you're already on a LangGraph stack and want a slightly tighter coupling than AG-UI gives you, `@ngaf/langgraph` is a direct adapter for LangGraph's native streaming API. It speaks the same `Agent` contract to `@ngaf/chat`, so your UI doesn't change either way. Pick whichever fits your team. + +For me, the durable bet is AG-UI. I'm biased (I helped grow the ecosystem around it at CopilotKit, and I'm a contributor to the spec), but the list of backends that already speak it covers most of what you'd reach for in 2026, and the protocol is small enough that I trust it not to drift into a multi-page spec that breaks every six months. The community around it is healthy, the maintainers are responsive, and the design choices have aged well. + +## A note on the rest of the iceberg + +The scaffold above is *short* on purpose. The framework intentionally hides the parts you don't want to think about on day one. + +The parts you'll want on day thirty: + +- **Errors and retries.** Per-message retry, transport-level backoff for transient SSE drops, graceful degradation when streaming is unavailable. Some corporate proxies buffer SSE. You'll find out the hard way if you don't plan for it. +- **Generative UI.** When the agent wants to render a richer surface than a tool-call card, `@ngaf/render` lets the backend stream a UI spec and the frontend resolves it against a registry of your approved Angular components. No arbitrary code, no `eval`, no design-system bypass. The agent picks from a menu you control. +- **Observability.** `@ngaf/telemetry` ships a PostHog-shaped sink that is *off by default*. Turn it on per-environment, point it at your own analytics, never ship app content to a vendor you didn't pick. +- **Testing.** Because the contract is signals all the way down, the testing story is "write a signal, the chat re-renders." `@ngaf/ag-ui` ships a `FakeAgent` you can hand-feed events to in a unit test. No SSE harness, no fixture loader, no test-only DI dance. + +Each of those is its own post. The point here is just that the protocol-to-signal-to-template chain is the *spine*, and everything else hangs off it. + +## Conclusion + +AG-UI is the protocol Angular devs have been quietly waiting for, whether they knew it or not. It standardizes the wire between the agent and the UI, it's small enough to hold in your head, and the event model maps onto Angular signals so cleanly that the runtime almost writes itself. Markus and the CopilotKit crew deserve real credit for that. *Thank you* for designing something Angular could meet on its own terms. + +With ThreadPlane (`@ngaf/ag-ui` and `@ngaf/chat` on npm), the wiring is three lines: a provider, an inject, and a ``. The interesting work (your tool cards, your interrupt flows, your generative UI surfaces, your design system) is where you actually want to spend the day. + +If you've been building agents in Angular by hand-rolling SSE parsing and gluing together React components in iframes, *stop*. The whole stack is here, it's open source (the adapters are MIT; `@ngaf/chat` is source-available with a free non-commercial tier), and the protocol underneath has real momentum from the community building it. + +I'd love to hear what you ship. If you wire this up and the docs come up short, [open an issue](https://github.com/threadplane/angular-agent-framework/issues) or come find us on Discord. And if you're building this inside an enterprise Angular app (design system, multi-tenant, regulated), [talk to us](/contact?source=blog_ag_ui_pillar&track=enterprise). We've helped a few teams through that gauntlet already. + +Now go ship something. 💚 diff --git a/apps/website/public/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui/hero.png b/apps/website/public/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..1b6df7c7a3ebf5fe8fbe47b7e3a8176b0626bec7 GIT binary patch literal 72694 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 Date: Fri, 22 May 2026 09:55:23 -0700 Subject: [PATCH 03/14] fix(website): drop unnecessary escape in readingTimeMin regex ESLint no-useless-escape flagged \- inside a character class. Moving the hyphen to the end of the class drops the escape without changing the matched set. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/src/app/blog/[slug]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/website/src/app/blog/[slug]/page.tsx b/apps/website/src/app/blog/[slug]/page.tsx index 48b6c9375..44165d91b 100644 --- a/apps/website/src/app/blog/[slug]/page.tsx +++ b/apps/website/src/app/blog/[slug]/page.tsx @@ -48,7 +48,7 @@ function formatDate(iso: string): string { function readingTimeMin(markdown: string): number { const words = markdown .replace(/```[\s\S]*?```/g, '') // strip code fences (not real reading) - .replace(/[#*_`>\-]/g, ' ') + .replace(/[#*_`>-]/g, ' ') .split(/\s+/) .filter(Boolean).length; return Math.max(1, Math.round(words / 220)); From 8e1fd807a83ad7170a84e8a80334cb85628b9fc0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 22 May 2026 10:14:01 -0700 Subject: [PATCH 04/14] fix(website): restore
wrapper on docs slug pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MdxRenderer refactor (root
to avoid nested-article hydration mismatch on blog) inadvertently dropped the
tag from docs pages too, breaking the docs e2e selectors (page.locator('article'), page.locator('article h2')). Restore the
on the docs page wrapper instead of inside MdxRenderer. The blog page keeps its own outer
; both surfaces now have exactly one
, satisfying semantic HTML and the existing e2e contracts without re-introducing the hydration warning. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index 739a386d0..80292566f 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -74,7 +74,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
-
+
-
+
{section === 'api' && (() => { const entries = loadApiDocs(library); const nameMap = API_NAME_MAP[library] ?? {}; From 3d4d0616bce8250ed88f27c5ac9d4aaada5f4dc0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 23 May 2026 08:05:21 -0700 Subject: [PATCH 05/14] docs(spec): blog landing page polish + filterable tags Spec for matching /blog to the brand standard already shipped on /blog/[slug] and adding a server-rendered ?tag= filter. Covers component breakdown, data flow, accessibility, testing, and risks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-23-blog-landing-polish-design.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-blog-landing-polish-design.md diff --git a/docs/superpowers/specs/2026-05-23-blog-landing-polish-design.md b/docs/superpowers/specs/2026-05-23-blog-landing-polish-design.md new file mode 100644 index 000000000..a708d962b --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-blog-landing-polish-design.md @@ -0,0 +1,165 @@ +# Blog landing page polish + filterable tags + +> Brings `/blog` to the same brand standard as the article page (shipped in PR #528) and adds a server-rendered query-param tag filter. + +## Goals + +- Match the visual treatment of the homepage and article page (`/blog/[slug]`) on the blog landing page (`/blog`). +- Make tag chips functional filters via shareable URLs (`/blog?tag=tutorial`). +- Extract reusable date + reading-time utilities so the slug page and landing page share one source. +- No regressions to existing functionality (RSS, sitemap, featured-post surfacing). + +## Non-goals + +- Multi-tag filtering (`?tag=foo&tag=bar`). Single-tag covers current need; can extend later. +- Static per-tag routes (`/blog/tag/[tag]`). Query param suffices at this volume. +- Card-chip click-to-filter. Top filter row is the canonical filter affordance to avoid nested-anchor HTML. +- Above-the-fold redesign (alternate hero shapes, horizontal scrollers, category bands). Deferred. + +## Architecture + +``` +apps/website/src/app/blog/ +├── page.tsx # MODIFIED: outer shell, header, filter, featured/grid +└── [slug]/page.tsx # MODIFIED: import shared utilities + +apps/website/src/components/blog/ +├── FeaturedPostCard.tsx # MODIFIED: Garamond title, formatted date, reading time +├── PostCard.tsx # MODIFIED: Garamond title, formatted date, reading time +└── BlogTagFilter.tsx # NEW: server-rendered filter chip row with active/all states + +apps/website/src/lib/ +└── blog.ts # MODIFIED: add formatPostDate + readingTimeMin exports +``` + +## Components + +### `blog/page.tsx` (modified) + +- Reads `searchParams.tag` (optional string) on the server. +- Outer wrapper: `
` — clears the fixed nav. +- Inner container: `
`. +- Header section: + - `Blog` + - `

` with `tokens.typography.h1.{family,size,line}`, `letterSpacing: -0.02em`, `color: tokens.colors.textPrimary`. + - Subhead `

` with `tokens.typography.bodyLg.{family,size,line}`, `color: tokens.colors.textSecondary`, `maxWidth: '60ch'`. +- ``. +- Featured-post block: shown **only when no filter active**. +- Grid: same `repeat(auto-fill, minmax(300px, 1fr))` layout for non-featured posts (filtered or full set). +- Empty state: if `?tag=…` matches zero posts, render a centered message ("No posts tagged *X* yet.") + a `View all posts`. + +### `BlogTagFilter` (new) + +Props: +```ts +interface BlogTagFilterProps { + activeTag?: string; // current ?tag value, undefined when on /blog + tags: string[]; // all known tags +} +``` + +Renders a horizontal flex row of chips: +- **"All"** chip — active when `activeTag` is undefined. `href="/blog"` when inactive; non-link `` when active. +- One chip per tag (sorted alphabetically) — active when `tag === activeTag`. `href="/blog?tag={tag}"` when inactive; clicking the currently active tag links back to `/blog` (toggle off). +- Active styling: `background: tokens.colors.accent, color: white`. +- Inactive styling: matches existing `TagChips` — `background: tokens.colors.accentSurface, color: tokens.colors.accent`. +- All chips: `padding: '6px 12px', borderRadius: 999, fontSize: 13, fontWeight: 500, textDecoration: 'none'`. +- Container: `display: flex, gap: 8, flexWrap: wrap, marginBottom: 32`. + +`BlogTagFilter` replaces the existing `` in `blog/page.tsx`. The existing `TagChips` component is **kept** for use inside `PostCard` (visual-only chips on cards). + +### `FeaturedPostCard` (modified) + +Same accent-bordered link card, but: +- `

` now uses `tokens.typography.h2.{family,size,line}` if those tokens exist, else falls back to `fontFamily: tokens.typography.h1.family, fontSize: '2rem', lineHeight: 1.15`. (Determine during implementation by inspecting `@ngaf/design-tokens`.) +- Description renders via `bodyLg` tokens. +- Bottom row keeps `` on the left, but the right side becomes a small uppercase mono eyebrow: `{formatPostDate(frontmatter.date)} · {readingTimeMin(content)} min read`. +- Use `Eyebrow` component for the FEATURED kicker (consistency with header). + +### `PostCard` (modified) + +- Top meta row replaces the bare `

diff --git a/apps/website/src/components/blog/PostCard.tsx b/apps/website/src/components/blog/PostCard.tsx index d290cf3da..c7fb3c525 100644 --- a/apps/website/src/components/blog/PostCard.tsx +++ b/apps/website/src/components/blog/PostCard.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; import { tokens } from '@ngaf/design-tokens'; import type { Post } from '../../lib/blog'; -import { formatPostDate, readingTimeMin } from '../../lib/blog'; +import { formatCardDate, readingTimeMin } from '../../lib/blog'; export function PostCard({ post }: { post: Post }) { const { slug, frontmatter, content } = post; @@ -22,6 +22,7 @@ export function PostCard({ post }: { post: Post }) { border: `1px solid ${tokens.surfaces.border}`, color: tokens.colors.textPrimary, textDecoration: 'none', + cursor: 'pointer', }} > - {formatPostDate(frontmatter.date)} · {minutes} min read + {formatCardDate(frontmatter.date)} · {minutes} min read

`), matching the affordance offered + * by the BlogTagFilter row on `/blog`. + */ export function TagChips({ tags }: { tags: string[] }) { if (tags.length === 0) return null; return ( @@ -12,18 +18,23 @@ export function TagChips({ tags }: { tags: string[] }) { }} > {tags.map((tag) => ( - {tag} - + ))}

); diff --git a/apps/website/src/lib/blog.ts b/apps/website/src/lib/blog.ts index bc0ed2132..7e1a764b6 100644 --- a/apps/website/src/lib/blog.ts +++ b/apps/website/src/lib/blog.ts @@ -115,6 +115,25 @@ export function formatPostDate(iso: string): string { }); } +/** + * Compact card date: "May 17" when the post is in the current year, + * "May 17, 2025" otherwise. Matches the article-page tone but shaves + * visual noise on landing cards where the year is redundant. + * + * Parses as UTC midnight and uses timeZone: 'UTC' for stability across + * reader locales. + */ +export function formatCardDate(iso: string, now: Date = new Date()): string { + const d = new Date(`${iso}T00:00:00Z`); + const sameYear = d.getUTCFullYear() === now.getUTCFullYear(); + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: 'numeric' }), + timeZone: 'UTC', + }); +} + /** * Estimate reading time in minutes from a markdown source. * @@ -130,3 +149,4 @@ export function readingTimeMin(markdown: string): number { .filter(Boolean).length; return Math.max(1, Math.round(words / 220)); } + From 159f3b80e85b0354c2b440779fd1e7670a6b5cf4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 25 May 2026 13:05:45 -0700 Subject: [PATCH 13/14] feat(website): Recent articles section on home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New canvas-surface section between FinalCTA and the footer that shows the three most recent non-featured posts using the existing PostCard. Hides itself when no eligible posts exist, so the home page stays clean while the blog catalog is small. - New helper getRecentNonFeatured(limit = 3) in lib/blog.ts filters the featured post out of getAllPosts (avoids duplicating /blog's hero). - New RecentArticles server component composes Section + Container + Eyebrow + h2 + grid + "View all articles →" tail link. - Three new unit tests for getRecentNonFeatured + three Playwright assertions on the home page (header id, card link, tail href). Plan: docs/superpowers/plans/2026-05-25-recent-articles-home-section.md Spec: docs/superpowers/specs/2026-05-25-recent-articles-home-section-design.md Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/e2e/recent-articles.spec.ts | 27 ++++++++ apps/website/src/app/page.tsx | 2 + .../src/components/landing/RecentArticles.tsx | 68 +++++++++++++++++++ apps/website/src/lib/blog.spec.ts | 33 +++++++++ apps/website/src/lib/blog.ts | 16 +++++ 5 files changed, 146 insertions(+) create mode 100644 apps/website/e2e/recent-articles.spec.ts create mode 100644 apps/website/src/components/landing/RecentArticles.tsx diff --git a/apps/website/e2e/recent-articles.spec.ts b/apps/website/e2e/recent-articles.spec.ts new file mode 100644 index 000000000..38dbf78e4 --- /dev/null +++ b/apps/website/e2e/recent-articles.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Home page — Recent articles', () => { + test('renders the section header on /', async ({ page }) => { + await page.goto('/'); + // Stable id, not copy — copy changes more often than structure. + await expect(page.locator('#recent-articles-heading')).toBeVisible(); + await expect(page.locator('#recent-articles-heading')).toHaveText(/Recent articles/i); + }); + + test('shows at least one post card linking to /blog/', async ({ page }) => { + await page.goto('/'); + const section = page.locator('section[aria-labelledby="recent-articles-heading"]'); + await expect(section).toBeVisible(); + // At least one card with a blog post link inside the section. + const firstCard = section.locator('a[href^="/blog/"]').first(); + await expect(firstCard).toBeVisible(); + }); + + test('"View all articles" link points to /blog', async ({ page }) => { + await page.goto('/'); + const section = page.locator('section[aria-labelledby="recent-articles-heading"]'); + const viewAll = section.getByRole('link', { name: /View all articles/i }); + await expect(viewAll).toBeVisible(); + await expect(viewAll).toHaveAttribute('href', '/blog'); + }); +}); diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index f1fc65f4d..6ecb251a9 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -9,6 +9,7 @@ import { WhitePaperBlock } from '../components/landing/WhitePaperBlock'; import { Promises } from '../components/landing/Promises'; import { HomeFAQ } from '../components/landing/HomeFAQ'; import { FinalCTA } from '../components/landing/FinalCTA'; +import { RecentArticles } from '../components/landing/RecentArticles'; import { tokens } from '@ngaf/design-tokens'; import { createPageMetadata, LONG_SUBHEAD, PRIMARY_TAGLINE } from '../lib/site-metadata'; @@ -119,6 +120,7 @@ export default async function HomePage() { + ); } diff --git a/apps/website/src/components/landing/RecentArticles.tsx b/apps/website/src/components/landing/RecentArticles.tsx new file mode 100644 index 000000000..c25fbc46d --- /dev/null +++ b/apps/website/src/components/landing/RecentArticles.tsx @@ -0,0 +1,68 @@ +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; +import { Section } from '../ui/Section'; +import { Container } from '../ui/Container'; +import { Eyebrow } from '../ui/Eyebrow'; +import { PostCard } from '../blog/PostCard'; +import { getRecentNonFeatured } from '../../lib/blog'; + +/** + * Marketing-home strip showing the three most recent non-featured posts. + * Renders nothing when no eligible posts exist, so the home page stays clean + * while the blog catalog is small. + */ +export function RecentArticles() { + const posts = getRecentNonFeatured(3); + if (posts.length === 0) return null; + + return ( +
+ +
+ + Blog + +

+ Recent articles +

+
+ +
+ {posts.map((p) => ( + + ))} +
+ +
+ + View all articles → + +
+
+
+ ); +} diff --git a/apps/website/src/lib/blog.spec.ts b/apps/website/src/lib/blog.spec.ts index 29f35e6c7..620020a35 100644 --- a/apps/website/src/lib/blog.spec.ts +++ b/apps/website/src/lib/blog.spec.ts @@ -145,4 +145,37 @@ describe('blog.ts', () => { const { getAllPosts } = await import('./blog'); expect(getAllPosts().map((p) => p.slug)).toEqual(['first-post']); }); + + it('getRecentNonFeatured excludes the featured post', async () => { + setupFs({ + '2026-05-01-first-post.mdx': post1, + '2026-05-10-second-post.mdx': post2, + }); + const { getRecentNonFeatured } = await import('./blog'); + // post2 is featured (see fixture at top of file). Expect only post1. + expect(getRecentNonFeatured().map((p) => p.slug)).toEqual(['first-post']); + }); + + it('getRecentNonFeatured caps at the limit', async () => { + setupFs({ + '2026-05-01-first-post.mdx': post1, + '2026-05-05-extra-a.mdx': post1.replace('First Post', 'Extra A'), + '2026-05-06-extra-b.mdx': post1.replace('First Post', 'Extra B'), + '2026-05-07-extra-c.mdx': post1.replace('First Post', 'Extra C'), + '2026-05-10-second-post.mdx': post2, + }); + const { getRecentNonFeatured } = await import('./blog'); + // post2 is featured and filtered out; remaining 4 should be capped to 3. + const result = getRecentNonFeatured(3); + expect(result).toHaveLength(3); + expect(result.map((p) => p.slug)).not.toContain('second-post'); + }); + + it('getRecentNonFeatured returns [] when only the featured post exists', async () => { + setupFs({ + '2026-05-10-second-post.mdx': post2, + }); + const { getRecentNonFeatured } = await import('./blog'); + expect(getRecentNonFeatured()).toEqual([]); + }); }); diff --git a/apps/website/src/lib/blog.ts b/apps/website/src/lib/blog.ts index 7e1a764b6..5e5039f2c 100644 --- a/apps/website/src/lib/blog.ts +++ b/apps/website/src/lib/blog.ts @@ -150,3 +150,19 @@ export function readingTimeMin(markdown: string): number { return Math.max(1, Math.round(words / 220)); } +/** + * Recent posts excluding the one currently surfaced as featured on /blog. + * + * Used by the home page "Recent articles" section so visitors don't see the + * same headline post twice (once in the featured slot at /blog and again on + * the home page). `getAllPosts()` already sorts newest-first and excludes + * drafts, so this is a thin filter on top. + * + * @param limit Maximum number of posts to return. Defaults to 3. + */ +export function getRecentNonFeatured(limit = 3): Post[] { + const featured = getFeaturedPost(); + return getAllPosts() + .filter((p) => p.slug !== featured?.slug) + .slice(0, limit); +} From 588b1a12c7ec1820ab49af79d723befbc2928a8b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 25 May 2026 13:06:51 -0700 Subject: [PATCH 14/14] docs(superpowers): spec + plans for blog landing polish & recent articles Captures the brainstorm outputs and implementation plans for the two blog-area shipments on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-23-blog-landing-polish.md | 763 ++++++++++++++++++ ...2026-05-25-recent-articles-home-section.md | 442 ++++++++++ ...-25-recent-articles-home-section-design.md | 169 ++++ 3 files changed, 1374 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-blog-landing-polish.md create mode 100644 docs/superpowers/plans/2026-05-25-recent-articles-home-section.md create mode 100644 docs/superpowers/specs/2026-05-25-recent-articles-home-section-design.md diff --git a/docs/superpowers/plans/2026-05-23-blog-landing-polish.md b/docs/superpowers/plans/2026-05-23-blog-landing-polish.md new file mode 100644 index 000000000..365989ad3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-blog-landing-polish.md @@ -0,0 +1,763 @@ +# Blog Landing Page Polish + Filterable Tags Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring `/blog` to the same brand standard as the article page (PR #528) and add a server-rendered `?tag=` filter, with shared date/reading-time utilities and brand-aligned card components. + +**Architecture:** Pure refactor of existing components plus one new ``. All filtering is server-side via Next.js `searchParams` — no client state. Date formatting and reading-time calculation move from `app/blog/[slug]/page.tsx` into `lib/blog.ts` so both pages share one source. Card hover reuses the existing `[data-ui="card"][data-hoverable]` CSS hook from `global.css`. + +**Tech Stack:** Next.js 16 (app router, async server components, async searchParams), `@ngaf/design-tokens`, Playwright e2e, existing `` + `` primitives, no new deps. + +**Spec:** [docs/superpowers/specs/2026-05-23-blog-landing-polish-design.md](docs/superpowers/specs/2026-05-23-blog-landing-polish-design.md) + +--- + +## Token reference (used throughout) + +The design tokens defined in [libs/design-tokens/src/lib/typography.ts](libs/design-tokens/src/lib/typography.ts): + +- `tokens.typography.h1`: Garamond, `clamp(48px, 6vw, 72px)`, line `1.08` +- `tokens.typography.h2`: Garamond, `clamp(36px, 4.5vw, 56px)`, line `1.12` +- `tokens.typography.h3`: **Inter** (not Garamond), `28px`, line `1.25`, weight `600` +- `tokens.typography.eyebrow`: Mono, `12px`, weight `700`, letter-spacing `0.12em`, uppercase +- `tokens.typography.bodyLg`: Inter, `20px`, line `1.6` +- `tokens.colors.accent` / `accentSurface` / `textPrimary` / `textSecondary` / `textMuted` +- `tokens.surfaces.canvas` / `surface` / `surfaceTinted` / `border` + +> **Note:** The spec said "Garamond on card titles" but the design system's `h3` token is intentionally Inter. Follow the tokens — Garamond on small headings (28px) reads worse than Inter, and the brand pop already lives in the page H1 + Featured H2. Featured uses h2 (Garamond), regular cards use h3 (Inter). + +--- + +## Task 1: Extract `formatPostDate` and `readingTimeMin` into `lib/blog.ts` + +Refactor — no behavior change. Two pages will import the same utilities. + +**Files:** +- Modify: `apps/website/src/lib/blog.ts` +- Modify: `apps/website/src/app/blog/[slug]/page.tsx` + +- [ ] **Step 1: Add the two functions to `lib/blog.ts`** + +Append to [apps/website/src/lib/blog.ts](apps/website/src/lib/blog.ts) (after `getAllSlugs`): + +```ts +/** + * Format an ISO date string (YYYY-MM-DD from frontmatter) as a human date. + * + * Parses as UTC midnight and formats with timeZone: 'UTC' so a date like + * '2026-05-21' never renders as 'May 20' for readers west of UTC. + */ +export function formatPostDate(iso: string): string { + const d = new Date(`${iso}T00:00:00Z`); + return d.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }); +} + +/** + * Estimate reading time in minutes from a markdown source. + * + * Strips fenced code blocks (not real reading), normalizes markdown + * punctuation, counts whitespace-separated tokens, and divides by 220 wpm. + * Returns at least 1. + */ +export function readingTimeMin(markdown: string): number { + const words = markdown + .replace(/```[\s\S]*?```/g, '') + .replace(/[#*_`>-]/g, ' ') + .split(/\s+/) + .filter(Boolean).length; + return Math.max(1, Math.round(words / 220)); +} +``` + +- [ ] **Step 2: Replace local copies in `[slug]/page.tsx` with the imports** + +In [apps/website/src/app/blog/[slug]/page.tsx](apps/website/src/app/blog/[slug]/page.tsx): + +Find this import line: + +```ts +import { getAllPosts, getPostBySlug } from '../../../lib/blog'; +``` + +Replace with: + +```ts +import { getAllPosts, getPostBySlug, formatPostDate, readingTimeMin } from '../../../lib/blog'; +``` + +Then delete the two local function definitions (`function formatDate(...)` and `function readingTimeMin(...)`) — they live in `lib/blog.ts` now. + +Update the one call site inside `BlogPostPage`: + +```ts +const minutes = readingTimeMin(post.content); +const primaryTag = ... +``` + +(`readingTimeMin` keeps the same name. `formatDate` was renamed to `formatPostDate` to be unambiguous — update the call below too.) + +Find: + +```tsx +{primaryTag} · {formatDate(post.frontmatter.date)} · {minutes} min read +``` + +Replace with: + +```tsx +{primaryTag} · {formatPostDate(post.frontmatter.date)} · {minutes} min read +``` + +- [ ] **Step 3: Type-check** + +Run: `npx tsc --noEmit -p apps/website/tsconfig.json` (or `pnpm exec nx typecheck website` if available) +Expected: PASS with no new errors. + +- [ ] **Step 4: Smoke-test the article page in the running dev server** + +Open `http://localhost:3000/blog/build-fullstack-agentic-angular-apps-using-ag-ui` and confirm the eyebrow still reads `TUTORIAL · MAY 21, 2026 · 11 MIN READ` (or whatever the post's primary tag and length are). No visual regression. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/lib/blog.ts apps/website/src/app/blog/\[slug\]/page.tsx +git commit -m "refactor(blog): extract formatPostDate + readingTimeMin to lib" +``` + +--- + +## Task 2: Create `BlogTagFilter` component + +New component — the filter chip row. + +**Files:** +- Create: `apps/website/src/components/blog/BlogTagFilter.tsx` + +- [ ] **Step 1: Create the component file** + +Write [apps/website/src/components/blog/BlogTagFilter.tsx](apps/website/src/components/blog/BlogTagFilter.tsx): + +```tsx +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; + +interface BlogTagFilterProps { + /** Currently active tag from ?tag=. Undefined when on /blog. */ + activeTag?: string; + /** All known tags (already sorted by caller, or sort here). */ + tags: string[]; +} + +const PILL_BASE: React.CSSProperties = { + padding: '6px 12px', + borderRadius: 999, + fontSize: 13, + fontWeight: 500, + textDecoration: 'none', + lineHeight: 1.2, +}; + +const ACTIVE: React.CSSProperties = { + ...PILL_BASE, + background: tokens.colors.accent, + color: '#ffffff', +}; + +const INACTIVE: React.CSSProperties = { + ...PILL_BASE, + background: tokens.colors.accentSurface, + color: tokens.colors.accent, +}; + +export function BlogTagFilter({ activeTag, tags }: BlogTagFilterProps) { + const sorted = [...tags].sort((a, b) => a.localeCompare(b)); + return ( +
+ {/* "All" pill */} + {activeTag ? ( + + All + + ) : ( + + All + + )} + + {sorted.map((tag) => { + const isActive = tag === activeTag; + // Clicking the active tag toggles back to /blog. + const href = isActive ? '/blog' : `/blog?tag=${encodeURIComponent(tag)}`; + return isActive ? ( + + {tag} + + ) : ( + + {tag} + + ); + })} +
+ ); +} +``` + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit -p apps/website/tsconfig.json` +Expected: PASS with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/components/blog/BlogTagFilter.tsx +git commit -m "feat(blog): add BlogTagFilter component for tag-based filtering" +``` + +--- + +## Task 3: Refactor `FeaturedPostCard` + +Match the article page brand: Eyebrow component, Garamond h2 via tokens, formatted date + reading time row. + +**Files:** +- Modify: `apps/website/src/components/blog/FeaturedPostCard.tsx` + +- [ ] **Step 1: Rewrite the component** + +Replace the entire contents of [apps/website/src/components/blog/FeaturedPostCard.tsx](apps/website/src/components/blog/FeaturedPostCard.tsx) with: + +```tsx +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; +import type { Post } from '../../lib/blog'; +import { formatPostDate, readingTimeMin } from '../../lib/blog'; +import { getAuthor } from '../../lib/blog-authors'; +import { AuthorByline } from './AuthorByline'; +import { Eyebrow } from '../ui/Eyebrow'; + +export function FeaturedPostCard({ post }: { post: Post }) { + const { slug, frontmatter, content } = post; + const author = getAuthor(frontmatter.author); + const minutes = readingTimeMin(content); + + return ( + + Featured +

+ {frontmatter.title} +

+

+ {frontmatter.description} +

+
+ + + {formatPostDate(frontmatter.date)} · {minutes} min read + +
+ + ); +} +``` + +- [ ] **Step 2: Smoke-test in the dev server** + +Reload `http://localhost:3000/blog` (or whatever the running dev server port is — `preview_list` if unsure). + +Expected: +- "FEATURED" reads as accent-blue uppercase mono. +- "Build Fullstack Agentic Angular Apps Using AG-UI" renders in Garamond, large. +- Byline "Brian Love · Founder, ThreadPlane" on the left. +- "MAY 21, 2026 · 11 MIN READ" on the right in small uppercase mono. +- Hover: subtle 1px lift + shadow (from existing global `[data-ui="card"][data-hoverable]:hover`). + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/components/blog/FeaturedPostCard.tsx +git commit -m "feat(blog): brand-polish FeaturedPostCard (Eyebrow, Garamond h2, reading time)" +``` + +--- + +## Task 4: Refactor `PostCard` + +Match the article page brand: top eyebrow row with formatted date + reading time, h3 via tokens (Inter, 28px, 600), brand card hover. + +**Files:** +- Modify: `apps/website/src/components/blog/PostCard.tsx` + +- [ ] **Step 1: Rewrite the component** + +Replace the entire contents of [apps/website/src/components/blog/PostCard.tsx](apps/website/src/components/blog/PostCard.tsx) with: + +```tsx +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; +import type { Post } from '../../lib/blog'; +import { formatPostDate, readingTimeMin } from '../../lib/blog'; + +export function PostCard({ post }: { post: Post }) { + const { slug, frontmatter, content } = post; + const minutes = readingTimeMin(content); + + return ( + + + {formatPostDate(frontmatter.date)} · {minutes} min read + +

+ {frontmatter.title} +

+

+ {frontmatter.description} +

+ {frontmatter.tags && frontmatter.tags.length > 0 ? ( +
+ {frontmatter.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} + + ); +} +``` + +- [ ] **Step 2: Smoke-test in the dev server** + +Reload `/blog`. Confirm: +- Top of card: "MAY 17, 2026 · X MIN READ" (uppercase mono, muted). +- Title in Inter 28px 600 (heavier than today). +- Description in textSecondary. +- Tag chips at the bottom: visual only (NOT clickable — clicking opens the post). +- Card hover: same brand lift + shadow as FeaturedPostCard. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/components/blog/PostCard.tsx +git commit -m "feat(blog): brand-polish PostCard (eyebrow row, token h3, reading time)" +``` + +--- + +## Task 5: Refactor `blog/page.tsx` — shell, header, filter, empty state + +This is the biggest task. Outer shell, brand header, server-side filter via `searchParams`, featured-when-no-filter, empty state. + +**Files:** +- Modify: `apps/website/src/app/blog/page.tsx` + +- [ ] **Step 1: Rewrite the page** + +Replace the entire contents of [apps/website/src/app/blog/page.tsx](apps/website/src/app/blog/page.tsx) with: + +```tsx +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; +import { createPageMetadata } from '../../lib/site-metadata'; +import { getAllPosts, getFeaturedPost, getAllTags } from '../../lib/blog'; +import { FeaturedPostCard } from '../../components/blog/FeaturedPostCard'; +import { PostCard } from '../../components/blog/PostCard'; +import { BlogTagFilter } from '../../components/blog/BlogTagFilter'; +import { Eyebrow } from '../../components/ui/Eyebrow'; + +export const metadata = createPageMetadata({ + title: 'Blog — ThreadPlane', + description: + 'Long-form writing on agent UI for Angular: streaming, generative UI, threads, interrupts, production patterns.', + pathname: '/blog', + type: 'website', +}); + +interface Props { + searchParams: Promise<{ tag?: string }>; +} + +export default async function BlogIndexPage({ searchParams }: Props) { + const { tag: activeTag } = await searchParams; + + const all = getAllPosts(); + const tags = getAllTags().map((t) => t.tag); + + const filtered = activeTag + ? all.filter((p) => p.frontmatter.tags?.includes(activeTag)) + : all; + + // Featured only when no filter is active — feels like a clean list otherwise. + const featured = activeTag ? null : getFeaturedPost(); + const grid = featured ? filtered.filter((p) => p.slug !== featured.slug) : filtered; + + return ( +
+
+
+ + Blog + +

+ Notes from ThreadPlane +

+

+ Writing on agent UI for Angular — production patterns, design + choices, and what we're shipping. +

+
+ + + + {featured ? : null} + + {grid.length === 0 ? ( +
+

+ No posts tagged {activeTag} yet. +

+ + View all posts + +
+ ) : ( +
+ {grid.map((p) => ( + + ))} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Smoke-test the unfiltered view** + +Reload `http://localhost:3000/blog`. Confirm: +- Page has the same top offset as `/blog/[slug]` — date eyebrow no longer cut off (no header element to clip here, but the `Blog` eyebrow should sit comfortably below the nav with breathing room). +- "BLOG" eyebrow in accent blue. +- "Notes from ThreadPlane" in Garamond, large. +- Subhead in muted gray bodyLg. +- Tag filter row: "All" pill is active (accent fill, white text); tag pills are inactive (accent tint). +- Featured card renders (AG-UI post), already brand-polished from Tasks 3/4. +- Grid below: at least the streaming-chat post renders with the new PostCard treatment. + +- [ ] **Step 3: Smoke-test the filtered view** + +Click any tag pill (e.g., `tutorial`). URL becomes `/blog?tag=tutorial`. Confirm: +- The clicked pill is now in active (accent-fill) state, "All" is inactive. +- The featured card is hidden. +- The grid shows only posts that include `tutorial` in their tags. + +Click the active pill again. URL goes back to `/blog`. State resets. + +- [ ] **Step 4: Smoke-test the empty state** + +Navigate to `http://localhost:3000/blog?tag=__nonexistent__`. Confirm: +- Empty state card renders: "No posts tagged *__nonexistent__* yet." +- "View all posts" link returns to `/blog`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/app/blog/page.tsx +git commit -m "feat(blog): brand-polish landing page + server-side ?tag= filter" +``` + +--- + +## Task 6: E2E tests for the filtered landing page + +Three tests that lock in the new behavior. + +**Files:** +- Create: `apps/website/e2e/blog.spec.ts` + +- [ ] **Step 1: Write the e2e tests** + +Create [apps/website/e2e/blog.spec.ts](apps/website/e2e/blog.spec.ts): + +```ts +import { test, expect } from '@playwright/test'; + +test.describe('Blog landing page', () => { + test('renders brand header + tag filter + at least one post card', async ({ page }) => { + await page.goto('/blog'); + + // Brand eyebrow + H1 + await expect(page.getByText('Blog', { exact: true }).first()).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: /Notes from ThreadPlane/i })).toBeVisible(); + + // Filter row contains the "All" chip in active state + await expect(page.getByText('All', { exact: true })).toBeVisible(); + + // At least one post link rendered + await expect(page.locator('a[href^="/blog/"]').first()).toBeVisible(); + }); + + test('clicking a tag chip filters the list via ?tag=', async ({ page }) => { + await page.goto('/blog'); + + // Click the "tutorial" chip — assumes at least one post is tagged tutorial. + await page.getByRole('link', { name: 'tutorial', exact: true }).first().click(); + + // URL reflects the filter + await expect(page).toHaveURL(/[?&]tag=tutorial(&|$)/); + + // The active chip uses aria-current=page + await expect(page.locator('[aria-current="page"]').filter({ hasText: 'tutorial' })).toBeVisible(); + }); + + test('unknown tag renders empty state with a view-all link', async ({ page }) => { + await page.goto('/blog?tag=__no_such_tag__'); + + await expect(page.getByText(/No posts tagged/i)).toBeVisible(); + await expect(page.getByRole('link', { name: 'View all posts' })).toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: Run the new tests** + +Run: `npx nx e2e website --skip-nx-cache -- --grep "Blog landing page"` +Expected: all three tests PASS. + +- [ ] **Step 3: Run the whole website e2e suite** + +Run: `npx nx e2e website --skip-nx-cache` +Expected: every test passes, including the existing docs and blog slug tests (no regressions from the lib refactor). + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/e2e/blog.spec.ts +git commit -m "test(blog): e2e for landing page header, tag filter, and empty state" +``` + +--- + +## Task 7: Manual verification at desktop + mobile + +Visual confirmation in Chrome via the preview MCP. + +**Files:** none — observation only. + +- [ ] **Step 1: Verify desktop layout (1440×900)** + +Resize the preview to `width: 1440, height: 900`. Navigate to `/blog`. Screenshot. Confirm visually: + +- Title and eyebrow aligned at the same left edge. +- Featured card has accent border, Garamond title, byline + date row. +- Post grid shows at least one card with the new top eyebrow row. +- No layout overflow on the right. + +- [ ] **Step 2: Verify mobile layout (375×812)** + +Resize to mobile preset. Navigate to `/blog`. Screenshot. Confirm: + +- Hamburger nav present. +- Header eyebrow + H1 + subhead stack cleanly. +- Filter pills wrap to multiple lines. +- Featured card adapts width. +- Post grid collapses to single column. + +- [ ] **Step 3: Verify filter persistence on a real click flow** + +Back at desktop. Navigate `/blog` → click `tutorial` chip → confirm URL changes and grid filters → click the now-active `tutorial` chip → confirm URL resets to `/blog` and "All" becomes active again. + +- [ ] **Step 4: Confirm article page still renders (regression check on Task 1's refactor)** + +Open `/blog/build-fullstack-agentic-angular-apps-using-ag-ui`. Confirm the eyebrow still reads `TUTORIAL · MAY 21, 2026 · 11 MIN READ` and the body renders normally. + +- [ ] **Step 5: No commit needed; verification only** + +If anything looks off, capture the screenshot, file the fix as a new task, and report back. Otherwise, this task closes. + +--- + +## Definition of done + +- All seven tasks committed. +- `nx lint website` clean. +- `nx e2e website` green (including the three new tests). +- Manual verification at desktop + mobile confirms brand parity. +- Empty filter state renders correctly. +- Article page (`/blog/[slug]`) shows no regression from the utility extraction. + +## Out of scope / follow-ups + +These are explicit non-goals from the spec, captured here so they don't get smuggled into this plan: + +- Multi-tag filtering (`?tag=a&tag=b`). +- Per-tag pre-rendered routes (`/blog/tag/[tag]/page.tsx`). +- Card chip click-to-filter (top filter row is the canonical affordance). +- Above-the-fold redesign (alternate hero shapes, horizontal scrollers, etc.). diff --git a/docs/superpowers/plans/2026-05-25-recent-articles-home-section.md b/docs/superpowers/plans/2026-05-25-recent-articles-home-section.md new file mode 100644 index 000000000..1e6a47286 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-recent-articles-home-section.md @@ -0,0 +1,442 @@ +# Recent Articles Home Page Section — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a server-rendered "Recent articles" section to the marketing home page (`/`) between `` and the footer, reusing the existing ``. + +**Architecture:** New `RecentArticles` server component reads from a thin new lib helper (`getRecentNonFeatured`) that filters the featured post out of `getAllPosts()`. No client state, no async data, no new dependencies. Reuses `
`, ``, ``, `` exactly as they exist today. + +**Tech Stack:** Next.js 16 (app router, RSC), `@ngaf/design-tokens`, Vitest (unit), Playwright (e2e). No new packages. + +**Spec:** [docs/superpowers/specs/2026-05-25-recent-articles-home-section-design.md](docs/superpowers/specs/2026-05-25-recent-articles-home-section-design.md) + +--- + +## File map + +- Modify: `apps/website/src/lib/blog.ts` — add `getRecentNonFeatured`. +- Modify: `apps/website/src/lib/blog.spec.ts` — add unit tests for `getRecentNonFeatured`. +- Create: `apps/website/src/components/landing/RecentArticles.tsx` — section component. +- Modify: `apps/website/src/app/page.tsx` — import + render `` after ``. +- Create: `apps/website/e2e/recent-articles.spec.ts` — e2e visibility + link. + +--- + +## Task 1: Unit-test `getRecentNonFeatured` + +TDD — write the failing test first, then implement. + +**Files:** +- Modify: `apps/website/src/lib/blog.spec.ts` + +- [ ] **Step 1: Add the failing test** + +Append three `it()` blocks at the end of the existing `describe('blog.ts', ...)` block (before its closing `})`). The fixtures `post1` and `post2` are already declared at the top of the file: + +```ts + it('getRecentNonFeatured excludes the featured post', async () => { + setupFs({ + '2026-05-01-first-post.mdx': post1, + '2026-05-10-second-post.mdx': post2, + }); + const { getRecentNonFeatured } = await import('./blog'); + // post2 is featured (see fixture at top of file). Expect only post1. + expect(getRecentNonFeatured().map((p) => p.slug)).toEqual(['first-post']); + }); + + it('getRecentNonFeatured caps at the limit', async () => { + setupFs({ + '2026-05-01-first-post.mdx': post1, + '2026-05-05-extra-a.mdx': post1.replace('First Post', 'Extra A'), + '2026-05-06-extra-b.mdx': post1.replace('First Post', 'Extra B'), + '2026-05-07-extra-c.mdx': post1.replace('First Post', 'Extra C'), + '2026-05-10-second-post.mdx': post2, + }); + const { getRecentNonFeatured } = await import('./blog'); + // post2 is featured and filtered out; remaining 4 should be capped to 3. + const result = getRecentNonFeatured(3); + expect(result).toHaveLength(3); + expect(result.map((p) => p.slug)).not.toContain('second-post'); + }); + + it('getRecentNonFeatured returns [] when only the featured post exists', async () => { + setupFs({ + '2026-05-10-second-post.mdx': post2, + }); + const { getRecentNonFeatured } = await import('./blog'); + expect(getRecentNonFeatured()).toEqual([]); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run from the repo root: + +```bash +npx nx test website --testFile=src/lib/blog.spec.ts +``` + +Expected: the three new `getRecentNonFeatured` tests FAIL with `TypeError: ... is not a function` (function doesn't exist yet). Existing tests stay green. + +- [ ] **Step 3: Implement the helper** + +In [apps/website/src/lib/blog.ts](apps/website/src/lib/blog.ts), append this export at the end of the file (after `readingTimeMin`): + +```ts +/** + * Recent posts excluding the one currently surfaced as featured on /blog. + * + * Used by the home page "Recent articles" section so visitors don't see the + * same headline post twice (once in the featured slot at /blog and again on + * the home page). `getAllPosts()` already sorts newest-first and excludes + * drafts, so this is a thin filter on top. + * + * @param limit Maximum number of posts to return. Defaults to 3. + */ +export function getRecentNonFeatured(limit = 3): Post[] { + const featured = getFeaturedPost(); + return getAllPosts() + .filter((p) => p.slug !== featured?.slug) + .slice(0, limit); +} +``` + +- [ ] **Step 4: Run the tests again — all green** + +```bash +npx nx test website --testFile=src/lib/blog.spec.ts +``` + +Expected: ALL tests pass (including the three new ones and the existing blog.ts suite). + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/lib/blog.ts apps/website/src/lib/blog.spec.ts +git commit -m "feat(blog): add getRecentNonFeatured helper for home page strip" +``` + +--- + +## Task 2: Build the `RecentArticles` component + +New server component that renders the section. Mirrors the visual structure of `HomeFAQ` (Section → Container → centered header → content) but left-aligns the header to match the article-grid posture used elsewhere on the page. + +**Files:** +- Create: `apps/website/src/components/landing/RecentArticles.tsx` + +- [ ] **Step 1: Create the component** + +Write [apps/website/src/components/landing/RecentArticles.tsx](apps/website/src/components/landing/RecentArticles.tsx): + +```tsx +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; +import { Section } from '../ui/Section'; +import { Container } from '../ui/Container'; +import { Eyebrow } from '../ui/Eyebrow'; +import { PostCard } from '../blog/PostCard'; +import { getRecentNonFeatured } from '../../lib/blog'; + +/** + * Marketing-home strip showing the three most recent non-featured posts. + * Renders nothing when no eligible posts exist, so the home page stays clean + * while the blog catalog is small. + */ +export function RecentArticles() { + const posts = getRecentNonFeatured(3); + if (posts.length === 0) return null; + + return ( +
+ +
+ + Blog + +

+ Recent articles +

+
+ +
+ {posts.map((p) => ( + + ))} +
+ +
+ + View all articles → + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Type-check** + +```bash +npx tsc --noEmit -p apps/website/tsconfig.json +``` + +Expected: PASS with no new errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/components/landing/RecentArticles.tsx +git commit -m "feat(website): add RecentArticles home page section component" +``` + +--- + +## Task 3: Wire `RecentArticles` into the home page + +**Files:** +- Modify: `apps/website/src/app/page.tsx` + +- [ ] **Step 1: Add the import** + +In [apps/website/src/app/page.tsx](apps/website/src/app/page.tsx), find the existing landing imports near the top of the file: + +```ts +import { FinalCTA } from '../components/landing/FinalCTA'; +``` + +Immediately after that line, add: + +```ts +import { RecentArticles } from '../components/landing/RecentArticles'; +``` + +- [ ] **Step 2: Render the section** + +Find the closing of the JSX fragment that contains ``: + +```tsx + + + +``` + +Insert `` between `` and the closing ``: + +```tsx + + + + +``` + +- [ ] **Step 3: Smoke-test in the running dev server** + +Confirm the dev server is up: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/ +``` + +Expected: `200`. + +Then check the new section renders: + +```bash +curl -s http://localhost:3000/ | grep -oE 'id="recent-articles-heading"' | head -1 +curl -s http://localhost:3000/ | grep -oE 'View all articles' | head -1 +``` + +Expected output: +``` +id="recent-articles-heading" +View all articles +``` + +Also confirm the section sits below the final-CTA heading in the rendered HTML: + +```bash +python3 -c " +t = open('/dev/stdin').read() +i_cta = t.find('final-cta-heading') +i_rec = t.find('recent-articles-heading') +print('cta idx:', i_cta, 'rec idx:', i_rec, 'ordered:', i_cta != -1 and i_rec != -1 and i_cta < i_rec) +" < <(curl -s http://localhost:3000/) +``` + +Expected: `ordered: True`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/app/page.tsx +git commit -m "feat(website): render RecentArticles after FinalCTA on home page" +``` + +--- + +## Task 4: E2E test + +Three Playwright assertions that lock in the new section's behavior on `/`. + +**Files:** +- Create: `apps/website/e2e/recent-articles.spec.ts` + +- [ ] **Step 1: Write the e2e tests** + +Write [apps/website/e2e/recent-articles.spec.ts](apps/website/e2e/recent-articles.spec.ts): + +```ts +import { test, expect } from '@playwright/test'; + +test.describe('Home page — Recent articles', () => { + test('renders the section header on /', async ({ page }) => { + await page.goto('/'); + // Stable id, not copy — copy changes more often than structure. + await expect(page.locator('#recent-articles-heading')).toBeVisible(); + await expect(page.locator('#recent-articles-heading')).toHaveText(/Recent articles/i); + }); + + test('shows at least one post card linking to /blog/', async ({ page }) => { + await page.goto('/'); + const section = page.locator('section[aria-labelledby="recent-articles-heading"]'); + await expect(section).toBeVisible(); + // At least one card with a blog post link inside the section. + const firstCard = section.locator('a[href^="/blog/"]').first(); + await expect(firstCard).toBeVisible(); + }); + + test('"View all articles" link points to /blog', async ({ page }) => { + await page.goto('/'); + const section = page.locator('section[aria-labelledby="recent-articles-heading"]'); + const viewAll = section.getByRole('link', { name: /View all articles/i }); + await expect(viewAll).toBeVisible(); + await expect(viewAll).toHaveAttribute('href', '/blog'); + }); +}); +``` + +- [ ] **Step 2: Run the new tests** + +```bash +npx nx e2e website --skip-nx-cache -- --grep "Recent articles" +``` + +Expected: all three tests PASS. + +- [ ] **Step 3: Run the whole website e2e suite to confirm no regressions** + +```bash +npx nx e2e website --skip-nx-cache +``` + +Expected: every test passes, including the existing landing-page, docs, and blog suites. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/e2e/recent-articles.spec.ts +git commit -m "test(website): e2e for Recent articles home section" +``` + +--- + +## Task 5: Manual verification + +Visual confirmation in the running dev server. No code, no commit. + +**Files:** none — observation only. + +- [ ] **Step 1: Desktop check** + +Open `http://localhost:3000/` in a browser at desktop width (≥1280px). Scroll to the bottom of the page. + +Confirm in order from bottom up (above the global footer): +- **Recent articles section** with eyebrow "BLOG" (accent blue, uppercase mono) + H2 "Recent articles" (Garamond, large). +- A grid of ``s below the header (currently 1 card given the repo state; more as posts land). +- A centered "View all articles →" link below the grid in accent color. +- **Above it**, the tinted `` section ("Stop stalling on agentic Angular.") still renders unchanged. + +- [ ] **Step 2: Mobile check** + +Resize to a 375px viewport (Chrome DevTools mobile preset). Confirm: +- Section header stacks left-aligned. +- Card grid collapses to a single column. +- Tail "View all articles →" link is still centered. +- No horizontal overflow. + +- [ ] **Step 3: Empty-state check (defensive)** + +Temporarily flip `featured: false` on the older `2026-05-17-build-a-streaming-chat-ui-in-angular-with-langgraph.mdx` to verify behavior at the boundary: with that change, `getFeaturedPost()` still picks the AG-UI post but now the streaming post is no longer featured, so it appears in the grid. Confirm: +- Grid shows the streaming-chat card. +- Header + tail link still render. + +Then **revert the frontmatter change** — this is a verification-only edit, do not commit it. + +```bash +git checkout apps/website/content/blog/2026-05-17-build-a-streaming-chat-ui-in-angular-with-langgraph.mdx +``` + +- [ ] **Step 4: Zero-eligible check (visual)** + +Temporarily edit `apps/website/src/components/landing/RecentArticles.tsx` to force `posts = []`: + +```tsx +const posts = getRecentNonFeatured(3).slice(0, 0); // TEMP — verifies null branch +``` + +Reload `/`. Confirm the section is **completely absent** from the rendered HTML (the FinalCTA is now the last block before the footer, with no gap). Then **revert**: + +```bash +git checkout apps/website/src/components/landing/RecentArticles.tsx +``` + +- [ ] **Step 5: No commit needed** + +If everything looks right, this task closes. If anything is off, file a follow-up and report back. + +--- + +## Definition of done + +- All five tasks committed (Tasks 1–4 produce commits; Task 5 is verification only). +- `npx nx lint website` clean. +- `npx nx test website` green including the three new `getRecentNonFeatured` unit tests. +- `npx nx e2e website` green including the three new Recent-articles e2e tests. +- Manual smoke at desktop + mobile confirms the section renders below `` and the "View all articles →" link navigates to `/blog`. +- When zero eligible posts exist, the section is absent from the rendered HTML. + +## Out of scope / follow-ups + +These are explicit non-goals from the spec, captured here so they don't get smuggled in: + +- No tag filter on the home page (lives only at `/blog`). +- No "Featured" callout on the home page — the featured post already lives at the top of `/blog`. +- No skeleton/loading state — server component reads from disk synchronously. +- No PostCard variant — reuse the existing component as-is. +- No client-side interactivity in this section. diff --git a/docs/superpowers/specs/2026-05-25-recent-articles-home-section-design.md b/docs/superpowers/specs/2026-05-25-recent-articles-home-section-design.md new file mode 100644 index 000000000..46fea2a38 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-recent-articles-home-section-design.md @@ -0,0 +1,169 @@ +# Recent Articles — Home Page Section + +**Status:** Design approved · ready for implementation plan +**Owner:** Brian Love +**Date:** 2026-05-25 + +## Goal + +Add a "Recent articles" section to the marketing home page that surfaces the latest blog content without redundantly repeating the featured post. Improves blog discoverability for visitors who land on `/` and never click into the nav. + +## Placement + +In `apps/website/src/app/page.tsx`, insert the new section **between `` and the end of the page fragment**. The layout's `