src/pages/writing/[year]/[slug].astro (108713B)
1 ---
2 import Base from '../../../layouts/Base.astro';
3 import { getCollection, render } from 'astro:content';
4 import type { GetStaticPaths } from 'astro';
5 import { postUrl } from '../../../lib/order';
6 import { proseShape, excerpt, longDate } from '../../../lib/prose';
7 import { docLang, hasHangul, langOf } from '../../../lib/hangul';
8 import { assertRendered } from '../../../lib/rendered';
9
10 export const getStaticPaths: GetStaticPaths = async () => {
11 const posts = (await getCollection('writing')).filter((p) => !p.data.draft);
12 return posts.map((p) => ({
13 // params must be the decoded form — Korean slugs are passed through verbatim.
14 params: { year: p.data.date.slice(0, 4), slug: p.id },
15 props: { post: p },
16 }));
17 };
18
19 const { post } = Astro.props;
20 assertRendered(post);
21 const { Content, headings } = await render(post);
22
23 const md = post.body ?? '';
24 const { shape, rhythm } = proseShape(md);
25
26 /**
27 * The byline, and it is a site-level constant rather than a per-post field on purpose.
28 *
29 * The reference prints one on every article — `by <strong>Nicholas Carlini</strong>`
30 * followed by the date, checked on three of its posts across 2024, 2025 and 2026 — so a
31 * byline belongs here. A byline names a person, so this is the person: the same string
32 * Base.astro holds as SITE_OWNER and prints in the copyright line at the foot of every
33 * page, and in the header slot on / and the 404 page. NOT 'sqzer' — that is the domain
34 * (sqzer.com), which Base.astro spends its own comment distinguishing from the
35 * owner's name, and which only ever appears in <title> and the feed title. Printing it
36 * here would have the byline say one name and the copyright line of the same page
37 * another.
38 *
39 * Duplicated rather than imported because SITE_OWNER is a module-scoped const in
40 * Base.astro's frontmatter, and nothing outside a component can import its frontmatter.
41 * Keep the two in sync: src/layouts/Base.astro, `const SITE_OWNER`.
42 *
43 * Site-level rather than per-post because the writing schema has no `author` field. A
44 * per-post byline — a guest post, a co-author — would need that field on the collection,
45 * which is a schema change.
46 */
47 const AUTHOR = 'Hyukjin An';
48
49 const year = post.data.date.slice(0, 4);
50
51 // The body's language, measured on prose only; see docLang in src/lib/hangul.ts.
52 const lang = docLang(md);
53
54 /*
55 * Which font to preload is a different question from which language to declare, and the
56 * two answers differ on real posts. `lang` is a ratio over prose with code fences removed,
57 * so We_need_to_talk is correctly an English document at 0.28 — and it still paints 753
58 * Hangul syllables, because both English essays carry a KR half. Preloading by `lang`
59 * would leave those in fallback behind a third-wave request. One Hangul character anywhere
60 * in the rendered body is enough to need the whole 118KB face, so the test is presence,
61 * not proportion, and it runs on the raw source including fences: 20 of the corpus's 55
62 * fenced blocks (52 code plates and 3 diagrams) contain Hangul, in comments, placeholders
63 * and diagram labels.
64 */
65 const ko = hasHangul(md, post.data.title, post.data.deck);
66
67 /**
68 * deck is empty on all 30 posts, so the description falls back to excerpt(): the opening
69 * paragraph as plain text, cut to 160 characters.
70 */
71 const description = post.data.deck ?? excerpt(md);
72
73 /**
74 * Contents are the default, not a reward for being technical.
75 *
76 * The premise is that every post gets headings. 14 of the 30 have none today and 2 have
77 * one; that is a gap in the prose, not a property of those documents, and the gate is
78 * written for the corpus they are becoming rather than the one they are. So it asks a
79 * single structural question — is there a heading structure here a reader could navigate
80 * — and nothing at all about genre.
81 *
82 * Three levels deep, counted from the document's own top heading rather than from <h1>.
83 * An absolute `depth <= 3` looks equivalent and is not, because satteri-prose demotes
84 * every heading by one in any document that contains an h1 (and no h6) — so "depth 3" is
85 * the author's ### in 안전하고 and the author's ## in tso, and an absolute filter silently
86 * means a different thing on either side of that. Counted in the built DOM:
87 *
88 * absolute <= 3 relative to top
89 * tso 5 11 six ### subsections kept
90 * journalctl 2 6 four ### sections kept
91 * sysmon 11 18
92 *
93 * On the absolute count journalctl — a command reference, the single most jumped-around
94 * document in the corpus — would show two entries. Every post with more than one heading
95 * starts at h2 after demotion, so today the two readings differ only in where they stop;
96 * the relative form is what keeps them in agreement if a document ever opens at h3 or the
97 * demotion rule moves.
98 *
99 * The distribution over the 30 posts, entries per document:
100 *
101 * 28 19 19 18 11 10 6 6 6 4 3 2 2 2 | 1 1 | 0 x14
102 * ^ the 14 that get contents today ^ ^ nothing to navigate ^
103 *
104 * >= 2 -> 14 >= 4 -> 10 >= 6 -> 9
105 * >= 3 -> 11 >= 5 -> 9 >= 7 -> 6
106 *
107 * 2, and it is ONE gate for both presentations. What decided it:
108 * - 1 is not a list. A single entry offers no choice — it is a link to a point in the
109 * middle of the page, filed under a heading that says "Contents" — and the rail would
110 * spend at least 240px of gutter and a full-height rule stating it. Two is the
111 * smallest list a reader can navigate WITH. It is the only threshold that follows
112 * from the premise rather than from this corpus's accidents, which is what makes it
113 * safe to leave in place while the 16 documents below the line are being written into.
114 * - not 6, the threshold the gap in this distribution suggests. That gap is exactly the
115 * artefact the premise dissolves: every document near it is one editing pass away
116 * from moving across it. 6 would also select nine documents while the page box
117 * reserves room for a rail on all thirty (see the rail block).
118 * - no `shape === 'technical'` test. A genre test keeps a long list of decorative
119 * headings out of an essay, and at two entries there is no long list to keep out. An
120 * essay with sections is a document with sections: this admits AIOdyssey and
121 * We_need_to_talk, both essay-shaped, both two real sections.
122 * - one gate, not two. A rail-only gate wider than the inline one would leave those
123 * posts with no contents at all below the width where the rail parks.
124 *
125 * What the 16 below the line get: nothing painted — no rail, no inline list, no rule.
126 * What does not change for them is the page box, which reserves the rail's gutter on
127 * every article whether or not a rail is painted in it; see `main:has(.post-shell)` in
128 * the rail block below for why that is the steady state rather than a concession.
129 */
130 /*
131 * One heading in `headings` was not written by the author. GFM footnotes close the document
132 * with <section class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>,
133 * and Sätteri's heading-id plugin collects it like any other — measured, depth 2, slug
134 * "footnote-label". It is drawn nowhere (.sr-only in base.css), so leaving it in puts an
135 * entry in the rail that jumps to an invisible target, and at a two-entry gate a post with
136 * one real heading and one footnote would get a whole rail for that. It also joins the
137 * Math.min below, so a footnoted document whose headings all start at h3 would take its
138 * base from the footnote label instead and mis-level every entry under it.
139 * No post uses footnotes today; two have exactly one heading, which is the case this
140 * protects. Filtered by slug rather than by text because the text is translatable.
141 */
142 const authored = headings.filter((h) => h.slug !== 'footnote-label');
143 const tocBase = authored.length ? Math.min(...authored.map((h) => h.depth)) : 2;
144 const toc = authored.filter((h) => h.depth <= tocBase + 2);
145 const showToc = toc.length >= 2;
146
147 /**
148 * Indent by position in this document's own hierarchy, not by absolute depth — the same
149 * reason the filter above is relative. 0, 1 or 2 by construction.
150 */
151 const tocLevel = (depth: number) => depth - tocBase;
152
153 const ld = {
154 '@context': 'https://schema.org',
155 '@type': 'BlogPosting',
156 headline: post.data.title,
157 datePublished: post.data.date,
158 inLanguage: lang,
159 /* Omitted rather than emitted empty. `description` is '' when the deck is blank AND
160 excerpt() finds no paragraph to fall back on — a post copied from the template whose
161 body has not been written yet. Both other sinks already drop it in that case:
162 Base.astro guards <meta name="description"> behind a truth test, and @astrojs/rss
163 omits the element. Emitted empty, this key would be the one place a blank field
164 still put an empty value into the page, asserting that the post has an empty
165 description. */
166 ...(description ? { description } : {}),
167 mainEntityOfPage: new URL(postUrl(post), Astro.site).href,
168 };
169 ---
170 <Base
171 title={`${post.data.title} :: debuman`}
172 description={description}
173 section="writing"
174 ko={ko}
175 >
176 {/* Outside .post-shell, and that placement is load-bearing rather than tidy. The shell
177 carries `container-type: inline-size`, which implies layout containment, which makes
178 it a containing block for position:fixed descendants. Put the rail inside it and
179 `right: var(--toc-x)`, a percentage expression, resolves against the 1024px shell
180 instead of the viewport, `top` measures from the shell, and the whole thing scrolls
181 away with the article. Here in <main>, which sets no containment, it is fixed to the
182 viewport as intended.
183 In reading order it precedes the article, which is where a contents list belongs. */}
184 {showToc && (
185 <nav class="toc-rail" aria-labelledby="toc-rail-title">
186 <p class="toc-rail__title" id="toc-rail-title">Contents</p>
187 <ol class="toc-rail__list">
188 {toc.map((h) => (
189 <li class="toc-rail__item" data-level={tocLevel(h.depth)}>
190 {/* data-target, not the href, is what the scroll-spy resolves: the headings of
191 a Korean post have Hangul slugs, and `a.hash` comes back percent-encoded in
192 some engines, so reading the slug straight out of the attribute skips a
193 decode step that has to be right for every one of them. */}
194 {/* lang per entry, and it is this rail's price for living outside .post.
195 The article declares its language on <article class="post">, and the
196 inline <details class="toc"> sits inside it, so the collapsed list inherits
197 that language and marks only the entries in the other script. The rail
198 cannot inherit it: it is hoisted out to <main> so that .post-shell's
199 `container-type` does not become its containing block, and out there the
200 nearest [lang] is <html lang="en">. Without a lang of their own, 15 of
201 sysmon's 18 entries, all 10 of bind9's and 8 of tso's 11 are Hangul
202 announced as English — the same strings the inline list announces
203 correctly. Per entry rather than one lang on the <nav>, because out here
204 `en` really is the document: how-to-win's list is 25 English headings to 3
205 Korean, and langOf() drops an agreeing value so a reader is not switched
206 for them. Same call the Writing index makes on the same kind of string. */}
207 <a class="toc-rail__link" href={`#${h.slug}`} data-target={h.slug} lang={langOf(h.text, 'en')}>{h.text}</a>
208 </li>
209 ))}
210 </ol>
211 </nav>
212 )}
213 <div class="post-shell">
214 <article class="post" data-shape={shape} data-rhythm={rhythm} lang={lang}>
215 <header class="post__head">
216 <p class="post__kicker">
217 <a href="/writing/">Writing</a>
218 <span class="post__sep" aria-hidden="true">·</span>
219 <a href={`/writing/#${year}`}>{year}</a>
220 </p>
221 <h1 class="post__title" lang={langOf(post.data.title, lang)}>{post.data.title}</h1>
222 {post.data.deck && <p class="post__deck">{post.data.deck}</p>}
223 {/* No reading-time estimate: the reference's header is a title, a byline and a
224 date, and it prints no estimate of how long the reading will take. The byline
225 takes the slot under the title. lang="en" because the word "by" and the
226 Latin-script name are the same on every post, most of which declare lang="ko" on
227 <article> — the same reason the contents rail labels its entries one by one. */}
228 {/* The gap between name and date is padding, so without the hidden comma the text
229 itself would read "Hyukjin AnJune 27, 2026" to a screen reader, to Reader View
230 (which takes this paragraph as the byline) and in anything copied from it. */}
231 <p class="post__byline" lang="en">by <span class="post__author">{AUTHOR}</span><span class="sr-only">, </span><time class="post__date" datetime={post.data.date}>{longDate(post.data.date)}</time></p>
232 </header>
233
234 {showToc && (
235 <details class="toc">
236 <summary class="toc__summary">Contents</summary>
237 <ol class="toc__list">
238 {toc.map((h) => (
239 <li class="toc__item" data-level={tocLevel(h.depth)}><a href={`#${h.slug}`} lang={langOf(h.text, lang)}>{h.text}</a></li>
240 ))}
241 </ol>
242 </details>
243 )}
244
245 <div class="prose"><Content /></div>
246 </article>
247 </div>
248 {/* JSON.stringify does not escape `<`, so a deck or a title containing `</script>` closes
249 this element early and the rest of the object is parsed as HTML. The title is the
250 author's and the deck is a field they fill in freely, so this is a string they can
251 type, not a string only the build produces. `<` is escaped as its `\u003c` form, which
252 is the same character to a JSON parser and inert to an HTML one. */}
253 <script type="application/ld+json" set:html={JSON.stringify(ld).replace(/</g, '\\u003c')} />
254
255 <script>
256 /*
257 * Current-section marking for the contents rail.
258 *
259 * Enhancement only. Without it every entry in the rail is still a working anchor, and
260 * Astro renders this script into all 30 article pages, so the first test is the guard
261 * that makes it a no-op on the 16 that have no rail today.
262 *
263 * No scroll handler, and no geometry measured to decide which entry is current — an
264 * IntersectionObserver, with a root shaped so that `isIntersecting` is already the
265 * answer. (Two reads further down: the rail's own scrollport, only when the section
266 * changes, and scrollY, only while the end of the document is on screen, to tell a page
267 * the reader has scrolled from one that fits on screen whole.)
268 *
269 * The reading line is 15% of the way down the viewport, and a heading counts as current
270 * once it has crossed it. Expressing that as "is the heading inside a band at the top of
271 * the viewport" does not work, and the failure is not theoretical: a band is a single
272 * boolean, so above-the-band and below-the-band are the same state, and an instant jump
273 * over the band — Home, a restored scroll position, window.scrollTo — changes nothing
274 * and fires no callback. So with a band, scrollTo(0, 0) from the foot of how-to-win
275 * would leave the rail marking "Conclusion" at the top of the page.
276 *
277 * So the root is not a band but a half-plane: 100,000px of margin above the viewport and
278 * -85% below, giving a box that runs from far above the document to the reading line.
279 * A heading intersects it exactly while it is above that line, "above" and "below" are
280 * now different states, and every crossing in either direction fires — instant jumps
281 * included. The tallest document is under 17,000px at 1440px, the narrowest window that
282 * shows the rail, so 100,000px is roughly six times it and nothing can drift out of the
283 * top of the root.
284 *
285 * 15% is 135px on a laptop, against the 16px (--sp-40) of scroll-margin the headings
286 * carry — so following a rail entry always lands its own heading above the line and
287 * marks the entry that was clicked. Where an h2 is followed immediately by its first
288 * h3, both clear the line at once and the h3 wins; that is the rule working, not an
289 * off-by-one, since the h2 has no text of its own at that point.
290 *
291 * The current section is then the LAST heading that has crossed, in document order.
292 * None crossed means the reader is above the first heading — in the lede, or back at the
293 * top of the document — and the answer there is the first entry, not nothing: a reader
294 * who scrolls to the foot of how-to-win and presses Home sees entry one marked. The
295 * marks on the way down may also have scrolled the rail down its own list, which would
296 * open it mid-entry with CONTENTS off the top, so whenever the answer is entry one the
297 * rail is returned to its own scrollTop 0 and the title and the first entry are whole.
298 *
299 * One correction to that rule, at the far end. A heading can only reach the line if the
300 * document is tall enough to push it there, so a final section shorter than 85% of the
301 * viewport can never be marked at all: on how-to-win, scrolled to the very bottom,
302 * "Conclusion" sits in full view well below the line, and the last-crossed rule alone
303 * would leave the rail pointing at the section before it. A second observer watches an
304 * empty marker appended to the end of <body>, against the plain viewport. It is on
305 * screen only when the page is scrolled as far as it goes, which is the one position a
306 * short final section can never be marked from, so there the last entry is the answer.
307 * Watching the end of the document rather than anything above it keeps this from firing
308 * while the last heading can still be scrolled up to the line.
309 */
310 const rail = document.querySelector('.toc-rail');
311 if (rail) {
312 const links = new Map();
313 const targets = [];
314 for (const a of rail.querySelectorAll('.toc-rail__link')) {
315 const el = document.getElementById(a.dataset.target);
316 if (!el) continue;
317 links.set(el.id, a);
318 targets.push(el); // rail order, which is heading order, which is document order
319 }
320
321 if (targets.length) {
322 const passed = new Set();
323 let atEnd = false;
324 let marked = null;
325
326 const apply = () => {
327 // Entry one is the floor, not null: above the first heading the reader is in the
328 // lede of section one, and that is what the rail should say.
329 let next = targets[0].id;
330 for (const el of targets) if (passed.has(el.id)) next = el.id;
331 // Only once the reader has moved: an article short enough to fit on screen whole
332 // shows its end marker on load, before anything has been read. A short one that
333 // scrolls a little can reach its end with no heading past the line, and the last
334 // entry is still the answer there, so scrollY counts as well as `passed`.
335 if (atEnd && (passed.size || scrollY > 0)) next = targets[targets.length - 1].id;
336
337 /* The one place the rail's own scroll position is set outright rather than nudged.
338 scrollIntoView({ block: 'nearest' }) is a no-op once the entry is already in
339 view, so coming back up to entry one from the foot of the article would leave
340 the list wherever the last nudge had put it: on how-to-win at 1440x900, with its
341 head scrolled out of view. At the top of the document the rail belongs at the
342 top of itself.
343 Written on every callback that lands here rather than only on a change: it is
344 one property write, the callbacks are section crossings and not frames, and it
345 also recovers a rail the reader had scrolled by hand. */
346 const atStart = next === targets[0].id;
347 if (atStart) rail.scrollTop = 0;
348
349 if (next === marked) return;
350
351 if (marked) links.get(marked).removeAttribute('aria-current');
352 marked = next;
353
354 const link = links.get(marked);
355 link.setAttribute('aria-current', 'location');
356 /* Keep the mark inside the rail's own scrollport — one of the 14 lists is taller
357 than it at 1440x900, and the count only rises as posts gain headings. Read only
358 when the section changes, which is a handful of times over an article rather
359 than once a frame. Skipped at the start, where `rail.scrollTop = 0` has already
360 put the whole head of the list on screen. */
361 if (!atStart && rail.scrollHeight > rail.clientHeight) link.scrollIntoView({ block: 'nearest' });
362 };
363
364 const reading = new IntersectionObserver(
365 (entries) => {
366 for (const entry of entries) {
367 if (entry.isIntersecting) passed.add(entry.target.id);
368 else passed.delete(entry.target.id);
369 }
370 apply();
371 },
372 { rootMargin: '100000px 0px -85% 0px' },
373 );
374 for (const el of targets) reading.observe(el);
375
376 // One pixel tall, pulled up over the pixel it adds, so it moves nothing and occupies
377 // the document's last pixel. Zero height does not work: when the document's height
378 // has a fraction that rounds the scroll range down, a zero-area marker sits just past
379 // the viewport at full scroll and is not detected, so a short final section is never
380 // marked.
381 const end = document.createElement('div');
382 end.setAttribute('aria-hidden', 'true');
383 end.style.height = '1px';
384 end.style.marginBlockStart = '-1px';
385 document.body.append(end);
386 new IntersectionObserver((entries) => {
387 atEnd = entries[entries.length - 1].isIntersecting;
388 apply();
389 }).observe(end);
390 }
391 }
392 </script>
393
394 <script>
395 /*
396 * Copy control for code blocks.
397 *
398 * Enhancement, and built here rather than emitted by the `sqzer:code-block` transformer
399 * on purpose: a button that cannot copy is worse than no button, so the control exists
400 * only where the script that gives it a job has run. What the transformer must not do is
401 * grow a second child — Astro reads `root.children[0]` and drops the rest — and what it
402 * does emit is the contract this reads: figure.code[data-language] > pre > code.
403 *
404 * The row the button lands in is NOT made here. base.css reserves it on every plate, so
405 * nothing reflows when this runs and the band is the same with a figcaption (where the
406 * label and the button share one row) and without (7 of the corpus's 52 plates, the ones
407 * whose fence names no language or `plaintext`, get no figcaption at all).
408 *
409 * What gets copied is `code.textContent`. Shiki's per-token <span>s contribute their text
410 * and nothing else, the break between two rows is a real newline text node, and the
411 * language label lives in a <figcaption> outside the <pre> — so the string is the
412 * fence's source back, exactly, with no markup and no label in it. There is no trailing
413 * newline, which is the fence's own shape and also the safer paste: a shell runs the
414 * last line when it arrives with one, and does not when it does not.
415 *
416 * Insertion is `prepend`, not `append`, so the tab order matches the reading order: the
417 * button at the top right of the plate, then the <pre>, which carries Shiki's own
418 * tabindex="0" as a scroll region.
419 */
420 const plates = document.querySelectorAll('.prose figure.code');
421 if (plates.length) {
422 /*
423 * One polite live region for the page, created up front. A region that is inserted
424 * and filled in the same task is missed by most screen readers, so it has to be in
425 * the tree before there is anything to say.
426 *
427 * role="status" is aria-live="polite" plus aria-atomic="true" without spelling either
428 * out. Assigning the same string twice is commonly swallowed as "no change", and
429 * copying the same block twice is a normal thing to do, so the region is emptied and
430 * refilled on the next frame instead.
431 */
432 const status = document.createElement('p');
433 status.className = 'sr-only';
434 status.setAttribute('role', 'status');
435 document.body.append(status);
436
437 // Emptied again two seconds later: the region sits after the footer, and a message
438 // left in it would be read out of context by anyone reading on to the end of the page.
439 let frame = 0;
440 let clear = 0;
441 const announce = (message) => {
442 cancelAnimationFrame(frame);
443 clearTimeout(clear);
444 status.textContent = '';
445 frame = requestAnimationFrame(() => {
446 status.textContent = message;
447 clear = setTimeout(() => { status.textContent = ''; }, 2000);
448 });
449 };
450
451 /*
452 * Three glyphs in one <svg>; CSS paints whichever data-state names. 16px box, 1.5px
453 * stroke, mitred joins and square caps — the page's own vocabulary is a 4px rule and a
454 * 1px hairline, so the icon is drawn in straight lines with no corner radius anywhere.
455 */
456 const GLYPHS = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="miter" aria-hidden="true" focusable="false"><g data-glyph="copy"><path d="M5.5 5.5V1.5h9v9h-4"/><rect x="1.5" y="5.5" width="9" height="9"/></g><g data-glyph="done"><path d="M3.5 8.5l3 3 6-7"/></g><g data-glyph="fail"><path d="M4.5 4.5l7 7m0-7l-7 7"/></g></svg>`;
457
458 /*
459 * navigator.clipboard needs a secure context. localhost and the deployed HTTPS site
460 * both are one, so the first branch is the one that runs in practice — but it can
461 * still reject (a permissions policy on an embedding page, a document that is not
462 * focused, a user denying the permission prompt some engines still show), and the
463 * whole point of a copy button is that the reader can tell whether it worked.
464 *
465 * So the fallback is also the failure affordance, in one gesture: select the block.
466 * execCommand('copy') is deprecated but implemented everywhere, and what it asks for is
467 * a user gesture rather than a permission — a click is one. If even that refuses, the
468 * selection stays up and the reader finishes the job with the keyboard, which is what
469 * the live region then tells them to do. Measured, with the async API forced to reject:
470 * a real click still copies through this path; a script-dispatched .click() does not,
471 * which is the gesture requirement doing its job.
472 */
473 const copy = async (code) => {
474 const text = code.textContent ?? '';
475 if (navigator.clipboard?.writeText) {
476 try {
477 await navigator.clipboard.writeText(text);
478 return 'done';
479 } catch { /* fall through to the selection path */ }
480 }
481 const selection = window.getSelection();
482 if (!selection) return 'fail';
483 const range = document.createRange();
484 range.selectNodeContents(code);
485 selection.removeAllRanges();
486 selection.addRange(range);
487 try {
488 if (document.execCommand('copy')) {
489 selection.removeAllRanges();
490 return 'done';
491 }
492 } catch { /* leave the selection standing — it is the fallback */ }
493 return 'fail';
494 };
495
496 for (const plate of plates) {
497 const code = plate.querySelector('pre > code');
498 if (!code) continue;
499
500 const lang = plate.dataset.language;
501 /* The control shows no text, so the name is the whole of it. It names the action and
502 the thing, never the outcome: a button whose accessible name changes to "Copied"
503 is a button that lies about what it does next. The outcome goes to the region. */
504 const name = lang && lang !== 'plaintext' ? `Copy ${lang} code` : 'Copy code';
505
506 const button = document.createElement('button');
507 button.type = 'button';
508 button.className = 'code__copy';
509 button.dataset.state = 'idle';
510 button.setAttribute('aria-label', name);
511 button.title = name; // the same string as a tooltip, for a pointer user
512 button.innerHTML = GLYPHS;
513
514 let revert = 0;
515 button.addEventListener('click', async () => {
516 const state = await copy(code);
517 button.dataset.state = state;
518 announce(state === 'done'
519 ? 'Copied'
520 : 'Copy failed. The code is selected — copy it with the keyboard.');
521 clearTimeout(revert);
522 revert = setTimeout(() => { button.dataset.state = 'idle'; }, 2000);
523 });
524
525 plate.prepend(button);
526 }
527 }
528 </script>
529 </Base>
530
531 {/* is:global, not scoped. Astro rewrites a scoped `.prose p` to `.prose p[data-astro-cid-…]`,
532 and the elements <Content /> emits carry no cid, so every body rule would silently miss.
533 Only this page ships this sheet. Every selector matches the article's wrapper,
534 .post-shell, or something inside it, except the contents rail, the hairline beside it
535 (`main:has(.toc-rail)::before`) and the two rules that move the page box out of its
536 way — `:root` and `main:has(.post-shell)`. Those two are the widening the rail asked
537 for; they apply to every article rather than only the ones carrying a rail, and outside
538 1440–1584px they give <main> the same margins base.css alone does (from 1440px up it is
539 also position: relative, which moves nothing). Nothing here touches the
540 site header: it is the same bar in the same place on every route, and base.css owns
541 it. All of this is explained in the rail block below. */}
542 <style is:global>
543 /* ══ Geometry ════════════════════════════════════════════════════════════
544 One left edge, with one bounded exception that is named here rather than left to be
545 discovered. The text column is the same 928px the index uses, hung off the same 72px
546 rail, so an article title starts exactly where the index entry title it was clicked
547 from started — measured 0.00px apart at 320 / 768 / 1024 / 1584 / 1700 / 1920, on
548 every article.
549
550 The exception is a width band, 1440 to 1584px, and it is the same for all 30
551 articles. There the rail's 240px floor does not fit in a centred gutter, so
552 `main:has(.post-shell)` pushes the page box right far enough to make it fit and the
553 two edges part: measured 72px at 1440, 48 at 1488, 24 at 1536, 0 from 1584 up and 0
554 below 1440, where the rail is parked and the inline list is the contents. Those are
555 layout-viewport widths, so a classic 15px scrollbar shifts the whole band 15px out
556 and the true worst case is 79.5px, at a 1440px window. It is a deliberate trade — the
557 alternative to the shift is to withhold the rail entirely across that 144px band —
558 and the cost is bounded by arithmetic rather than by a literal: --page-left is a
559 max() against the centred position, so the moment the gutter can seat the rail on its
560 own the shift is exactly zero and never negative. The gutter is reserved whether or
561 not a rail is painted in it, so moving from a post with contents to one without does
562 not step the left edge. See the rail block below for
563 the geometry, the --w-content note in tokens.css and the --content-w note in
564 src/pages/writing/index.astro for the other two ends of the same edge.
565
566 Inside that band the site header does not follow the page box. Restating base.css's
567 header padding against --page-left would make the bar slide right and go asymmetric
568 on article pages and nowhere else, so the bar stays where it is on every route, and
569 across the band the wordmark stays on the centred edge while the page box leaves it.
570 Measured, page box minus wordmark, at a real window with a classic scrollbar: 79.5px
571 at 1440, 49.5 at 1500, 7.5 at 1584, 0.00 at 1700 / 1920 / 2560 and 0.00 at 1262 and
572 below. It is the same shift already quoted above, read against the bar instead of
573 against the index. The rail block below has the argument; base.css has the contract.
574
575 `scrollbar-gutter: stable` on the root does not change any number in this block — a
576 classic scrollbar still takes its 15px out of the layout viewport, which is why the
577 worst case is at 1440 and why the shift is not yet 0 at a 1584px window. What it
578 changes is that pages too short to scroll lose the same 15px, so the header's own
579 centring is computed from one width across the site instead of two.
580
581 Nothing breaks out of the edge on the page's own side: the rail lives outside .post
582 and the measure is never widened to reach it. */
583 .post {
584 --rail-w: 4.5rem; /* 72px, the index date rail */
585 --rail-gap: 1.5rem; /* 24px */
586 --fs-kicker: clamp(0.63rem, 0.36vw + 0.54rem, 0.88rem); /* 10.08→14.08 */
587
588 /* Korean leading. Measured from the shipped fonts: a Pretendard syllable advances
589 0.8643em, times KoBody's 94.29% size-adjust = 0.8149em, so the 928px column holds
590 71.2 syllables at 16px (Lora averages about 110 Latin characters on a wrapped line
591 there, measured on the two English essays). Hangul also fills a 0.840em ink band on
592 every character, where Lora's lowercase mostly occupies its 0.500em x-height and
593 only the occasional ascender or descender reaches the 1.031em extreme - so at 1.6
594 the Korean line loses about a third of its interline white. 1.75 returns nearly half
595 of it. 1.90 would equalise it, at the price of the --sp-para gap between paragraphs
596 no longer reading as a gap. */
597 --lh-ko: 1.75;
598
599 display: grid;
600 grid-template-columns: var(--rail-w) minmax(0, 1fr);
601 column-gap: var(--rail-gap);
602 max-width: calc(var(--rail-w) + var(--rail-gap) + var(--w-content)); /* 1024px */
603 margin-inline: auto;
604 }
605 .post > * { grid-column: 2; }
606
607 /* The query container is this wrapper, not `.post` itself. An element cannot be
608 styled by a container query it establishes, so with `container-type` on `.post`
609 the rail-collapse block below would reach every descendant but never `.post`: the
610 children would move to column 1 while the columns stayed `72px minmax(0, 1fr)`, and
611 the whole article would render 72px wide on any viewport under ~623px. */
612 .post-shell { container: post / inline-size; }
613
614 /* ══ Header ══════════════════════════════════════════════════════════════
615 Every Gazette header style is built around a picture: .article-header is a grid
616 whose image slot is 316–500px tall (`height: clamp(19.75rem, 28.35vw + 13.11rem,
617 31.25rem)`) and the text is the narrow column beside it. Remove the image and
618 nothing holds the grid open, so the header has to be rebuilt, not emptied.
619
620 Three things stand in for the picture, and none of them is filler:
621 1. the 4px x 3rem rule, laid down as the page's first ink. The Gazette hangs it
622 off .article-header__meta::before; this site's index already hangs it above
623 the year kicker, and matching the index matters more than matching the
624 Gazette's internal placement — it is the mark you just clicked away from.
625 2. air above it and --sp-64 below the byline. An opening gap wider than the
626 body's 48px block rhythm is what actually says "the header ended".
627 3. the title at --fs-h1, not --fs-h1-large. Measured over the real 30 titles: at
628 --fs-h1 every one sets on a single line from 1024 up and the worst at 375 is
629 three lines, on one title; --fs-h1-large pushes four more titles onto a second
630 line at 375 and still sets one on three.
631
632 How much air. The number that matters is not this padding but the distance from the
633 bar's bottom rule to the 4px mark, and this padding is only the second half of it:
634 `main` already opens with --sp-64 above anything an article draws. At --sp-80 the two
635 stack to about 138px at 1700 and 133 at 1500, under a bar about 60px tall — two
636 large steps of nothing before the first ink, which is what reads as a hole rather
637 than as an opening.
638
639 Measured on the reference at 1700, from the bottom of the bar above it to the top of
640 its own article header: 0px on three of its five story styles (split-screen,
641 fullscreen, full-width-text-below — the header simply begins against the bar) and 94
642 to 158px on the two classic ones, which hang an eyebrow block in the gap. Its heading
643 lands 89.5 / 118.2 / 137.7 / 201.7px below the bar across those styles. So the
644 reference's own answer spans from flush to roughly what --sp-80 would give, and the
645 low end of it is where a header with no picture belongs.
646
647 --sp-32 puts the total at a measured 94.8px at 1700 and 93.8 at 1500 — the
648 reference's classic figure, a 43.3px lift from --sp-80, and still about twice the
649 48px block rhythm inside the body, so point 2 above survives intact. It is not
650 collapsed to nothing: 94px of quiet above a 4px rule is an opening, 0px would make
651 the rule read as part of the bar. Measured across the range, header rule to the top
652 of the kicker's mark: 64.0 at 320, 74.7 at 768, 81.6 at 1024, 88.1 at 1262, 92.9 at
653 1440, 93.8 at 1500, 94.2 at 1584, 94.8 at 1700, 95.9 at 1920, 96.0 at 2560. The 320
654 end is both clamps at their floor, which is right where the bar has folded onto two
655 rows and the date rail has collapsed — the wrapped header wants less air above it,
656 not the same. */
657 .post__head {
658 display: grid;
659 grid-template-columns: subgrid;
660 grid-column: 1 / -1;
661 padding-block: var(--sp-32) var(--sp-64);
662 }
663 .post__head > * { grid-column: 2; }
664
665 .post__kicker {
666 margin: 0;
667 font-family: var(--font-body);
668 font-size: var(--fs-kicker);
669 font-weight: 700;
670 line-height: var(--lh-medium);
671 letter-spacing: 0.105rem; /* 1.68px, as measured on .article-header__category */
672 text-transform: uppercase;
673 font-variant-numeric: tabular-nums;
674 color: var(--ppc-text);
675 }
676 /* The signature motif, a 4px rule in --ppc-ui. This page draws it here, on each
677 thematic break, above the footnotes, beside a code plate's language label, under table
678 headers, and as the contents rail's current-entry mark. */
679 .post__kicker::before {
680 content: "";
681 display: block;
682 width: 3rem;
683 border-block-start: 4px solid var(--ppc-ui);
684 margin-block-end: var(--sp-40);
685 }
686 /* No rule under these two, at rest or on hover: hover and focus are the colour step
687 below. `padding-block-end` therefore paints nothing, and on an inline box it changes
688 no line box and no layout: A/B'd with it removed, .post__kicker, .post__title, .prose
689 and the document height are identical at 1700 and at 375, and the only number that
690 moves is this link's own border box, 19.97 -> 18.00 at 1700. What that box still
691 carries is the focus ring and the hit area, which reach 0.14em further down. */
692 .post__kicker a { color: inherit; text-decoration: none; padding-block-end: 0.14em; }
693 /* Hover is a colour step, not a rule. The reference draws its animated underline in one
694 place only - the header nav - and every link in the body of a page, its writing index,
695 its code list and even its year rail, simply lifts from rgb(160,32,32) to
696 rgb(192,48,48) with no decoration at all. Measured over CDP on all three. */
697 .post__kicker a:focus-visible { color: var(--crimson); }
698 @media (hover: hover) {
699 .post__kicker a:hover { color: var(--crimson); }
700 }
701
702 /* Forced colours override `color` at rest and on hover alike, so the colour step above
703 paints nothing there. An underline is what that mode can still draw, so it is the
704 hover and focus mark here, 0.3em down to clear this line's tracked capitals. */
705 @media (forced-colors: active) {
706 .post__kicker a:focus-visible { text-decoration: underline; text-underline-offset: 0.3em; }
707 }
708 @media (forced-colors: active) and (hover: hover) {
709 .post__kicker a:hover { text-decoration: underline; text-underline-offset: 0.3em; }
710 }
711 .post__sep { margin-inline: 0.35em; color: var(--page-hairline); }
712
713 /* The date sits in the byline, whole, not out in the rail. `09.08` in the 72px date
714 column with the year three lines above it in the kicker would split one date across
715 two places and print neither of them whole. The reference puts the whole thing on the
716 byline: `by <b>name</b>` then a 2em gap then the date in a muted grey, and that is the
717 shape reproduced here. The format is not the reference's — it writes 2025-01-05
718 where this writes "September 8, 2026" — so longDate() supplies the text and
719 `datetime` keeps the ISO form for machines. longDate is pinned to UTC; reading these
720 dates in a local time zone can shift them by a day.
721
722 No tabular-nums: there is no column of dates here to line up. */
723 .post__date {
724 padding-inline-start: 2em; /* the reference's own gap between name and date */
725 white-space: nowrap;
726 }
727
728 /* Stays in the measure column, on the entry title's edge. This heading is the entry you
729 clicked, and it lands where that entry title was on the Writing index — measured
730 0.00px apart at 320 / 768 / 1024 / 1700 / 1920, and apart only inside the rail's
731 1440–1584 band. Moving it alone would also part it from its own kicker, byline and
732 first paragraph, which are all in this column; moving the whole header out would give
733 the article two left edges, one for the header and one for the body under it.
734
735 The cost, stated rather than hidden: this <h1> does not share the header's left edge.
736 Measured, wordmark to title: 96px at 1700, and 175.5px at a 1440px window, where the
737 page box is pushed right for the rail and the bar stays put. */
738 .post__title {
739 grid-row: 2;
740 margin-block: var(--sp-24) 0; /* .article-header h1 { margin: spacing-24 0 } */
741 font-size: var(--fs-h1);
742 word-break: keep-all;
743 text-wrap: balance;
744 }
745
746 /* Empty on all 30 posts today. The synthesised excerpt is deliberately NOT printed
747 here: it is the article's own first sentence, and showing it directly above that
748 same sentence is a duplication the reader sees at once. It goes to the metadata only:
749 <meta name="description">, the JSON-LD and the feed. */
750 .post__deck {
751 margin-block-start: var(--sp-24);
752 font-family: var(--font-display);
753 font-size: var(--fs-h4);
754 line-height: var(--lh-close);
755 color: var(--page-fg);
756 word-break: keep-all;
757 text-wrap: pretty;
758 }
759
760 /* No tabular-nums: the only digits here are the date's, set in running text with no
761 column to line up. The name carries the weight and the page's own ink, the way the
762 reference sets its byline (`by` plain, the name at 600 in its own colour); 700 rather
763 than 600 because that is this site's one strong weight and Lora ships it. `by` stays
764 at --page-meta, so the two read as label and value. */
765 .post__byline {
766 margin-block-start: var(--sp-24);
767 font-size: var(--fs-small);
768 line-height: var(--lh-base);
769 letter-spacing: 0.02em;
770 color: var(--page-meta);
771 }
772 .post__author { font-weight: 700; color: var(--page-fg); }
773
774 /* ══ Contents ════════════════════════════════════════════════════════════
775 14 of the 30 posts carry one today and the design assumes all 30 eventually will (see
776 the note beside showToc). It has two presentations and exactly one of them is on at
777 any viewport width: the fixed rail in the left gutter from 1440px up, this collapsed
778 inline list below that. The switch is a single media query further down, which turns one
779 on and the other off in the same block, so "both" and "neither" are not reachable
780 states — and the hairline beside the rail is generated in that same block, so it
781 cannot outlive the thing it marks either.
782
783 This is the narrow form: collapsed, so it costs one line of the column it sits in. */
784 .toc {
785 margin-block-end: var(--sp-64);
786 border-block: 1px solid var(--page-hairline);
787 padding-block: var(--sp-30);
788 }
789 .toc__summary {
790 cursor: pointer;
791 font-family: var(--font-body);
792 font-size: var(--fs-kicker);
793 font-weight: 700;
794 letter-spacing: 0.105rem;
795 text-transform: uppercase;
796 color: var(--ppc-text);
797 }
798 .toc__list { margin: var(--sp-40) 0 var(--sp-30); padding: 0; list-style: none; }
799 /* keep-all, for the same reason the rail below declares it: 12 of the 14 posts that
800 carry contents are Korean, and a Korean line breaks between word units, never inside
801 one. keep-all behaves as `normal` for the Latin headings of the other two, so one
802 declaration still covers both.
803 This list sits beside .prose rather than inside it, so it inherits nothing from the
804 .prose rule further down and would default to `word-break: normal`. Without this
805 line the longest heading (73 characters, on the cognitive-dissonance post) breaks
806 inside a Korean word at phone and tablet widths, and two more on the same post do at
807 375. Wrapping itself is rare - at layout widths of 320, 375, 430 and 768 with no
808 classic scrollbar, 6, 3, 2 and 1 of the 136 entries wrap, and none from 1024 up - so
809 the defect is rare only because the wrapping is.
810
811 break-word is the escape valve and never has to fire: at 320, where the box is
812 narrowest, the widest segment keep-all refuses to break is 72.1px of Hangul, or
813 92.5px counting the one Latin filename among the headings, against a 256px text box.
814 Measured with it: 0 in-word breaks and 0 overflow at 320 / 375 / 768 / 1024 / 1439. */
815 .toc__item {
816 margin-block-start: 0.4rem;
817 font-size: var(--fs-medium);
818 line-height: var(--lh-base);
819 word-break: keep-all;
820 overflow-wrap: break-word;
821 }
822 /* One step per level, off this document's own top level - see tocLevel(). A rule keyed
823 on `data-depth="3"` would only be equivalent while the filter stopped at depth 3; the
824 list reaches one level deeper, where h3 and h4 would share an indent. That would
825 flatten tso's six ### subsections onto their ## parents, and sysmon's seven and
826 journalctl's four with them. */
827 .toc__item[data-level="1"] { padding-inline-start: var(--sp-40); }
828 .toc__item[data-level="2"] { padding-inline-start: calc(2 * var(--sp-40)); }
829 /* No underline at rest or on hover. The link is set in the page's ink, and hover and
830 focus are the colour step to --crimson, the same gesture as the kicker's. The 0.07em
831 of `padding-block-end` paints nothing; on an inline box it moves no line box, and
832 what it still extends is the link's own border box, which the focus ring and the hit
833 area follow. */
834 .toc__item a { font-weight: 400; text-decoration: none; color: var(--page-fg);
835 padding-block-end: 0.07em; }
836 .toc__item a:focus-visible { color: var(--crimson); }
837 @media (hover: hover) {
838 .toc__item a:hover { color: var(--crimson); }
839 }
840
841 /* Forced colours override `color` at rest and on hover alike, so the colour step above
842 paints nothing there. An underline is what that mode can still draw, so it is the
843 hover and focus mark here. */
844 @media (forced-colors: active) {
845 .toc__item a:focus-visible { text-decoration: underline; }
846 }
847 @media (forced-colors: active) and (hover: hover) {
848 .toc__item a:hover { text-decoration: underline; }
849 }
850
851 /* ── the rail ────────────────────────────────────────────────────────────
852 The reference site floats a fixed #leftnav at `left: calc(50% - 31em)` and slides it
853 to `left: -10em` under 80em. Same behaviour, positioned off this grid's own tokens
854 rather than a hand-fitted em value.
855
856 The geometry is declared once, on :root, and three rules read it: the rail's own
857 inline-size, the rail's --toc-x inside the switch, and the page box's start margin.
858 The hairline that runs beside the rail reads only --toc-gap from it and otherwise
859 follows the page box through <main>. Nothing below restates any of it, which is the whole reason these
860 cannot drift apart.
861
862 `100%`, never `100vw`. Every consumer of these properties resolves a percentage
863 against the initial containing block — the fixed rail directly, <main> through the
864 body's content box, which is the same width — and the ICB excludes a classic
865 scrollbar. `100vw` includes it unless the root carries `scrollbar-gutter: stable` and
866 the engine then drops the gutter from vw: base.css sets the property, but engines
867 differ on the second half, so the geometry does not hang on it. Without it, a real 15px scrollbar
868 at 1440 comes straight out of --toc-edge in a `100vw` formula and leaves the rail 1px
869 from the viewport edge. Written in `100%` the edge is 16.00px with the scrollbar and
870 16.00px without.
871
872 Where it sits. --page-left is the x of the page box's left edge and it is the larger
873 of two demands: the centred position every other page uses, and whatever the rail
874 needs in order to stand --toc-edge clear of the viewport. From 1584 up the centred
875 position wins and the max() is inert; from 1440 to 1583 the rail wins and the page
876 box is pushed right, which is the shift. The rail is laid into the gutter inline-end
877 first, --toc-gap clear of the date rail's left edge, so it stays pinned to the page
878 box rather than to the viewport: widen --w-content and the rail slides outward with
879 the text instead of drifting over it.
880
881 Its width is a floor, not a leftover. A centred gutter has only 168px to spare at the
882 switch, too narrow to read; 240px is guaranteed wherever the rail is painted (see
883 .toc-rail__link for what decided the number), and the gutter grows it to 320px where
884 the gutter is wide enough to pay for the growth out of empty space.
885
886 The clearance it must never lose is geometric rather than hoped for, and on both
887 sides. Its inline-end edge is --toc-gap from the page box by construction, since
888 `right` is written off --page-left and nothing else; its inline-start edge is exactly
889 --toc-edge from the viewport whenever the rail is what set --page-left, and further in
890 when the centred position did. The rail cannot reach the 72px date rail, let alone the
891 measure, at any viewport. */
892 :root {
893 --toc-gap: 1.5rem; /* 24px, = .post's --rail-gap: one column gutter */
894 --toc-edge: var(--sp-40); /* 16px of air at the viewport edge */
895 --toc-w-min: 15rem; /* 240px, the floor — reached at 1440, held to 1584 */
896 --toc-w-max: 20rem; /* 320px, the cap — reached at 1744 */
897
898 /* Three terms, and they are written in the order they bind: the cap past which more
899 width buys no reading, the floor the reading needs raised over what a centred gutter
900 happens to leave over, and the room that actually exists. The third is the one that
901 keeps the guarantee geometric rather than hopeful, and it is not theoretical - the
902 floor is in rem, so a reader on Chrome's 24px root asks for a 360px rail out of a
903 gutter that cannot seat one. Measured at 12 / 14 / 16 / 20 / 24px roots against
904 320 / 768 / 1024 / 1440 / 1584 / 1700 / 1920, it binds in exactly one of those
905 cells, 24px at 1440: at a 16px root it is 352.5px against a 240px ask at 1440 and
906 grows from there, so it is inert at every width the rail is painted at. Where it
907 does bind the page box keeps its full --w-page and ends up flush against --root-pad
908 on its right (measured 1072px and 27.22px), and the rail still keeps its --toc-edge
909 on the left; without it the rail would run off the left of the screen instead. */
910 --toc-rail-w: min(
911 var(--toc-w-max),
912 max(
913 var(--toc-w-min),
914 calc((100% - var(--w-page)) / 2 - var(--toc-gap) - var(--toc-edge))
915 ),
916 calc(100% - var(--w-page) - var(--root-pad) - var(--toc-gap) - var(--toc-edge))
917 );
918 --page-left: max(
919 calc((100% - var(--w-page)) / 2),
920 calc(var(--toc-edge) + var(--toc-rail-w) + var(--toc-gap))
921 );
922 }
923
924 .toc-rail {
925 /* The rail's inline-end coordinate and its top. Both are local to this box: the
926 hairline beside it is generated on <main> and reads neither (see the rule block
927 inside the switch). Anything that moves the rail moves --toc-x rather than `right`,
928 so that the slide has one coordinate to interpolate.
929
930 --toc-y is the rail's own top and nothing else's. The list keeps the inset because
931 a list wants air under the header; the line does not, because a line that starts
932 short of the header reads as cut off at its top. Measured from the header's bottom
933 border at 1440 / 1700 / 1920 / 2560: about 43 / 52 / 60 / 60px of clearance to the
934 top of the rail. */
935 --toc-x: 100%;
936 --toc-y: var(--sp-120);
937
938 position: fixed;
939 z-index: 1;
940 top: var(--toc-y);
941 inline-size: var(--toc-rail-w);
942 /* Parked: right edge on x = 0, entirely left of the viewport. This is the off state
943 and it is also the default, so a page that never reaches 1440px never paints it.
944 Leftward overflow is not scrollable in an LTR document, so the parked box adds no
945 horizontal scroll - verified at 320 / 375 / 768 / 1024 / 1262 / 1366 / 1439, with
946 and without a classic scrollbar. */
947 right: var(--toc-x);
948 visibility: hidden;
949 /* Distance is --page-left - --toc-gap, 256px at the switch. `visibility` interpolates
950 as a step that counts as visible for any progress in (0,1), so one transition keeps
951 the box painted for the whole slide out and shows it immediately on the way in.
952 The hairline does not ride this: it is generated by the switch rather than moved by
953 it, so it appears and disappears at the boundary instead of travelling.
954 prefers-reduced-motion is handled twice over: the global rule at the foot of
955 base.css kills every transition with !important, and the local rule below says so
956 where a reader of this block will look. */
957 transition: right 0.45s ease, visibility 0.45s;
958
959 /* The box stays exactly as tall as its list, capped at the viewport. The 240px floor
960 takes most of the overflow out: measured at 1440x900, where the box has 754px of
961 room, the lists that scroll inside themselves go from three at 168px to one at 240.
962 The one left is how-to-win, 1,014px of list, and it still scrolls at every rail
963 width measured up to 392px — so this is not decoration and neither is the
964 scroll-spy's scrollIntoView. Capping the box rather than sizing it to the viewport
965 is also what keeps the gutter under the list clickable (hit-tested at x = 200,
966 y = 400 / 700 / 1000 / 1200 on journalctl at 1700: the page, not this nav) and keeps
967 `rail.scrollHeight > rail.clientHeight` — the guard the scroll-spy reads — meaning
968 what it says. */
969 max-block-size: calc(100dvh - var(--toc-y) - var(--sp-48));
970 overflow-y: auto;
971 overscroll-behavior: contain;
972
973 /* No scrollbar, and no gradient in its place either.
974
975 The bar itself first: a thin scrollbar paints an 11px track hard against this box's
976 inline-end edge, which is exactly where the hairline is. Measured on how-to-win at
977 1700x860 with `scrollbar-width: thin` and a --sand-dark thumb: thumb at
978 x = 299-304 (#eed6aa), rule at x = 306, two verticals 2px apart running the same
979 700px of gutter. At a glance that is a double rule, and the thinner of the two is
980 the one the design means. The other candidates do not survive the same look.
981 Moving it (direction: rtl on the scrollport) parks the identical bar on the other
982 edge, 16px off the viewport, where it has even less to belong to; restyling cannot
983 make an 11px bar not a bar; and the need for it cannot be removed — how-to-win is
984 1,014px of list against a 754px box at 1440x900, and 28 entries is the corpus's own
985 worst case, not a hypothetical.
986
987 No gradient either. .code pre's four-gradient overflow cue, turned 90° for this box,
988 is two `local` masks in --page-bg that scroll with the list and two `scroll` shadows
989 fixed to the box, the mask covering the shadow at whichever end has no more list to
990 show. On .code pre the shadow falls on a --sand plate that is already a surface, and
991 it reads as depth; here it would fall on the page ground, where a 12px shadow across
992 a 240px column reads as a grey band lying over the list — with the list scrolled
993 even a little, that band sits behind the word CONTENTS. An overflow cue that has to
994 be explained is not one.
995
996 The masks alone would do nothing: a background paints behind text, so a --page-bg
997 layer over --page-bg ground is invisible by construction — their whole job in that
998 cue is covering the shadow at the two ends. The top edge clips whatever entry it
999 lands on (fading the ink itself would take mask-image), and the scroll-spy's
1000 `rail.scrollTop = 0` at the document top means the resting state of the rail is its
1001 own top, with CONTENTS and entry one whole.
1002
1003 What the hidden scrollbar costs is dragging, and only dragging: the list is still
1004 reachable by wheel, by trackpad, by keyboard (every entry is a link, and tabbing
1005 through scrolls the port) and by the scroll-spy's own scrollIntoView. */
1006 scrollbar-width: none;
1007
1008 /* The +1px is the column the hairline is painted in: the line lands on the rail's own
1009 last pixel (see the rule block inside the switch), so this keeps --sp-30 of clear
1010 space between the text and the line. */
1011 padding-inline-end: calc(var(--sp-30) + 1px);
1012 font-family: var(--font-body);
1013
1014 /* base.css styles the site navigation by element — `nav { display: flex; gap:
1015 --sp-50; flex-wrap: wrap }` — so any <nav> on the site is a wrapping flex row with
1016 a 24px gap unless it says otherwise. Without this line the title and the list
1017 become two flex items and the gap stacks on top of the title's own margin. Stated
1018 rather than inherited, because an element selector that reaches a second nav is
1019 the kind of thing that is invisible until something moves 24px. */
1020 display: block;
1021 }
1022
1023 /* `scrollbar-width: none` covers Chrome 121+, Firefox and Safari 18.2+; this covers the
1024 WebKit and Blink builds below those. base.css already reaches for the same pseudo on
1025 .code pre, so it is the file's own idiom rather than a second mechanism. */
1026 .toc-rail::-webkit-scrollbar { inline-size: 0; block-size: 0; }
1027
1028 /* The kicker idiom, at --fs-small rather than --fs-kicker: --fs-kicker is declared on
1029 .post and the rail is deliberately not inside .post. No 4px rule above it - that mark
1030 opens a section of the page, and this is furniture beside one. */
1031 .toc-rail__title {
1032 margin: 0 0 var(--sp-30);
1033 font-size: var(--fs-small);
1034 font-weight: 700;
1035 line-height: var(--lh-medium);
1036 letter-spacing: 0.105rem;
1037 text-transform: uppercase;
1038 color: var(--ppc-text);
1039 }
1040
1041 .toc-rail__list { margin: 0; padding: 0; list-style: none; }
1042
1043 /* The 4px mark, reused as a position marker instead of a section opener. Laid down
1044 transparent on every row so that marking one costs no layout shift. */
1045 .toc-rail__item {
1046 border-inline-start: 4px solid transparent;
1047 padding-inline-start: var(--sp-30);
1048 }
1049 .toc-rail__item + .toc-rail__item { margin-block-start: 0.4rem; }
1050 .toc-rail__item[data-level="1"] .toc-rail__link { padding-inline-start: var(--sp-30); }
1051 .toc-rail__item[data-level="2"] .toc-rail__link { padding-inline-start: calc(2 * var(--sp-30)); }
1052 .toc-rail__item:has(> [aria-current]) { border-inline-start-color: var(--ppc-ui); }
1053
1054 /* Two lines, then an ellipsis. Clamping keeps the list scannable; the link text is
1055 untouched, so the accessible name is always the full heading however few lines are
1056 painted, and hover or keyboard focus lifts the clamp for the reader too.
1057
1058 240px is the rail's floor, and this block is the reason for the number. Measured in
1059 the built page over all 136 entries on the 14 posts that carry contents, at 1440x900
1060 (13.88px type), rail inline-size against entries whose text does not fit two lines,
1061 against entries that fit on one, and against the number of DOCUMENTS hiding any
1062 heading text at all:
1063
1064 rail 168 192 200 216 240 256 280 312 392
1065 clamped 25 10 8 5 3 2 2 1 0
1066 one-line 58 73 76 88 96 105 113 123 131
1067 documents 6 6 4 2 1 1 1 1 0
1068
1069 The deciding row is the last one, not the first. At 168px — what a centred gutter
1070 leaves over at the switch — six of the 14 documents hide heading text. 200 clears
1071 bind9 and 안전하고, 216 clears sysmon and tso, and 240 clears how-to-win: 13 of the 14
1072 then print every heading in full. Everything past 240 buys exactly one more document,
1073 인지부조화이론, whose two longest headings are 57 and 73 characters against a corpus
1074 median of 18 and which does not print them whole at any width measured below 392px —
1075 a rail wider than the date rail, its gutter and a quarter of the measure together.
1076 Those two are a writing problem, not a width.
1077
1078 So 240 is a floor and not a cap, and the geometry above is written to guarantee it by
1079 moving the page box rather than by hoping for a wide viewport. A 240px cap on what a
1080 centred gutter has spare would be exactly backwards: it changes nothing in this table
1081 for anyone below 1584px, and below 1584 is precisely where the rail is starved. Where
1082 the gutter is wide enough to pay for more out of empty space it grows to the 320px
1083 cap, and that is worth having but is not what the floor is for — rendered at 1920,
1084 where it reaches the cap: 123 of the 136 entries on one line and one entry clamped.
1085
1086 What else the floor buys, at 1440x900 where the box has 754px of room: the lists that
1087 scroll inside themselves go 3 at 168 to 2 at 192 and 200 to 1 from 216 up. The one
1088 left is how-to-win, 1,014px of list at 240 and still scrolling at every width
1089 measured up to 392, so the scroll-spy's scrollIntoView is still doing work — do not
1090 delete it on the strength of the floor.
1091
1092 keep-all is kept, and it holds with room to spare. The narrowest text box the rail
1093 offers is the level-2 entry at the deepest indent, 192.1px at the 240px floor against
1094 120.1px at 168, and the widest run that keep-all refuses to break is a property of
1095 the type, not of the box: 106.9px for sysmon_view.py, a Latin filename rather than one
1096 of the Korean 어절 the rule exists for, and 83.3px for the widest Hangul run, 84.1px at
1097 1920 where the type reaches its 14px cap. That is 85px of slack, where 168 would leave
1098 13. (Set bold as the current entry, the filename is 111.3px.) Note what the segments are: keep-all still takes the break opportunity in front of
1099 an opening parenthesis, so a nine-syllable run that brackets a gloss halfway through is
1100 two segments and not one, and the whitespace-delimited word is the wrong unit to
1101 measure. These figures come from wrapping each heading in a 1px box with break-word
1102 off and reading the extent of every line it produced. Measured over all 136 entries
1103 at every column of the table above: 0 entries overflow their box and 0 break inside a
1104 Korean word at any of them, so break-word never fires. It stays as the escape valve
1105 for anything past that.
1106
1107 The clamp stays at two lines. At 240 exactly three entries exceed it and all three are
1108 in 인지부조화이론; a third line would spend the uniform one-to-two-line rhythm — the thing
1109 that makes the list scannable — on one document. */
1110 .toc-rail__link {
1111 display: -webkit-box;
1112 -webkit-box-orient: vertical;
1113 -webkit-line-clamp: 2;
1114 overflow: hidden;
1115 font-size: var(--fs-small);
1116 font-weight: 400;
1117 line-height: var(--lh-base);
1118 text-decoration: none;
1119 color: var(--page-meta);
1120 word-break: keep-all;
1121 overflow-wrap: break-word;
1122 /* The other half of the `nav a` inheritance. base.css sets the site nav in
1123 synthesised small-caps — right for five one-word section names across the top of
1124 the page, wrong for sentence-shaped headings. The border and padding resets
1125 override nothing in base.css as it stands, where `nav a` carries no bottom border
1126 or padding; they keep one off the rail if nav links are given one, since a 4px
1127 border plus 2px of padding would be 6px of dead space under each entry, 168px down
1128 how-to-win's 28. The rail draws its own current-item mark on the inline edge, so it
1129 wants neither. */
1130 font-variant-caps: normal;
1131 border-block-end: 0;
1132 padding-block-end: 0;
1133 }
1134 /* Hover and focus are the colour step to --crimson, the same gesture as the kicker and
1135 the inline list. The hover half sits inside the pointer gate: on a touch screen a
1136 bare :hover would leave one rail entry coloured until the next tap elsewhere, which
1137 on a rail of up to 28 entries reads as a wrong current-item mark. The clamp-lift at
1138 the end of this group (`.toc-rail__link:hover, .toc-rail__link:focus-visible`) is
1139 deliberately left ungated -- it is a way to read a clipped heading, not a way to mark
1140 one, and gating it would take that away from a touch reader entirely. */
1141 /* The ring is drawn inside the link: the rail scrolls, and a ring outside it is cut off
1142 by the scrollport on the last entry and on any entry at the edge of a scrolled rail. */
1143 .toc-rail__link:focus-visible { color: var(--crimson); outline-offset: -3px; }
1144 @media (hover: hover) {
1145 .toc-rail__link:hover { color: var(--crimson); }
1146 }
1147
1148 /* Forced colours override `color` at rest and on hover alike, so the colour step above
1149 paints nothing there. An underline is what that mode can still draw, so it is the
1150 hover and focus mark here, at the 0.18em offset the prose uses. */
1151 @media (forced-colors: active) {
1152 .toc-rail__link:focus-visible { text-decoration: underline; text-underline-offset: 0.18em; }
1153 }
1154 @media (forced-colors: active) and (hover: hover) {
1155 .toc-rail__link:hover { text-decoration: underline; text-underline-offset: 0.18em; }
1156 }
1157 .toc-rail__link[aria-current] { color: var(--ppc-text); font-weight: 700; }
1158 /* Reading the clipped half of a heading should not need a pointer. */
1159 .toc-rail__link:hover,
1160 .toc-rail__link:focus-visible { -webkit-line-clamp: none; }
1161
1162 /* ── which presentation ──────────────────────────────────────────────────
1163 1440px. What the switch has to guarantee is that pushing the page box out to
1164 --page-left still leaves the measure a gutter on its other side:
1165
1166 vw >= --toc-edge + 240 + --toc-gap + --w-page + right gutter
1167 = 16 + 240 + 24 + 1024 + 96 = 1400px
1168
1169 1440 clears that with 40px to spare and leaves 136px on the right. It is also the
1170 width at which a centred page box first leaves room for a 168px rail,
1171
1172 vw >= --w-page + 2 x (168 + --toc-gap + --toc-edge) = 1440px
1173
1174 and it is kept there rather than lowered to 1400 so that every width either
1175 presentation was checked at stays on the same side of the switch. 96px is the
1176 article's own date rail plus its gutter: the measure keeps the same clearance to its
1177 right as it has to its left inside the page box. The width below which the document
1178 would actually scroll sideways is lower still - --toc-edge + 240 + --toc-gap +
1179 --w-page + --root-pad, about 1327px.
1180
1181 Measured rail width: 240.0px at 1440, held to 1584, 256.0 at 1616, 320.0 from 1744 up
1182 where it holds at the cap. Below the switch the rail parks off-screen and the inline
1183 list is the contents. This is the one place either presentation is turned on or off,
1184 so they cannot both be showing and cannot both be hidden — checked in a browser at 21
1185 widths from 320 to 2560, both sides of the switch included, with and without a classic
1186 scrollbar, and on a post with no contents at all, where neither appears.
1187
1188 If --w-content moves, this literal moves with it - a media query cannot read a
1189 custom property, which is why the rail's own geometry above is written so that a
1190 stale literal here can only park the rail early, never push it onto the text or the
1191 page box off the screen. */
1192 @media (min-width: 1440px) {
1193 .toc-rail {
1194 --toc-x: calc(100% - var(--page-left) + var(--toc-gap));
1195 visibility: visible;
1196 }
1197
1198 /* The page box moves out to --page-left. The header does NOT go with it.
1199
1200 This rule is a no-op from 1584 up: --page-left IS the centred position there, so
1201 `--page-left - --root-pad` is the margin `margin-inline: auto` was already
1202 computing. It bites only in the 1440-1584 band.
1203
1204 A second rule here could restate base.css's header padding against --page-left
1205 and keep the wordmark on the page box's left edge through the shift. It does not
1206 exist, and the reason is what the shift looks like from the reading chair rather
1207 than from the arithmetic. base.css's padding is symmetric by construction;
1208 --page-left is not, because it is a max() that grows on the LEFT only — so inside
1209 the band such an override makes the bar asymmetric and slides the whole thing
1210 right, on article pages and nowhere else. Every other route keeps a still bar, so
1211 the one place a reader spends real time would be the one place the furniture moves
1212 under them. A bar that is in the same place on every page is worth more than a left
1213 edge shared with body text across a 144px band, and that is the trade this file
1214 takes. base.css states the fixed-bar contract; nothing on an article page may
1215 restate the header's padding.
1216
1217 What it costs, stated rather than discovered: inside the band an article's page box
1218 starts up to 79.5px right of the wordmark above it — measured 79.5 at a 1440px
1219 window, 49.5 at 1500, 7.5 at 1584, 0.00 at 1700 and up and 0.00 at 1262 and below.
1220 Bounded by the same arithmetic as everything else here: --page-left is a max()
1221 against the centred position, so it is zero the moment a centred gutter can seat
1222 the rail, and never negative.
1223
1224 Keyed on the ARTICLE, not on the rail. Keyed on `main:has(.toc-rail)`, the gutter
1225 would be reserved only where a rail is actually painted, and the price would be a
1226 step: moving between a post with contents and one without would move the left edge
1227 by up to 72px inside the band (79.5 with a classic scrollbar). Under the premise
1228 this page is built on — every post gets headings — the two forms converge, because
1229 every article ends up with a rail; keying on .post-shell arrives at that steady
1230 state early instead of shipping a step that exists only for as long as 16 documents
1231 are missing their headings.
1232
1233 What it costs meanwhile, stated rather than discovered: those 16 get an empty 280px
1234 left gutter between 1440 and 1584, and their text sits up to 72px right of the
1235 Writing index entry it was clicked from. It is the same divergence every post with
1236 a rail has, bounded by the same arithmetic (--page-left is a max() against the
1237 centred position, so it is 0 from 1584 up and 0 below 1440), and it is uniform
1238 across all 30 articles instead of stepping between them. The rule below is keyed on
1239 the rail: a hairline with nothing beside it is a stray line, so the gutter is
1240 reserved on every article and marked on none that has no list to mark. */
1241 main:has(.post-shell) {
1242 /* Containing block for the rule below, and nothing else reads it. `position:
1243 relative` with `z-index: auto` establishes no stacking context, so the rail's own
1244 `z-index: 1` still resolves against the root; and `position: fixed` ignores a
1245 relative ancestor entirely, so the rail does not become a child coordinate
1246 system. Verified at 1440 / 1700 / 1920: the rail's computed inline-end edge is
1247 unchanged to the pixel. */
1248 position: relative;
1249 margin-inline-start: calc(var(--page-left) - var(--root-pad));
1250 margin-inline-end: auto;
1251 }
1252
1253 /* ── the rule ──────────────────────────────────────────────────────────
1254 The hairline down the rail's inline-end edge. It is a box in the DOCUMENT rather
1255 than a box in the viewport, and that is what keeps its top joined to the header.
1256
1257 A fixed line starts too low. `position: fixed` with `inset-block: var(--toc-y) 0`
1258 begins --sp-120 down the VIEWPORT — 103px at 1440, 112 at 1700, 120 from 1920 up —
1259 while the header is about 60px tall: on how-to-win at 1700x860 that puts the header
1260 rule at y = 59 and the line's top at y = 112, 52px of empty gutter between them.
1261 Scrolling does not close it either: a viewport-fixed line brings the band back at
1262 every scroll position, by then with nothing above it at all because the header is
1263 `position: static` and has scrolled away.
1264
1265 Why no fixed box can be right here. It has one top, and the top this line needs is
1266 in two places: at the header's bottom edge while the header is on screen, and at
1267 the viewport's own top once it is not. An absolutely positioned box whose
1268 containing block begins at the header is both of those for free — scrolling carries
1269 its top off the top of the screen and the line simply runs to the edge.
1270
1271 So: `inset-block: 0` inside <main>. Nothing here names a height, and that is the
1272 point. <main> is in normal flow directly under a full-bleed, `position: static`
1273 header and opens with padding rather than margin, so no margin collapses through
1274 it and main's padding box begins EXACTLY at the header's bottom border. The number
1275 that edge takes is 59.70px at 1440 and 59.88 at 1700 — 16 + a 26.70/26.88 shared
1276 line box + 16 + a 1px border — but it is never written down here, so --sp-40,
1277 --fs-nav or --lh-medium can all move and the line follows the header with no edit
1278 in this file. `inset-block-end: 0` is the same trade at the other end: main's
1279 padding box ends with its --sp-120 of bottom padding, directly above the site
1280 footer, so at the foot of the document the line stops where the footer starts.
1281
1282 What it costs: the line is as long as <main> — nearly 4,000px on journalctl at 1700
1283 — instead of one screen tall, so it travels while the fixed rail stands still. A
1284 1px vertical line that spans the whole viewport at every scroll position is
1285 indistinguishable from one pinned to it — there is no feature on it that can be
1286 seen to move — and in exchange the top is right at scrollY 0 and at every scrollY
1287 after it. Measured on journalctl and how-to-win at 1700x860, at scrollY 0 / 400 /
1288 800 / 1200 and at the foot of each: painted from the header's edge, or from y = 0
1289 once past it, down to the viewport floor, or to the footer's top at the foot.
1290
1291 It cannot be a pseudo-element of .toc-rail. .toc-rail is `position: fixed`, which
1292 makes it the containing block for any absolutely positioned descendant, so an abs
1293 child of it would be measured from the list box and clipped back to the list's own
1294 height. Hung on <main> the two are unrelated boxes, and the rail is free to stay
1295 fixed.
1296
1297 Where it sits across is derived, not restated. The rail's inline-end edge is
1298 --toc-gap inside the page box's left edge by construction, and main's padding box
1299 begins --root-pad OUTSIDE that same edge (the margin above, plus base.css's
1300 `padding-inline: --root-pad`), so the line stands at --root-pad - --toc-gap from
1301 main's inline start, less its own 1px. Measured: -1.53px at 1440 and -1.00px from
1302 1488 up, where --root-pad reaches its 24px cap and cancels --toc-gap exactly — so
1303 across almost the whole band the line is simply the pixel before <main>. Both terms
1304 are :root tokens, --page-left is stated once, and the line tracks the page box
1305 because <main> does. The -1px puts the line on the rail's own last pixel rather
1306 than in the gap past it; the rail's padding-inline-end reserves that pixel (see
1307 .toc-rail). Measured at 1700x860: painted column x = 306, against a rail edge at
1308 306.5.
1309
1310 Declared inside the switch and nowhere else. It has no parked state — a line whose
1311 x is measured from <main> would stand in the left margin at every width below
1312 1440 — so it is simply not generated there, and this media query is the one place
1313 where any part of either presentation is turned on or off. The one thing that
1314 costs is the slide: crossing 1440 the rail slides in from the left and the line
1315 appears at once beside it. That is a resize-only artefact, against an off-screen
1316 box on every one of these pages at every width. */
1317 main:has(.toc-rail)::before {
1318 content: "";
1319 position: absolute;
1320 inset-block: 0;
1321 inset-inline-start: calc(var(--root-pad) - var(--toc-gap) - 1px);
1322 inline-size: 1px;
1323 /* The header's rule, not the page hairline. Both lines frame the same reading
1324 column, so they are one mark in two places; --page-hairline also measured 1.93:1
1325 against --page-bg, which is under the 4.5:1 a 1px line needs to read as drawn. */
1326 background: var(--crimson);
1327 /* The empty gutter beside the list belongs to the page, not to this nav. */
1328 pointer-events: none;
1329 }
1330
1331 .toc { display: none; }
1332 }
1333
1334 /* The line is a painted box, and this is the one mode that does not keep it.
1335
1336 Forced colours replace `background-color` with Canvas and `border-color` with a system
1337 colour, so a border survives Windows High Contrast Mode and a background does not.
1338 Measured in this page with forced-colors emulated, two 1px strips of one colour laid
1339 side by side over the gutter: as a border it paints
1340 rgb(0, 0, 0), as a background rgb(255, 255, 255) — the Canvas it sits on, which is to
1341 say nothing at all.
1342
1343 So the line is drawn as a border here and only here, with no colour of its own so the
1344 UA forces one. box-sizing is border-box, so the 1px border is the same 1px box in the
1345 same place; nothing else about the geometry moves. Outside the switch above rather
1346 than inside it,
1347 because a nested @media is not worth relying on through the build; the selector only
1348 matches an element the switch has already generated. */
1349 @media (forced-colors: active) {
1350 main:has(.toc-rail)::before {
1351 background: none;
1352 border-inline-end: 1px solid;
1353 }
1354
1355 /* The rail hides its scrollbar and paints no overflow cue of any kind, so the only
1356 thing saying the list continues is the entry the top edge clips. That trade is
1357 legible at normal contrast; a High Contrast reader should not have to take it. So
1358 the scrollbar comes back here, in the one mode where a second vertical bar is the
1359 lesser problem — it is drawn in system colours against a system Canvas, so it does
1360 not read as a tan twin of the rule the way a --sand-dark thumb would. */
1361 .toc-rail { scrollbar-width: thin; }
1362 .toc-rail::-webkit-scrollbar { inline-size: auto; block-size: auto; }
1363
1364 }
1365
1366 @media (prefers-reduced-motion: reduce) {
1367 .toc-rail { transition: none; }
1368 }
1369
1370 /* Fixed furniture has no place on paper. A paper page is normally narrower than the
1371 switch, so the inline list is already the one showing; this block is what keeps the
1372 invariant true when it is not, since `print` and `min-width: 1440px` can both match
1373 and only this one may win. */
1374 @media print {
1375 .toc-rail { display: none; }
1376 /* `display: none` on the rail does not take the rule with it: the rule is a
1377 pseudo-element of <main>, and <main> prints. `content: none` generates no box at
1378 all. */
1379 main:has(.toc-rail)::before { content: none; }
1380 .toc { display: block; }
1381
1382 /* `display: block` shows the <details>, not the list inside it. A closed <details>
1383 hides its own content, and the print pipeline does not open it: measured with
1384 Page.printToPDF on journalctl without the rule below, 405,092 bytes with the summary
1385 closed against 423,023 for the same page with the list opened by hand. So what
1386 paper gets from the line above alone is the word CONTENTS between two rules and
1387 nothing under it — the one presentation left on paper, printing none of itself.
1388
1389 ::details-content is the UA's own box for that hidden content, so this prints the
1390 list without touching the open state on screen: no `open` attribute to set and
1391 restore, no beforeprint handler to depend on, and nothing at all outside @media
1392 print. An engine without the pseudo-element ignores the line and prints the
1393 summary alone. The marker goes with it — a disclosure triangle in front of a
1394 list that is already there is an instruction to click, on the one medium where
1395 nothing can be. */
1396 .toc::details-content { content-visibility: visible; }
1397 .toc__summary { list-style: none; }
1398 }
1399
1400 /* ══ Body ════════════════════════════════════════════════════════════════ */
1401 .prose {
1402 font-size: var(--fs-body);
1403 line-height: var(--lh-large);
1404 /* Korean breaks between 어절, never inside one. keep-all behaves as `normal` for
1405 Latin, so it is safe for the whole column; break-word is the escape valve for a
1406 bare URL longer than the measure. */
1407 word-break: keep-all;
1408 overflow-wrap: break-word;
1409 }
1410 .post[lang='ko'] .prose { line-height: var(--lh-ko); }
1411
1412 /* Gazette block rhythm, as measured:
1413 :where(.is-layout-constrained) > * → spacing-48
1414 :where(p + p) → spacing-24
1415 :where(.wp-block-heading + p) → spacing-24 */
1416 .prose > * { margin-block-start: var(--sp-48); }
1417 .prose > :first-child { margin-block-start: 0; }
1418 /* Paragraph to paragraph is the reference's .7em (--sp-para, 11.2px at this 16px body),
1419 not the Gazette's 24px. The two selectors take separate declarations because they
1420 are two different joins: a paragraph continuing a paragraph is the tightest join in
1421 the document, and a heading handing off to its own first paragraph is a section
1422 opening, which keeps --sp-24. The 48px block rhythm, the 64px above an h2/h3 and the
1423 list and figure gaps are all written off their own tokens and none of them reads
1424 --sp-para. */
1425 .prose > p + p { margin-block-start: var(--sp-para); }
1426 .prose > :is(h2, h3, h4, h5, h6) + p { margin-block-start: var(--sp-24); }
1427 /* A heading opens a section, so it gets more air than an ordinary block. */
1428 .prose > :is(h2, h3) { margin-block-start: var(--sp-64); }
1429 .prose > :is(h2, h3, h4, h5, h6) + :is(h3, h4, h5, h6) { margin-block-start: var(--sp-24); }
1430 .prose > :is(h2, h3, h4, h5, h6) { scroll-margin-block-start: var(--sp-40); }
1431 .prose p { text-wrap: pretty; }
1432
1433 /* ── staccato ────────────────────────────────────────────────────────────
1434 Measured on the real corpus at this 928px column and 16px body: 532 of 789
1435 paragraphs (67%) set as a single line, and 19 documents are over half —
1436 dont-assume and journalctl are at 100%, i-have-to has 66 one-liners out of 69.
1437 proseShape()'s 60-character threshold does not move with the column or the type
1438 size: it reads the source, not the rendered line.
1439 A one-line Korean paragraph is a 28px line over an 11px gap; fifty in a row is not
1440 prose, it is verse. Those documents take the book setting instead: the indent marks
1441 the paragraph and the gap goes to zero. Indent OR gap, never both — two signals for
1442 one boundary. proseShape() picks them, 10 today; nothing is hand-tagged. 1em is the
1443 conventional Korean indent of one full square, a little wider than a KoBody
1444 syllable's 0.815em advance. */
1445 .post[data-rhythm='staccato'] .prose > p + p {
1446 margin-block-start: 0;
1447 text-indent: 1em;
1448 }
1449 /* A paragraph opening after any other block already has 48px of air above it;
1450 indenting it as well would mark one boundary twice. */
1451 .post[data-rhythm='staccato'] .prose > :not(p) + p { text-indent: 0; }
1452
1453 /* ── links ───────────────────────────────────────────────────────────────
1454 Colour, not weight or underline (base.css): a prose link is crimson at the weight of
1455 the text around it and draws no rule. This offset is for the one prose link that
1456 does draw one, a footnote marker or backref on hover (see footnotes below); <ins> and
1457 <abbr> repeat it, so every underline in the prose sits at one depth. */
1458 .prose a { text-underline-offset: 0.18em; }
1459
1460 /* ── emphasis ────────────────────────────────────────────────────────────
1461 Pretendard ships no italic, so a browser would synthesise an oblique by shearing
1462 Hangul — which skews the vertical stems the syllable block is built on. All 14 <em>
1463 runs in the corpus are on Korean pages, and eight of them already sit inside
1464 quotation marks, where the slant says nothing the marks do not. 500 is a real
1465 instance on the shipped face's 400–700 weight axis and does not collide with strong
1466 at 700. */
1467 .prose em { font-style: italic; }
1468 .post[lang='ko'] .prose em { font-style: normal; font-weight: 500; }
1469
1470 /* ── inline semantics ────────────────────────────────────────────────────────
1471 Six elements Markdown has no syntax for, which an author therefore writes as raw HTML.
1472 satteri-guard lets them through (rawHtml is off, so they ship as opaque raw nodes),
1473 and without this block every one of them renders at its UA default -- which on this
1474 ground means a browser-yellow <mark> and a <kbd> in the engine's own monospace at full
1475 size. Zero in the corpus today; all six are one keystroke away in any technical post,
1476 and the whole cost of covering them is this block.
1477
1478 <del> is deliberately absent: strikethrough is the one of these that Markdown DOES have
1479 syntax for (~~x~~), the UA line-through is correct, and a colour change would say
1480 something the author did not. */
1481 .prose kbd {
1482 font-family: var(--font-mono);
1483 font-size: 0.875em;
1484 /* Same plate, border and padding as the inline code span below -- a key and a code span
1485 are the same kind of object on the page, and a second treatment for them would be a
1486 second vocabulary. What carries the difference is the element itself, which is what a
1487 screen reader announces. */
1488 background: var(--sand);
1489 border: 1px solid var(--sand-dark);
1490 padding: 0.1em 0.3em;
1491 }
1492 /* The palette has no yellow, and --ppc-wash -- the token the ladder nominally reserves
1493 for emphasis grounds -- is capped at 1.25:1 against the page by design. Rendered, that
1494 is not a highlight: #f7ebeb on #fffbf4 is 1.13:1, a band the eye does not find.
1495 --sand is the one ground on this page that is provably legible at a glance, because
1496 every code plate and every inline code span already sits on it, and black over it is
1497 18.2:1. The padding is what turns a fill that hugs the glyphs into a marked run;
1498 leaving out the code span's 1px border and mono face is what keeps it from reading
1499 as code. */
1500 .prose mark { background: var(--sand); color: inherit; padding: 0.1em 0.2em; }
1501 /* A dotted underline is the one abbreviation convention that survives without a tooltip,
1502 and `help` is the cursor that says a title is there to be read. */
1503 .prose abbr[title] {
1504 text-decoration: underline dotted;
1505 text-underline-offset: 0.18em;
1506 cursor: help;
1507 }
1508 /* line-height: 0 is what keeps a footnote marker or an exponent from opening up the line
1509 it sits in; the offsets then place the glyph without the UA's `vertical-align`, which
1510 is what does the opening. */
1511 .prose :is(sub, sup) {
1512 font-size: 0.75em;
1513 line-height: 0;
1514 position: relative;
1515 vertical-align: baseline;
1516 }
1517 .prose sup { inset-block-end: 0.45em; }
1518 .prose sub { inset-block-start: 0.25em; }
1519 /* <ins> is underlined by the UA. The offset matches the link rule's, so it sits on the
1520 same line as every other underline in the prose, and the weight stays at the body's. */
1521 .prose ins { text-decoration-thickness: from-font; text-underline-offset: 0.18em; }
1522 .prose small { font-size: var(--fs-medium); color: var(--page-meta); }
1523
1524 /* ── quotes ──────────────────────────────────────────────────────────────
1525 .wp-block-quote::before is an 8px bar with a 1rem gap, as measured. Its
1526 spacing-120 block margins are for pull quotes; the quotes in this corpus are
1527 citations and set-off notes inside running text, so they keep the ordinary 48px
1528 rhythm and the body size. */
1529 .prose blockquote {
1530 display: grid;
1531 grid-template-columns: calc(8px + 1rem) minmax(0, 1fr);
1532 margin-inline: 0;
1533 }
1534 .prose blockquote::before {
1535 content: "";
1536 grid-column: 1;
1537 grid-row: 1 / span 20;
1538 border-inline-start: 8px solid var(--ppc-ui);
1539 margin-inline-end: 1rem;
1540 }
1541 .prose blockquote > * { grid-column: 2; }
1542 .prose blockquote > :first-child { margin-block-start: 0; }
1543 /* A quoted paragraph is still a paragraph, so it takes --sp-para with the rest. This
1544 rule exists only to reach inside the blockquote, which `.prose > p + p` cannot. */
1545 .prose blockquote p + p { margin-block-start: var(--sp-para); }
1546
1547 /* ── lists ───────────────────────────────────────────────────────────────
1548 113 bullets and 59 numbers across the corpus, nearly all inside technical
1549 documents where a list is a procedure. The marker hangs outside the measure so
1550 the text edge stays on the column's left edge. */
1551 .prose :is(ul, ol) { padding-inline-start: 1.4em; margin-inline: 0; }
1552 .prose li + li { margin-block-start: var(--sp-30); }
1553 .prose li > :is(ul, ol) { margin-block-start: var(--sp-30); }
1554 .prose li::marker { color: var(--page-meta); }
1555 .prose ol { font-variant-numeric: tabular-nums; }
1556 /* A list inside a staccato document keeps its gaps: the indent is for paragraphs. */
1557 .post[data-rhythm='staccato'] .prose li { text-indent: 0; }
1558
1559 /* CommonMark's loose list, restored. A blank line between items wraps each item's content
1560 in <p>, which is the author's own gesture for "these are paragraphs, not entries"; with
1561 `p { margin: 0 }` in base.css a loose list and a tight one would render pixel for
1562 pixel the same. `:has(> p)` reads the distinction straight off the markup, and it is
1563 uniform per list -- CommonMark makes a list loose or tight as a whole, so either every
1564 item carries a <p> or none does and a mixed list cannot occur. */
1565 .prose li:has(> p) + li { margin-block-start: var(--sp-40); }
1566
1567 /* ── task lists ──────────────────────────────────────────────────────────────
1568 GFM emits <ul class="contains-task-list"><li class="task-list-item">
1569 <input type="checkbox" disabled>. Unstyled that is a bullet AND a grey system checkbox
1570 on every row: two markers for one list. Zero in the corpus; a checklist is the most
1571 likely thing a procedural post (journalctl, bind9 and sysmon are all procedures) grows
1572 next, which is what earns it a block.
1573
1574 The box hangs in the marker gutter, so a task item's text starts on exactly the column
1575 a plain item's text does, and it does that with inline margins rather than a grid. A
1576 grid holds only for a tight list: GFM emits the checkbox as a direct child of the
1577 <li> only in a TIGHT list, and moves it inside the item's first <p> the moment the list
1578 is loose. Rendered as a grid, the loose rows get an unstyled grey system checkbox (the
1579 child selector misses it), the <p> pinned to the text column with an empty marker
1580 column beside it, and the item's second paragraph and nested list at the wrong indent.
1581
1582 The arithmetic below has no opinion about which shape it is in, because it is all on
1583 the input itself: -1.4em pulls the box out by the list's own indent, and the end margin
1584 gives back exactly what is left over, so the next inline content resumes on the li's
1585 content edge either way. font-size: inherit is load-bearing and not tidiness -- a UA
1586 gives an <input> 13.33px, and every em here has to be the list's em, not that one.
1587 Only .task-list-item is touched, so a plain <li> sharing the list keeps its bullet. */
1588 .prose .task-list-item { list-style: none; }
1589 /* Descendant, not child: in a loose list this input is li > p > input. */
1590 .prose .task-list-item input[type='checkbox'] {
1591 --box: 0.8em;
1592 font-size: inherit;
1593 margin-inline: -1.4em calc(1.4em - var(--box));
1594 /* appearance: none rather than accent-color: the control is `disabled`, and a disabled
1595 native checkbox is greyed out by the engine whatever accent it is given -- so the one
1596 state that has to read, "this is done", is the one accent-color cannot paint. */
1597 appearance: none;
1598 inline-size: var(--box);
1599 block-size: var(--box);
1600 /* The empty box IS this row's marker, so it is drawn in the colour the discs beside it
1601 are drawn in (`li::marker { color: var(--page-meta) }` above). --sand-dark, the
1602 border the inline code span and the copy control use, is 1.37:1 against the page
1603 and leaves the unchecked rows looking unmarked next to the filled ones -- which reads
1604 as "these items have no bullet", not as "these items are not done". */
1605 border: 1px solid var(--page-meta);
1606 background: var(--page-bg);
1607 /* The box is a picture of state, not a control -- it is disabled, and a pointer that
1608 says otherwise invites a click that does nothing. */
1609 cursor: default;
1610 /* An inline box with no text in it sits its bottom edge on the baseline, which is a
1611 touch low against the row it marks. */
1612 transform: translateY(0.05em);
1613 }
1614 /* Filled, not ticked. A drawn checkmark needs either a glyph outside the Latin subset or
1615 a pseudo-element on a form control, and the second is not reliable across engines; a
1616 filled crimson square against an empty one is unambiguous, costs one declaration
1617 and cannot fail to paint. The state is in the DOM as well (`checked`), which is what
1618 assistive tech reads either way. */
1619 .prose .task-list-item input[type='checkbox']:checked {
1620 background: var(--ppc-ui);
1621 border-color: var(--ppc-ui);
1622 }
1623 /* Its own block rather than the forced-colours block that follows the Contents switch:
1624 these two selectors have the same specificity as the task-list rules above, so an
1625 override has to come after them in source order. Placed before them, appearance stays
1626 `none` in forced colours and the checked square goes on painting a background the
1627 mode has already discarded. A background fill is exactly what forced colours drop, so
1628 "done" would become indistinguishable from "not done". Handing the box back to the
1629 engine is better than any fallback of ours: a High Contrast reader gets a real system
1630 checkbox, in system colours, carrying the state that is in the DOM either way. */
1631 @media (forced-colors: active) {
1632 .prose .task-list-item input[type='checkbox'] {
1633 appearance: auto;
1634 transform: none;
1635 /* No hang here. The box keeps its --box size (inline-size and block-size still
1636 apply to the native control), sits at the start of the row with 0.25em after it,
1637 and the item's text starts that much further in than a plain item's. */
1638 margin-inline: 0 0.25em;
1639 }
1640 }
1641
1642 /* ── figures ─────────────────────────────────────────────────────────────
1643 Built by src/lib/satteri-figure.mjs: 26 images, 24 figures, 11 captions.
1644
1645 Width is the body measure, not an extension. 21 of the 26 images are narrower than
1646 928px (356–896px) and six are strips as short as 12–38px tall; stretching any of
1647 them to the column would upscale a screenshot into mush. So no `width` is declared at
1648 all — satteri-prose.mjs stamps the intrinsic width/height, the browser takes that as
1649 the used width, and only the 5 oversize ones (1115–1965px) are pulled back by
1650 max-width. An image without a `width` never grows past its own pixels.
1651
1652 No plate, and no frame of any kind: no --sand mat behind the images, no border
1653 around them, no padding to inset them. A photograph on this site is the thing itself,
1654 not a picture of a picture hung on the page. .fig__plate stays because it is
1655 satteri-figure's DOM contract and because the flex row below is what puts a
1656 three-image set on one line; it is layout and nothing else. A narrow strip, down to
1657 the 779x12 one, sits on the page ground, which is what every other block here does. */
1658 /* Every image, not only the ones satteri-figure lifted into a <figure>. It converts a
1659 paragraph holding nothing BUT images, so an image with text beside it, a linked image
1660 [](b), an image in a list or a table cell, and a raw-HTML <img> all stay bare --
1661 while satteri-prose stamps the intrinsic width on them just the same. Measured on a
1662 fixture carrying one bare 1965px screenshot: documentElement.scrollWidth 2379 against
1663 clientWidth 1659, i.e. the whole page scrolling sideways by 720px. None of the corpus's
1664 26 images escape today, which is what makes this the cheapest kind of fix: one rule,
1665 before the first post that does. `height: auto` is the other half -- without it the
1666 stamped height would hold while the width shrank, and the picture would squash. */
1667 .prose img { max-width: 100%; height: auto; }
1668
1669 .prose .fig { margin-inline: 0; }
1670 .prose .fig__plate {
1671 display: flex;
1672 flex-wrap: wrap;
1673 gap: var(--sp-40);
1674 align-items: flex-start;
1675 }
1676 .prose .fig img { display: block; }
1677 /* 인지부조화이론.md puts three 600x800 scans on three adjacent lines, which
1678 CommonMark folds into a single paragraph. They are a set, so they share a row. */
1679 .prose .fig[data-count='3'] img { flex: 1 1 0; min-width: 0; }
1680 .prose .fig figcaption {
1681 margin-block-start: var(--sp-30);
1682 font-size: var(--fs-medium);
1683 line-height: var(--lh-base);
1684 color: var(--page-meta);
1685 text-wrap: pretty;
1686 }
1687
1688 /* ── tables ──────────────────────────────────────────────────────────────
1689 Wrapped in .scroller by satteri-prose.mjs, so a wide table scrolls inside itself
1690 and the page never scrolls sideways. tabindex makes the overflow keyboard-
1691 reachable, which is why the wrapper is a focusable region. */
1692 .prose .scroller { overflow-x: auto; }
1693 .prose .scroller:focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: 2px; }
1694 .prose table {
1695 border-collapse: collapse;
1696 width: 100%;
1697 font-size: var(--fs-medium);
1698 line-height: var(--lh-base);
1699 }
1700 .prose :is(th, td) {
1701 text-align: start;
1702 vertical-align: top;
1703 padding: var(--sp-30) var(--sp-40) var(--sp-30) 0;
1704 border-block-end: 1px solid var(--page-hairline);
1705 }
1706 .prose th {
1707 font-family: var(--font-body);
1708 font-size: var(--fs-small);
1709 font-weight: 700;
1710 letter-spacing: 0.105rem;
1711 text-transform: uppercase;
1712 color: var(--ppc-text);
1713 border-block-end: 4px solid var(--ppc-ui);
1714 white-space: nowrap;
1715 }
1716
1717 /* ── code ────────────────────────────────────────────────────────────────
1718 The <figure class="code"> wrapper and the Shiki palette are owned by the
1719 `codeBlock` transformer in astro.config.mjs (theme: css-variables). Only the block
1720 rhythm and the inline span belong to this page. */
1721 .prose figure.code { margin-inline: 0; }
1722 /* The one fence that never reaches the plate. Astro ORs its own
1723 defaultExcludeLanguages = ["math"] into the highlight check, so a ```math fence skips
1724 the highlighter and arrives as a bare <pre> -- no plate, no scroll region, no copy
1725 control, and no overflow-x, which is the part that matters: a long line in a bare <pre>
1726 scrolls the PAGE. No value of excludeLangs un-excludes it and the corpus has 0 of them,
1727 so this is a guard, not a fix. Scoped away from .code pre, whose own overflow-x is an
1728 inline style this could not outrank in any case. */
1729 .prose pre:not(.code *) { max-width: 100%; overflow-x: auto; }
1730 .prose :not(pre) > code {
1731 font-family: var(--font-mono);
1732 font-size: 0.875em;
1733 background: var(--sand);
1734 border: 1px solid var(--sand-dark);
1735 padding: 0.1em 0.3em;
1736 word-break: break-all;
1737 }
1738
1739 /* ── thematic break ──────────────────────────────────────────────────────
1740 It reuses the header's mark rather than introducing a second kind of line onto a
1741 page whose only graphic element is that mark. */
1742 .prose hr {
1743 width: 3rem;
1744 height: 0;
1745 border: 0;
1746 border-block-start: 4px solid var(--ppc-ui);
1747 margin-block: var(--sp-80);
1748 }
1749
1750 /* ── footnotes ──────────────────────────────────────────────────────────────
1751 DOM contract, emitted by Sätteri's GFM footnotes:
1752 <sup><a data-footnote-ref aria-describedby="footnote-label">1</a></sup> in the body
1753 <section data-footnotes class="footnotes">
1754 <h2 class="sr-only" id="footnote-label">Footnotes</h2>
1755 <ol><li id="user-content-fn-1"><p>… <a class="data-footnote-backref">↩</a></p></li>
1756
1757 Zero in the corpus, and the most plausible missing construct in it: the posts that
1758 translate or comment on a source cite it with a parenthesis today. Unstyled, the
1759 apparatus renders at body size with nothing between it and the last paragraph, which
1760 reads as one more section rather than as notes.
1761
1762 The separator is the 4px x 3rem mark, not a full-width hairline -- the same mark <hr>
1763 and the code plate's language label already use, and the only graphic element this page
1764 has. A hairline would be a second kind of line for the same job.
1765
1766 The `↩` is U+21A9, and Lora has no glyph for it (the master in fonts-src/ carries no
1767 arrows at all), so no subset can supply it and that one glyph comes from a system
1768 fallback face. Left as it is on purpose: it is a single mark at the end of a note, the
1769 alternative is an ASCII digraph in the middle of typeset prose, and the Sätteri
1770 option that would change it (`gfm: { footnotes: { backContent } }`) is a worse trade
1771 than the fallback. */
1772 .prose .footnotes {
1773 margin-block-start: var(--sp-80);
1774 font-size: var(--fs-medium);
1775 line-height: var(--lh-base);
1776 color: var(--page-meta);
1777 }
1778 .prose .footnotes::before {
1779 content: '';
1780 display: block;
1781 inline-size: 3rem;
1782 block-size: 4px;
1783 background: var(--ppc-ui);
1784 margin-block-end: var(--sp-40);
1785 }
1786 /* The list is the notes themselves, so it sits on the text column rather than hanging its
1787 numbers off it -- the numbers ARE the content here, being what the body refers to. */
1788 .prose .footnotes ol { margin-block: 0; }
1789 .prose .footnotes li { scroll-margin-block-start: var(--sp-40); }
1790 /* Following a marker lands the reader in the middle of a list of near-identical
1791 paragraphs; this is the one thing that says which one was asked for. --ppc-wash is the
1792 section's emphasis ground, and the inline padding puts a margin around the text
1793 without moving it. */
1794 .prose .footnotes li:target {
1795 background: var(--ppc-wash);
1796 padding-inline: var(--sp-30);
1797 margin-inline: calc(-1 * var(--sp-30));
1798 }
1799 /* Same story as the checkbox: the mark is a background, and this mode has none. */
1800 @media (forced-colors: active) {
1801 .prose .footnotes li:target { background: none; border-inline-start: 2px solid; }
1802 }
1803 /* Both ends of the round trip. These keep an explicit accent colour of their own rather
1804 than inheriting the global link colour, because a bare numeral and a bare arrow are
1805 small targets that want the darker end of the family. They stay real links with a real focus ring
1806 -- the site's :focus-visible outline is untouched here. */
1807 .prose [data-footnote-ref],
1808 .prose .data-footnote-backref {
1809 text-decoration: none;
1810 font-weight: 400;
1811 color: var(--ppc-text);
1812 }
1813 /* The one link in the prose that draws an underline, and only on hover. These two are
1814 a bracketed numeral and a return arrow, roughly 8px of ink each, set inside running
1815 prose; on hover they take the colour step every prose link takes (base.css) and this
1816 plain underline with it, not an animated rule -- a 0.3s centre-out wipe across 8px is
1817 a flicker rather than a gesture. They are a deliberate pair -- same colour, same
1818 weight, same rule -- so neither changes without the other. Nothing renders here today
1819 either way: there are no footnotes in content/writing/. */
1820 .prose [data-footnote-ref]:hover,
1821 .prose .data-footnote-backref:hover { text-decoration: underline; }
1822 /* The backref is the last thing on a note's last line and reads as punctuation, not as a
1823 word, so it gets the space a word would have had and nothing else. */
1824 .prose .data-footnote-backref { margin-inline-start: 0.25em; }
1825
1826 /* ══ Rail collapse ═══════════════════════════════════════════════════════
1827 The same 36rem breakpoint the index uses, so both pages fold at one width. */
1828 @container post (max-width: 36rem) {
1829 .post { grid-template-columns: minmax(0, 1fr); }
1830 .post > * { grid-column: 1; }
1831 .post__head { display: flex; flex-direction: column; align-items: flex-start; }
1832 .post__kicker { order: -2; }
1833 }
1834 </style>