Files
crashtestdev/docs/plans/2026-05-26-astro-blog-rebuild.md
Ken a951c00e35 fix: purge AI slop from judgment essay, add voice linting pipeline
- Killed 16 em-dashes (Claude fingerprint, 15.9/1000 words -> 0)
- Replaced all clause-dash-elaboration patterns with periods, colons, restructuring
- Removed 'The One-Sentence Version' section (restated the intro, voice anti-pattern)
- Broke tricolon at lines 56-58 (too-clean parallel structure)
- Collapsed 'How to Actually Help' listicle into connective prose
- Added receipt link for ~100x inference cost claim
- Heading dashes replaced with colons (Tradesman Analogy, Excellence vs Functional)

New tooling:
- scripts/lint-voice.sh: mechanical anti-slop linter (em-dashes, trigger words,
  hedging, filler, receipts, sentence uniformity)
- .amplifier/skills/voice-check/SKILL.md: LLM-as-judge voice authenticity check
  (8 dimensions against VOICE.md profile)
- .amplifier/AGENTS.md: standing rule requiring both checks before any publish

🤖 Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-27 00:42:18 +00:00

2124 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Crash Test Dev Blog Rebuild — Implementation Plan
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
**Goal:** Replace the Gatsby 2 blog with a modern Astro 6 site implementing the workbench design system from DESIGN.md.
**Architecture:** Static site generated by Astro 6. CSS custom properties from the design token system (no Tailwind, no preprocessors). Self-hosted fonts (Source Serif 4, Inter, JetBrains Mono). Markdown/MDX content collections with Zod-validated frontmatter. Three typographic voices: serif for prose, sans for structure, mono for evidence. Code blocks break wider than prose to create the narrow-wide-narrow scroll rhythm.
**Tech Stack:** Astro 6, TypeScript, @astrojs/mdx, @astrojs/rss, Shiki (built-in)
**Design references (read before implementing):**
- `DESIGN.md` — All token values and component descriptions
- `.design/specs/design-tokens-2026-05-26.md` — CSS custom properties block (lines 459563), font stacks, spacing semantics
- `.design/specs/components-2026-05-26.md` — Six component specs with exact token references
---
## Phase 1: Foundation
### Task 1: Create branch and scaffold Astro project
**Files:**
- Remove: `gatsby-config.js`, `yarn.lock`, `src/gatsby-theme-blog/` (entire directory)
- Create: `package.json`, `astro.config.mjs`, `tsconfig.json`, `src/env.d.ts`
- Modify: `.gitignore`
**Step 1: Create the branch and remove Gatsby files**
```bash
cd /home/ken/workspace/crashtestdev
git checkout -b astro-rebuild
rm gatsby-config.js yarn.lock
rm -rf src/gatsby-theme-blog
rm -rf node_modules
```
**Step 2: Create `package.json`**
```json
{
"name": "crashtestdev",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
}
}
```
**Step 3: Install dependencies**
```bash
npm install astro @astrojs/mdx @astrojs/rss
npm install -D typescript
```
**Step 4: Create `astro.config.mjs`**
```js
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
export default defineConfig({
// Update these for your deployment:
// - For project site: site = 'https://kenotron.github.io', base = '/crashtestdev'
// - For custom domain: site = 'https://yourdomain.com', remove base
site: 'https://kenotron.github.io',
base: '/crashtestdev',
integrations: [mdx()],
});
```
**Step 5: Create `tsconfig.json`**
```json
{
"extends": "astro/tsconfigs/strict"
}
```
**Step 6: Create `src/env.d.ts`**
```ts
/// <reference types="astro/client" />
```
**Step 7: Update `.gitignore`**
Replace the "gatsby files" section at the bottom of `.gitignore`. Find these lines:
```
# gatsby files
.cache/
public
```
Replace with:
```
# astro files
dist/
.astro/
```
**Step 8: Create a minimal page so the build succeeds**
Create `src/pages/index.astro`:
```astro
---
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Crash Test Dev</title>
</head>
<body>
<h1>Crash Test Dev</h1>
<p>Site is being rebuilt.</p>
</body>
</html>
```
**Step 9: Verify**
```bash
npm run build
```
Expected: Build succeeds, output in `dist/` directory. You should see something like:
```
building ...
✓ Completed in X.XXs.
```
**Step 10: Commit**
```bash
git add -A
git commit -m "feat: scaffold Astro project, remove Gatsby"
```
---
### Task 2: Add design tokens as CSS custom properties
**Files:**
- Create: `src/styles/tokens.css`
**Step 1: Create `src/styles/tokens.css`**
This is the exact CSS custom properties block from `.design/specs/design-tokens-2026-05-26.md` (lines 459563):
```css
:root {
/* Surfaces */
--surface-base: #F5F0E8;
--surface-card: #FEFCF7;
--surface-code: #EEEAE2;
--surface-inset: #E8E3DA;
/* Text */
--text-primary: #2B2421;
--text-secondary: #6B5F55;
--text-tertiary: #948880;
/* Accents */
--accent-primary: #A0522D;
--accent-primary-hover: #8B4726;
--accent-secondary: #7D6C2F;
--accent-secondary-hover: #6B5C28;
/* Code */
--code-text: #3D342E;
/* Borders */
--border-subtle: #DDD7CD;
--border-default: #C2BAA9;
--border-strong: #8E857A;
/* Focus */
--focus-ring: #A0522D;
/* State: Success */
--success-bg: #E8EDDF;
--success-text: #4A5E3A;
--success-border: #B5C4A0;
/* State: Warning */
--warning-bg: #F2E8D0;
--warning-text: #7A5C1F;
--warning-border: #D4BC7C;
/* State: Error */
--error-bg: #F2DED6;
--error-text: #8B3A2A;
--error-border: #CFA090;
/* State: Info */
--info-bg: #E5E2DA;
--info-text: #4A4540;
--info-border: #B8B2A8;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(43, 36, 33, 0.04);
--shadow-md: 0 2px 8px rgba(43, 36, 33, 0.06);
/* Typography families */
--font-prose: 'Source Serif 4', 'Source Serif Pro', Georgia, 'Times New Roman', serif;
--font-structure: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-evidence: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
/* Typography scale */
--type-xs: 0.75rem;
--type-sm: 0.875rem;
--type-base: 1.125rem;
--type-lg: 1.375rem;
--type-xl: 1.75rem;
--type-2xl: 2.25rem;
--type-3xl: 2.75rem;
/* Line heights */
--leading-tight: 1.15;
--leading-snug: 1.25;
--leading-heading: 1.35;
--leading-normal: 1.5;
--leading-relaxed: 1.6;
--leading-prose: 1.7;
/* Spacing */
--space-0: 0;
--space-px: 1px;
--space-0-5: 0.25rem;
--space-1: 0.5rem;
--space-1-5: 0.75rem;
--space-2: 1rem;
--space-2-5: 1.25rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 2.5rem;
--space-6: 3rem;
--space-8: 4rem;
--space-10: 5rem;
--space-12: 6rem;
/* Border radius */
--rounded-none: 0;
--rounded-sm: 2px;
--rounded-md: 4px;
--rounded-lg: 6px;
--rounded-xl: 8px;
--rounded-full: 9999px;
/* Layout */
--width-prose: 45rem;
--width-code: 52.5rem;
--width-container: 68.75rem;
--gutter: 1.5rem;
/* Motion */
--duration-fast: 100ms;
--duration-normal: 150ms;
--duration-slow: 250ms;
--ease-default: cubic-bezier(0.25, 0.1, 0.25, 1.0);
--ease-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);
}
@media (prefers-reduced-motion: reduce) {
:root {
--duration-fast: 0ms;
--duration-normal: 0ms;
--duration-slow: 0ms;
}
}
```
**Step 2: Verify the file exists and has the right token count**
```bash
grep -c '\-\-' src/styles/tokens.css
```
Expected: A number around 6070 (the count of CSS custom properties defined).
**Step 3: Commit**
```bash
git add src/styles/tokens.css
git commit -m "feat: add design tokens as CSS custom properties"
```
---
### Task 3: Download and self-host fonts
**Files:**
- Create: `scripts/download-fonts.py`
- Create: `src/fonts/` (8 woff2 files, created by the script)
- Create: `src/styles/fonts.css`
Fonts are placed in `src/fonts/` (not `public/fonts/`) so Vite processes them at build time. This ensures font URLs work correctly regardless of the `base` path configuration.
**Step 1: Create the font download script at `scripts/download-fonts.py`**
```python
#!/usr/bin/env python3
"""Download Google Fonts woff2 files for self-hosting (latin subset only)."""
import re
import os
import urllib.request
FONTS_DIR = os.path.join(os.path.dirname(__file__), "..", "src", "fonts")
os.makedirs(FONTS_DIR, exist_ok=True)
GOOGLE_FONTS_URL = (
"https://fonts.googleapis.com/css2?"
"family=Source+Serif+4:ital,wght@0,400;0,600;1,400"
"&family=Inter:wght@400;500;600;700"
"&family=JetBrains+Mono:wght@400"
"&display=swap"
)
headers = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
}
req = urllib.request.Request(GOOGLE_FONTS_URL, headers=headers)
css = urllib.request.urlopen(req).read().decode()
# Parse: /* subset */ @font-face { ... } — keep only 'latin' blocks
pattern = r'/\*\s*([\w-]+)\s*\*/\s*@font-face\s*\{([^}]+)\}'
blocks = re.findall(pattern, css)
downloaded = []
for subset, block in blocks:
if subset != "latin":
continue
family_m = re.search(r"font-family:\s*'([^']+)'", block)
weight_m = re.search(r"font-weight:\s*(\d+)", block)
style_m = re.search(r"font-style:\s*(\w+)", block)
url_m = re.search(r"url\(([^)]+\.woff2)\)", block)
if not all([family_m, weight_m, url_m]):
continue
family = family_m.group(1).lower().replace(" ", "-")
weight = weight_m.group(1)
style = style_m.group(1) if style_m else "normal"
suffix = "-italic" if style == "italic" else ""
filename = f"{family}-{weight}{suffix}.woff2"
filepath = os.path.join(FONTS_DIR, filename)
print(f"Downloading {filename}...")
urllib.request.urlretrieve(url_m.group(1), filepath)
downloaded.append(filename)
print(f"\nDownloaded {len(downloaded)} font files to src/fonts/:")
for f in sorted(downloaded):
print(f" {f}")
```
**Step 2: Run the script**
```bash
python3 scripts/download-fonts.py
```
Expected output:
```
Downloading source-serif-4-400.woff2...
Downloading source-serif-4-600.woff2...
Downloading source-serif-4-400-italic.woff2...
Downloading inter-400.woff2...
Downloading inter-500.woff2...
Downloading inter-600.woff2...
Downloading inter-700.woff2...
Downloading jetbrains-mono-400.woff2...
Downloaded 8 font files to src/fonts/:
inter-400.woff2
inter-500.woff2
inter-600.woff2
inter-700.woff2
jetbrains-mono-400.woff2
source-serif-4-400-italic.woff2
source-serif-4-400.woff2
source-serif-4-600.woff2
```
**Step 3: Verify the files exist**
```bash
ls -la src/fonts/*.woff2 | wc -l
```
Expected: `8`
If the download fails (network issues, API changes), download manually from [Google Fonts](https://fonts.google.com/) and save to `src/fonts/` using the same naming convention.
**Step 4: Create `src/styles/fonts.css`**
```css
/* Source Serif 4 — Prose voice (Ken's voice on the page) */
@font-face {
font-family: 'Source Serif 4';
src: url('../fonts/source-serif-4-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Source Serif 4';
src: url('../fonts/source-serif-4-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Source Serif 4';
src: url('../fonts/source-serif-4-400-italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Inter — Structure voice (headings, UI, navigation) */
@font-face {
font-family: 'Inter';
src: url('../fonts/inter-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('../fonts/inter-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('../fonts/inter-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('../fonts/inter-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* JetBrains Mono — Evidence voice (code, proof, receipts) */
@font-face {
font-family: 'JetBrains Mono';
src: url('../fonts/jetbrains-mono-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
```
**Step 5: Commit**
```bash
git add scripts/download-fonts.py src/fonts/ src/styles/fonts.css
git commit -m "feat: self-host fonts (Source Serif 4, Inter, JetBrains Mono)"
```
---
### Task 4: Create BaseLayout component
**Files:**
- Create: `src/layouts/BaseLayout.astro`
- Modify: `src/pages/index.astro` (update to use layout)
**Step 1: Create `src/layouts/BaseLayout.astro`**
```astro
---
import '../styles/tokens.css';
import '../styles/fonts.css';
interface Props {
title: string;
description?: string;
}
const {
title,
description = 'Technical publishing by Ken Chau',
} = Astro.props;
const siteTitle = title === 'Crash Test Dev' ? title : `${title} — Crash Test Dev`;
const base = import.meta.env.BASE_URL;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<title>{siteTitle}</title>
<link rel="alternate" type="application/rss+xml" title="Crash Test Dev" href={`${base}rss.xml`} />
</head>
<body>
<a href="#main-content" class="skip-link">Skip to content</a>
<slot name="nav" />
<main id="main-content">
<slot />
</main>
</body>
</html>
<style is:global>
/* Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 100%;
scroll-behavior: smooth;
}
body {
font-family: var(--font-prose);
font-size: var(--type-base);
line-height: var(--leading-prose);
color: var(--text-primary);
background-color: var(--surface-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Links - base treatment */
a {
color: var(--accent-primary);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-default);
}
a:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
a:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* Skip-to-content link */
.skip-link {
position: absolute;
top: -100%;
left: var(--space-2);
padding: var(--space-1) var(--space-2);
background: var(--surface-card);
color: var(--text-primary);
font-family: var(--font-structure);
font-size: var(--type-sm);
border-radius: var(--rounded-md);
z-index: 100;
text-decoration: none;
}
.skip-link:focus {
top: var(--space-1);
}
/* Page container */
main {
max-width: var(--width-container);
margin: 0 auto;
padding: 0 var(--gutter);
}
/* Selection */
::selection {
background-color: var(--accent-primary);
color: var(--surface-card);
}
</style>
```
**Step 2: Update `src/pages/index.astro` to use the layout**
Replace the entire file:
```astro
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Crash Test Dev">
<div style="max-width: var(--width-prose); margin: var(--space-8) auto;">
<h1 style="font-family: var(--font-structure); font-size: var(--type-3xl); font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; line-height: var(--leading-tight);">
Crash Test Dev
</h1>
<p style="margin-top: var(--space-3); color: var(--text-secondary);">
Site rebuild in progress. Fonts, tokens, and layout are loading.
</p>
</div>
</BaseLayout>
```
**Step 3: Verify**
```bash
npm run build
```
Expected: Build succeeds. The output `dist/index.html` should contain the CSS custom properties and @font-face declarations inlined/linked.
```bash
grep -c 'Source Serif' dist/crashtestdev/index.html 2>/dev/null || grep -c 'Source Serif' dist/index.html
```
Expected: At least 1 match (confirming fonts are included in the build output).
**Step 4: Commit**
```bash
git add src/layouts/BaseLayout.astro src/pages/index.astro
git commit -m "feat: base layout with tokens, fonts, and global styles"
```
---
### Task 5: Build NavBar component
**Files:**
- Create: `src/components/NavBar.astro`
- Modify: `src/pages/index.astro` (add NavBar)
Spec reference: `.design/specs/components-2026-05-26.md` section 6 (nav-bar).
**Step 1: Create `src/components/NavBar.astro`**
```astro
---
const currentPath = Astro.url.pathname;
const base = import.meta.env.BASE_URL;
const navLinks = [
{ label: 'Posts', href: base },
];
function isActive(href: string): boolean {
// "Posts" is active on the index and any post page
return currentPath === href || currentPath.startsWith(`${base}posts/`);
}
---
<nav aria-label="Site navigation">
<div class="nav-inner">
<a href={base} class="site-name">Crash Test Dev</a>
<div class="nav-links">
{navLinks.map(link => (
<a
href={link.href}
class:list={['nav-link', { active: isActive(link.href) }]}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
))}
</div>
</div>
</nav>
<style>
nav {
border-bottom: 1px solid var(--border-subtle);
position: static;
}
.nav-inner {
max-width: var(--width-container);
margin: 0 auto;
padding: var(--space-2) var(--gutter);
display: flex;
align-items: center;
justify-content: space-between;
}
.site-name {
font-family: var(--font-structure);
font-size: var(--type-base);
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
letter-spacing: -0.01em;
transition: color var(--duration-fast) var(--ease-default);
}
.site-name:hover {
color: var(--accent-primary);
text-decoration: none;
}
.nav-links {
display: flex;
gap: var(--space-4);
}
.nav-link {
font-family: var(--font-structure);
font-size: var(--type-sm);
font-weight: 400;
color: var(--text-secondary);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-default);
}
.nav-link:hover {
color: var(--text-primary);
text-decoration: none;
}
.nav-link.active {
font-weight: 500;
color: var(--text-primary);
text-decoration: underline;
text-decoration-color: var(--accent-primary);
text-decoration-thickness: 2px;
text-underline-offset: 6px;
}
/* Responsive: stack on narrow viewports */
@media (max-width: 599px) {
.nav-inner {
flex-direction: column;
gap: var(--space-1);
text-align: center;
}
.nav-links {
gap: var(--space-3);
}
}
</style>
```
**Step 2: Update `src/pages/index.astro` to include the NavBar**
Replace the entire file:
```astro
---
import BaseLayout from '../layouts/BaseLayout.astro';
import NavBar from '../components/NavBar.astro';
---
<BaseLayout title="Crash Test Dev">
<NavBar slot="nav" />
<div style="max-width: var(--width-prose); margin: var(--space-8) auto;">
<h1 style="font-family: var(--font-structure); font-size: var(--type-3xl); font-weight: 700; color: var(--text-primary); letter-spacing: -0.02em; line-height: var(--leading-tight);">
Crash Test Dev
</h1>
<p style="margin-top: var(--space-3); color: var(--text-secondary);">
Site rebuild in progress. NavBar, fonts, and tokens are working.
</p>
</div>
</BaseLayout>
```
**Step 3: Verify**
```bash
npm run build
```
Expected: Build succeeds. The HTML output should contain the `<nav>` element with "Crash Test Dev" and "Posts".
**Step 4: Commit**
```bash
git add src/components/NavBar.astro src/pages/index.astro
git commit -m "feat: NavBar component with active state and responsive layout"
```
---
### Task 6: Build PostHeader component
**Files:**
- Create: `src/components/PostHeader.astro`
Spec reference: `.design/specs/components-2026-05-26.md` section 5 (post-header).
**Step 1: Create `src/components/PostHeader.astro`**
```astro
---
interface Props {
title: string;
date: Date;
type?: string;
readingTime?: number;
}
const { title, date, type = 'post', readingTime } = Astro.props;
const formattedDate = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const isoDate = date.toISOString().split('T')[0];
---
<header class="post-header">
<span class="badge">{type}</span>
<h1 class="title">{title}</h1>
<p class="meta">
<time datetime={isoDate}>{formattedDate}</time>
{readingTime && <span> · {readingTime} min read</span>}
</p>
</header>
<style>
.post-header {
max-width: var(--width-prose);
margin: 0 auto;
padding-top: var(--space-8);
padding-bottom: var(--space-8);
text-align: center;
}
.badge {
display: inline-block;
font-family: var(--font-structure);
font-size: var(--type-xs);
font-weight: 600;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: var(--leading-normal);
}
.title {
font-family: var(--font-structure);
font-size: var(--type-3xl);
font-weight: 700;
color: var(--text-primary);
line-height: var(--leading-tight);
letter-spacing: -0.02em;
margin-top: var(--space-2);
}
.meta {
font-family: var(--font-structure);
font-size: var(--type-sm);
font-weight: 400;
color: var(--text-tertiary);
line-height: var(--leading-normal);
margin-top: var(--space-2);
}
/* Responsive title sizing */
@media (max-width: 719px) {
.title {
font-size: var(--type-2xl);
line-height: var(--leading-snug);
}
.post-header {
padding-top: var(--space-6);
padding-bottom: var(--space-6);
}
}
@media (max-width: 479px) {
.title {
font-size: var(--type-xl);
line-height: var(--leading-snug);
}
.post-header {
padding-top: var(--space-5);
padding-bottom: var(--space-5);
}
}
</style>
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds. The component is not yet used on any page, but it compiles without errors.
**Step 3: Commit**
```bash
git add src/components/PostHeader.astro
git commit -m "feat: PostHeader component with responsive title sizing"
```
---
### Task 7: Smoke test page and verify foundation
**Files:**
- Modify: `src/pages/index.astro` (replace with a full smoke test)
**Step 1: Replace `src/pages/index.astro` with a foundation test page**
```astro
---
import BaseLayout from '../layouts/BaseLayout.astro';
import NavBar from '../components/NavBar.astro';
import PostHeader from '../components/PostHeader.astro';
---
<BaseLayout title="Crash Test Dev">
<NavBar slot="nav" />
<PostHeader
title="Speeding Up Webpack Typescript Incremental Builds by 7x"
date={new Date('2019-08-16')}
type="post"
readingTime={12}
/>
<div style="max-width: var(--width-prose); margin: 0 auto;">
<p>This is body text in Source Serif 4 (prose voice). It should be 18px with generous line-height.</p>
<p style="margin-top: var(--space-3); font-family: var(--font-structure); font-size: var(--type-sm); color: var(--text-secondary);">
This is meta text in Inter (structure voice). 14px, secondary color.
</p>
<p style="margin-top: var(--space-3); font-family: var(--font-evidence); color: var(--code-text); background: var(--surface-code); padding: var(--space-2); border-radius: var(--rounded-md); border-left: 3px solid var(--accent-primary);">
const evidence = "JetBrains Mono (evidence voice)";
</p>
</div>
</BaseLayout>
```
**Step 2: Start dev server and verify visually**
```bash
npm run dev
```
Open the URL shown (likely `http://localhost:4321/crashtestdev/`). Verify:
- [ ] Page background is warm off-white (#F5F0E8), not pure white
- [ ] Nav bar shows "Crash Test Dev" left, "Posts" right, with a subtle bottom border
- [ ] Post header shows "POST" badge in terracotta, large title, date + reading time
- [ ] Body text is in a serif font (Source Serif 4)
- [ ] Meta text is in a sans font (Inter)
- [ ] Code line is in a monospace font (JetBrains Mono) with warm code background
- [ ] The terracotta accent (#A0522D) appears on the badge and code block left border
Stop the dev server (Ctrl+C) after verification.
**Step 3: Verify build**
```bash
npm run build
```
Expected: Clean build with no errors.
**Step 4: Commit**
```bash
git add src/pages/index.astro
git commit -m "feat: smoke test page verifying foundation (fonts, tokens, layout)"
```
---
## Phase 2: Content + Components
### Task 8: Write prose styling for markdown content
**Files:**
- Create: `src/styles/prose.css`
This is the full typographic treatment for rendered markdown content. Every value traces to a design token.
Spec references:
- `DESIGN.md` Typography and Layout sections
- `.design/specs/design-tokens-2026-05-26.md` spacing.semantic values
**Step 1: Create `src/styles/prose.css`**
```css
/* ============================================================
Prose Styling — Markdown content rendering
Applied via a .prose wrapper around <Content /> in post pages.
Every value traces to a CSS custom property from tokens.css.
============================================================ */
/* --- Headings (Structure voice: Inter) --- */
.prose h2 {
font-family: var(--font-structure);
font-size: var(--type-2xl);
font-weight: 600;
line-height: var(--leading-snug);
letter-spacing: -0.015em;
color: var(--text-primary);
margin-top: var(--space-8);
margin-bottom: var(--space-2);
}
.prose h3 {
font-family: var(--font-structure);
font-size: var(--type-xl);
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.01em;
color: var(--text-primary);
margin-top: var(--space-5);
margin-bottom: var(--space-1-5);
}
.prose h4 {
font-family: var(--font-structure);
font-size: var(--type-lg);
font-weight: 600;
line-height: var(--leading-heading);
letter-spacing: -0.005em;
color: var(--text-primary);
margin-top: var(--space-4);
margin-bottom: var(--space-1);
}
/* --- Body text (Prose voice: Source Serif 4) --- */
.prose p {
margin-bottom: var(--space-3);
}
.prose > *:last-child {
margin-bottom: 0;
}
/* --- Links --- */
.prose a {
color: var(--accent-primary);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-default);
}
.prose a:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
/* --- Blockquotes --- */
.prose blockquote {
font-family: var(--font-prose);
font-size: 1.25rem;
font-style: italic;
line-height: var(--leading-relaxed);
color: var(--text-primary);
border-left: 3px solid var(--accent-primary);
padding-left: var(--space-3);
margin-top: var(--space-3);
margin-bottom: var(--space-3);
}
.prose blockquote p {
margin-bottom: var(--space-2);
}
.prose blockquote p:last-child {
margin-bottom: 0;
}
/* --- Lists --- */
.prose ul,
.prose ol {
padding-left: var(--space-3);
margin-bottom: var(--space-3);
}
.prose li {
margin-bottom: var(--space-1);
}
.prose li:last-child {
margin-bottom: 0;
}
.prose li > ul,
.prose li > ol {
margin-top: var(--space-1);
margin-bottom: 0;
}
/* --- Inline code (Evidence voice: JetBrains Mono) --- */
.prose code {
font-family: var(--font-evidence);
font-size: 0.9em;
font-weight: 400;
color: var(--code-text);
background-color: var(--surface-code);
padding: 0.15em 0.35em;
border-radius: var(--rounded-sm);
}
/* Reset inline code styles inside code blocks (Shiki handles those) */
.prose pre code {
font-size: 1rem;
background: none;
padding: 0;
border-radius: 0;
color: inherit;
}
/* --- Code blocks (breakout width) --- */
.prose pre {
/* Break out of prose column */
position: relative;
left: 50%;
transform: translateX(-50%);
width: min(var(--width-code), calc(100vw - var(--gutter) * 2));
max-width: var(--width-code);
/* Styling */
border: 1px solid var(--border-subtle);
border-left: 3px solid var(--accent-primary);
border-radius: var(--rounded-md);
padding: var(--space-3);
margin-top: var(--space-5);
margin-bottom: var(--space-5);
overflow-x: auto;
line-height: var(--leading-relaxed);
font-family: var(--font-evidence);
tab-size: 2;
}
/* Warm scrollbar for code blocks (where supported) */
.prose pre::-webkit-scrollbar {
height: 6px;
}
.prose pre::-webkit-scrollbar-track {
background: transparent;
}
.prose pre::-webkit-scrollbar-thumb {
background: var(--border-default);
border-radius: var(--rounded-full);
}
.prose pre::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Focus for keyboard-scrollable code blocks */
.prose pre:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
/* --- Horizontal rules --- */
.prose hr {
border: none;
border-top: 1px solid var(--border-subtle);
margin: var(--space-6) auto;
max-width: 5rem;
}
/* --- Strong and emphasis --- */
.prose strong {
font-weight: 600;
}
.prose em {
font-style: italic;
}
/* --- Images (if any appear in content) --- */
.prose img {
max-width: 100%;
height: auto;
border-radius: var(--rounded-md);
margin-top: var(--space-3);
margin-bottom: var(--space-3);
}
/* --- Tables --- */
.prose table {
width: 100%;
border-collapse: collapse;
margin-top: var(--space-3);
margin-bottom: var(--space-3);
font-size: var(--type-sm);
}
.prose th {
font-family: var(--font-structure);
font-weight: 600;
text-align: left;
padding: var(--space-1) var(--space-2);
border-bottom: 2px solid var(--border-default);
color: var(--text-primary);
}
.prose td {
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
}
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds. The CSS file is valid (no import errors).
**Step 3: Commit**
```bash
git add src/styles/prose.css
git commit -m "feat: prose styling for markdown content (headings, code, lists, blockquotes)"
```
---
### Task 9: Configure Shiki syntax highlighting with warm theme
**Files:**
- Modify: `astro.config.mjs` (add shikiConfig with custom workbench theme)
Spec reference: `.design/specs/components-2026-05-26.md` section 2 (code-block), Syntax Highlighting Palette Direction table.
**Step 1: Update `astro.config.mjs`**
Replace the entire file:
```js
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
// Custom Shiki theme: warm colors matching the workbench design system.
// Keywords = terracotta, strings = tarnished brass, comments = tertiary gray.
const workbenchTheme = {
name: 'workbench',
type: 'light',
colors: {
'editor.background': '#EEEAE2',
'editor.foreground': '#3D342E',
},
tokenColors: [
{
scope: ['comment', 'punctuation.definition.comment'],
settings: { foreground: '#948880', fontStyle: 'italic' },
},
{
scope: ['keyword', 'storage.type', 'storage.modifier', 'keyword.control', 'keyword.operator.expression'],
settings: { foreground: '#A0522D' },
},
{
scope: ['string', 'string.quoted', 'string.template'],
settings: { foreground: '#7D6C2F' },
},
{
scope: ['constant.numeric', 'constant.language'],
settings: { foreground: '#7D6C2F' },
},
{
scope: ['entity.name.function', 'support.function', 'meta.function-call'],
settings: { foreground: '#2B2421', fontStyle: 'bold' },
},
{
scope: ['entity.name.type', 'support.type', 'entity.name.class', 'support.class'],
settings: { foreground: '#B07051' },
},
{
scope: ['variable', 'variable.other', 'variable.parameter'],
settings: { foreground: '#3D342E' },
},
{
scope: ['punctuation', 'meta.brace', 'meta.bracket'],
settings: { foreground: '#6B5F55' },
},
{
scope: ['entity.name.tag', 'punctuation.definition.tag'],
settings: { foreground: '#A0522D' },
},
{
scope: ['entity.other.attribute-name'],
settings: { foreground: '#7D6C2F' },
},
{
scope: ['support.type.property-name', 'meta.object-literal.key'],
settings: { foreground: '#2B2421' },
},
{
scope: ['keyword.operator', 'keyword.operator.assignment'],
settings: { foreground: '#6B5F55' },
},
{
scope: ['meta.import', 'keyword.control.import', 'keyword.control.from', 'keyword.control.export'],
settings: { foreground: '#A0522D' },
},
],
};
export default defineConfig({
site: 'https://kenotron.github.io',
base: '/crashtestdev',
integrations: [mdx()],
markdown: {
shikiConfig: {
theme: workbenchTheme,
},
},
});
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds. The Shiki theme is registered and will be used for code blocks.
**Step 3: Commit**
```bash
git add astro.config.mjs
git commit -m "feat: custom Shiki 'workbench' theme with warm syntax colors"
```
---
### Task 10: Build Callout component
**Files:**
- Create: `src/components/Callout.astro`
Spec reference: `.design/specs/components-2026-05-26.md` section 4 (callout).
**Step 1: Create `src/components/Callout.astro`**
```astro
---
interface Props {
type?: 'note' | 'pattern' | 'antipattern' | 'warning' | 'tip';
}
const { type = 'note' } = Astro.props;
const config: Record<string, { borderColor: string; labelColor: string; label: string }> = {
note: { borderColor: 'var(--border-default)', labelColor: 'var(--text-secondary)', label: 'Note' },
pattern: { borderColor: 'var(--accent-primary)', labelColor: 'var(--accent-primary)', label: 'Pattern' },
antipattern: { borderColor: 'var(--error-border)', labelColor: 'var(--error-text)', label: 'Anti-pattern' },
warning: { borderColor: 'var(--warning-border)', labelColor: 'var(--warning-text)', label: 'Warning' },
tip: { borderColor: 'var(--accent-secondary)',labelColor: 'var(--accent-secondary)', label: 'Tip' },
};
const { borderColor, labelColor, label } = config[type];
---
<aside
class="callout"
role="note"
aria-label={`${label} callout`}
style={`border-left-color: ${borderColor};`}
>
<span class="callout-label" aria-hidden="true" style={`color: ${labelColor};`}>
{label}
</span>
<div class="callout-body">
<slot />
</div>
</aside>
<style>
.callout {
max-width: var(--width-prose);
background-color: var(--surface-inset);
border: 1px solid var(--border-subtle);
border-left-width: 3px;
border-radius: var(--rounded-lg);
padding: var(--space-3);
margin-top: var(--space-4);
margin-bottom: var(--space-4);
}
.callout-label {
display: block;
font-family: var(--font-structure);
font-size: var(--type-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: var(--leading-normal);
margin-bottom: var(--space-1);
}
.callout-body {
font-family: var(--font-prose);
font-size: var(--type-base);
font-weight: 400;
line-height: var(--leading-prose);
color: var(--text-primary);
}
.callout-body :global(p) {
margin-bottom: var(--space-3);
}
.callout-body :global(p:last-child) {
margin-bottom: 0;
}
</style>
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds. The Callout component is available for use in MDX files.
**Step 3: Commit**
```bash
git add src/components/Callout.astro
git commit -m "feat: Callout component with 5 types (note, pattern, antipattern, warning, tip)"
```
---
### Task 11: Set up content collection with schema
**Files:**
- Create: `src/content.config.ts`
- Create: `src/content/posts/` directory (empty for now)
- Create: `src/utils/reading-time.ts`
**Step 1: Create `src/content.config.ts`**
```ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const posts = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
type: z.enum(['post', 'pattern', 'lesson']).default('post'),
tags: z.array(z.string()).default([]),
summary: z.string().optional(),
}),
});
export const collections = { posts };
```
**Step 2: Create the posts directory**
```bash
mkdir -p src/content/posts
```
**Step 3: Create `src/utils/reading-time.ts`**
```ts
/**
* Estimate reading time from raw markdown content.
* Average reading speed: 200 words per minute.
*/
export function getReadingTime(text: string): number {
const words = text.trim().split(/\s+/).length;
return Math.max(1, Math.ceil(words / 200));
}
```
**Step 4: Verify**
```bash
npm run build
```
Expected: Build succeeds. The content collection is configured but empty (no posts yet).
**Step 5: Commit**
```bash
git add src/content.config.ts src/content/posts/.gitkeep src/utils/reading-time.ts
# If .gitkeep doesn't work, the directory will be committed with posts in the next task
git commit -m "feat: content collection schema and reading time utility" || \
(touch src/content/posts/.gitkeep && git add -A && git commit -m "feat: content collection schema and reading time utility")
```
---
### Task 12: Create post page template
**Files:**
- Create: `src/pages/posts/[...slug].astro`
**Step 1: Create `src/pages/posts/[...slug].astro`**
```astro
---
import { getCollection, render } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import NavBar from '../../components/NavBar.astro';
import PostHeader from '../../components/PostHeader.astro';
import { getReadingTime } from '../../utils/reading-time';
import '../../styles/prose.css';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
interface Props {
post: CollectionEntry<'posts'>;
}
const { post } = Astro.props;
const { Content } = await render(post);
const readingTime = post.body ? getReadingTime(post.body) : undefined;
---
<BaseLayout title={post.data.title} description={post.data.summary}>
<NavBar slot="nav" />
<PostHeader
title={post.data.title}
date={post.data.date}
type={post.data.type}
readingTime={readingTime}
/>
<article class="prose-wrapper">
<div class="prose">
<Content />
</div>
</article>
</BaseLayout>
<style>
.prose-wrapper {
max-width: var(--width-prose);
margin: 0 auto;
padding-bottom: var(--space-12);
}
</style>
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds. No post pages are generated yet (no content in the collection). The template is ready.
**Step 3: Commit**
```bash
git add src/pages/posts/
git commit -m "feat: post page template with prose styling and reading time"
```
---
### Task 13: Migrate existing posts
**Files:**
- Create: `src/content/posts/localize-react-without-bloating-the-bundle.md`
- Create: `src/content/posts/speeding-up-webpack-typescript-incremental-builds-by-7x.md`
The two existing posts live at `content/posts/` (old Gatsby location). They need updated frontmatter for the new schema. Key changes:
- Remove `path` (Astro routes from filename)
- Remove `heroImage` (no hero images in new design)
- Add `type`, `tags`, `summary`
- Simplify `date` to `YYYY-MM-DD`
- Convert the `.mdx` file to `.md` (it doesn't use any MDX features)
**Step 1: Create `src/content/posts/localize-react-without-bloating-the-bundle.md`**
Copy from `content/posts/localize-react-without-bloating-the-bundle.md` but replace the frontmatter. The new frontmatter:
```yaml
---
title: Localize React without Bloating the Bundle
date: 2019-08-16
type: post
tags: [react, localization, webpack, performance]
summary: A performant approach to React localization using dynamic imports and react-intl-universal, keeping your main bundle free of locale strings.
---
```
The body content stays exactly the same (everything after the original `---` closing delimiter). Copy the body text verbatim — do not modify the prose.
**Step 2: Create `src/content/posts/speeding-up-webpack-typescript-incremental-builds-by-7x.md`**
Copy from `content/posts/speeding-up-webpack-typescript-incremental-builds-by-7x.mdx` but replace the frontmatter. The new frontmatter:
```yaml
---
title: Speeding Up Webpack Typescript Incremental Builds by 7x
date: 2019-08-16
type: post
tags: [webpack, typescript, performance, microsoft]
summary: How the Outlook team at Microsoft cut incremental build times from 35s to 5s by defeating four performance enemies in the Webpack pipeline.
---
```
Same rule: body content stays verbatim. This was `.mdx` but contains no JSX/MDX syntax, so save as `.md`.
**Step 3: Verify**
```bash
npm run build
```
Expected: Build succeeds and generates two post pages. Look for output like:
```
▶ src/pages/posts/[...slug].astro
└─ ...posts/localize-react-without-bloating-the-bundle/index.html
└─ ...posts/speeding-up-webpack-typescript-incremental-builds-by-7x/index.html
```
**Step 4: Commit**
```bash
git add src/content/posts/
git commit -m "feat: migrate 2 existing posts to Astro content collection"
```
---
### Task 14: Build ArticleCard component
**Files:**
- Create: `src/components/ArticleCard.astro`
Spec reference: `.design/specs/components-2026-05-26.md` section 1 (article-card).
**Step 1: Create `src/components/ArticleCard.astro`**
```astro
---
interface Props {
title: string;
date: Date;
type: string;
summary?: string;
slug: string;
readingTime?: number;
}
const { title, date, type, summary, slug, readingTime } = Astro.props;
const base = import.meta.env.BASE_URL;
const formattedDate = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<article class="card" aria-label={`${type}: ${title}`}>
<a href={`${base}posts/${slug}/`} class="card-link">
<div class="badge-row">
<span class="badge">{type}</span>
{readingTime && <span class="reading-time">{readingTime} min read</span>}
</div>
<h2 class="title">{title}</h2>
{summary && <p class="summary">{summary}</p>}
<time datetime={date.toISOString().split('T')[0]}>{formattedDate}</time>
</a>
</article>
<style>
.card {
max-width: var(--width-prose);
}
.card-link {
display: block;
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--rounded-lg);
padding: var(--space-3);
box-shadow: var(--shadow-sm);
text-decoration: none;
color: inherit;
transition:
box-shadow var(--duration-fast) var(--ease-default),
transform var(--duration-fast) var(--ease-default);
}
.card-link:hover {
box-shadow: var(--shadow-md);
text-decoration: none;
}
.card-link:hover .title {
color: var(--accent-primary);
}
.card-link:active {
box-shadow: var(--shadow-sm);
transform: translateY(1px);
}
.card-link:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.badge-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-1-5);
}
.badge {
font-family: var(--font-structure);
font-size: var(--type-xs);
font-weight: 600;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: var(--leading-normal);
}
.reading-time {
font-family: var(--font-structure);
font-size: var(--type-xs);
font-weight: 400;
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
.title {
font-family: var(--font-structure);
font-size: var(--type-xl);
font-weight: 600;
color: var(--text-primary);
line-height: var(--leading-snug);
margin-bottom: var(--space-1);
transition: color var(--duration-fast) var(--ease-default);
}
.summary {
font-family: var(--font-prose);
font-size: var(--type-base);
font-weight: 400;
color: var(--text-secondary);
line-height: var(--leading-prose);
margin-bottom: var(--space-2);
}
time {
font-family: var(--font-structure);
font-size: var(--type-sm);
font-weight: 400;
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
</style>
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds.
**Step 3: Commit**
```bash
git add src/components/ArticleCard.astro
git commit -m "feat: ArticleCard component with hover states and card-on-desk styling"
```
---
### Task 15: Build index page
**Files:**
- Modify: `src/pages/index.astro` (replace smoke test with real index)
**Step 1: Replace `src/pages/index.astro`**
```astro
---
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import NavBar from '../components/NavBar.astro';
import ArticleCard from '../components/ArticleCard.astro';
import { getReadingTime } from '../utils/reading-time';
const posts = await getCollection('posts');
const sortedPosts = posts.sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime()
);
---
<BaseLayout title="Crash Test Dev">
<NavBar slot="nav" />
<section class="post-list" aria-label="All posts">
{sortedPosts.map((post) => (
<ArticleCard
title={post.data.title}
date={post.data.date}
type={post.data.type}
summary={post.data.summary}
slug={post.id}
readingTime={post.body ? getReadingTime(post.body) : undefined}
/>
))}
</section>
</BaseLayout>
<style>
.post-list {
max-width: var(--width-prose);
margin: var(--space-6) auto var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
</style>
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds. The index page lists both posts as ArticleCards.
Optionally preview:
```bash
npm run preview
```
Open the preview URL. Verify:
- [ ] Two article cards appear, sorted by date (newest first)
- [ ] Each card has: badge, title, summary, date
- [ ] Cards have warm off-white background with subtle shadow
- [ ] Hover shows deeper shadow and title turns terracotta
**Step 3: Commit**
```bash
git add src/pages/index.astro
git commit -m "feat: index page listing posts with ArticleCards"
```
---
### Task 16: Add RSS feed
**Files:**
- Create: `src/pages/rss.xml.ts`
**Step 1: Create `src/pages/rss.xml.ts`**
```ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
export async function GET(context: APIContext) {
const posts = await getCollection('posts');
const base = import.meta.env.BASE_URL;
return rss({
title: 'Crash Test Dev',
description: 'Technical publishing by Ken Chau',
site: context.site!,
items: posts
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime())
.map((post) => ({
title: post.data.title,
pubDate: post.data.date,
description: post.data.summary ?? '',
link: `${base}posts/${post.id}/`,
})),
});
}
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds and generates an `rss.xml` file in the output. Check:
```bash
find dist -name 'rss.xml'
```
Expected: A path like `dist/crashtestdev/rss.xml` (or `dist/rss.xml` depending on base config).
**Step 3: Commit**
```bash
git add src/pages/rss.xml.ts
git commit -m "feat: RSS feed with @astrojs/rss"
```
---
### Task 17: Configure GitHub Pages deployment
**Files:**
- Create: `.github/workflows/deploy.yml`
The existing `.github/workflows/azure-static-web-apps-salmon-sea-0f0c8771e.yml` is for the old Azure deployment. Keep it for now (it won't trigger on the new branch). The new workflow deploys to GitHub Pages.
**Step 1: Create `.github/workflows/deploy.yml`**
```yaml
name: Deploy to GitHub Pages
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
```
**Step 2: Verify**
```bash
npm run build
```
Expected: Build succeeds. The workflow file is valid YAML. Deployment will activate when the branch is merged to `master`.
**Step 3: Commit**
```bash
git add .github/workflows/deploy.yml
git commit -m "ci: add GitHub Pages deployment workflow"
```
---
### Task 18: Final verification and cleanup
**Files:**
- No new files. This is a verification and cleanup task.
**Step 1: Run a clean build**
```bash
rm -rf dist .astro
npm run build
```
Expected: Clean build with no errors and no warnings.
**Step 2: Preview the site**
```bash
npm run preview
```
Open the preview URL (likely `http://localhost:4321/crashtestdev/`). Walk through the full site:
- [ ] **Index page:** Two article cards. Warm off-white background. Cards have subtle shadow.
- [ ] **Card interaction:** Hover shows deeper shadow, title turns terracotta. Click navigates to post.
- [ ] **Post page:** NavBar at top with "Posts" link active (terracotta underline). Post header centered with badge, title, date, reading time. Prose content in serif font.
- [ ] **Typography:** Body text in Source Serif 4. Headings in Inter. Code in JetBrains Mono. All three voices are visually distinct.
- [ ] **Code blocks:** Break wider than prose. Warm background (#EEEAE2). 3px terracotta left border. Syntax colors are warm (terracotta keywords, brass strings).
- [ ] **Colors:** Page background is warm off-white (#F5F0E8), not pure white. Text is brown-black (#2B2421), not blue-black. Links are terracotta (#A0522D).
- [ ] **Spacing:** Generous vertical rhythm. H2s have big gaps above them. Code blocks have 40px vertical margins.
- [ ] **Responsive:** Narrow the browser. Nav stacks. Title size decreases. Code blocks respect gutters.
- [ ] **RSS:** Navigate to `{base}rss.xml`. Should show valid XML with both posts.
Stop the preview (Ctrl+C).
**Step 3: Lint DESIGN.md tokens**
```bash
design.md lint DESIGN.md
```
Expected: 0 errors (same as before — the site implementation doesn't change DESIGN.md).
**Step 4: Verify CSS token compliance**
Spot-check that implemented CSS matches DESIGN.md tokens:
```bash
# Check that the surface-base color is used
grep -r '#F5F0E8' src/styles/tokens.css
# Check that all three font families are referenced
grep -c 'font-prose\|font-structure\|font-evidence' src/styles/tokens.css
```
Expected: Matches found for each check.
**Step 5: Final commit**
```bash
git add -A
git status
```
If there are any uncommitted changes, commit them:
```bash
git commit -m "chore: final verification cleanup"
```
**Step 6: Push the branch**
```bash
git push -u origin astro-rebuild
```
The branch is ready for review. When merged to `master`, the GitHub Pages workflow will deploy the site.
---
## Summary
| Phase | Tasks | What you get |
|-------|-------|-------------|
| **Phase 1: Foundation** | Tasks 17 | Astro scaffold, design tokens, self-hosted fonts, BaseLayout, NavBar, PostHeader. A working page with correct fonts, colors, and layout. |
| **Phase 2: Content + Components** | Tasks 818 | Prose styling, warm Shiki theme, Callout, content collection, post template, 2 migrated posts, ArticleCard, index page, RSS, GitHub Pages deployment. A complete working blog. |
**Total:** 18 tasks across 2 phases.
**What's deferred (future phases):**
- Audio player component + TTS narration pipeline
- Patterns and Lessons content types (nav links, collection schemas)
- Search functionality
- Analytics
- Custom domain configuration