www.git

www

~ Personal site — projects, posts, about. Built with sqzass.
git clone https://github.com/debuman/www.git
commit 37e3f907fb08dd61accae44aacc2bb987b00d2d3
parent b91126d702a6b51845c05b8b90d249245fdd2bea
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:10:14 +0900

Move the language switch into the page, add a reading rail

The switch was in the masthead, where it read as a global setting — "show me
this site in Korean". What it actually does is go to the other edition of the
document you are reading. Moving it into the page makes that meaning obvious
from its position, and leaves the chrome holding only the three sections and
search.

It sits with the date on posts, under the title elsewhere, under the badges on
a project page, and above the door on the home page. Labels are EN and KO in
both languages: chrome here is English, and a two-letter code reads the same to
someone who only reads one of them, which "English 한국어" does not. The order is
always EN then KO — putting the current language first would swap the two words
every time you switched, so the thing you just clicked moves out from under the
cursor. The language you are reading is the brightest thing in the row; the one
you can go to is a link with an empty underline that fills with accent on
hover. The reference site has this inverted, so working out where you are means
comparing two greys.

The reading rail is the table of contents in its second form. Above 1280px it
stands in the right margin: one vertical hairline per heading, each as tall as
that section's share of the article, so it maps the document rather than
listing it — a list says how many parts there are, a map says which part is
half the piece. Sections you have read are ink, the one you are in is a
partially filled accent stub, the rest are the same hairline the inline TOC
uses for its left rule. Hovering reveals the headings in place: nothing widens,
nothing slides, the labels fade up and the ticks step one rung along the
hairline ladder, all at the sheet's single duration.

Position comes from scroll arithmetic against boundaries measured once, not
from an IntersectionObserver, which is why clicking a heading does not fight
the smooth scroll — during the jump you really are passing through those
sections. The reading line is offset by the heading's own scroll-margin-top, so
"you are here" and "go here" come from one declaration and cannot disagree.
Row heights are a grid track string, which hands overlap to the browser and
deletes the hand-written collision solver the reference needs.

Below 1280px the rail does not exist and the inline list renders in flow, with
its nesting intact. The list is also what remains if the script never arrives —
the rule that hides it is behind a class the script adds. There is exactly one
table of contents in the accessibility tree at every width, and the native
scrollbar stays, which is what lets the rail be nearly invisible at rest.

Both posts turn their table of contents on; they have three headings each and
were the reason the partial existed.

Diffstat:
Mcontent/posts/listings-that-cannot-show-their-own-dates.ko.md | 2+-
Mcontent/posts/listings-that-cannot-show-their-own-dates.md | 2+-
Mcontent/posts/what-this-site-is-built-on.ko.md | 2+-
Mcontent/posts/what-this-site-is-built-on.md | 2+-
Mstatic/css/main.css | 157++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mstatic/fonts/display-kr.woff2 | 0binary
Mstatic/js/ui.js | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/about.html | 2++
Mtemplates/base.html | 3---
Mtemplates/home.html | 6++++++
Mtemplates/page.html | 2++
Atemplates/partials/lang.html | 45+++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/post.html | 14+++++++++++---
Mtemplates/posts.html | 2++
Mtemplates/project.html | 3+++
Mtemplates/projects.html | 2++
16 files changed, 404 insertion(+), 15 deletion(-)

diff --git a/content/posts/listings-that-cannot-show-their-own-dates.ko.md b/content/posts/listings-that-cannot-show-their-own-dates.ko.md
@@ -2,7 +2,7 @@
 title = "자기 날짜를 못 보여 주는 목록"
 description = "날짜순으로 세워 놓고 그 날짜를 숨기는 생성기. 고치는 데 든 값과, 일부러 빼놓은 것."
 date = 2026-08-03
-toc = false
+toc = true
 +++
 
 [sqzass](@/projects/sqzass.md) 는 처음부터 섹션을 날짜순으로 정렬할 수 있었다.
diff --git a/content/posts/listings-that-cannot-show-their-own-dates.md b/content/posts/listings-that-cannot-show-their-own-dates.md
@@ -2,7 +2,7 @@
 title = "Listings that cannot show their own dates"
 description = "A generator that sorts by date and then hides it. What the fix cost, and what I left out."
 date = 2026-08-03
-toc = false
+toc = true
 +++
 
 [sqzass](@/projects/sqzass.md) has always been able to sort a section by date.
diff --git a/content/posts/what-this-site-is-built-on.ko.md b/content/posts/what-this-site-is-built-on.ko.md
@@ -2,7 +2,7 @@
 title = "이 사이트는 무엇으로 만들어졌나"
 description = "바이너리 하나, 스타일시트 하나, 스크립트 둘, 그리고 생성기 말고는 빌드 단계가 없다."
 date = 2026-08-02
-toc = false
+toc = true
 +++
 
 이 사이트는 직접 만든 [sqzass](@/projects/sqzass.md) 가 빌드하고, 바이너리를
diff --git a/content/posts/what-this-site-is-built-on.md b/content/posts/what-this-site-is-built-on.md
@@ -2,7 +2,7 @@
 title = "What this site is built on"
 description = "One binary, one stylesheet, two scripts, and no build step that is not the generator."
 date = 2026-08-02
-toc = false
+toc = true
 +++
 
 This site is built by [sqzass](@/projects/sqzass.md), which I wrote, and
diff --git a/static/css/main.css b/static/css/main.css
@@ -276,13 +276,13 @@ main { display: block; padding-bottom: clamp(5rem, 12vw, 9rem); }
   padding: 0; border: 0; background: none; cursor: pointer;
   font: inherit; font-size: 0.7rem; letter-spacing: 0.12em;
   text-transform: uppercase; color: var(--ink-3);
-  /* 좁은 화면에서 "한국어"가 세 줄로 쪼개져 마스트헤드 높이를 밀어 올린다.
-     컨트롤 라벨은 낱말 하나이고, 낱말 하나는 줄바꿈하지 않는다. */
+  /* 컨트롤 라벨은 낱말 하나이고, 낱말 하나는 줄바꿈하지 않는다. body 가
+     overflow-wrap: anywhere 라 이걸 빼면 좁은 화면에서 "Search" 가 쪼개져
+     마스트헤드 높이를 밀어 올린다. */
   white-space: nowrap;
   transition: color var(--tr-ink);
 }
 .chip:hover { color: var(--ink); }
-.chip-lang { font-size: 0.7rem; }
 
 /* 키를 말하는 컨트롤 하나만 진짜 키캡이 된다 — 터미널에서 모는 도구라 얇은
    테두리에 작은 반경. 네 칩에 전부 테두리를 두르면 테두리 없는 타일·원장·
@@ -366,12 +366,48 @@ main { display: block; padding-bottom: clamp(5rem, 12vw, 9rem); }
   text-wrap: pretty;
 }
 
+/* 표제 아래 한 줄. 날짜와 언어가 여기 함께 선다 — 둘 다 이 문서에 대한 사실이고,
+   따로 세우면 제목과 본문 사이에 줄만 하나 더 생긴다. 비어 있으면 gap 도 여백도
+   없다(빈 flex 컨테이너라 높이가 0 이다). */
 .page-meta {
+  display: flex; flex-wrap: wrap; align-items: baseline;
+  justify-content: center; gap: 1.1rem;
   margin: 1.2rem 0 0;
   font-family: var(--mono); font-size: 0.74rem;
   letter-spacing: 0.08em; font-variant-numeric: tabular-nums;
   color: var(--ink-3);
 }
+.page-meta:empty { margin: 0; }
+
+/* ---------- language switch ---------- */
+
+/* 두 글자 코드 둘. 사이에 구분자를 두지 않는다 — 간격이 이미 나누고 있고,
+   가운뎃점이나 슬래시를 넣으면 두 낱말이 한 낱말처럼 붙어 보인다.
+
+   현재 언어가 제일 밝다. 크롬의 현재 항목이 밝아지는 것과 같은 규칙이고,
+   반대로 두면 "지금 어디에 있는지"를 알려고 두 회색을 견줘야 한다.
+   갈 수 있는 쪽에는 밑줄 자리가 비어 있다가 손이 닿으면 액센트로 채워진다 —
+   색만으로 뜻을 전하지 않는다는 이 사이트의 규칙 그대로다. */
+/* 자기 크기를 스스로 갖는다. .page-meta 안에도 서고 프로젝트 히어로의 배지 줄
+   아래에도 서는데, 부모마다 크기가 달라지면 같은 컨트롤이 페이지마다 다른
+   물건으로 보인다. */
+.langs {
+  display: inline-flex; gap: 0.55rem;
+  font-family: var(--mono); font-size: 0.7rem;
+  letter-spacing: 0.16em; text-transform: uppercase;
+}
+/* 히어로에서는 배지 줄 바로 아래 붙는다 — 둘 다 이 프로젝트에 대한 사실이다. */
+.hero-main > .langs { margin-top: 0.85rem; }
+.lang-cur { color: var(--ink); }
+/* 갈 수 있는 쪽은 이 사이트의 링크 단이다(--ink-3). --ink-4 는 날짜와 펜스의
+   단이라 "있지만 누를 것은 아닌" 자리이고, 라이트 테마에서는 1.7:1 로 사실상
+   안 보인다. 지금 판(--ink)과의 차이는 이 단에서도 충분히 크다. */
+.langs a {
+  color: var(--ink-3);
+  border-bottom: 1px solid transparent;
+  transition: color var(--tr-ink), border-color var(--tr-ink);
+}
+.langs a:hover { color: var(--ink-2); border-bottom-color: var(--accent); }
 
 .eyebrow {
   margin: 0 0 0.9rem;
@@ -436,9 +472,14 @@ main { display: block; padding-bottom: clamp(5rem, 12vw, 9rem); }
   max-width: 44ch; color: var(--ink-2); text-wrap: pretty;
 }
 
+/* 첫 화면에서는 언어 줄이 문 위에 선다. 문 뒤에 붙이면 가운데 정렬 스택에서
+   한 줄이 통째로 접힌 자리 아래로 내려간다. 문의 margin-top 이 이 줄에 맞춰
+   깎여 있으므로(clamp(0.85rem…)), 두 값은 함께 고쳐야 한다. */
+.hero-home .page-meta { margin-top: clamp(1.3rem, 3vw, 1.9rem); }
+
 /* 문. 채운 알약이 아니라 밑줄 그은 라벨이다. */
 .door {
-  display: inline-block; margin-top: clamp(1.8rem, 4vw, 2.75rem);
+  display: inline-block; margin-top: clamp(0.85rem, 1.8vw, 1.15rem);
   padding-bottom: 0.35rem;
   font-size: 0.72rem; letter-spacing: 0.22em; text-transform: uppercase;
   color: var(--ink-2);
@@ -925,7 +966,7 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
 
 /* ---------- toc & pager ---------- */
 
-.toc { margin: 2rem auto 0; max-width: var(--read); padding: 1rem 1.25rem; border-left: 1px solid var(--rule-2); }
+.toc { margin: 2rem auto 2.5rem; max-width: var(--read); padding: 1rem 1.25rem; border-left: 1px solid var(--rule-2); }
 .toc-label {
   margin: 0 0 0.5rem; font-size: 0.66rem;
   letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-4);
@@ -936,6 +977,112 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
 .toc a { color: var(--ink-3); transition: color var(--tr-ink); }
 .toc a:hover { color: var(--accent); }
 
+/* ---------- reading rail ---------- */
+
+/* 목차의 두 번째 모습. 넓은 화면에서 오른쪽 여백에 서고, 절마다 세로 헤어라인
+   한 칸이며 칸의 높이가 그 절이 글에서 차지하는 몫이다. 목록은 절이 몇 개인지
+   말하고 지도는 어느 절이 긴지 말한다 — 같은 자료의 다른 사실이라 둘 다 있어도
+   중복이 아니다.
+
+   레일이 실제로 만들어졌을 때만 목록이 물러난다(.has-rail 은 ui.js 가 붙인다).
+   스크립트가 못 오면 목록이 그대로 남고, 그게 이 장치의 폴백이다. 문서 안에
+   목차는 언제나 한 벌이고 접근성 트리에도 한 벌만 남는다. */
+
+.rail { display: none; }
+
+/* 이 시트의 첫 min-width 다. 나머지는 전부 max-width — 넓게 시작해서 좁아지면
+   뺀다는 자세이고, 이것만 예외인 이유는 레일이 없던 자리를 요구하는 유일한
+   요소이기 때문이다. 좁은 화면에서는 줄이는 게 아니라 뺀다. */
+@media (min-width: 80rem) {
+  .rail {
+    display: grid;
+    /* 왼쪽이 눈금, 오른쪽이 이름. 선을 먼저 긋고 그 옆에 글을 두는 것은 .toc 의
+       border-left 와 blockquote 가 이미 쓰는 이 집의 곁줄 어법이다. 눈금을 창
+       끝에 붙이면 스크롤바 바로 옆에 세로줄이 하나 더 생긴다. */
+    grid-template-columns: 1px minmax(0, 1fr);
+    column-gap: 0.9rem;
+    row-gap: 0.4rem;
+    /* 행 높이는 ui.js 가 grid-template-rows 로 쓴다. 겹침 풀이를 격자에 맡기면
+       참조 구현이 손으로 짠 두 번의 밀어내기 패스와 그 안의 상수 셋이 통째로
+       없어진다. */
+
+    position: fixed;
+    /* 창이 아니라 본문에 붙는다. right:0 으로 매달면 2560px 에서 레일만 500px
+       멀어져 무엇의 눈금인지 알 수 없게 된다. .wrap 은 --gutter 만큼 안쪽
+       패딩이 있으므로 글자가 끝나는 자리는 50vw + --read/2 - --gutter 다.
+       거기서 2.5rem 떨어뜨린다 — 이 부품의 유일한 임의값이고, 본문과 레일이
+       서로 다른 것으로 읽히는 최소 거리다. 폭도 같은 값들에서 끌어내므로
+       --read 를 고치면 레일이 따라온다(.wrap-ledger 가 같은 방법을 쓴다). */
+    left: calc(50vw + var(--read) / 2 - var(--gutter) + 2.5rem);
+    width: min(14rem, calc(50vw - var(--read) / 2 + var(--gutter) - 4rem));
+    top: 50%; transform: translateY(-50%);
+    height: min(58vh, 30rem);
+    /* 마스트헤드(30) 아래. 올라온 이름이 크롬을 덮으면 안 된다. 검색은 modal
+       <dialog> 라 top layer 에 뜨므로 셈에 넣지 않는다. */
+    z-index: 20;
+    /* 레일 자체는 손을 받지 않는다. 여백 전체를 투명한 판으로 덮으면 나중에 그
+       여백에 놓는 것이 전부 눌리지 않는다. */
+    pointer-events: none;
+  }
+
+  .rail-seg {
+    position: relative;
+    background: var(--rule-2);
+    pointer-events: auto;
+    transition: background-color var(--tr-ink);
+  }
+  /* 1px 은 겨눌 수 없다. 보이는 선은 그대로 두고 손이 닿는 자리만 넓힌다.
+     오른쪽 1rem 은 column-gap(0.9rem)보다 넓다 — 눈금에서 이름으로 커서가
+     건너가는 중간에서 반응이 끊기면 이름이 도로 사라진다. 세로 -0.2rem 은
+     row-gap 의 절반이라 칸 사이에서도 안 끊긴다. */
+  .rail-seg::before { content: ""; position: absolute; inset: -0.2rem -1rem; }
+
+  /* 읽은 만큼. 높이는 ui.js 가 스크롤에서 직접 쓴다 — 전환을 걸면 화면보다 한
+     박자 늦게 따라와 거짓말을 한다. 이미 연속값에 매인 것은 애니메이션하지
+     않는다. 색은 상태라서 전환한다. */
+  .rail-fill {
+    display: block; width: 100%; height: 0;
+    background: var(--ink-3);
+    transition: background-color var(--tr-ink);
+  }
+  /* 세 단이다: 헤어라인은 아직 안 읽은 것, 잉크는 읽은 것, 주황은 지금 읽는
+     것. 주황이 색 하나로만 말하지 않는다 — 지금 칸은 이 열에서 유일하게 덜 찬
+     칸이고(위는 다 차고 아래는 비어 있다), 그 이름은 유일하게 --ink 다.
+     클래스로 표시하는 이유: 왼쪽이 눈금이라 DOM 에서 눈금이 이름보다 앞에 오고,
+     CSS 에는 앞 형제를 짚는 선택자가 없다. */
+  .rail-fill.is-now { background: var(--accent); }
+
+  .rail-label {
+    align-self: start;
+    /* 두 줄까지. 칸의 아래끝 2.2rem 이 정확히 두 줄 높이라, 넘치면 옆 칸을
+       침범한다. 지금 글의 제일 긴 제목이 30자이고 1280px 에서 한 줄이 26자쯤이라
+       두 줄에 들어온다.
+       ponytail: 여기 걸리는 제목이 잦아지면 breakpoint 를 84rem 으로 올려 이름
+       열을 넓히는 쪽이 먼저다. 글자 크기를 줄이는 건 그다음. */
+    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
+    overflow: hidden;
+    font-size: 0.72rem; line-height: 1.35;
+    color: var(--ink-3);
+    opacity: 0; pointer-events: none;
+    transition: opacity var(--tr-ink), color var(--tr-ink);
+  }
+  .rail-label[aria-current] { color: var(--ink); }
+
+  /* 드러나는 것은 숨어 있던 것이지 자리를 옮긴 것이 아니다. 이름은 제자리에서
+     밝아지고 눈금은 색이 한 단 오른다 — 굵어지지도, 밀려나지도 않는다. 박자는
+     이 시트의 유일한 박자 하나다. */
+  .rail:hover .rail-label,
+  .rail:focus-within .rail-label { opacity: 1; pointer-events: auto; }
+  .rail:hover .rail-seg,
+  .rail:focus-within .rail-seg { background: var(--rule-3); }
+
+  /* 지금 절 위에 손이 올라가도 현재 표시를 지우지 않는다. hover 와 현재를 같게
+     칠하면 목차에 커서를 얹는 것만으로 위치 표시가 사라진다. */
+  .rail-label:not([aria-current]):hover { color: var(--accent); }
+
+  .has-rail .toc { display: none; }
+}
+
 .pager {
   display: flex; gap: 1.5rem;
   margin-top: clamp(3.5rem, 8vw, 5.5rem); padding-top: 1.75rem;
diff --git a/static/fonts/display-kr.woff2 b/static/fonts/display-kr.woff2
Binary files differ.
diff --git a/static/js/ui.js b/static/js/ui.js
@@ -78,4 +78,179 @@
       pre.appendChild(btn);
     });
   }
+
+  /* ---------- reading rail ---------- */
+
+  /* 넓은 화면에서 오른쪽 여백에 서는 목차. 항목마다 세로 헤어라인 한 칸이고,
+     칸의 높이가 그 절이 글에서 차지하는 몫이다. 항목이 다 같은 크기인 목록은
+     "가운데 절이 이 글의 절반"이라는 사실을 말하지 못한다.
+
+     자료는 본문에 이미 있는 .toc 다. 이 파일이 못 오면 그 목록이 그대로 남는다 —
+     목록을 감추는 CSS 가 여기서 붙이는 .has-rail 뒤에 있다.
+
+     넓은 화면인지는 여기서 묻지 않는다. 그 판단은 main.css 의 min-width 하나가
+     갖고 있다. 두 곳에 두면 두 값이 조용히 어긋나고, 그때 나오는 증상은 "레일이
+     보이는데 안 찬다"라 숫자 문제로 보이지 않는다. 숨어 있을 때 여기가 하는 일은
+     짧은 배열 하나에 대한 산수뿐이라 값이 없다.
+
+     이른 반환이 여러 번 필요해서 안쪽 IIFE 다. 바깥 IIFE 에서 return 하면 나중에
+     이 아래에 절을 하나 더 붙이는 사람이 조용히 잃는다. */
+  (function () {
+    /* 읽는 줄. 마스트헤드에 가리지 않은 높이의 위에서 30%.
+       ponytail: 절이 이 거리보다 짧으면 그 절의 이름을 눌렀을 때 다음 절이
+       현재가 된다. 짧은 절이 많은 글이 생기면 0.2 쪽으로 내린다. */
+    var LINE = 0.3;
+    /* 이만큼도 못 구르는 문서에는 진행 표시를 그리지 않는다. 못 움직이는 진행
+       표시는 진행을 말하지 않는다. */
+    var FLOOR = 240;
+
+    var toc = document.querySelector(".toc");
+    var prose = document.querySelector(".prose");
+    if (!toc || !prose) return;
+    if (root.scrollHeight - window.innerHeight < FLOOR) return;
+
+    var host = document.createElement("nav");
+    host.className = "rail";
+    /* 이름은 목록이 이미 갖고 있다. JS 안에 문자열을 두면 번역도 안 되고
+       tools/fonts.py 의 눈에도 안 띈다. */
+    host.setAttribute("aria-label", toc.getAttribute("aria-label") || "");
+
+    /* 목차의 링크를 전부 쓴다. 깊이별로 고르지 않는다 — 레일이 그리는 것은
+       개요가 아니라 분량이고, 분량에는 깊이가 없다. 구조는 좁은 화면의 목록이
+       들여쓰기로 이미 말한다. */
+    var rows = [];
+    toc.querySelectorAll("a[href^='#']").forEach(function (a) {
+      var href = a.getAttribute("href");
+      /* 한국어 제목은 id 가 한글이다(#택하지-않은-우회로). "#"+id 를
+         querySelector 에 넘기면 CSS 선택자로 파싱돼 던진다. 소스에 적힌 값을
+         그대로 쓰므로 디코딩도 필요 없다 — decodeURIComponent 는 제목에 % 가
+         들어간 날 오히려 던진다. */
+      var head = document.getElementById(href.slice(1));
+      if (!head) return;
+
+      var seg = document.createElement("span");
+      seg.className = "rail-seg";
+      var fill = document.createElement("i");
+      fill.className = "rail-fill";
+      seg.appendChild(fill);
+
+      var label = document.createElement("a");
+      label.className = "rail-label";
+      label.setAttribute("href", href);
+      label.textContent = a.textContent;
+
+      /* 격자가 두 열이라 넣는 순서대로 눈금·이름이 한 행이 된다. 행 번호를 셀
+         필요가 없고, 그래서 이름의 세로 위치가 곧 그 절의 위치다. */
+      host.appendChild(seg);
+      host.appendChild(label);
+      rows.push({ fill: fill, label: label, head: head, f: -1 });
+    });
+    /* 항목이 하나뿐인 목차는 목차가 아니라 링크 하나다. */
+    if (rows.length < 2) return;
+
+    /* 목차 바로 뒤에 꽂는다. body 끝에 붙이면 화면에서는 본문 옆이지만 탭
+       순서에서는 본문 뒤가 되고, 다 읽은 뒤에야 닿는 목차는 아무도 안 쓴다. */
+    toc.insertAdjacentElement("afterend", host);
+    root.classList.add("has-rail");
+
+    var tops = [];
+    var lead = 0;
+    var at = -1;
+
+    function absTop(el) { return el.getBoundingClientRect().top + window.scrollY; }
+
+    /* 재는 곳은 여기 하나다. 스크롤 중에는 아무것도 재지 않고 산수만 한다. */
+    function layout() {
+      /* 읽는 줄이 마스트헤드에 가리는 만큼을 제목 자신의 scroll-margin-top 에서
+         가져온다. main.css 가 앵커 점프를 위해 이미 선언해 둔 값이고, 브라우저가
+         눌러서 뛸 때 쓰는 바로 그 값이다 — 한 군데서 나오므로 "여기 있다"와
+         "여기로 간다"가 어긋날 수 없다. --masthead-h 를 고쳐도 따라온다. */
+      var pad = parseFloat(getComputedStyle(rows[0].head).scrollMarginTop) || 0;
+      lead = pad + (window.innerHeight - pad) * LINE;
+
+      tops = [];
+      var prev = 0;
+      rows.forEach(function (r) {
+        /* 앞 경계보다 위로는 못 간다. 서브픽셀이나 sticky 때문에 뒤 제목이 앞
+           제목보다 위로 재어지면 높이가 음수인 칸이 생긴다. */
+        prev = Math.max(absTop(r.head), prev);
+        tops.push(prev);
+      });
+      /* 읽기는 본문이 끝나는 데서 끝난다. 페이저와 콜로폰은 글이 아니다. 레일의
+         좌표계는 첫 제목부터 본문 끝까지다 — 머리말은 목차에 없으므로 레일에도
+         없다. */
+      tops.push(Math.max(absTop(prose) + prose.offsetHeight, prev + 1));
+
+      /* 칸의 높이가 그 절의 몫이다. 아래끝을 두면 아주 짧은 절도 사라지지 않고
+         두 줄짜리 이름이 옆 칸을 침범하지도 않는다 — 겹침 풀이를 격자에 맡긴다.
+         ponytail: 절이 열한 개를 넘으면 아래끝의 합이 상자보다 커져 넘친다.
+         그때 손댈 곳은 2.2rem 과 min(58vh, 30rem) 둘이다. */
+      var total = tops[tops.length - 1] - tops[0];
+      var track = "";
+      for (var i = 0; i < rows.length; i++) {
+        track += " minmax(2.2rem, " +
+                 ((tops[i + 1] - tops[i]) / total * 100).toFixed(3) + "fr)";
+      }
+      host.style.gridTemplateRows = track.slice(1);
+      progress();
+    }
+
+    function progress() {
+      if (!tops.length) return;
+      var line = window.scrollY + lead;
+      /* 문서 끝에 닿으면 마지막 절은 끝난 것이다. 읽는 줄은 화면 아래 한 뼘을
+         영영 못 지나가므로, 이게 없으면 마지막 칸은 절대 안 찬다. */
+      var done = window.scrollY + window.innerHeight >= root.scrollHeight - 2;
+      /* 첫 제목 위에서는 아무것도 현재가 아니다. 아직 시작도 안 한 사람에게
+         "1절에 있다"고 말하면 그 표시는 남은 페이지 내내 못 믿는다. */
+      var now = -1;
+
+      for (var i = 0; i < rows.length; i++) {
+        var f = done ? 1 : (line - tops[i]) / Math.max(1, tops[i + 1] - tops[i]);
+        f = f < 0 ? 0 : f > 1 ? 1 : f;
+        /* 값이 그대로면 손대지 않는다. 스크롤 한 번에 절 수만큼 쓰는 자리다. */
+        if (rows[i].f !== f) {
+          rows[i].fill.style.height = (f * 100).toFixed(2) + "%";
+          rows[i].f = f;
+        }
+        /* 화면 상자가 아니라 줄 하나로 고른다. 제목 여럿이 같이 보일 때 누가
+           이기는지 따질 일이 없다 — 줄 위에 있는 마지막 절이다. 관찰자를 쓰지
+           않으므로 부드러운 스크롤과 싸울 일도 없다: 눌러서 뛰는 동안 표시가
+           지나가는 절들을 훑는 것은, 실제로 그 절들을 지나가고 있어서다. */
+        if (line >= tops[i]) now = i;
+      }
+      if (done) now = rows.length - 1;
+
+      if (now === at) return;
+      if (at >= 0) {
+        rows[at].label.removeAttribute("aria-current");
+        rows[at].fill.classList.remove("is-now");
+      }
+      if (now >= 0) {
+        rows[now].label.setAttribute("aria-current", "true");
+        rows[now].fill.classList.add("is-now");
+      }
+      at = now;
+    }
+
+    var ticking = false;
+    window.addEventListener("scroll", function () {
+      if (ticking) return;
+      ticking = true;
+      requestAnimationFrame(function () { ticking = false; progress(); });
+    }, { passive: true });
+
+    var timer;
+    window.addEventListener("resize", function () {
+      clearTimeout(timer);
+      timer = setTimeout(layout, 120);
+    });
+
+    /* 한글 서브셋은 unicode-range 뒤에 있어서 첫 페인트 뒤에 온다. 오면 본문
+       높이가 바뀌고, 그 전에 잰 경계는 전부 틀린 값이 된다. */
+    window.addEventListener("load", layout);
+    if ("ResizeObserver" in window) new ResizeObserver(layout).observe(prose);
+
+    layout();
+  })();
 })();
diff --git a/templates/about.html b/templates/about.html
@@ -4,6 +4,7 @@
     흐름을 끊고, 폭과 비율을 문서마다 다시 정해야 한다. 머리에 두면 자리가
     한 곳이고 규격도 하나다. #}
 {% extends "base.html" %}
+{% from "partials/lang.html" import lang_switch %}
 
 {% block content %}
 <div class="wrap wrap-read">
@@ -19,6 +20,7 @@
     {%- if page.description %}
     <p class="page-lede">{{ page.description }}</p>
     {%- endif %}
+    <div class="page-meta">{{ lang_switch(page) }}</div>
   </header>
 
   <div class="fence" aria-hidden="true">+++</div>
diff --git a/templates/base.html b/templates/base.html
@@ -103,9 +103,6 @@
         <span class="chip-word">{{ t("search") }}</span>
       </button>
       {%- endif %}
-      {%- for lang in page.translations %}
-      <a class="chip chip-lang" href="{{ lang.url }}" hreflang="{{ lang.code }}">{{ lang.name }}</a>
-      {%- endfor %}
       <a class="chip chip-icon chip-github" href="https://github.com/sqzer-x" aria-label="GitHub" title="GitHub">
         <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
       </a>
diff --git a/templates/home.html b/templates/home.html
@@ -7,6 +7,7 @@
     카피는 전부 [extra] 에 있고 이 파일은 구조만 안다. #}
 {% extends "base.html" %}
 {% from "partials/ledger.html" import ledger %}
+{% from "partials/lang.html" import lang_switch %}
 
 {% block content %}
 {%- set ko = page.language == "ko" %}
@@ -19,6 +20,11 @@
     {%- if page.extra.hero_sub %}
     <p class="statement-sub" data-rise="2">{{ page.extra.hero_sub }}</p>
     {%- endif %}
+    {#- 홈에도 둔다. 참조 사이트는 홈에서 이걸 빠뜨렸는데, 영어 첫 화면에 도착한
+        사람에게 한국어로 가는 길이 사이트 전체에 하나도 없다는 뜻이 된다.
+        문보다 **앞에** 세우는 이유는 히어로가 가운데 정렬이라서다 — 뒤에 붙이면
+        한 줄만큼이 통째로 접힌 자리 아래로 내려가고, 문까지 같이 밀린다. #}
+    <div class="page-meta" data-rise="3">{{ lang_switch(page) }}</div>
     <a class="door" href="{{ home }}about/" data-rise="3">{{ page.extra.hero_door }}</a>
   </div>
 </section>
diff --git a/templates/page.html b/templates/page.html
@@ -1,5 +1,6 @@
 {#- 산문 한 장. About 이 이걸 쓴다. #}
 {% extends "base.html" %}
+{% from "partials/lang.html" import lang_switch %}
 
 {% block content %}
 <div class="wrap wrap-read">
@@ -8,6 +9,7 @@
     {%- if page.description %}
     <p class="page-lede">{{ page.description }}</p>
     {%- endif %}
+    <div class="page-meta">{{ lang_switch(page) }}</div>
   </header>
 
   <div class="fence" aria-hidden="true">+++</div>
diff --git a/templates/partials/lang.html b/templates/partials/lang.html
@@ -0,0 +1,45 @@
+{#- 언어 전환. 크롬이 아니라 본문에 붙는다.
+
+    마스트헤드에 있을 때는 "이 사이트를 한국어로 본다"는 전역 설정처럼 보였는데,
+    실제로 하는 일은 **지금 이 문서의 다른 판으로 간다**는 것이다. 자리를 문서
+    안으로 옮기면 그 뜻이 자리로 드러나고, 크롬에는 세 갈래와 검색만 남는다.
+
+    라벨이 EN / KO 인 이유: 이 사이트의 크롬은 두 언어에서 영어다. 그리고 이건 두
+    낱말이 나란히 서서 서로를 설명하는 자리다 — "한국어" 옆의 "EN" 은 무엇을
+    고르는 것인지 말해 주지만, "English" 옆의 "한국어" 는 한쪽만 읽는 사람에게
+    한쪽만 읽힌다. 두 글자짜리 코드는 어느 쪽에서도 같게 읽힌다.
+
+    지금 보고 있는 언어가 **더 밝다**. 참조 사이트는 반대로 두었는데(현재가 제일
+    어둡다) 그러면 "어디에 있는지"를 알려고 두 회색을 견줘 봐야 한다. 이 집의
+    규칙은 이미 정해져 있다 — 내비의 현재 항목이 눈금과 함께 밝아진다.
+
+    번역이 없는 페이지에서는 통째로 안 나온다. page.translations 가 비면 아무것도
+    그리지 않는다. 없는 판으로 가는 링크나 회색 처리된 라벨을 남기지 않는 이유는,
+    둘 다 "있는데 지금 못 쓴다"는 뜻이 되기 때문이다. 없는 것은 없다. #}
+
+{% macro lang_cur(page) -%}
+<span class="lang-cur" aria-current="true">{{ page.language }}</span>
+{%- endmacro %}
+
+{#- lang 은 이 요소의 글자가 무슨 말인지를 말하는 속성이다. 여기 글자는 라틴 두
+    자라 lang="ko" 를 붙이면 스크린리더가 "ko" 를 한국어 음가로 읽는다.
+    목적지의 언어는 hreflang 이 이미 지고 있다. #}
+{% macro lang_alt(l) -%}
+<a href="{{ l.url }}" hreflang="{{ l.code }}" title="{{ l.name }}">{{ l.code }}</a>
+{%- endmacro %}
+
+{#- 순서는 언제나 EN, KO 다. 현재 언어를 앞에 세우면 언어를 바꿀 때마다 두 낱말이
+    자리를 맞바꿔서, 방금 누른 것이 커서 밑에서 도망간다. #}
+{% macro lang_switch(page) -%}
+{%- if page.translations %}
+<nav class="langs" aria-label="{{ t("language") }}">
+  {%- if page.language == "en" %}
+  {{ lang_cur(page) }}
+  {%- for l in page.translations %}{{ lang_alt(l) }}{% endfor %}
+  {%- else %}
+  {%- for l in page.translations %}{{ lang_alt(l) }}{% endfor %}
+  {{ lang_cur(page) }}
+  {%- endif %}
+</nav>
+{%- endif %}
+{%- endmacro %}
diff --git a/templates/post.html b/templates/post.html
@@ -2,6 +2,7 @@
     필요한데, 숫자는 두 언어에서 같게 읽히고 tabular-nums 로 자리가 맞는다. #}
 {% extends "base.html" %}
 {% from "partials/toc.html" import toc_list %}
+{% from "partials/lang.html" import lang_switch %}
 
 {% block content %}
 <article class="wrap wrap-read">
@@ -15,10 +16,17 @@
     {%- if page.description %}
     <p class="page-lede">{{ page.description }}</p>
     {%- endif %}
-    {%- if page.date %}
-    <p class="page-meta">
+    {#- 날짜와 언어는 같은 줄에 선다. 둘 다 "이 문서가 무엇인가"에 대한 사실이고,
+        따로 세우면 제목과 본문 사이에 줄이 하나 더 생긴다.
+        바깥 if 가 필요한 이유: 이 div 안에는 줄바꿈이 있어서 둘 다 없을 때
+        :empty 가 안 걸린다. 목록 템플릿은 한 줄로 붙어 있어 걸린다. #}
+    {%- if page.date or page.translations %}
+    <div class="page-meta">
+      {%- if page.date %}
       <time datetime="{{ page.date.date }}">{{ page.date.year }}.{% if page.date.month < 10 %}0{% endif %}{{ page.date.month }}.{% if page.date.day < 10 %}0{% endif %}{{ page.date.day }}</time>
-    </p>
+      {%- endif %}
+      {{ lang_switch(page) }}
+    </div>
     {%- endif %}
   </header>
 
diff --git a/templates/posts.html b/templates/posts.html
@@ -1,6 +1,7 @@
 {#- 글 목록. 섹션이 `sort_by = "date"` 라 최신순이고, 이제 목록 항목이 자기
     날짜를 실어 오므로 그 순서의 근거가 화면에 보인다. #}
 {% extends "base.html" %}
+{% from "partials/lang.html" import lang_switch %}
 {% from "partials/ledger.html" import ledger %}
 
 {% block content %}
@@ -10,6 +11,7 @@
     {%- if page.description %}
     <p class="page-lede">{{ page.description }}</p>
     {%- endif %}
+    <div class="page-meta">{{ lang_switch(page) }}</div>
   </header>
 
   <div class="fence" aria-hidden="true">+++</div>
diff --git a/templates/project.html b/templates/project.html
@@ -10,6 +10,7 @@
     없는 항목만 숫자가 빠지면 줄이 어긋난다. #}
 {% extends "base.html" %}
 {% from "partials/icons.html" import i_out, i_arrow %}
+{% from "partials/lang.html" import lang_switch %}
 
 {% block content %}
 <div class="{{ "wrap" if page.extra.tier == "landing" else "wrap wrap-read" }} project" data-tier="{{ page.extra.tier }}"
@@ -46,6 +47,8 @@
         {%- if page.extra.repo %}<li class="stars">{{ page.extra.stars }} ★</li>{% endif %}
       </ul>
 
+      {{ lang_switch(page) }}
+
       {%- if page.extra.actions %}
       <div class="btn-row">
         {%- for a in page.extra.actions %}
diff --git a/templates/projects.html b/templates/projects.html
@@ -9,6 +9,7 @@
 
     스키마는 총체적이다. 없는 값은 "" 나 [] 이고 키를 빼지 않는다. #}
 {% extends "base.html" %}
+{% from "partials/lang.html" import lang_switch %}
 {% from "partials/icons.html" import i_site, i_repo %}
 
 {% block content %}
@@ -18,6 +19,7 @@
     {%- if page.description %}
     <p class="page-lede">{{ page.description }}</p>
     {%- endif %}
+    <div class="page-meta">{{ lang_switch(page) }}</div>
   </header>
 
   <div class="fence" aria-hidden="true">+++</div>