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 index 077f0689..16f7f3b7 100644 --- 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 @@ -67,7 +67,7 @@ Three boxes. Two seams. **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. `@threadplane/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@threadplane/chat` is the UI. It reads from that contract and renders. The two are decoupled on purpose. We'll get to why. +**The Angular side.** This is what ThreadPlane provides. `@threadplane/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@threadplane/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 @@ -150,7 +150,7 @@ That's it. Three files, maybe twenty lines of code, and you have a working strea
The AG-UI streaming demo running in the browser. A user message reads 'What is Threadplane?' and the assistant has streamed back a multi-sentence response with regenerate, copy, and feedback controls underneath.`. 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. +With ThreadPlane (`@threadplane/ag-ui` and `@threadplane/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; `@threadplane/chat` is source-available with a free non-commercial tier), and the protocol underneath has real momentum from the community building it. diff --git a/apps/website/e2e/blog.spec.ts b/apps/website/e2e/blog.spec.ts new file mode 100644 index 00000000..1c187dda --- /dev/null +++ b/apps/website/e2e/blog.spec.ts @@ -0,0 +1,37 @@ +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: /Articles 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(); + }); +}); diff --git a/apps/website/e2e/recent-articles.spec.ts b/apps/website/e2e/recent-articles.spec.ts new file mode 100644 index 00000000..38dbf78e --- /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/blog/[slug]/page.tsx b/apps/website/src/app/blog/[slug]/page.tsx index eae17364..529a4887 100644 --- a/apps/website/src/app/blog/[slug]/page.tsx +++ b/apps/website/src/app/blog/[slug]/page.tsx @@ -6,7 +6,7 @@ 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 { getAllPosts, getPostBySlug, formatPostDate, readingTimeMin } from '../../../lib/blog'; import { getAuthor } from '../../../lib/blog-authors'; import { extractHeadings } from '../../../lib/extract-headings'; import { createPageMetadata } from '../../../lib/site-metadata'; @@ -23,36 +23,16 @@ export async function generateMetadata({ params }: Params): Promise { const { slug } = await params; const post = getPostBySlug(slug); if (!post || post.frontmatter.draft) { - return { title: 'Post not found — Threadplane' }; + return { title: 'Post not found — ThreadPlane' }; } return createPageMetadata({ - title: `${post.frontmatter.title} — Threadplane`, + title: `${post.frontmatter.title} — ThreadPlane`, description: post.frontmatter.description, pathname: `/blog/${post.slug}`, type: 'article', }); } -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; @@ -80,7 +60,7 @@ export default async function BlogPostPage({ params }: Params) {
- {primaryTag} · {formatDate(post.frontmatter.date)} · {minutes} min read + {primaryTag} · {formatPostDate(post.frontmatter.date)} · {minutes} min read

; +} + +export default async function BlogIndexPage({ searchParams }: Props) { + const { tag: activeTag } = await searchParams; + const all = getAllPosts(); - const featured = getFeaturedPost(); - const rest = featured ? all.filter((p) => p.slug !== featured.slug) : all; 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} -
- {rest.map((p) => ( - - ))} +
+
+
+ + Blog + +

+ Articles 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) => ( + + ))} +
+ )}
); diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index 99c708a3..0f668257 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -102,11 +102,63 @@ 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; 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 ul, .docs-prose ol { + margin-bottom: 1.25rem; + padding-left: 1.5rem; + list-style-position: outside; +} +/* Use longhand `list-style-type` to dodge Lightning CSS shorthand rewriting + `list-style: disc` to `list-style: outside` (which drops the bullet type + and lets Tailwind's `ol, ul, menu { list-style: none }` preflight win). */ +.docs-prose ul { list-style-type: disc; } +.docs-prose ol { list-style-type: decimal; } +.docs-prose li { margin-bottom: 0.5rem; line-height: 1.6; } +.docs-prose li > p { margin-bottom: 0.5rem; } .docs-prose li::marker { color: var(--color-text-muted, #555770); } +.docs-prose ul ul, .docs-prose ol ol, .docs-prose ul ol, .docs-prose ol ul { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +/* Figures (screenshots + caption). + * Goal: lift the screenshot off the page so it reads as a distinct artifact + * rather than an inline asset. Tinted backdrop + soft shadow + clean caption. + * Scoped to image figures via :has(> img) so it doesn't collide with the + * code-block figures from rehype-pretty-code or component figures (e.g. + * AgUiArchDiagram). */ +.docs-prose figure:has(> img) { + margin: 2.5rem 0; + padding: 0.75rem 0.75rem 0; + background: rgba(0, 64, 144, 0.035); + border: 1px solid rgba(0, 64, 144, 0.1); + border-radius: 0.75rem; +} +.docs-prose figure:has(> img) > img { + display: block; + width: 100%; + height: auto; + border-radius: 0.5rem; + box-shadow: 0 4px 16px rgba(0, 32, 72, 0.1); +} +.docs-prose figure:has(> img) > figcaption { + margin: 0; + padding: 0.875rem 0.5rem 0.625rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-text-muted, #555770); + text-align: center; + font-style: italic; +} +/* Bare images (no figure wrapper) — give them the same lift treatment. */ +.docs-prose > p > img, +.docs-prose > img { + display: block; + max-width: 100%; + height: auto; + margin: 2rem auto; + border-radius: 0.5rem; + box-shadow: 0 4px 16px rgba(0, 32, 72, 0.08); +} .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; } @@ -114,9 +166,11 @@ html { .docs-prose td { padding: 0.5rem 0.75rem; border-bottom: 1px solid rgba(0, 64, 144, 0.08); color: #555770; } .docs-prose td code { font-size: 0.8em; } -/* UI primitive — Card hover */ +/* UI primitive — Card hover. + * Hoverable cards are virtually always links; pointer is the right default. + * If a non-link gets `data-hoverable`, override inline. */ [data-ui="card"][data-hoverable] { - cursor: default; + cursor: pointer; } [data-ui="card"][data-hoverable]:hover { box-shadow: var(--shadow-md); diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 0a450505..54a57324 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 '@threadplane/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/blog/BlogTagFilter.tsx b/apps/website/src/components/blog/BlogTagFilter.tsx new file mode 100644 index 00000000..585c0615 --- /dev/null +++ b/apps/website/src/components/blog/BlogTagFilter.tsx @@ -0,0 +1,77 @@ +import Link from 'next/link'; +import { tokens } from '@threadplane/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, + cursor: 'pointer', + display: 'inline-block', +}; + +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} + + ); + })} +
+ ); +} diff --git a/apps/website/src/components/blog/FeaturedPostCard.tsx b/apps/website/src/components/blog/FeaturedPostCard.tsx index 9883c986..a81107a8 100644 --- a/apps/website/src/components/blog/FeaturedPostCard.tsx +++ b/apps/website/src/components/blog/FeaturedPostCard.tsx @@ -1,42 +1,57 @@ import Link from 'next/link'; import { tokens } from '@threadplane/design-tokens'; import type { Post } from '../../lib/blog'; +import { formatCardDate, 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 } = post; + const { slug, frontmatter, content } = post; const author = getAuthor(frontmatter.author); + const minutes = readingTimeMin(content); + return ( - Featured +

- Featured - -

{frontmatter.title}

-

+

{frontmatter.description}

- + {formatCardDate(frontmatter.date)} · {minutes} min read +
); diff --git a/apps/website/src/components/blog/PostCard.tsx b/apps/website/src/components/blog/PostCard.tsx index 1ff33b01..241e9f45 100644 --- a/apps/website/src/components/blog/PostCard.tsx +++ b/apps/website/src/components/blog/PostCard.tsx @@ -1,38 +1,66 @@ import Link from 'next/link'; import { tokens } from '@threadplane/design-tokens'; import type { Post } from '../../lib/blog'; +import { formatCardDate, readingTimeMin } from '../../lib/blog'; export function PostCard({ post }: { post: Post }) { - const { slug, frontmatter } = post; + const { slug, frontmatter, content } = post; + const minutes = readingTimeMin(content); + return ( -
+

- {frontmatter.date} - -

{frontmatter.title}

-

+

{frontmatter.description}

{frontmatter.tags && frontmatter.tags.length > 0 ? ( -
+
{frontmatter.tags.map((tag) => ( `), 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/components/landing/RecentArticles.tsx b/apps/website/src/components/landing/RecentArticles.tsx new file mode 100644 index 00000000..e0694d91 --- /dev/null +++ b/apps/website/src/components/landing/RecentArticles.tsx @@ -0,0 +1,68 @@ +import Link from 'next/link'; +import { tokens } from '@threadplane/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 29f35e6c..620020a3 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 c59a509e..5e5039f2 100644 --- a/apps/website/src/lib/blog.ts +++ b/apps/website/src/lib/blog.ts @@ -98,3 +98,71 @@ export function getAllTags(): { tag: string; count: number }[] { export function getAllSlugs(): string[] { return getAllPosts().map((p) => p.slug); } + +/** + * 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', + }); +} + +/** + * 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. + * + * 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)); +} + +/** + * 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); +} 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 00000000..365989ad --- /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 00000000..1e6a4728 --- /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-23-blog-landing-polish-design.md b/docs/superpowers/specs/2026-05-23-blog-landing-polish-design.md new file mode 100644 index 00000000..a708d962 --- /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 `