diff --git a/static/css/main.css b/static/css/main.css
@@ -974,63 +974,56 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
/* ---------- reading rail ---------- */
-/* 목차의 두 번째 모습. 넓은 화면에서 오른쪽 여백에 서고, 절마다 세로 헤어라인
- 한 칸이며 칸의 높이가 그 절이 글에서 차지하는 몫이다. 목록은 절이 몇 개인지
- 말하고 지도는 어느 절이 긴지 말한다 — 같은 자료의 다른 사실이라 둘 다 있어도
- 중복이 아니다.
+/* 목차의 두 번째 모습. 넓은 화면에서 오른쪽 가장자리에 서고, 절마다 세로
+ 헤어라인 한 칸이며 칸의 높이가 그 절이 글에서 차지하는 몫이다. 목록은 절이
+ 몇 개인지 말하고 지도는 어느 절이 긴지 말한다.
레일이 실제로 만들어졌을 때만 목록이 물러난다(.has-rail 은 ui.js 가 붙인다).
- 스크립트가 못 오면 목록이 그대로 남고, 그게 이 장치의 폴백이다. 문서 안에
- 목차는 언제나 한 벌이고 접근성 트리에도 한 벌만 남는다. */
+ 스크립트가 못 오면 목록이 그대로 남고, 그게 이 장치의 폴백이다.
+
+ 처음 판은 본문 칼럼에 붙여 놓고 눈금 자체만 손을 받게 했다. 재 보니 레일이
+ 차지한 넓이의 14% 만 반응했고, 눈금에서 이름으로 커서를 옮기면 이름이
+ 대부분 사라졌다 — 이름과 세로로 겹치는 28% 구간에서만 건너갈 수 있었다.
+ 가끔 되는 조작은 안 되는 조작보다 나쁘다. 지금은 여백 전체가 손을 받는다. */
.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);
+ /* 이름이 안쪽, 눈금이 바깥쪽이다. 눈금은 스크롤 막대 옆에 서는 스크롤
+ 가구이고 이름은 글 쪽으로 흐른다 — 반대로 두면 눈이 글에서 멀어지는
+ 방향으로 읽어 나가야 한다. */
+ grid-template-columns: minmax(0, 1fr) 1px;
column-gap: 0.9rem;
- row-gap: 0.4rem;
+ row-gap: 0.3rem;
/* 행 높이는 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));
+ /* 창 가장자리에 붙는다. 본문 칼럼에 매달았더니 1920px 에서 레일이 창 끝에서
+ 551px 떨어진 자리에 섰다 — 손이 가는 자리가 아니다. 스크롤 막대를 향해
+ 뻗는 손이 지나가는 띠가 이 화면에서 가장 자주 밟히는 빈 자리이고,
+ 여기 두면 찾으려 하지 않아도 걸린다.
+ 폭은 본문과 겹치지 않는 만큼까지만 — 1280px 에서 28px 이 남는다. */
+ right: 0;
+ width: min(16rem, calc(50vw - var(--read) / 2 + var(--gutter) - 1.5rem));
+ padding-right: 1rem;
top: 50%; transform: translateY(-50%);
- height: min(58vh, 30rem);
- /* 마스트헤드(30) 아래. 올라온 이름이 크롬을 덮으면 안 된다. 검색은 modal
- <dialog> 라 top layer 에 뜨므로 셈에 넣지 않는다. */
+ height: min(64vh, 34rem);
+ /* 마스트헤드(30) 아래. 올라온 이름이 크롬을 덮으면 안 된다. */
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; }
+ /* 눈금은 세 단의 밝기다: 아직 안 읽은 것, 읽은 것, 지금 읽는 것. 색조는 한
+ 가지뿐이라 1px 에서도 순서가 읽힌다 — 주황을 섞었더니 지금 자리가 이 열에서
+ 제일 어두운 표시가 됐고(2.1:1), 읽은 자리가 그보다 두 배 진했다. 과거가
+ 현재보다 크게 그려지면 그건 위치 표시가 아니다. */
+ .rail-seg { background: var(--rule-3); }
/* 읽은 만큼. 높이는 ui.js 가 스크롤에서 직접 쓴다 — 전환을 걸면 화면보다 한
박자 늦게 따라와 거짓말을 한다. 이미 연속값에 매인 것은 애니메이션하지
@@ -1040,18 +1033,17 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
background: var(--ink-3);
transition: background-color var(--tr-ink);
}
- /* 세 단이다: 헤어라인은 아직 안 읽은 것, 잉크는 읽은 것, 주황은 지금 읽는
- 것. 주황이 색 하나로만 말하지 않는다 — 지금 칸은 이 열에서 유일하게 덜 찬
- 칸이고(위는 다 차고 아래는 비어 있다), 그 이름은 유일하게 --ink 다.
- 클래스로 표시하는 이유: 왼쪽이 눈금이라 DOM 에서 눈금이 이름보다 앞에 오고,
- CSS 에는 앞 형제를 짚는 선택자가 없다. */
- .rail-fill.is-now { background: var(--accent); }
+ /* 지금 절이 이 열에서 제일 진하고, 유일하게 덜 찬 칸이다. 색 하나로만 말하지
+ 않는다. */
+ .rail-fill.is-now { background: var(--ink); }
.rail-label {
align-self: start;
+ /* 오른쪽 정렬이라 이름들이 눈금에 붙어 한 덩이로 읽힌다. 왼쪽 정렬로 두면
+ 길이가 제각각인 세 구절이 빈 자리에 흩어져 목록으로 안 보인다. */
+ text-align: right;
/* 두 줄까지. 칸의 아래끝 2.2rem 이 정확히 두 줄 높이라, 넘치면 옆 칸을
- 침범한다. 지금 글의 제일 긴 제목이 30자이고 1280px 에서 한 줄이 26자쯤이라
- 두 줄에 들어온다.
+ 침범한다.
ponytail: 여기 걸리는 제목이 잦아지면 breakpoint 를 84rem 으로 올려 이름
열을 넓히는 쪽이 먼저다. 글자 크기를 줄이는 건 그다음. */
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
@@ -1064,12 +1056,13 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
.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); }
+ .rail:focus-within .rail-seg { background: var(--ink-4); }
/* 지금 절 위에 손이 올라가도 현재 표시를 지우지 않는다. hover 와 현재를 같게
칠하면 목차에 커서를 얹는 것만으로 위치 표시가 사라진다. */
@@ -1078,23 +1071,6 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
.has-rail .toc { display: none; }
}
-.pager {
- display: flex; gap: 1.5rem;
- margin-top: clamp(3.5rem, 8vw, 5.5rem); padding-top: 1.75rem;
- border-top: 1px solid var(--rule);
-}
-.pager-link { flex: 1; transition: color var(--tr-ink); }
-.pager-next { text-align: right; }
-.pager-next:only-child { margin-left: auto; flex: 0 1 22rem; }
-.pager-dir {
- display: block; font-size: 0.64rem;
- letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-4);
- margin-bottom: 0.35rem;
-}
-.pager-title { display: block; font-size: 1rem; font-weight: var(--w-item); color: var(--ink-2); transition: color var(--tr-ink); }
-.pager-link:hover .pager-title { color: var(--ink); }
-@media (max-width: 40rem) { .pager { flex-direction: column; } }
-
/* ---------- prose ---------- */
.prose { font-size: 1.02rem; line-height: 1.78; }
diff --git a/static/js/ui.js b/static/js/ui.js
@@ -81,52 +81,68 @@
/* ---------- reading rail ---------- */
- /* 넓은 화면에서 오른쪽 여백에 서는 목차. 항목마다 세로 헤어라인 한 칸이고,
+ /* 넓은 화면에서 오른쪽 가장자리에 서는 목차. 항목마다 세로 헤어라인 한 칸이고,
칸의 높이가 그 절이 글에서 차지하는 몫이다. 항목이 다 같은 크기인 목록은
"가운데 절이 이 글의 절반"이라는 사실을 말하지 못한다.
자료는 본문에 이미 있는 .toc 다. 이 파일이 못 오면 그 목록이 그대로 남는다 —
목록을 감추는 CSS 가 여기서 붙이는 .has-rail 뒤에 있다.
+ 첫 칸은 제목에서 첫 소제목까지다. 그게 없을 때 재 보니 이 글의 34% 가 어느
+ 칸에도 안 들어갔고, 처음 750px 을 굴리는 동안 레일이 통째로 죽어 있었다.
+ 도착해서 읽기 시작한 사람에게 아무 반응이 없는 진행 표시는 고장으로 보인다.
+
넓은 화면인지는 여기서 묻지 않는다. 그 판단은 main.css 의 min-width 하나가
갖고 있다. 두 곳에 두면 두 값이 조용히 어긋나고, 그때 나오는 증상은 "레일이
- 보이는데 안 찬다"라 숫자 문제로 보이지 않는다. 숨어 있을 때 여기가 하는 일은
- 짧은 배열 하나에 대한 산수뿐이라 값이 없다.
+ 보이는데 안 찬다"라 숫자 문제로 보이지 않는다.
- 이른 반환이 여러 번 필요해서 안쪽 IIFE 다. 바깥 IIFE 에서 return 하면 나중에
- 이 아래에 절을 하나 더 붙이는 사람이 조용히 잃는다. */
+ 이른 반환이 여러 번 필요해서 안쪽 IIFE 다. */
(function () {
- /* 읽는 줄. 마스트헤드에 가리지 않은 높이의 위에서 30%.
- ponytail: 절이 이 거리보다 짧으면 그 절의 이름을 눌렀을 때 다음 절이
- 현재가 된다. 짧은 절이 많은 글이 생기면 0.2 쪽으로 내린다. */
+ /* 읽는 줄. 마스트헤드에 가리지 않은 높이의 위에서 30%. */
var LINE = 0.3;
- /* 이만큼도 못 구르는 문서에는 진행 표시를 그리지 않는다. 못 움직이는 진행
- 표시는 진행을 말하지 않는다. */
+ /* 이만큼도 못 구르는 문서에는 진행 표시를 그리지 않는다. */
var FLOOR = 240;
var toc = document.querySelector(".toc");
var prose = document.querySelector(".prose");
+ var title = document.querySelector(".page-title");
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 = [];
+ var heads = [];
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 el = document.getElementById(href.slice(1));
+ if (el) heads.push({ el: el, href: href, text: a.textContent });
+ });
+ /* 소제목이 하나뿐인 목차는 목차가 아니라 링크 하나다. */
+ if (heads.length < 2) return;
+
+ /* 첫 칸은 머리말이다. 제목을 이름으로 쓰고 <main id="content"> 로 간다 —
+ 건너뛰기 링크가 이미 쓰는 진짜 앵커라, 가짜 "#" 도 클릭 핸들러도 필요
+ 없다. */
+ var parts = [{ el: prose, href: "#content", text: (title && title.textContent) || "" }]
+ .concat(heads);
+
+ var host = document.createElement("nav");
+ host.className = "rail";
+ /* 이름은 목록이 이미 갖고 있다. JS 안에 문자열을 두면 번역도 안 되고
+ tools/fonts.py 의 눈에도 안 띈다. */
+ host.setAttribute("aria-label", toc.getAttribute("aria-label") || "");
+
+ var rows = parts.map(function (s) {
+ /* 격자가 두 열이라 넣는 순서대로 이름·눈금이 한 행이 된다. 이름이 앞이고
+ 눈금이 뒤다 — 화면에서 이름이 안쪽, 눈금이 바깥쪽이다. */
+ var label = document.createElement("a");
+ label.className = "rail-label";
+ label.setAttribute("href", s.href);
+ label.textContent = s.text;
var seg = document.createElement("span");
seg.className = "rail-seg";
@@ -134,19 +150,10 @@
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 });
+ host.appendChild(seg);
+ return { fill: fill, label: label, el: s.el, f: -1 };
});
- /* 항목이 하나뿐인 목차는 목차가 아니라 링크 하나다. */
- if (rows.length < 2) return;
/* 목차 바로 뒤에 꽂는다. body 끝에 붙이면 화면에서는 본문 옆이지만 탭
순서에서는 본문 뒤가 되고, 다 읽은 뒤에야 닿는 목차는 아무도 안 쓴다. */
@@ -164,8 +171,8 @@
/* 읽는 줄이 마스트헤드에 가리는 만큼을 제목 자신의 scroll-margin-top 에서
가져온다. main.css 가 앵커 점프를 위해 이미 선언해 둔 값이고, 브라우저가
눌러서 뛸 때 쓰는 바로 그 값이다 — 한 군데서 나오므로 "여기 있다"와
- "여기로 간다"가 어긋날 수 없다. --masthead-h 를 고쳐도 따라온다. */
- var pad = parseFloat(getComputedStyle(rows[0].head).scrollMarginTop) || 0;
+ "여기로 간다"가 어긋날 수 없다. */
+ var pad = parseFloat(getComputedStyle(heads[0].el).scrollMarginTop) || 0;
lead = pad + (window.innerHeight - pad) * LINE;
tops = [];
@@ -173,18 +180,16 @@
rows.forEach(function (r) {
/* 앞 경계보다 위로는 못 간다. 서브픽셀이나 sticky 때문에 뒤 제목이 앞
제목보다 위로 재어지면 높이가 음수인 칸이 생긴다. */
- prev = Math.max(absTop(r.head), prev);
+ prev = Math.max(absTop(r.el), prev);
tops.push(prev);
});
- /* 읽기는 본문이 끝나는 데서 끝난다. 페이저와 콜로폰은 글이 아니다. 레일의
- 좌표계는 첫 제목부터 본문 끝까지다 — 머리말은 목차에 없으므로 레일에도
- 없다. */
+ /* 읽기는 본문이 끝나는 데서 끝난다. 페이저와 콜로폰은 글이 아니다. */
tops.push(Math.max(absTop(prose) + prose.offsetHeight, prev + 1));
/* 칸의 높이가 그 절의 몫이다. 아래끝을 두면 아주 짧은 절도 사라지지 않고
- 두 줄짜리 이름이 옆 칸을 침범하지도 않는다 — 겹침 풀이를 격자에 맡긴다.
- ponytail: 절이 열한 개를 넘으면 아래끝의 합이 상자보다 커져 넘친다.
- 그때 손댈 곳은 2.2rem 과 min(58vh, 30rem) 둘이다. */
+ 두 줄짜리 이름이 옆 칸을 침범하지도 않는다.
+ ponytail: 절이 열두 개를 넘으면 아래끝의 합이 상자보다 커져 넘친다.
+ 그때 손댈 곳은 2.2rem 과 min(64vh, 34rem) 둘이다. */
var total = tops[tops.length - 1] - tops[0];
var track = "";
for (var i = 0; i < rows.length; i++) {
@@ -201,9 +206,9 @@
/* 문서 끝에 닿으면 마지막 절은 끝난 것이다. 읽는 줄은 화면 아래 한 뼘을
영영 못 지나가므로, 이게 없으면 마지막 칸은 절대 안 찬다. */
var done = window.scrollY + window.innerHeight >= root.scrollHeight - 2;
- /* 첫 제목 위에서는 아무것도 현재가 아니다. 아직 시작도 안 한 사람에게
- "1절에 있다"고 말하면 그 표시는 남은 페이지 내내 못 믿는다. */
- var now = -1;
+ /* 머리말 칸이 있으므로 첫 화면부터 현재가 있다. 도착한 사람은 실제로
+ 머리말을 읽고 있다. */
+ var now = 0;
for (var i = 0; i < rows.length; i++) {
var f = done ? 1 : (line - tops[i]) / Math.max(1, tops[i + 1] - tops[i]);
@@ -215,8 +220,7 @@
}
/* 화면 상자가 아니라 줄 하나로 고른다. 제목 여럿이 같이 보일 때 누가
이기는지 따질 일이 없다 — 줄 위에 있는 마지막 절이다. 관찰자를 쓰지
- 않으므로 부드러운 스크롤과 싸울 일도 없다: 눌러서 뛰는 동안 표시가
- 지나가는 절들을 훑는 것은, 실제로 그 절들을 지나가고 있어서다. */
+ 않으므로 부드러운 스크롤과 싸울 일도 없다. */
if (line >= tops[i]) now = i;
}
if (done) now = rows.length - 1;
@@ -226,10 +230,8 @@
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");
- }
+ rows[now].label.setAttribute("aria-current", "true");
+ rows[now].fill.classList.add("is-now");
at = now;
}
|