a951c00e35
- 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>
2124 lines
51 KiB
Markdown
2124 lines
51 KiB
Markdown
# 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 459–563), 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 459–563):
|
||
|
||
```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 60–70 (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 1–7 | Astro scaffold, design tokens, self-hosted fonts, BaseLayout, NavBar, PostHeader. A working page with correct fonts, colors, and layout. |
|
||
| **Phase 2: Content + Components** | Tasks 8–18 | 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
|