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

51 KiB
Raw Permalink Blame History

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

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

{
  "name": "crashtestdev",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview"
  }
}

Step 3: Install dependencies

npm install astro @astrojs/mdx @astrojs/rss
npm install -D typescript

Step 4: Create astro.config.mjs

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

{
  "extends": "astro/tsconfigs/strict"
}

Step 6: Create src/env.d.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:

---
---
<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

npm run build

Expected: Build succeeds, output in dist/ directory. You should see something like:

 building ...
 ✓ Completed in X.XXs.

Step 10: Commit

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):

: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

grep -c '\-\-' src/styles/tokens.css

Expected: A number around 6070 (the count of CSS custom properties defined).

Step 3: Commit

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

#!/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

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

ls -la src/fonts/*.woff2 | wc -l

Expected: 8

If the download fails (network issues, API changes), download manually from Google Fonts and save to src/fonts/ using the same naming convention.

Step 4: Create src/styles/fonts.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

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

---
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:

---
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

npm run build

Expected: Build succeeds. The output dist/index.html should contain the CSS custom properties and @font-face declarations inlined/linked.

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

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

---
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:

---
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

npm run build

Expected: Build succeeds. The HTML output should contain the <nav> element with "Crash Test Dev" and "Posts".

Step 4: Commit

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

---
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

npm run build

Expected: Build succeeds. The component is not yet used on any page, but it compiles without errors.

Step 3: Commit

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

---
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

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

npm run build

Expected: Clean build with no errors.

Step 4: Commit

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

/* ============================================================
   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

npm run build

Expected: Build succeeds. The CSS file is valid (no import errors).

Step 3: Commit

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:

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

npm run build

Expected: Build succeeds. The Shiki theme is registered and will be used for code blocks.

Step 3: Commit

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

---
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

npm run build

Expected: Build succeeds. The Callout component is available for use in MDX files.

Step 3: Commit

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

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

mkdir -p src/content/posts

Step 3: Create src/utils/reading-time.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

npm run build

Expected: Build succeeds. The content collection is configured but empty (no posts yet).

Step 5: Commit

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

---
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

npm run build

Expected: Build succeeds. No post pages are generated yet (no content in the collection). The template is ready.

Step 3: Commit

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:

---
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:

---
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

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

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

---
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

npm run build

Expected: Build succeeds.

Step 3: Commit

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

---
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

npm run build

Expected: Build succeeds. The index page lists both posts as ArticleCards.

Optionally preview:

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

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

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

npm run build

Expected: Build succeeds and generates an rss.xml file in the output. Check:

find dist -name 'rss.xml'

Expected: A path like dist/crashtestdev/rss.xml (or dist/rss.xml depending on base config).

Step 3: Commit

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

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

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

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

rm -rf dist .astro
npm run build

Expected: Clean build with no errors and no warnings.

Step 2: Preview the site

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

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:

# 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

git add -A
git status

If there are any uncommitted changes, commit them:

git commit -m "chore: final verification cleanup"

Step 6: Push the branch

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