blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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      [![](a)](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>