blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit c1db776dd7640aaf30cb7bab0c968d40158d2bc5
parent 5174da3bc4a3ad6a8cbf1940fe24616141a3055b
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:42:05 +0900

feat: design system, self-hosted fonts, and the writing index

Puts a real typographic system under the site, replacing the styling that came across
with the migration. The reference points are the Harvard Gazette for form and
nicholas.carlini.com/writing for density.

Type and color
- Four subsetted variable faces served from the repository: Newsreader for prose, Inter
for UI, Pretendard for Korean, plus an Inter italic. No third-party font requests, so
nothing external can stall first paint.
- Korean faces are gated behind unicode-range, so a reader who never meets a Hangul
character never downloads the 210 KB that serves it.
- Per-face size-adjust aligns x-heights across the three families, which keeps the
fallback swap from shifting the page.
- Five color families of four steps each on an OKLCH ladder with fixed lightness per
step, so every step holds its contrast when the hue changes. Harvard Crimson is the
brand color.
- Small-caps is deliberately absent: none of the three families ships an smcp table, so
asking for it yields a synthesized fake. Uppercase with tracking stands in for it.

Writing index
- Grouped by year, with a date rail and hairline rules instead of thumbnails. Every
entry is the title and its date, which is what a reader scanning 30 posts needs.

Article pages
- Typesetting is derived from the body text: measure, leading and excerpt length follow
the document's own shape rather than a single fixed template.
- Code blocks become a figure with the language named in a caption, keyed on
data-language rather than the class, which Astro rewrites before transformers run.
- Shiki now aliases vi to ini rather than falling back silently, which is what the
build was warning about.

Code and documentation are English throughout, matching the site's default language.

Diffstat:
M.gitattributes | 6+++---
M.github/workflows/deploy.yml | 8++++----
M.gitignore | 1+
MREADME.md | 54+++++++++++++++++++++++++++++-------------------------
Mastro.config.mjs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcontent/_template.md | 2+-
Apublic/fonts/inter-italic-var.woff2 | 0binary
Apublic/fonts/inter-var.woff2 | 0binary
Apublic/fonts/newsreader-var.woff2 | 0binary
Apublic/fonts/pretendard-var.woff2 | 0binary
Mscripts/build-info.mjs | 7++++---
Mscripts/check-content.mjs | 43+++++++++++++++++++++----------------------
Msrc/content.config.ts | 14+++++++-------
Msrc/layouts/Base.astro | 20+++++++++++++++-----
Msrc/lib/order.ts | 9++++++---
Asrc/lib/prose.ts | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pages/writing/[year]/[slug].astro | 2+-
Msrc/pages/writing/index.astro | 274++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Asrc/styles/base.css | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/styles/fonts.css | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/styles/tokens.css | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
21 files changed, 750 insertion(+), 91 deletion(-)

diff --git a/.gitattributes b/.gitattributes
@@ -1,6 +1,6 @@
-# 이 저장소는 리눅스 CI 에서 빌드되고 Windows 에서 편집된다.
-# .gitattributes 가 없으면 core.autocrlf=true 인 워킹트리가 CI 와 다른 바이트를 보게 되고,
-# 콘텐츠 해시·SRI 를 쓰는 자산 파이프라인에서 재현성이 깨진다.
+# Edited on Windows, built on Linux CI. Without this file a core.autocrlf=true working
+# tree hands CI different bytes than the author sees, which breaks any pipeline that
+# hashes file contents.
 * text=auto eol=lf
 
 *.png  -text
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
@@ -10,7 +10,7 @@ permissions:
   pages: write
   id-token: write
 
-# 동시에 두 배포가 겹치지 않게. 진행 중인 배포는 끊지 않는다.
+# Serialise deploys without cancelling one that is already running.
 concurrency:
   group: pages
   cancel-in-progress: false
@@ -23,11 +23,11 @@ jobs:
       - uses: withastro/action@v6
         with:
           node-version: 24
-          # npm run build 로 돌려야 prebuild(콘텐츠 게이트)가 함께 실행된다.
-          # astro build 를 직접 부르면 게이트가 건너뛰어진다.
+          # Must go through npm run build so that prebuild — the content gate — runs.
+          # Calling astro build directly would skip it.
           build-cmd: npm run build
         env:
-          # 백필이 끝나면 all 로 올리고 **다시 내리지 않는다**.
+          # Raise to `all` once decks and tags are backfilled, and do not lower it again.
           CONTENT_STRICT: urls,schema
 
   deploy:
diff --git a/.gitignore b/.gitignore
@@ -3,3 +3,4 @@ dist/
 .astro/
 public/build-info.json
 .DS_Store
+.fonts-work/
diff --git a/README.md b/README.md
@@ -1,49 +1,53 @@
 # blog.sqzer.com
 
-개인 연구 노트. Astro 로 짓고 GitHub Actions 가 GitHub Pages 에 배포한다.
+Personal research notes. Built with Astro, deployed to GitHub Pages by GitHub Actions.
 
-## 글 쓰기
+## Writing
 
 ```bash
-cp content/_template.md content/writing/my-post.md   # 또는 직접 생성
+cp content/_template.md content/writing/my-post.md
 npm run dev
 ```
 
-`content/writing/<slug>.md` 하나가 글 하나다. 파일명이 곧 URL 슬러그이고,
-발행 주소는 front matter 의 `date` 에서 연도를 따 **`/writing/<year>/<slug>/`** 가 된다.
+One file in `content/writing/<slug>.md` is one post. The filename becomes the URL slug,
+and the published address takes its year from the front matter `date`:
+**`/writing/<year>/<slug>/`**.
 
 ```yaml
 ---
-title: ""        # 필수
-titleKo: ""      # 한글 원제 보존용, 선택
-deck: ""         # 인덱스에 제목과 함께 나오는 한 문장. 비어 있어도 빌드는 통과한다
-date: 2026-09-17 # 필수. YYYY-MM-DD
-type: essay      # essay | research — 하위 메뉴가 이 값으로 갈린다
+title: ""        # required
+titleKo: ""      # optional — keeps the original Korean title when a post is translated
+deck: ""         # one sentence shown beside the title in the index. May be empty for now
+date: 2026-09-17 # required, YYYY-MM-DD
+type: essay      # essay | research — sub-navigation splits on this
 tags: []
-draft: true      # true 면 빌드에서 제외된다
+draft: true      # excluded from the build while true
 ---
 ```
 
-초안은 **로컬에 두고 완성됐을 때 push** 한다. 이 저장소는 공개다.
+Drafts stay local until they are finished. **This repository is public.**
 
-이미지는 `public/uploads/` 에 두고 본문에서 `/uploads/...` 로 참조한다.
+Images live in `public/uploads/` and are referenced from prose as `/uploads/...`.
 
-## 배포
+## Deploying
 
-`main` 에 push 하면 Actions 가 빌드해 Pages 로 올린다. 빌드 앞에 콘텐츠 게이트가 걸려 있어
-(`npm run prebuild`) 스키마 위반·URL 충돌·없는 이미지 참조가 있으면 **배포되지 않는다.**
+Pushing to `main` builds the site and publishes it to Pages. A content gate runs before the
+build (`npm run prebuild`), so a schema violation, a URL collision or a missing image
+reference **stops the deploy** instead of shipping a broken page.
 
 ```bash
-npm run check    # 게이트만 따로 돌리기
-npm run build    # 게이트 + 빌드
+npm run check    # gate only
+npm run build    # gate, then build
 ```
 
-게이트 엄격도는 워크플로의 `CONTENT_STRICT` 로 조절한다. 지금은 `urls,schema` 이고,
-덱·태그 백필이 끝나면 `all` 로 올린 뒤 다시 내리지 않는다.
+Strictness is controlled by `CONTENT_STRICT` in the workflow. It is `urls,schema` today;
+once decks and tags are backfilled it moves to `all` and does not move back.
 
-## 설계 메모
+## Design notes
 
-- 정식 URL 은 전부 트레일링 슬래시로 끝난다
-- URL 슬러그는 **ASCII 만** 소문자화한다(한글 파일명은 그대로 살린다)
-- 목록 정렬은 `date DESC → Intl.Collator('en') title → path` — DB·파일시스템 순서에 맡기지 않는다
-- 연도를 URL 에 넣는 이유는 `essay`·`research` 같은 하위 뷰 이름과 슬러그가 충돌하지 않게 하기 위해서다
+- Every canonical URL ends in a trailing slash.
+- Slugs lowercase **ASCII only** — Korean filenames survive unchanged.
+- Lists sort by `date DESC → Intl.Collator('en') title → path`, never by filesystem order.
+- The year sits in the URL so that post slugs can never collide with sub-view names
+  such as `essay` or `research`.
+- Type, colour and spacing follow one token set in `src/styles/tokens.css`.
diff --git a/astro.config.mjs b/astro.config.mjs
@@ -1,11 +1,82 @@
 // @ts-check
 import { defineConfig } from 'astro/config';
 
+/**
+ * Grammars that resolve the 10 fence languages the corpus actually uses.
+ * Anything outside this list makes `ensureLanguagesLoaded` throw and **fails the build**,
+ * which is better than a silent fallback like `[Shiki] The language "vi" doesn't exist`.
+ * Aliases already ship in the Shiki bundle: bash/sh/zsh resolve to shellscript,
+ * console resolves to shellsession.
+ */
+const LANGS = [
+  'plaintext', 'bash', 'shellscript', 'shellsession',
+  'ini', 'jsonc', 'json', 'mermaid', 'xml', 'python',
+];
+
+/**
+ * Code block wrapper. Wraps `<pre>` in `<figure class="code">` and labels the language.
+ *
+ * Do not match on the class: it catches nothing. Astro's built-in transformer runs
+ * before user transformers and rewrites `shiki` to `astro-code` in `class`
+ * (the pre hook in @astrojs/internal-helpers/dist/shiki.js). The only stable contract
+ * is `dataLanguage`, which that same hook sets directly.
+ *
+ * The root hook must leave **exactly one** child. Astro reads only the first child of
+ * root, via `codeToHast(...).then((root) => root.children[0])`
+ * (@astrojs/markdown-satteri/dist/satteri-processor.js).
+ */
+const codeBlock = {
+  name: 'sqzer:code-block',
+  root(root) {
+    const pre = root.children[0];
+    if (!pre || pre.type !== 'element' || pre.tagName !== 'pre') return;
+
+    const lang = String(pre.properties?.dataLanguage ?? 'plaintext');
+    // ```bash wrap — opt in per block, for things like log dumps whose lines cannot be kept.
+    const wrap = /(^|\s)wrap(\s|$)/.test(String(this.options?.meta?.__raw ?? ''));
+
+    // Shiki's tabindex="0" alone leaves a screen reader announcing this scroll region unnamed.
+    pre.properties.role = 'group';
+    pre.properties['aria-label'] = lang === 'plaintext' ? 'Code' : `${lang} code`;
+
+    const children = [pre];
+    // A fence with no language arrives as `plaintext`, which names nothing, so it gets no label.
+    if (lang !== 'plaintext') {
+      children.unshift({
+        type: 'element',
+        tagName: 'figcaption',
+        properties: { className: ['code__lang'], 'aria-hidden': 'true' },
+        children: [{ type: 'text', value: lang }],
+      });
+    }
+
+    root.children = [{
+      type: 'element',
+      tagName: 'figure',
+      properties: { className: ['code'], dataLanguage: lang, ...(wrap ? { dataWrap: '' } : {}) },
+      children,
+    }];
+  },
+};
+
 export default defineConfig({
   site: 'https://blog.sqzer.com',
   output: 'static',
-  // 정식 URL 은 전부 트레일링 슬래시로 끝난다. directory 포맷이 dist/<path>/index.html 을 만든다.
-  // ⚠️ 프리렌더 페이지의 슬래시는 Astro 가 강제하지 않는다 — 실제 집행자는 vercel.json 이다.
+  // Every canonical URL ends in a trailing slash; the directory format emits dist/<path>/index.html.
   trailingSlash: 'always',
   build: { format: 'directory' },
+  markdown: {
+    syntaxHighlight: { type: 'shiki', excludeLangs: ['math'] },
+    shikiConfig: {
+      // The css-variables theme emits token colors as `var(--astro-code-token-*)`, so the
+      // OKLCH ladder in tokens.css drives syntax highlighting instead of a second palette.
+      theme: 'css-variables',
+      langs: LANGS,
+      // ```vi in the corpus is journald.conf, not vim script. That is INI.
+      langAlias: { vi: 'ini' },
+      // Preserve lines by default. Folding is opt-in per block via ```<lang> wrap.
+      wrap: false,
+      transformers: [codeBlock],
+    },
+  },
 });
diff --git a/content/_template.md b/content/_template.md
@@ -8,4 +8,4 @@ tags: []
 draft: true
 ---
 
-본문은 여기부터.
+Body starts here.
diff --git a/public/fonts/inter-italic-var.woff2 b/public/fonts/inter-italic-var.woff2
Binary files differ.
diff --git a/public/fonts/inter-var.woff2 b/public/fonts/inter-var.woff2
Binary files differ.
diff --git a/public/fonts/newsreader-var.woff2 b/public/fonts/newsreader-var.woff2
Binary files differ.
diff --git a/public/fonts/pretendard-var.woff2 b/public/fonts/pretendard-var.woff2
Binary files differ.
diff --git a/scripts/build-info.mjs b/scripts/build-info.mjs
@@ -1,8 +1,9 @@
 /**
- * build-info.mjs — 사이트가 자기 출처를 말하게 한다.
+ * build-info.mjs — lets the site state its own provenance.
  *
- * 배포 훅/CI 는 payload 를 돌려주지 않으므로 "배포가 끝났는가"를 플랫폼에 물을 수 없다.
- * 대신 **라이브 사이트에 "너는 어느 content 커밋으로 지어졌나"** 를 묻는다.
+ * A deploy pipeline cannot be asked "is my commit live yet?", because the hook that starts
+ * it carries no payload back. So the question is put to the site instead: it publishes the
+ * commit it was built from, and a check reads that.
  */
 import { writeFile, mkdir } from 'node:fs/promises';
 import { execFileSync } from 'node:child_process';
diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs
@@ -1,12 +1,11 @@
 /**
- * check-content.mjs — 배포 전 콘텐츠 게이트.
+ * check-content.mjs — the pre-deploy content gate.
  *
- * CI/CD 가 배포 수단 그 자체가 되면서 이것이 **유일한 안전망**이다.
- * 자동 테스트 0개였던 이전 시스템의 자리를 대신한다.
+ * Since pushing is the only way to publish, this is the only safety net the site has.
  *
- * 단계적 엄격도: CONTENT_STRICT 로 어디까지 에러로 볼지 정한다.
- *   urls,schema  — 마이그레이션 직후(덱·태그가 아직 비어 있다)
- *   all          — 백필 완료 후. **한 번 올리면 다시 내리지 않는다**
+ * Strictness is staged through CONTENT_STRICT:
+ *   urls,schema  right after the migration, while decks and tags are still empty
+ *   all          once the backfill is done. Raise it once and do not lower it again.
  */
 import { readdir, readFile } from 'node:fs/promises';
 import { existsSync } from 'node:fs';
@@ -22,7 +21,7 @@ const warn = (f, m) => warns.push(`${f}: ${m}`);
 const req = (k, f, m) => (on(k) ? err(f, m) : warn(f, m));
 
 const asciiLower = (s) => s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
-/** 하위 뷰 이름과 충돌하면 라우트가 깨진다. 연도 세그먼트가 막아 주지만 방어선을 하나 더 둔다. */
+/** The year segment already keeps slugs away from sub-view names; this is a second line. */
 const RESERVED = new Set(['essay', 'research', 'video', 'slides', 'podcast', 'index', 'page', 'tags', 'feed', 'about']);
 
 const files = [];
@@ -31,43 +30,43 @@ const files = [];
   if (existsSync(d))
     for (const f of await readdir(d)) if (f.endsWith('.md') && !f.startsWith('_')) files.push(path.join(d, f));
 }
-if (files.length === 0) err('(전체)', 'content 에서 마크다운을 하나도 못 찾았다 — 체크아웃 실패일 수 있다');
+if (files.length === 0) err('(all)', 'no markdown found under content — the checkout may have failed');
 
 const seen = new Map();
 for (const abs of files) {
   const rel = path.relative(SRC, abs).split(path.sep).join('/');
   const raw = await readFile(abs, 'utf8');
   const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
-  if (!m) { err(rel, 'front matter 가 없다'); continue; }
+  if (!m) { err(rel, 'missing front matter'); continue; }
   const fm = m[1];
   const get = (k) => (new RegExp(`^${k}:\s*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
 
   const title = get('title');
   const date = get('date');
-  if (!title) err(rel, 'title 이 없다');
-  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) err(rel, `date 가 YYYY-MM-DD 가 아니다: ${date || '(없음)'}`);
+  if (!title) err(rel, 'missing title');
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) err(rel, `date is not YYYY-MM-DD: ${date || '(none)'}`);
 
   const slug = asciiLower(path.basename(abs, '.md'));
-  if (RESERVED.has(slug)) err(rel, `예약어와 충돌하는 슬러그: ${slug}`);
-  if (slug !== slug.normalize('NFC')) err(rel, '파일명이 NFC 정규화되지 않았다 (한글 자모 분리)');
+  if (RESERVED.has(slug)) err(rel, `slug collides with a reserved name: ${slug}`);
+  if (slug !== slug.normalize('NFC')) err(rel, 'filename is not NFC-normalised (decomposed Hangul jamo)');
 
   const url = `/writing/${date.slice(0, 4)}/${slug}/`;
-  if (seen.has(url)) err(rel, `URL 충돌: ${url} (이미 ${seen.get(url)})`);
+  if (seen.has(url)) err(rel, `URL collision: ${url} (already taken by ${seen.get(url)})`);
   else seen.set(url, rel);
 
-  // 덱·태그는 백필 전이므로 단계적으로만 강제한다
-  if (!get('deck')) req('deck', rel, 'deck 이 비어 있다');
-  if (/^tags:\s*\[\s*\]\s*$/m.test(fm)) req('tags', rel, 'tags 가 비어 있다');
+  // Decks and tags are still being backfilled, so these are staged rather than hard errors.
+  if (!get('deck')) req('deck', rel, 'deck is empty');
+  if (/^tags:\s*\[\s*\]\s*$/m.test(fm)) req('tags', rel, 'tags is empty');
 
-  // 본문이 참조하는 이미지가 실제로 있는가
+  // Every image the prose references must exist.
   for (const im of raw.matchAll(/\/uploads\/([^\s"')\]]+)/g)) {
     const key = im[1].replace(/[.,)]+$/, '');
-    if (!existsSync(path.join(SRC, 'public', 'uploads', key))) req('uploads', rel, `참조된 이미지가 없다: /uploads/${key}`);
+    if (!existsSync(path.join(SRC, 'public', 'uploads', key))) req('uploads', rel, `referenced image is missing: /uploads/${key}`);
   }
 }
 
-console.log(`check-content: 문서 ${files.length}개 / URL ${seen.size}개 / STRICT=${[...STRICT].join(',')}`);
+console.log(`check-content: ${files.length} documents, ${seen.size} URLs, STRICT=${[...STRICT].join(',')}`);
 for (const w of warns) console.log(`  warn  ${w}`);
 for (const e of errors) console.error(`  ERROR ${e}`);
-if (errors.length) { console.error(`\n실패 ${errors.length}건 — 빌드를 중단한다.`); process.exit(1); }
-console.log(`  경고 ${warns.length}건 / 에러 0건 — 통과`);
+if (errors.length) { console.error(`\n${errors.length} error(s) — stopping the build.`); process.exit(1); }
+console.log(`  ${warns.length} warning(s), 0 errors — passed`);
diff --git a/src/content.config.ts b/src/content.config.ts
@@ -1,19 +1,19 @@
 import { defineCollection, z } from 'astro:content';
 import { glob } from 'astro/loaders';
 
-/** Hugo 규칙: URL 세그먼트는 **ASCII 만** 소문자화한다. 한글·키릴은 건드리지 않는다. */
+/** Lowercase ASCII only, leaving Korean and Cyrillic untouched. */
 export const asciiLower = (s: string): string =>
   s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
 
 /**
- * YAML 이 `2025-12-07` 을 Date 로 파싱한다. **로컬 시간으로 읽으면 안 된다** —
- * UTC 기준으로 뽑아야 코퍼스 날짜가 하루 밀리지 않는다.
+ * YAML parses `2025-12-07` into a Date. It must be read back in UTC: reading it in local
+ * time shifts roughly half the corpus by a day, which would also move every post URL.
  */
 const dateString = z.union([z.string(), z.date()]).transform((v) =>
   typeof v === 'string' ? v.slice(0, 10) : v.toISOString().slice(0, 10),
 );
 
-/** `deck:` 을 비우면 YAML 이 null 을 준다. undefined 로 흡수하지 않으면 빌드가 죽는다. */
+/** An empty `deck:` parses as null, which fails validation unless absorbed to undefined. */
 const optText = z.preprocess(
   (v) => (v === null || v === '' ? undefined : v),
   z.string().optional(),
@@ -26,12 +26,12 @@ const writing = defineCollection({
   loader: glob({ base: './content/writing', pattern: '*.md', generateId: slugOf }),
   schema: z.object({
     title: z.string(),
-    /** 한글 원제 보존용. 영문 제목으로 갈아탈 때 원본을 잃지 않는다. */
+    /** Keeps the original Korean title when a post switches to an English one. */
     titleKo: optText,
     date: dateString,
-    /** 인덱스에서 덱이 곧 카드다. 백필 전까지는 비어 있어도 된다. */
+    /** In an index without images the deck carries the entry. May be empty until backfilled. */
     deck: optText,
-    /** 하위 뷰(Essay/Research)는 경로가 아니라 이 값으로 가른다. */
+    /** Sub-views split on this value rather than on a URL segment. */
     type: z.enum(['essay', 'research']).optional(),
     tags: z.array(z.string()).default([]),
     draft: z.boolean().optional(),
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
@@ -1,6 +1,10 @@
 ---
-interface Props { title: string; description?: string }
-const { title, description } = Astro.props;
+import '../styles/fonts.css';
+import '../styles/tokens.css';
+import '../styles/base.css';
+
+interface Props { title: string; description?: string; section?: string }
+const { title, description, section = 'slate' } = Astro.props;
 const NAV = [
   { label: 'About',   href: '/' },
   { label: 'Papers',  href: '/papers/' },
@@ -9,6 +13,9 @@ const NAV = [
   { label: 'Writing', href: '/writing/' },
 ];
 const here = Astro.url.pathname;
+// An exact match would leave every nav item unmarked on /writing/2026/vtzero/.
+// Only '/' compares exactly, so that About is not current on every page.
+const isCurrent = (href: string) => (href === '/' ? here === '/' : here.startsWith(href));
 ---
 <!doctype html>
 <html lang="en">
@@ -19,16 +26,19 @@ const here = Astro.url.pathname;
     {description && <meta name="description" content={description} />}
     <link rel="canonical" href={new URL(here, Astro.site)} />
     <link rel="alternate" type="application/rss+xml" title="sqzer" href="/index.xml" />
+    <link rel="preload" href="/fonts/newsreader-var.woff2" as="font" type="font/woff2" crossorigin />
+    <link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
   </head>
-  <body>
+  <body class={`is-${section}`}>
+    <a class="skip" href="#main">Skip to content</a>
     <header>
       <a href="/">sqzer</a>
       <nav>
         {NAV.map((n) => (
-          <a href={n.href} aria-current={here === n.href ? 'page' : undefined}>{n.label}</a>
+          <a href={n.href} aria-current={isCurrent(n.href) ? 'page' : undefined}>{n.label}</a>
         ))}
       </nav>
     </header>
-    <main><slot /></main>
+    <main id="main"><slot /></main>
   </body>
 </html>
diff --git a/src/lib/order.ts b/src/lib/order.ts
@@ -1,6 +1,9 @@
 /**
- * 목록 정렬 — date DESC → Intl.Collator('en') title → path.
- * 이 순서가 인덱스·피드·sitemap 을 동시에 결정한다. DB/파일시스템 순서에 맡기면 안 된다.
+ * Ordering: date DESC, then title by Intl.Collator('en'), then path.
+ *
+ * This single order drives the index, the feed and the sitemap at once, so it must never
+ * be delegated to filesystem or database order. Sorting by raw code points instead would
+ * put `TSO` ahead of `journalctl` and silently reshuffle a whole year of posts.
  */
 const collator = new Intl.Collator('en');
 
@@ -12,5 +15,5 @@ export function byHugoOrder<T extends Sortable>(a: T, b: T): number {
   return t !== 0 ? t : (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
 }
 
-/** 글의 정식 경로. 연도는 date 에서 파생된다. */
+/** Canonical path for a post. The year is derived from the front matter date. */
 export const postUrl = (p: Sortable): string => `/writing/${p.data.date.slice(0, 4)}/${p.id}/`;
diff --git a/src/lib/prose.ts b/src/lib/prose.ts
@@ -0,0 +1,112 @@
+/**
+ * prose.ts — derives typesetting decisions from the body of a post.
+ *
+ * Why read the body instead of the front matter: `type` is unset on all 30 posts and
+ * `deck` is empty on all 30, so any branch that keys off front matter is dead code today.
+ * The body already knows the shape of the document — how many subheadings it has and how
+ * long its paragraphs run.
+ */
+
+/** Excluded from length and shape measurements: code fences, images, tables, link URLs. */
+function plain(md: string): string {
+  return md
+    .replace(/^---[\s\S]*?^---/m, '')
+    .replace(/```[\s\S]*?```/g, '')
+    .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+    .replace(/^\s*\|.*$/gm, '')
+    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
+}
+
+/** Keeps only lines outside code fences, so a `# comment` inside one is not read as a heading. */
+function outsideFences(md: string): string {
+  let inFence = false;
+  const out: string[] = [];
+  for (const line of md.split('\n')) {
+    if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
+    if (!inFence) out.push(line);
+  }
+  return out.join('\n');
+}
+
+/**
+ * Reading time: 500 Korean characters per minute plus 230 Latin words per minute.
+ * A Korean syllable carries less than a Latin word, so Korean is counted by character.
+ * Across the corpus this yields 1 minute (work-is-finding) to 24 (how-to-win-best-paper-ko).
+ */
+export function readingMinutes(md: string): number {
+  const t = plain(md);
+  const kor = (t.match(/[가-힣]/g) ?? []).length;
+  const lat = (t.match(/[A-Za-z0-9][A-Za-z0-9'’-]*/g) ?? []).length;
+  return Math.max(1, Math.ceil(kor / 500 + lat / 230));
+}
+
+export type ProseShape = 'essay' | 'technical';
+export type ProseRhythm = 'flow' | 'staccato';
+
+/**
+ * The typographic shape of a document.
+ *
+ * shape:
+ *   technical — three or more subheadings; the headings are the structure (11 posts).
+ *   essay     — no subheadings, or decorative ones; the paragraph flow is everything (19).
+ *
+ * rhythm:
+ *   staccato  — no subheadings, median paragraph <= 60 characters, 8+ paragraphs.
+ *               A 728px column at 20px fits about 36 Korean characters per line, so a
+ *               60-character median is 1.7 lines. One-line paragraphs stacked at 24px
+ *               intervals stop reading as prose and start reading as verse.
+ *               (i-have-to: 69 paragraphs, median 40 chars; will-the-next: 92 at 45.)
+ *               These switch from blank-line breaks to indented paragraphs in CSS.
+ *   flow      — everything else; keeps blank-line paragraph breaks.
+ *
+ * Measured split: staccato 11, technical 11, flow-essay 8 = 30.
+ */
+export function proseShape(md: string): {
+  shape: ProseShape;
+  rhythm: ProseRhythm;
+  paragraphs: number;
+  medianChars: number;
+} {
+  const body = outsideFences(plain(md));
+  const headings = (body.match(/^#{1,6}\s/gm) ?? []).length;
+
+  const lengths: number[] = [];
+  for (const block of body.split(/\n\s*\n/)) {
+    const lines = block.split('\n').filter((l) => l.trim());
+    if (lines.length === 0) continue;
+    // Headings, tables, quotes, lists and images are not paragraphs.
+    if (/^[#|>\-*!]/.test(lines[0].trimStart())) continue;
+    lengths.push(lines.join(' ').length);
+  }
+  lengths.sort((a, b) => a - b);
+  const median = lengths.length ? lengths[Math.floor(lengths.length / 2)] : 0;
+
+  return {
+    shape: headings >= 3 ? 'technical' : 'essay',
+    rhythm: headings === 0 && median <= 60 && lengths.length >= 8 ? 'staccato' : 'flow',
+    paragraphs: lengths.length,
+    medianChars: median,
+  };
+}
+
+/**
+ * Fallback excerpt used where a deck is missing. Because `deck` is empty on all 30 posts,
+ * every page currently ships an empty `<meta name="description">` — a real defect in the
+ * deployed build.
+ */
+export function excerpt(md: string, max = 160): string {
+  const body = outsideFences(plain(md));
+  for (const block of body.split(/\n\s*\n/)) {
+    const t = block.split('\n').filter((l) => l.trim()).join(' ').trim();
+    if (!t || /^[#|>\-*!]/.test(t)) continue;
+    const clean = t.replace(/[*_`]/g, '').replace(/\s+/g, ' ');
+    return clean.length <= max ? clean : clean.slice(0, max - 1).trimEnd() + '…';
+  }
+  return '';
+}
+
+/** Date formatting, pinned to UTC: reading in local time shifts corpus dates by a day. */
+const DATE_FMT = new Intl.DateTimeFormat('en-US', {
+  timeZone: 'UTC', year: 'numeric', month: 'long', day: 'numeric',
+});
+export const longDate = (iso: string): string => DATE_FMT.format(new Date(`${iso}T00:00:00Z`));
diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -7,7 +7,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
   const posts = await getCollection('writing');
   return posts
     .filter((p) => !p.data.draft)
-    // ⚠️ params 는 **디코딩된 원문**이어야 한다. 한글 슬러그가 여기에 그대로 들어간다.
+    // params must be the decoded form — Korean slugs are passed through verbatim.
     .map((p) => ({ params: { year: p.data.date.slice(0, 4), slug: p.id }, props: { post: p } }));
 };
 
diff --git a/src/pages/writing/index.astro b/src/pages/writing/index.astro
@@ -7,21 +7,265 @@ const posts = (await getCollection('writing'))
   .filter((p) => !p.data.draft)
   .sort(byHugoOrder);
 
-// 연도로 묶는다 — URL 축과 일치시킨다.
+// Grouped by year, matching the axis already present in the URL.
 const years = [...new Set(posts.map((p) => p.data.date.slice(0, 4)))].sort().reverse();
+
+/** Rail date. The year is stated by the section kicker, so only MM.DD is repeated. */
+const md = (iso: string) => `${iso.slice(5, 7)}.${iso.slice(8, 10)}`;
+
+/** lang="ko" only on titles that contain Hangul — used for speech and font fallback. */
+const HANGUL = /[ᄀ-ᇿ㄰-㆏가-힣]/;
+
+const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
 ---
-<Base title="Writing :: sqzer">
-  <h1>Writing</h1>
-  {years.map((y) => (
-    <section>
-      <h2 id={y}>{y}</h2>
-      <ul>
-        {posts.filter((p) => p.data.date.slice(0, 4) === y).map((p) => (
-          <li>
-            <a href={postUrl(p)}>{p.data.title}</a>{p.data.deck ? <>: {p.data.deck}</> : null}
-          </li>
-        ))}
-      </ul>
-    </section>
-  ))}
+<Base title="Writing :: sqzer" description="Essays and technical notes, 2024–2026.">
+  <div class="index">
+    <header class="index__head">
+      <h1 class="index__title">Writing</h1>
+      {posts.length > 0 && (
+        <p class="index__count">{posts.length} posts · {span}</p>
+      )}
+    </header>
+
+    {posts.length === 0 && <p class="index__empty">Nothing here yet.</p>}
+
+    {years.map((y) => (
+      <section class="year">
+        <h2 class="year__label" id={y}>{y}</h2>
+        <ol class="year__list">
+          {posts.filter((p) => p.data.date.slice(0, 4) === y).map((p) => (
+            <li class="entry" data-type={p.data.type}>
+              <time class="entry__date" datetime={p.data.date}>{md(p.data.date)}</time>
+              <h3 class="entry__title" lang={HANGUL.test(p.data.title) ? 'ko' : undefined}>
+                <a href={postUrl(p)}>{p.data.title}</a>
+              </h3>
+              {p.data.deck && (
+                <p class="entry__deck" lang={HANGUL.test(p.data.deck) ? 'ko' : undefined}>{p.data.deck}</p>
+              )}
+            </li>
+          ))}
+        </ol>
+      </section>
+    ))}
+  </div>
 </Base>
+
+<style>
+  /* ────────────────────────────────────────────────────────────────
+     Page-local tokens.
+     Defers to the global token set once Base provides it; until then these stand alone
+     using the clamp literals measured from the Gazette.
+     ──────────────────────────────────────────────────────────────── */
+  .index {
+    /* Grid */
+    --content-w: 45.5rem;  /* 728px, the Gazette content measure */
+    --rail-w: 4.5rem;      /* 72px date rail */
+    --rail-gap: 1.5rem;    /* 24px  */
+
+    /* Type scale, as measured */
+    --fs-small:  var(--wp-small,  clamp(0.6875rem, 0.688rem + ((1vw - 0.2rem) * 0.256), 0.875rem));  /* 11→14 */
+    --fs-medium: var(--wp-medium, clamp(0.75rem,   0.75rem  + ((1vw - 0.2rem) * 0.342), 1rem));      /* 12→16 */
+    --fs-large:  var(--wp-large,  clamp(1rem,      1rem     + ((1vw - 0.2rem) * 0.342), 1.25rem));   /* 16→20 */
+    --fs-h5:     var(--wp-h5,     clamp(1.25rem,   1.25rem  + ((1vw - 0.2rem) * 0.342), 1.5rem));    /* 20→24 */
+    --fs-h1:     var(--wp-h1,     clamp(2.125rem,  2.125rem + ((1vw - 0.2rem) * 2.568), 4rem));      /* 34→64 */
+    --fs-kicker: var(--wp-kicker, clamp(0.63rem,   0.36vw + 0.54rem, 0.88rem));                      /* 10.08→14.08 */
+
+    --lh-tight: 1.1;
+    --lh-medium: 1.4;
+    --lh-base: 1.5;
+
+    --gap-kicker: clamp(1rem, 0.72vw + 0.83rem, 1.5rem);                            /* 16→24 */
+    --entry-pad:  clamp(0.75rem, 0.75rem + ((1vw - 0.2rem) * 0.342), 1rem);         /* 12→16 */
+    --gap-section: clamp(2rem, 1.8rem + 1vw, 3rem);                                 /* spacing-48 */
+
+    /* Colour: desk = crimson */
+    --ppc-text: var(--desk-text, #771120);   /* dark   — kickers */
+    --ppc-ui:   var(--desk-ui,   #a51c30);   /* base   — 4px rules, focus ring */
+    --ppc-wash: var(--desk-wash, #f7ebeb);   /* wash   — row hover */
+    --ink:      var(--color-black,      #000);
+    --meta:     var(--color-slate,      #605a4e);
+    --hairline: var(--color-grey,       #b9b8ad);
+    --paper:    var(--color-sand-light, #fffbf4);
+
+    --font-body:    var(--font-sans,  "Inter", "Pretendard", system-ui, sans-serif);
+    --font-display: var(--font-serif, "Newsreader", "Pretendard", Georgia, serif);
+    /* Swapping this one line to var(--font-body) flips all 30 titles to sans. */
+    --index-title-font: var(--font-display);
+
+    container: index / inline-size;
+    max-width: min(100%, calc(var(--rail-w) + var(--rail-gap) + var(--content-w))); /* 824px */
+    margin-inline: auto;
+    font-family: var(--font-body);
+    color: var(--ink);
+  }
+
+  /* Masthead */
+  .index__head {
+    padding-left: calc(var(--rail-w) + var(--rail-gap));
+    margin-bottom: var(--gap-section);
+  }
+
+  .index__title {
+    margin: 0;
+    font-family: var(--font-display);
+    font-size: var(--fs-h1);
+    font-weight: 500;
+    line-height: var(--lh-tight);
+    letter-spacing: -0.01em;
+    /* Newsreader overlaps Sanomat at opsz 56-72; pinned even at a 320px viewport. */
+    font-variation-settings: 'opsz' 56;
+    color: var(--ink);
+  }
+
+  .index__count {
+    margin: 0.75rem 0 0;
+    font-size: var(--fs-small);
+    line-height: var(--lh-base);
+    letter-spacing: 0.02em;
+    font-variant-numeric: tabular-nums;
+    color: var(--meta);
+  }
+
+  .index__empty {
+    padding-left: calc(var(--rail-w) + var(--rail-gap));
+    font-size: var(--fs-large);
+    line-height: 1.6;
+    color: var(--ink);
+  }
+
+  /* Year as section kicker, ported from .featured-articles__category */
+  .year__label {
+    position: sticky;
+    top: var(--index-sticky-top, 0px);
+    z-index: 2;
+    margin: 0;
+    padding: var(--gap-kicker) 0 var(--gap-kicker) calc(var(--rail-w) + var(--rail-gap));
+    background: var(--paper);
+    scroll-margin-top: var(--index-sticky-top, 0px);
+
+    font-family: var(--font-body);
+    font-size: var(--fs-kicker);
+    font-weight: 700;
+    line-height: var(--lh-medium);
+    letter-spacing: 0.105rem;   /* 1.68px */
+    text-transform: uppercase;
+    font-variant-numeric: tabular-nums;
+    color: var(--ppc-text);
+  }
+
+  /* Signature motif, used once per year heading — three times on this page. */
+  .year__label::before {
+    content: "";
+    display: block;
+    width: 3rem;
+    border-top: 4px solid var(--ppc-ui);
+    margin-bottom: var(--gap-kicker);
+  }
+
+  .year__list {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+  }
+
+  /* Entry */
+  .entry {
+    position: relative;
+    display: grid;
+    grid-template-columns: var(--rail-w) minmax(0, 1fr);
+    column-gap: var(--rail-gap);
+    align-items: baseline;
+    padding-block: var(--entry-pad);
+    border-bottom: 1px solid var(--hairline);
+    transition: background-color 0.12s linear;
+  }
+
+  .entry:last-child { border-bottom: none; }
+  .entry:hover { background-color: var(--ppc-wash); }
+
+  .entry__date {
+    grid-column: 1;
+    grid-row: 1;
+    justify-self: end;          /* right-aligned: the vertical spine of the page */
+    font-size: var(--fs-small);
+    line-height: var(--lh-base);
+    font-variant-numeric: tabular-nums;
+    font-feature-settings: "tnum" 1;
+    letter-spacing: 0.02em;
+    color: var(--meta);
+    white-space: nowrap;
+  }
+
+  .entry__title {
+    grid-column: 2;
+    grid-row: 1;
+    margin: 0;
+    font-family: var(--index-title-font);
+    font-size: var(--fs-h5);
+    font-weight: 500;
+    line-height: var(--lh-medium);
+    font-optical-sizing: auto;
+    color: var(--ink);
+    word-break: keep-all;       /* stops Korean titles breaking mid-word */
+    text-wrap: balance;
+  }
+
+  .entry__title a {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  /* The whole row is the hit area, ported from .featured-article__title a:before */
+  .entry__title a::before {
+    content: "";
+    position: absolute;
+    inset: 0;
+  }
+
+  .entry:hover .entry__title a {
+    text-decoration: underline;
+    text-underline-offset: 0.14em;
+    text-decoration-thickness: from-font;
+  }
+
+  .entry__title a:focus-visible { outline: 0; }
+  .entry__title a:focus-visible::before {
+    outline: 2px solid var(--ppc-ui);
+    outline-offset: 3px;
+  }
+
+  /* Deck. None exist yet; when backfilled it lands on row 2 with no layout change. */
+  .entry__deck {
+    grid-column: 2;
+    margin: 0.375rem 0 0;
+    font-family: var(--font-body);
+    font-size: var(--fs-medium);
+    line-height: var(--lh-base);
+    color: var(--ink);
+    word-break: keep-all;
+    text-wrap: pretty;
+  }
+
+  /* Folds when the rail no longer fits */
+  @container index (max-width: 36rem) {
+    .index__head,
+    .index__empty,
+    .year__label { padding-left: 0; }
+
+    .entry { grid-template-columns: minmax(0, 1fr); }
+
+    .entry__date {
+      grid-column: 1;
+      grid-row: 1;
+      justify-self: start;
+      margin-bottom: 0.25rem;
+    }
+
+    .entry__title { grid-column: 1; grid-row: 2; }
+    .entry__deck  { grid-column: 1; }
+  }
+
+  @media (prefers-reduced-motion: reduce) {
+    .entry { transition: none; }
+  }
+</style>
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -0,0 +1,74 @@
+/* Baseline typography. Layout specifics live with each template. */
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+  margin: 0;
+  background: var(--page-bg);
+  color: var(--page-fg);
+  font-family: var(--font-body);
+  font-size: var(--fs-body);
+  line-height: var(--lh-large);
+  font-optical-sizing: auto;
+  -webkit-font-smoothing: antialiased;
+}
+
+h1, h2, h3, h4, h5, h6 {
+  font-family: var(--font-display);
+  font-weight: 500;
+  color: var(--black);          /* headlines stay pure black */
+  text-wrap: balance;
+  margin: 0;
+}
+h1 { font-size: var(--fs-h1); line-height: var(--lh-tight); letter-spacing: -0.015em; }
+h2 { font-size: var(--fs-h2); line-height: var(--lh-snug); }
+h3 { font-size: var(--fs-h3); line-height: var(--lh-snug); }
+h4 { font-size: var(--fs-h4); line-height: var(--lh-close); }
+h5 { font-size: var(--fs-h5); line-height: var(--lh-medium); }
+h6 { font-size: var(--fs-h6); line-height: var(--lh-medium); }
+
+p { margin: 0; }
+p + p { margin-block-start: var(--sp-24); }
+
+/* Prose links carry no colour: weight and underline only. */
+a { color: inherit; font-weight: 700; text-decoration: underline;
+    text-underline-offset: 0.18em; text-decoration-thickness: from-font; }
+
+:focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: 2px; }
+
+.skip { position: absolute; left: -9999px; }
+.skip:focus { left: var(--root-pad); top: var(--root-pad); z-index: 10;
+  background: var(--page-bg); padding: var(--sp-30) var(--sp-40);
+  border: 2px solid var(--ppc-ui); }
+
+/* Shell */
+body > header, main { max-width: var(--w-content); margin-inline: auto;
+  padding-inline: var(--root-pad); }
+body > header { padding-block: var(--sp-48) var(--sp-40);
+  border-bottom: 1px solid var(--page-hairline);
+  display: flex; flex-wrap: wrap; gap: var(--sp-40);
+  align-items: baseline; justify-content: space-between; }
+main { padding-block: var(--sp-64) var(--sp-120); }
+
+/* Wordmark and navigation.
+   small-caps is deliberately avoided: none of Newsreader, Inter or Pretendard ships an
+   `smcp` feature, so `font-variant: small-caps` would be synthesised by the browser.
+   Synthesis scales capitals down, which thins their stems — on a serif with a 3.95 stroke
+   contrast the hairlines disappear around 14px.
+   The kicker idiom is used instead: uppercase, absolute letter-spacing, 700, body face.
+   .105rem is 1.68px, which reads as 0.153em at 11px and tightens to 0.12em at 14px. */
+body > header > a { font-family: var(--font-body); font-size: var(--fs-medium);
+  text-transform: uppercase; letter-spacing: 0.105rem; font-weight: 700;
+  text-decoration: none; color: var(--page-fg); }
+nav { display: flex; gap: var(--sp-40); flex-wrap: wrap; }
+nav a { font-family: var(--font-body); font-size: var(--fs-small);
+  text-transform: uppercase; letter-spacing: 0.105rem; font-weight: 700;
+  text-decoration: none; color: var(--page-meta);
+  /* The current item is marked by a 4px rule in the section colour. It is laid down
+     transparent on every item so that marking one causes no layout shift. */
+  border-block-end: 4px solid transparent; padding-block-end: 2px; }
+nav a:hover { color: var(--ppc-text); }
+nav a[aria-current='page'] { color: var(--ppc-text); border-block-end-color: var(--ppc-ui); }
+
+@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
diff --git a/src/styles/fonts.css b/src/styles/fonts.css
@@ -0,0 +1,59 @@
+/* Self-hosted subsets. All three are variable fonts with their axes intact.
+   The character set was taken from the current corpus, so a Korean syllable that has
+   never appeared before will fall back to a system face until the subset is regenerated. */
+
+@font-face {
+  font-family: 'Newsreader';
+  src: url('/fonts/newsreader-var.woff2') format('woff2-variations');
+  font-weight: 200 800;
+  font-style: normal;
+  font-display: swap;
+  /* Corrects Newsreader (cap .708) against Sanomat (cap .750). */
+  size-adjust: 106%;
+}
+
+@font-face {
+  font-family: 'Inter';
+  src: url('/fonts/inter-var.woff2') format('woff2-variations');
+  font-weight: 100 900;
+  font-style: normal;
+  font-display: swap;
+  /* x-height is within 0.22% of Neue Haas Grotesk Text — no adjustment needed. */
+}
+
+/* For body <em> and photo credits. The display italic is never used in headlines,
+   so it is not shipped. */
+@font-face {
+  font-family: 'Inter';
+  src: url('/fonts/inter-italic-var.woff2') format('woff2-variations');
+  font-weight: 100 900;
+  font-style: italic;
+  font-display: swap;
+}
+
+/* Korean fallback, gated by unicode-range: an English-only page downloads none of it. */
+@font-face {
+  font-family: 'PretendardBody';
+  src: url('/fonts/pretendard-var.woff2') format('woff2-variations');
+  font-weight: 45 930;
+  font-style: normal;
+  font-display: swap;
+  size-adjust: 102.9%;
+  unicode-range: U+1100-11FF, U+3130-318F, U+A960-A97F, U+AC00-D7A3, U+D7B0-D7FF;
+}
+@font-face {
+  font-family: 'PretendardDisplay';
+  src: url('/fonts/pretendard-var.woff2') format('woff2-variations');
+  font-weight: 45 930;
+  font-style: normal;
+  font-display: swap;
+  /* Headings are scaled to match the display face, which carries size-adjust: 106%. */
+  size-adjust: 105.4%;
+  unicode-range: U+1100-11FF, U+3130-318F, U+A960-A97F, U+AC00-D7A3, U+D7B0-D7FF;
+}
+
+:root {
+  --font-display: 'Newsreader', 'PretendardDisplay', Georgia, 'Times New Roman', serif;
+  --font-body: 'Inter', 'PretendardBody', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
@@ -0,0 +1,81 @@
+/* Design tokens. Type, spacing and grid values are measured from The Harvard Gazette;
+   the colour ladder is re-derived around a crimson anchor. Single light theme — neither
+   reference site ships a dark mode. */
+
+:root {
+  color-scheme: light;
+
+  /* Colour: five families x four steps. base sits at OKLCH L=.4701 with chroma at .901 of
+     the gamut maximum, both taken from the crimson anchor #a51c30.
+       dark   -> --ppc-text  (kickers)
+       base   -> --ppc-ui    (rules, quote bars, focus rings)
+       bright -> on black only
+       wash   -> emphasis backgrounds
+     All 24 pairs were contrast-checked: dark >= 4.5, base >= 3.0, bright >= 4.5 on black,
+     wash <= 1.25. */
+  --crimson-dark:   #771120;  --crimson:   #a51c30;  --crimson-bright: #faa2a2;  --crimson-wash: #f7ebeb;
+  --blue-dark:      #0e455f;  --blue:      #186285;  --blue-bright:    #70cafa;  --blue-wash:    #e6f0f7;
+  --ochre-dark:     #553a0c;  --ochre:     #775315;  --ochre-bright:   #f6ae30;  --ochre-wash:   #f7ede0;
+  --purple-dark:    #5e127b;  --purple:    #831daa;  --purple-bright:  #dda3fa;  --purple-wash:  #f3ebf7;
+  --slate-dark:     #453f34;  --slate:     #605a4e;  --slate-bright:   #c4bdb0;  --slate-wash:   #f5eee0;
+
+  /* Neutrals */
+  --black: #000000;
+  --grey: #b9b8ad;            /* hairlines only */
+  --sand: #faedda;            /* surface: pre, figure, blockquote */
+  --sand-light: #fffbf4;      /* page background */
+  --sand-dark: #eed6aa;       /* borders */
+
+  /* Page roles. A section overrides the --ppc-* trio. */
+  --page-bg: var(--sand-light);
+  --page-fg: var(--black);
+  --page-meta: var(--slate);
+  --page-hairline: var(--grey);
+  --ppc-text: var(--crimson-dark);
+  --ppc-ui: var(--crimson);
+  --ppc-wash: var(--crimson-wash);
+
+  /* Type scale. Every step is fluid and reaches its maximum at 1488px, the wide measure. */
+  --fs-small:    clamp(0.6875rem, 0.2568vw + 0.6361rem, 0.8750rem);  /* 11→14 */
+  --fs-medium:   clamp(0.7500rem, 0.3425vw + 0.6815rem, 1.0000rem);  /* 12→16 */
+  --fs-body:     clamp(1.0000rem, 0.3425vw + 0.9315rem, 1.2500rem);  /* 16→20 */
+  --fs-xlarge:   clamp(1.2500rem, 1.0274vw + 1.0445rem, 2.0000rem);  /* 20→32 */
+  --fs-2xlarge:  clamp(2.5000rem, 1.3699vw + 2.2260rem, 3.5000rem);  /* 40→56 */
+  --fs-h6:       1.125rem;                                          /* 18, fixed */
+  --fs-h5:       clamp(1.2500rem, 0.3425vw + 1.1815rem, 1.5000rem);  /* 20→24 */
+  --fs-h4:       clamp(1.5000rem, 0.6849vw + 1.3630rem, 2.0000rem);  /* 24→32 */
+  --fs-h3:       clamp(1.6250rem, 1.1986vw + 1.3853rem, 2.5000rem);  /* 26→40 */
+  --fs-h2:       clamp(1.7500rem, 1.7123vw + 1.4075rem, 3.0000rem);  /* 28→48 */
+  --fs-h1:       clamp(2.1250rem, 2.5685vw + 1.6113rem, 4.0000rem);  /* 34→64 */
+  --fs-h1-large: clamp(2.5000rem, 4.1096vw + 1.6781rem, 5.5000rem);  /* 40→88 */
+
+  /* Line heights */
+  --lh-tight: 1.1;   /* h1 */
+  --lh-snug: 1.2;    /* h2, h3 */
+  --lh-close: 1.25;  /* h4 */
+  --lh-medium: 1.4;  /* h5, h6 */
+  --lh-base: 1.5;
+  --lh-large: 1.6;   /* body */
+
+  /* Spacing */
+  --sp-20: 0.44rem;  --sp-30: 0.67rem;  --sp-40: 1rem;     --sp-50: 1.5rem;
+  --sp-60: 2.25rem;  --sp-70: 3.38rem;
+  --sp-24: 1.5rem;                                          /* between paragraphs */
+  --sp-32: clamp(1.5rem, 0.5vw + 1.4rem, 2rem);
+  --sp-48: clamp(2rem, 1vw + 1.8rem, 3rem);                 /* block rhythm */
+  --sp-64: clamp(2.5rem, 2.2vw + 2rem, 4rem);
+  --sp-80: clamp(2.5rem, 2.5vw + 2rem, 5rem);
+  --sp-120: clamp(4rem, 3.5vw + 3.3rem, 7.5rem);
+
+  /* Grid */
+  --w-content: 728px;
+  --w-wide: 1488px;
+  --root-pad: clamp(1rem, 1rem + ((1vw - 0.48rem) * 1.923), 2rem);  /* 16→32 */
+}
+
+/* Per-section palette, swapped by a class on <body>. */
+.is-writing { --ppc-text: var(--crimson-dark); --ppc-ui: var(--crimson); --ppc-wash: var(--crimson-wash); }
+.is-code    { --ppc-text: var(--blue-dark);    --ppc-ui: var(--blue);    --ppc-wash: var(--blue-wash); }
+.is-talk    { --ppc-text: var(--ochre-dark);   --ppc-ui: var(--ochre);   --ppc-wash: var(--ochre-wash); }
+.is-papers  { --ppc-text: var(--purple-dark);  --ppc-ui: var(--purple);  --ppc-wash: var(--purple-wash); }
+.is-slate   { --ppc-text: var(--slate-dark);   --ppc-ui: var(--slate);   --ppc-wash: var(--slate-wash); }