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); }
|