blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit 87002dd449870fa572ac26b81d668b115589dfb9
parent 9f810dcd230587d0ddc97731e95854c522ed386d
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Tue, 22 Sep 2026 16:23:05 +0900

feat: rebuild the design on Lora, and fill the Code and About pages

Retunes the whole visual system against two references — the Harvard Gazette for form,
nicholas.carlini.com for density — and replaces the placeholder Code and About pages with
real content. Every number below was measured in a browser against the built output.

Type
- One Latin face. Lora replaces Newsreader and Inter, so the site ships two subset files
instead of three: 22KB Latin plus a 118KB Korean face gated by unicode-range, which a
reader who never meets a Hangul character never downloads.
- Every Korean metric was re-derived against Lora rather than carried over: KoBody's
size-adjust 102.9% to 94.29%, the local-fallback overrides with it. The arithmetic is
in fonts.css next to each value.
- Body is a flat 16px, not a clamp. It had reached 18.81px at 1440 against the
reference's 16, which is what read as too large. Headings came down with it: h1 64 to
38px, h2 48 to 28.5px. Paragraph spacing 24px to 11.2px, the reference's own .7em.
- Header type is measured from the reference's header rather than from body, so body
could fall without dragging the bar down with it.

Geometry
- The measure widens 728 to 928px, spending 100px of each side gutter at 1488. The cost
is a longer line in Korean and it was taken deliberately.
- The header is full-bleed and identical on every route: symmetric padding, same wordmark
and nav positions, measured across seven routes at ten widths. Two separate causes were
fixed — it used to travel with the article page box, and it used to shift 7.5px between
pages that scroll and pages that do not.
- A contents rail stands in the left gutter above 1440px, with a rule that runs from under
the header to the foot of the viewport, and falls back to an inline disclosure below.

Article pages
- The date is one thing in one place: "December 7, 2025" on the byline, not 09.08 in the
rail with the year three lines above it. Reading time is gone.
- Mermaid fences render as diagrams, baked to inline SVG at build time. A reader who opens
a page without one downloads 110 bytes gzipped for the privilege; the post with three
gets 18KB and no extra request, and they render with JavaScript off.
- Code blocks carry a copy control. Images carry no frame.

Pages
- /code/ reads content/code/*.md, seven projects, so an eighth is one file and a push.
Descriptions are traceable to each repository's own description or README.
- /about/ is rewritten as running prose with a portrait, replacing the stub. Its GitHub
link pointed at an account that 404s; it now points at the live one.
- Section pages print no page title — the bar already names the page — and keep an
sr-only h1 so the document still has one.
- A footer carries the copyright on every page, its closing year stamped at build time.

Verified: 36 pages, 0 errors; the content gate passes and now validates the code
collection too; no horizontal overflow from 320 to 2560px; the Korean face is fetched
only by the 32 pages that paint Hangul.

Diffstat:
Mastro.config.mjs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acontent/_template-code.md | 12++++++++++++
Mcontent/about.md | 40+++++++++++++++++++++++++++++++---------
Acontent/code/doomsday.md | 7+++++++
Acontent/code/ommp.md | 7+++++++
Acontent/code/sqzass.md | 7+++++++
Acontent/code/transtation.md | 7+++++++
Acontent/code/uaf.md | 7+++++++
Acontent/code/udfx.md | 7+++++++
Acontent/code/usfx.md | 7+++++++
Afonts-src/OFL-Lora.txt | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afonts-src/lora-var.woff2 | 0binary
Rpublic/fonts/pretendard-var.woff2 -> fonts-src/pretendard-var.woff2 | 0binary
Mpackage-lock.json | 1457+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mpackage.json | 8++++++--
Dpublic/fonts/inter-italic-var.woff2 | 0binary
Dpublic/fonts/inter-var.woff2 | 0binary
Apublic/fonts/ko-02f8cebd.woff2 | 0binary
Apublic/fonts/latin-9bba8f25.woff2 | 0binary
Dpublic/fonts/newsreader-var.woff2 | 0binary
Apublic/uploads/profile.jpg | 0binary
Mscripts/check-content.mjs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Ascripts/render-diagrams.mjs | 355+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/subset-fonts.py | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/content.config.ts | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/layouts/Base.astro | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Asrc/lib/diagrams.mjs | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/diagrams/48b6ca45b842c6d7.svg | 1+
Asrc/lib/diagrams/dc1c6779ce6337ef.svg | 1+
Asrc/lib/diagrams/e4164af5ea9dc19d.svg | 1+
Asrc/lib/hangul.ts | 16++++++++++++++++
Msrc/lib/prose.ts | 16++++++----------
Asrc/lib/satteri-diagram.mjs | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/satteri-figure.mjs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/satteri-korean.mjs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/satteri-prose.mjs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pages/code/index.astro | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/pages/index.astro | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/pages/papers/index.astro | 7++++++-
Msrc/pages/talk/index.astro | 7++++++-
Msrc/pages/writing/[year]/[slug].astro | 1834++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/pages/writing/index.astro | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/styles/base.css | 595+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/styles/fonts.css | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/styles/tokens.css | 321++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
45 files changed, 6357 insertion(+), 271 deletion(-)

diff --git a/astro.config.mjs b/astro.config.mjs
@@ -1,16 +1,31 @@
 // @ts-check
 import { defineConfig } from 'astro/config';
+import { satteri } from '@astrojs/markdown-satteri';
+import satteriFigure from './src/lib/satteri-figure.mjs';
+import satteriProse from './src/lib/satteri-prose.mjs';
+import satteriDiagram from './src/lib/satteri-diagram.mjs';
+import satteriKorean from './src/lib/satteri-korean.mjs';
 
 /**
- * Grammars that resolve the 10 fence languages the corpus actually uses.
- * Anything outside this list makes `ensureLanguagesLoaded` throw and **fails the build**,
- * which is better than a silent fallback like `[Shiki] The language "vi" doesn't exist`.
+ * Grammars preloaded into the Shiki highlighter: the fence languages the corpus uses.
+ *
+ * A warm-up list, NOT a gate — measured rather than assumed. A fence in a language outside
+ * it still highlights (```rust emits data-language="rust" with real tokens), and a fence in
+ * a language that exists nowhere logs `[Shiki] The language "notalanguage" doesn't exist,
+ * falling back to "plaintext"`, emits data-language="plaintext" and leaves the build green.
+ * An earlier note here claimed the opposite — that anything outside the list "fails the
+ * build" — and nothing in this pipeline does that. What the list buys is that the languages
+ * the corpus does use are resolved once at startup instead of on first sight.
  * Aliases already ship in the Shiki bundle: bash/sh/zsh resolve to shellscript,
  * console resolves to shellsession.
+ *
+ * `mermaid` is deliberately absent. It is the one entry left in excludeLangs below, so the
+ * highlight plugin returns before Shiki is called: the grammar would be loaded and never
+ * used, because a mermaid fence becomes a picture rather than highlighted source.
  */
 const LANGS = [
   'plaintext', 'bash', 'shellscript', 'shellsession',
-  'ini', 'jsonc', 'json', 'mermaid', 'xml', 'python',
+  'ini', 'jsonc', 'json', 'xml', 'python',
 ];
 
 /**
@@ -25,13 +40,26 @@ const LANGS = [
  * root, via `codeToHast(...).then((root) => root.children[0])`
  * (@astrojs/markdown-satteri/dist/satteri-processor.js).
  */
+/**
+ * Fence words whose grammar is not what the word says. `langAlias` below hands the
+ * grammar to Shiki, but Shiki writes `dataLanguage` from the *unresolved* word, so
+ * without this same map the wrapper labels a journald.conf block "vi" — a label that
+ * is both visible in the figcaption and the hook every per-language CSS rule matches on.
+ * One map, consumed in both places, is the only way those two stay in agreement.
+ */
+const ALIAS = { vi: 'ini' };
+
 const codeBlock = {
   name: 'sqzer:code-block',
   root(root) {
     const pre = root.children[0];
     if (!pre || pre.type !== 'element' || pre.tagName !== 'pre') return;
 
-    const lang = String(pre.properties?.dataLanguage ?? 'plaintext');
+    const raw = String(pre.properties?.dataLanguage ?? 'plaintext');
+    const lang = ALIAS[raw] ?? raw;
+    // Re-stamp the pre so the attribute a reader is shown and the attribute CSS
+    // matches on are the resolved grammar, not the author's word for it.
+    pre.properties.dataLanguage = lang;
     // ```bash wrap — opt in per block, for things like log dumps whose lines cannot be kept.
     const wrap = /(^|\s)wrap(\s|$)/.test(String(this.options?.meta?.__raw ?? ''));
 
@@ -66,14 +94,85 @@ export default defineConfig({
   trailingSlash: 'always',
   build: { format: 'directory' },
   markdown: {
-    syntaxHighlight: { type: 'shiki', excludeLangs: ['math'] },
+    // Sätteri is the default Markdown processor in Astro 7, and `markdown.rehypePlugins`
+    // now throws unless @astrojs/markdown-remark is installed alongside it. Plugins go
+    // here instead, as Sätteri visitor objects.
+    //   mdast: satteriKorean  — CommonMark inline rules that misfire on Korean prose
+    //   mdast: satteriFigure  — <figure>/<figcaption> out of the corpus's own convention
+    //   hast:  satteriProse   — heading demotion, intrinsic image size, table scrollers
+    //   hast:  satteriDiagram — a mermaid fence -> the SVG baked from it before the build
+    // Ordering matters: hastPlugins run after highlighting and BEFORE heading-id
+    // collection, so `headings` from render() reports the demoted depths.
+    processor: satteri({
+      mdastPlugins: [satteriKorean(), satteriFigure()],
+      hastPlugins: [satteriProse({ publicDir: 'public' }), satteriDiagram()],
+      /*
+       * Parser flags. Astro hands Sätteri only `{ gfm, smartPunctuation }` and every other
+       * entry in `Features` defaults off, so what is listed here is the whole of the
+       * difference from stock — and each line is a defect that was measurable in dist/.
+       */
+      features: {
+        /*
+         * Smart punctuation, minus the dash rule. Quotes and ellipses are worth having in
+         * 70-odd lines of prose; the dash rule is not, because it does not know what a CLI
+         * flag is. Measured in the built site before this line: journalctl's
+         * `#### 시간 범위 지정 (--since, --until)` shipped as
+         * `(–since, –until)`, an en dash a reader cannot paste into a shell. Only prose
+         * outside backticks was ever affected — code spans and fences are untouched — but on
+         * a sysadmin blog a flag named in a heading is normal writing, not markup.
+         */
+        smartPunctuation: { dashes: false },
+        /*
+         * `## Heading { #id }` sets the anchor. Without it the braces render as literal
+         * text in the heading AND in the contents list, and the generated slug swallows
+         * them (`…-custom-id-`). Now that every post is written to carry a contents rail,
+         * an author needs a way to keep an anchor stable across a retitling. No corpus
+         * heading contains a brace, so nothing existing changes shape.
+         */
+        headingAttributes: true,
+        /*
+         * Not enabled, and each is a decision rather than an oversight:
+         *   math          0 real formulas in the corpus. The 2 `$…$` hits are shell
+         *                 variables, and `singleDollarTextMath` is on by default — turning
+         *                 math on would eat them.
+         *   subscript     `~sub~` collides head-on with GFM strikethrough, which is the
+         *                 very collision satteri-tilde exists to undo.
+         *   superscript   `^x^` is unused, and ^ appears in regexes and shell prose.
+         *   directive     `:::note` blocks need styling per directive to mean anything.
+         *   wikilinks     no wiki.
+         *   definitionList  0 in the corpus; a table or a list carries the same content.
+         *   rawHtml       leaving HTML as an opaque `raw` node is the safer default, and
+         *                 it is the switch that also governs sanitisation.
+         */
+      },
+    }),
+    /*
+     * `mermaid` is the only exclusion this file asks for: the fence has to survive
+     * highlighting intact so satteriDiagram can swap in the SVG that
+     * scripts/render-diagrams.mjs baked from it.
+     *
+     * `math` was listed here too and is gone because it never did anything. Astro ORs its
+     * own `defaultExcludeLanguages = ["math"]` into this check unconditionally
+     * (@astrojs/internal-helpers/dist/markdown.js), so math is excluded whether or not it
+     * is named, and no value of this option — and no langAlias — can un-exclude it.
+     *
+     * What that costs, measured: an excluded language skips the highlight plugin, so a
+     * ```math fence is the one construct in the language that arrives as a bare
+     * `<pre><code>` with no plate, no scroll region and no copy control. Everything else
+     * lands on the plate, including the two cases that look like they might not — an
+     * indented code block and a fence with no language both come through as
+     * figure.code[data-language=plaintext]. The corpus has 0 math fences and math parsing
+     * is off, so this is left alone rather than fixed with a plugin; `.prose pre` in the
+     * article template keeps the bare case from scrolling the page sideways.
+     */
+    syntaxHighlight: { type: 'shiki', excludeLangs: ['mermaid'] },
     shikiConfig: {
       // The css-variables theme emits token colors as `var(--astro-code-token-*)`, so the
       // OKLCH ladder in tokens.css drives syntax highlighting instead of a second palette.
       theme: 'css-variables',
       langs: LANGS,
       // ```vi in the corpus is journald.conf, not vim script. That is INI.
-      langAlias: { vi: 'ini' },
+      langAlias: ALIAS,
       // Preserve lines by default. Folding is opt-in per block via ```<lang> wrap.
       wrap: false,
       transformers: [codeBlock],
diff --git a/content/_template-code.md b/content/_template-code.md
@@ -0,0 +1,12 @@
+---
+# The link text. The project's own name, not the repo slug.
+name: ""
+# An absolute https:// URL, or a path on this site ending in "/".
+url: ""
+# Position in the list, ascending. Numbered in tens in content/code, so a new project
+# goes between 30 and 40 as `order: 35` and nothing else has to be renumbered.
+order: 0
+draft: true
+---
+
+One sentence saying what it does. This body IS the description the Code page prints.
diff --git a/content/about.md b/content/about.md
@@ -1,15 +1,37 @@
 ---
-title: "whoami"
+title: "About"
 date: 2024-10-31
 ---
 
-# whoami
-분석하고 본질 탐구하는거 좋아함
+![Hyukjin An](/uploads/profile.jpg)
 
-# Contact
-Email: contact.extinct932@passmail.net
+Hyukjin An — Software & Security Engineer, and a founder, based in South Korea.
+Most places online I go by **SQZER**, because I like squeezing a thing down until it
+breaks and shows me what it was made of. Squeezer is fine too.
 
-# Link
-- Github: https://github.com/devastator-x
-- Service: https://lacrymaria.com
-- LinkedIn: https://www.linkedin.com/in/devastator-x
\ No newline at end of file
+I like having a hand in the whole lifecycle of a service, from the hardware and the
+systems underneath it through DevSecOps and on to planning and writing the thing. That
+has taken me through enterprise infrastructure, SysOps, full-stack development and
+security engineering; these days I work as a software engineer inside the security
+domain. I started out as a developer, so my languages are C++, Rust and Python, and I
+spend a lot of my open-source time building the tools security testing actually needs
+and contributing to other people's. Right now most of my attention goes to OPSEC and
+zero trust.
+
+The rest of my time goes to running my own things. As a solo founder that means
+reviewing the services I already have, launching new ones, and doing every other job
+myself — finding the business model, handling the legal side, working out how anyone is
+supposed to hear about it.
+
+Hardware is part of the fun. My first homelab ran on a Dell PowerEdge R650; now it is a
+Lenovo ThinkStation P3 Tower under Proxmox. Day to day I work on a ThinkPad X1 Carbon
+Gen 13 Aura Edition running Arch Linux, in Ghostty and NeoVIM, with Codex CLI and Claude
+Code alongside.
+
+What I write is under [writing](/writing/), and the tools I have published are under
+[code](/code/) — including [sqzass](https://github.com/sqzer-x/sqzass), the static site
+generator that builds [sqzer.com](https://sqzer.com/). There is more on
+[GitHub](https://github.com/sqzer-x), a service of mine at
+[lacrymaria.com](https://lacrymaria.com), and I am on
+[LinkedIn](https://www.linkedin.com/in/devastator-x). Mail reaches me at
+contact.extinct932@passmail.net.
diff --git a/content/code/doomsday.md b/content/code/doomsday.md
@@ -0,0 +1,7 @@
+---
+name: "Doomsday"
+url: "https://github.com/sqzer-x/doomsday"
+order: 50
+---
+
+A GNOME Shell extension that counts down to the dates you care about from the panel, with as many D-Days as you like added, edited and switched between from its own menu rather than from a preferences window.
diff --git a/content/code/ommp.md b/content/code/ommp.md
@@ -0,0 +1,7 @@
+---
+name: "OMMP — Oh My Music Player"
+url: "https://github.com/sqzer-x/ommp"
+order: 30
+---
+
+A standalone terminal music player for Linux that runs with no daemon behind it, browsing and searching a local library and drawing album art as a real image in any terminal that implements Kitty, Sixel or the iTerm2 graphics protocol.
diff --git a/content/code/sqzass.md b/content/code/sqzass.md
@@ -0,0 +1,7 @@
+---
+name: "sqzass"
+url: "https://github.com/sqzer-x/sqzass"
+order: 20
+---
+
+A static site generator written in Rust that resolves pretty URLs, redirects and cache headers itself, so one build is correct on GitHub Pages, Vercel, Cloudflare Pages or a plain directory served over HTTP.
diff --git a/content/code/transtation.md b/content/code/transtation.md
@@ -0,0 +1,7 @@
+---
+name: "transtation"
+url: "https://github.com/sqzer-x/transtation"
+order: 10
+---
+
+A VLESS + REALITY proxy you host yourself, with optional Cloudflare WARP egress, a server that installs either as a container with no added capabilities or natively under systemd, and a native client whose killswitch fails closed.
diff --git a/content/code/uaf.md b/content/code/uaf.md
@@ -0,0 +1,7 @@
+---
+name: "UAF — Ultimate AP Finder"
+url: "https://github.com/sqzer-x/uaf"
+order: 40
+---
+
+A Linux wireless access-point scanner for PCI-DSS audits that classifies every AP in range by its encryption, highlights the open ones, will connect to one of them to map its DHCP, gateway and clients, and exports the result as CSV or JSON.
diff --git a/content/code/udfx.md b/content/code/udfx.md
@@ -0,0 +1,7 @@
+---
+name: "UDFX — Ultimate Domain Finder X"
+url: "https://github.com/sqzer-x/udfx"
+order: 60
+---
+
+A PowerShell script that reads every domain, subdomain and DNS record out of Active-Directory-integrated DNS by querying LDAP directly, for air-gapped Windows networks, and needs nothing more than an ordinary domain user account.
diff --git a/content/code/usfx.md b/content/code/usfx.md
@@ -0,0 +1,7 @@
+---
+name: "USFX — Ultimate Subdomain Finder X"
+url: "https://github.com/sqzer-x/usfx"
+order: 70
+---
+
+A Python command-line tool that finds subdomains on an internal network through twelve techniques that each work offline, from brute force and zone transfer to DNSSEC walking and certificate SAN extraction, against whichever DNS servers you name.
diff --git a/fonts-src/OFL-Lora.txt b/fonts-src/OFL-Lora.txt
@@ -0,0 +1,93 @@
+Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/fonts-src/lora-var.woff2 b/fonts-src/lora-var.woff2
Binary files differ.
diff --git a/public/fonts/pretendard-var.woff2 b/fonts-src/pretendard-var.woff2
diff --git a/package-lock.json b/package-lock.json
@@ -1,17 +1,34 @@
 {
-  "name": "blog-astro",
+  "name": "blog",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "blog-astro",
+      "name": "blog",
       "dependencies": {
         "astro": "^7.3.3"
       },
+      "devDependencies": {
+        "mermaid": "12.0.0"
+      },
       "engines": {
         "node": ">=22.12.0"
       }
     },
+    "node_modules/@antfu/install-pkg": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz",
+      "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "package-manager-detector": "^1.8.0",
+        "tinyexec": "^1.3.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
     "node_modules/@astrojs/compiler-binding": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.4.1.tgz",
@@ -318,6 +335,13 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@braintree/sanitize-url": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
+      "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@bruits/satteri-darwin-arm64": {
       "version": "0.10.5",
       "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz",
@@ -495,6 +519,50 @@
         "node": ">=18"
       }
     },
+    "node_modules/@chevrotain/cst-dts-gen": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
+      "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@chevrotain/gast": "11.1.2",
+        "@chevrotain/types": "11.1.2",
+        "lodash-es": "4.17.23"
+      }
+    },
+    "node_modules/@chevrotain/gast": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz",
+      "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@chevrotain/types": "11.1.2",
+        "lodash-es": "4.17.23"
+      }
+    },
+    "node_modules/@chevrotain/regexp-to-ast": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz",
+      "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/@chevrotain/types": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
+      "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/@chevrotain/utils": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz",
+      "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
     "node_modules/@clack/core": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz",
@@ -972,6 +1040,25 @@
         "node": ">=18"
       }
     },
+    "node_modules/@iconify/types": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+      "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@iconify/utils": {
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz",
+      "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@antfu/install-pkg": "^2.0.1",
+        "@iconify/types": "^2.0.0",
+        "import-meta-resolve": "^4.2.0"
+      }
+    },
     "node_modules/@img/colour": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1527,6 +1614,19 @@
       "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
       "license": "MIT"
     },
+    "node_modules/@mermaid-js/parser": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.0.tgz",
+      "integrity": "sha512-K8BeapFUfrfxbRAUQAG5oBOCwo1+bNWzaVnPxTCutkfrTBn6T/j91FIhqxWJ32SUeQ9T3iy1zcmPZ5ROZEvDrg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@chevrotain/types": "~11.1.2"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      }
+    },
     "node_modules/@napi-rs/wasm-runtime": {
       "version": "1.2.4",
       "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
@@ -1937,6 +2037,290 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@types/d3": {
+      "version": "7.4.3",
+      "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+      "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-array": "*",
+        "@types/d3-axis": "*",
+        "@types/d3-brush": "*",
+        "@types/d3-chord": "*",
+        "@types/d3-color": "*",
+        "@types/d3-contour": "*",
+        "@types/d3-delaunay": "*",
+        "@types/d3-dispatch": "*",
+        "@types/d3-drag": "*",
+        "@types/d3-dsv": "*",
+        "@types/d3-ease": "*",
+        "@types/d3-fetch": "*",
+        "@types/d3-force": "*",
+        "@types/d3-format": "*",
+        "@types/d3-geo": "*",
+        "@types/d3-hierarchy": "*",
+        "@types/d3-interpolate": "*",
+        "@types/d3-path": "*",
+        "@types/d3-polygon": "*",
+        "@types/d3-quadtree": "*",
+        "@types/d3-random": "*",
+        "@types/d3-scale": "*",
+        "@types/d3-scale-chromatic": "*",
+        "@types/d3-selection": "*",
+        "@types/d3-shape": "*",
+        "@types/d3-time": "*",
+        "@types/d3-time-format": "*",
+        "@types/d3-timer": "*",
+        "@types/d3-transition": "*",
+        "@types/d3-zoom": "*"
+      }
+    },
+    "node_modules/@types/d3-array": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+      "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-axis": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+      "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-brush": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+      "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-chord": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+      "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-contour": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+      "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-array": "*",
+        "@types/geojson": "*"
+      }
+    },
+    "node_modules/@types/d3-delaunay": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+      "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-dispatch": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+      "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-drag": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+      "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-dsv": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+      "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-fetch": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+      "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-dsv": "*"
+      }
+    },
+    "node_modules/@types/d3-force": {
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+      "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-format": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+      "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-geo": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
+      "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "*"
+      }
+    },
+    "node_modules/@types/d3-hierarchy": {
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+      "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-polygon": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+      "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-quadtree": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+      "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-random": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz",
+      "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
+    "node_modules/@types/d3-scale-chromatic": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+      "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-selection": {
+      "version": "3.0.12",
+      "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz",
+      "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-time-format": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+      "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-transition": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+      "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-zoom": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+      "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-interpolate": "*",
+        "@types/d3-selection": "*"
+      }
+    },
     "node_modules/@types/estree": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1952,6 +2336,13 @@
         "@types/estree": "*"
       }
     },
+    "node_modules/@types/geojson": {
+      "version": "7946.0.16",
+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/hast": {
       "version": "3.0.5",
       "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
@@ -1979,6 +2370,14 @@
         "@types/unist": "*"
       }
     },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@types/unist": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -1991,6 +2390,17 @@
       "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
       "license": "ISC"
     },
+    "node_modules/@upsetjs/venn.js": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
+      "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
+      "dev": true,
+      "license": "MIT",
+      "optionalDependencies": {
+        "d3-selection": "^3.0.0",
+        "d3-transition": "^3.0.1"
+      }
+    },
     "node_modules/am-i-vibing": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz",
@@ -2182,6 +2592,21 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/chevrotain": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
+      "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@chevrotain/cst-dts-gen": "11.1.2",
+        "@chevrotain/gast": "11.1.2",
+        "@chevrotain/regexp-to-ast": "11.1.2",
+        "@chevrotain/types": "11.1.2",
+        "@chevrotain/utils": "11.1.2",
+        "lodash-es": "4.17.23"
+      }
+    },
     "node_modules/chokidar": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -2268,6 +2693,16 @@
       "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
       "license": "MIT"
     },
+    "node_modules/cose-base": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
+      "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "layout-base": "^1.0.0"
+      }
+    },
     "node_modules/crossws": {
       "version": "0.3.5",
       "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
@@ -2351,131 +2786,716 @@
       "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
       "license": "CC0-1.0"
     },
-    "node_modules/defu": {
-      "version": "6.1.7",
-      "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
-      "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
-      "license": "MIT"
-    },
-    "node_modules/dequal": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
-      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+    "node_modules/cytoscape": {
+      "version": "3.34.3",
+      "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz",
+      "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==",
+      "dev": true,
       "license": "MIT",
       "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/destr": {
-      "version": "2.0.5",
-      "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
-      "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
-      "license": "MIT"
-    },
-    "node_modules/detect-libc": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=8"
+        "node": ">=0.10"
       }
     },
-    "node_modules/devalue": {
-      "version": "5.9.2",
-      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz",
-      "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==",
-      "license": "MIT"
-    },
-    "node_modules/devlop": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
-      "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+    "node_modules/cytoscape-cose-bilkent": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz",
+      "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
-        "dequal": "^2.0.0"
+        "cose-base": "^1.0.0"
       },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/wooorm"
+      "peerDependencies": {
+        "cytoscape": "^3.2.0"
       }
     },
-    "node_modules/diff": {
-      "version": "9.0.0",
-      "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
-      "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">=0.3.1"
+    "node_modules/cytoscape-fcose": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
+      "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cose-base": "^2.2.0"
+      },
+      "peerDependencies": {
+        "cytoscape": "^3.2.0"
       }
     },
-    "node_modules/dom-serializer": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
-      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+    "node_modules/cytoscape-fcose/node_modules/cose-base": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz",
+      "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
-        "domelementtype": "^2.3.0",
-        "domhandler": "^5.0.2",
-        "entities": "^4.2.0"
-      },
-      "funding": {
-        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+        "layout-base": "^2.0.0"
       }
     },
-    "node_modules/domelementtype": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
-      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fb55"
-        }
-      ],
-      "license": "BSD-2-Clause"
+    "node_modules/cytoscape-fcose/node_modules/layout-base": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz",
+      "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
+      "dev": true,
+      "license": "MIT"
     },
-    "node_modules/domhandler": {
-      "version": "5.0.3",
-      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
-      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
-      "license": "BSD-2-Clause",
+    "node_modules/d3": {
+      "version": "7.9.0",
+      "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+      "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+      "dev": true,
+      "license": "ISC",
       "dependencies": {
-        "domelementtype": "^2.3.0"
+        "d3-array": "3",
+        "d3-axis": "3",
+        "d3-brush": "3",
+        "d3-chord": "3",
+        "d3-color": "3",
+        "d3-contour": "4",
+        "d3-delaunay": "6",
+        "d3-dispatch": "3",
+        "d3-drag": "3",
+        "d3-dsv": "3",
+        "d3-ease": "3",
+        "d3-fetch": "3",
+        "d3-force": "3",
+        "d3-format": "3",
+        "d3-geo": "3",
+        "d3-hierarchy": "3",
+        "d3-interpolate": "3",
+        "d3-path": "3",
+        "d3-polygon": "3",
+        "d3-quadtree": "3",
+        "d3-random": "3",
+        "d3-scale": "4",
+        "d3-scale-chromatic": "3",
+        "d3-selection": "3",
+        "d3-shape": "3",
+        "d3-time": "3",
+        "d3-time-format": "4",
+        "d3-timer": "3",
+        "d3-transition": "3",
+        "d3-zoom": "3"
       },
       "engines": {
-        "node": ">= 4"
-      },
-      "funding": {
-        "url": "https://github.com/fb55/domhandler?sponsor=1"
+        "node": ">=12"
       }
     },
-    "node_modules/domutils": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
-      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
-      "license": "BSD-2-Clause",
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "dev": true,
+      "license": "ISC",
       "dependencies": {
-        "dom-serializer": "^2.0.0",
-        "domelementtype": "^2.3.0",
-        "domhandler": "^5.0.3"
+        "internmap": "1 - 2"
       },
-      "funding": {
-        "url": "https://github.com/fb55/domutils?sponsor=1"
+      "engines": {
+        "node": ">=12"
       }
     },
-    "node_modules/dset": {
-      "version": "3.1.4",
-      "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz",
-      "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==",
-      "license": "MIT",
+    "node_modules/d3-axis": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+      "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+      "dev": true,
+      "license": "ISC",
       "engines": {
-        "node": ">=4"
+        "node": ">=12"
       }
     },
-    "node_modules/entities": {
-      "version": "4.5.0",
-      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+    "node_modules/d3-brush": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+      "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "3",
+        "d3-transition": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-chord": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+      "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-path": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-contour": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+      "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "^3.2.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-delaunay": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+      "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "delaunator": "5"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-drag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-selection": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dsv": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+      "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "commander": "7",
+        "iconv-lite": "0.6",
+        "rw": "1"
+      },
+      "bin": {
+        "csv2json": "bin/dsv2json.js",
+        "csv2tsv": "bin/dsv2dsv.js",
+        "dsv2dsv": "bin/dsv2dsv.js",
+        "dsv2json": "bin/dsv2json.js",
+        "json2csv": "bin/json2dsv.js",
+        "json2dsv": "bin/json2dsv.js",
+        "json2tsv": "bin/json2dsv.js",
+        "tsv2csv": "bin/dsv2dsv.js",
+        "tsv2json": "bin/dsv2json.js"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dsv/node_modules/commander": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+      "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-fetch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+      "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-dsv": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-force": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+      "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-quadtree": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+      "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-geo": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+      "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.5.0 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-hierarchy": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+      "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-polygon": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+      "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-quadtree": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+      "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-random": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+      "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-sankey": {
+      "version": "0.12.3",
+      "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
+      "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "d3-array": "1 - 2",
+        "d3-shape": "^1.2.0"
+      }
+    },
+    "node_modules/d3-sankey/node_modules/d3-array": {
+      "version": "2.12.1",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+      "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "internmap": "^1.0.0"
+      }
+    },
+    "node_modules/d3-sankey/node_modules/d3-path": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
+      "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
+      "dev": true,
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/d3-sankey/node_modules/d3-shape": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
+      "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "d3-path": "1"
+      }
+    },
+    "node_modules/d3-sankey/node_modules/internmap": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+      "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale-chromatic": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+      "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-interpolate": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-transition": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-dispatch": "1 - 3",
+        "d3-ease": "1 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "d3-selection": "2 - 3"
+      }
+    },
+    "node_modules/d3-zoom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-transition": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/dagre-d3-es": {
+      "version": "7.0.14",
+      "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
+      "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "d3": "^7.9.0",
+        "lodash-es": "^4.17.21"
+      }
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.23",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
+      "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/defu": {
+      "version": "6.1.7",
+      "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+      "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+      "license": "MIT"
+    },
+    "node_modules/delaunator": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
+      "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "robust-predicates": "^3.0.2"
+      }
+    },
+    "node_modules/dequal": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/destr": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+      "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/devalue": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz",
+      "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==",
+      "license": "MIT"
+    },
+    "node_modules/devlop": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+      "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+      "license": "MIT",
+      "dependencies": {
+        "dequal": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/diff": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
+      "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/dompurify": {
+      "version": "3.4.15",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
+      "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
+      "dev": true,
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
+    "node_modules/dset": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz",
+      "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/elkjs": {
+      "version": "0.9.3",
+      "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz",
+      "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
+      "dev": true,
+      "license": "EPL-2.0"
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
       "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
       "license": "BSD-2-Clause",
       "engines": {
@@ -2491,6 +3511,19 @@
       "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
       "license": "MIT"
     },
+    "node_modules/es-toolkit": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
+      "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
+      "dev": true,
+      "license": "MIT",
+      "workspaces": [
+        "docs",
+        "benchmarks",
+        "tests/types",
+        "tests/browser-compat"
+      ]
+    },
     "node_modules/esbuild": {
       "version": "0.28.2",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -2676,6 +3709,13 @@
         "uncrypto": "^0.1.3"
       }
     },
+    "node_modules/hachure-fill": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
+      "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/hast-util-to-html": {
       "version": "9.0.5",
       "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
@@ -2734,6 +3774,40 @@
       "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
       "license": "BSD-2-Clause"
     },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/import-meta-resolve": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
+      "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/iron-webcrypto": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
@@ -2798,6 +3872,46 @@
       "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
       "license": "MIT"
     },
+    "node_modules/katex": {
+      "version": "0.16.47",
+      "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
+      "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
+      "dev": true,
+      "funding": [
+        "https://opencollective.com/katex",
+        "https://github.com/sponsors/katex"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "commander": "^8.3.0"
+      },
+      "bin": {
+        "katex": "cli.js"
+      }
+    },
+    "node_modules/katex/node_modules/commander": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+      "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/khroma": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
+      "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==",
+      "dev": true
+    },
+    "node_modules/layout-base": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
+      "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/lightningcss": {
       "version": "1.33.0",
       "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -3059,6 +4173,13 @@
         "url": "https://opencollective.com/parcel"
       }
     },
+    "node_modules/lodash-es": {
+      "version": "4.17.23",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
+      "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/lru-cache": {
       "version": "11.5.2",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
@@ -3088,6 +4209,19 @@
         "source-map-js": "^1.2.1"
       }
     },
+    "node_modules/marked": {
+      "version": "16.4.2",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
+      "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 20"
+      }
+    },
     "node_modules/mdast-util-to-hast": {
       "version": "13.2.1",
       "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
@@ -3115,6 +4249,41 @@
       "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
       "license": "CC0-1.0"
     },
+    "node_modules/mermaid": {
+      "version": "12.0.0",
+      "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.0.0.tgz",
+      "integrity": "sha512-/wQXC9iBxoGV8p3erbvaXs9h77VyLDBH6GdayVjj3hEcSQhFU4N1WUhUppotCEqlIxI2pRMwjwBSwTB1MfZBgQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@braintree/sanitize-url": "^7.1.2",
+        "@iconify/utils": "^3.0.2",
+        "@mermaid-js/parser": "^2.0.0",
+        "@types/d3": "^7.4.3",
+        "@upsetjs/venn.js": "^2.0.0",
+        "chevrotain": "~11.1.2",
+        "cytoscape": "^3.34.0",
+        "cytoscape-cose-bilkent": "^4.1.0",
+        "cytoscape-fcose": "^2.2.0",
+        "d3": "^7.9.0",
+        "d3-sankey": "^0.12.3",
+        "dagre-d3-es": "7.0.14",
+        "dayjs": "^1.11.21",
+        "dompurify": "^3.4.12",
+        "elkjs": "^0.9.3",
+        "es-toolkit": "^1.45.1",
+        "katex": "^0.16.47",
+        "khroma": "^2.1.0",
+        "marked": "^16.3.0",
+        "roughjs": "^4.6.6",
+        "stylis": "^4.3.6",
+        "ts-dedent": "^2.2.0",
+        "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      }
+    },
     "node_modules/micromark-util-character": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
@@ -3382,6 +4551,13 @@
       "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
       "license": "MIT"
     },
+    "node_modules/path-data-parser": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
+      "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/piccolore": {
       "version": "0.1.3",
       "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -3406,6 +4582,24 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/points-on-curve": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
+      "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/points-on-path": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz",
+      "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-data-parser": "0.1.0",
+        "points-on-curve": "0.2.0"
+      }
+    },
     "node_modules/postcss": {
       "version": "8.5.28",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@@ -3529,6 +4723,13 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/robust-predicates": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+      "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
+      "dev": true,
+      "license": "Unlicense"
+    },
     "node_modules/rolldown": {
       "version": "1.2.9",
       "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
@@ -3562,6 +4763,33 @@
         "@rolldown/binding-win32-x64-msvc": "1.2.9"
       }
     },
+    "node_modules/roughjs": {
+      "version": "4.6.6",
+      "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
+      "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hachure-fill": "^0.5.2",
+        "path-data-parser": "^0.1.0",
+        "points-on-curve": "^0.2.0",
+        "points-on-path": "^0.2.1"
+      }
+    },
+    "node_modules/rw": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+      "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+      "dev": true,
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/satteri": {
       "version": "0.10.5",
       "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
@@ -3727,6 +4955,13 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/stylis": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
+      "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/svgo": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz",
@@ -3812,6 +5047,16 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/ts-dedent": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz",
+      "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.10"
+      }
+    },
     "node_modules/tslib": {
       "version": "2.8.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -4040,6 +5285,20 @@
         }
       }
     },
+    "node_modules/uuid": {
+      "version": "14.0.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
+      "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist-node/bin/uuid"
+      }
+    },
     "node_modules/verkit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.0.tgz",
diff --git a/package.json b/package.json
@@ -7,12 +7,16 @@
   },
   "scripts": {
     "dev": "astro dev",
-    "prebuild": "node scripts/check-content.mjs && node scripts/build-info.mjs",
+    "prebuild": "node scripts/check-content.mjs && node scripts/render-diagrams.mjs && node scripts/build-info.mjs",
     "build": "astro build",
     "preview": "astro preview",
-    "check": "node scripts/check-content.mjs"
+    "check": "node scripts/check-content.mjs",
+    "diagrams": "node scripts/render-diagrams.mjs"
   },
   "dependencies": {
     "astro": "^7.3.3"
+  },
+  "devDependencies": {
+    "mermaid": "12.0.0"
   }
 }
diff --git a/public/fonts/inter-italic-var.woff2 b/public/fonts/inter-italic-var.woff2
Binary files differ.
diff --git a/public/fonts/inter-var.woff2 b/public/fonts/inter-var.woff2
Binary files differ.
diff --git a/public/fonts/ko-02f8cebd.woff2 b/public/fonts/ko-02f8cebd.woff2
Binary files differ.
diff --git a/public/fonts/latin-9bba8f25.woff2 b/public/fonts/latin-9bba8f25.woff2
Binary files differ.
diff --git a/public/fonts/newsreader-var.woff2 b/public/fonts/newsreader-var.woff2
Binary files differ.
diff --git a/public/uploads/profile.jpg b/public/uploads/profile.jpg
Binary files differ.
diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs
@@ -65,7 +65,77 @@ for (const abs of files) {
   }
 }
 
-console.log(`check-content: ${files.length} documents, ${seen.size} URLs, STRICT=${[...STRICT].join(',')}`);
+/* ────────────────────────────────────────────────────────────────────────────
+   content/code — the Code index.
+
+   A collection this script does not know about is not "probably fine": the gate is the
+   only thing standing between a push and the live site, so an unlisted directory is
+   content that ships unchecked. Astro's own schema in src/content.config.ts already
+   rejects a malformed `url`, a missing `name` and a non-integer `order` — but only at
+   build time, and this script runs in `prebuild`, before that. What the schema cannot see
+   at all is an empty body, because the body is where the description lives here, and two
+   projects claiming the same position in a hand-ranked list.
+
+   The directory is optional on purpose: absent, the block does nothing, so a checkout
+   without it fails no differently than before. `files.length === 0` above is the
+   emptiness alarm, and it deliberately still counts only content/writing — seven project
+   stubs are not evidence that the prose survived the checkout.
+   ──────────────────────────────────────────────────────────────────────────── */
+const codeFiles = [];
+{
+  const d = path.join(SRC, 'content', 'code');
+  if (existsSync(d))
+    for (const f of await readdir(d)) if (f.endsWith('.md') && !f.startsWith('_')) codeFiles.push(path.join(d, f));
+}
+
+/** The same test src/content.config.ts applies, so the two cannot disagree about a link. */
+const isProjectUrl = (v) => {
+  if (v.startsWith('/')) return v.endsWith('/');
+  try {
+    const u = new URL(v);
+    return (u.protocol === 'https:' || u.protocol === 'http:') && u.hostname.includes('.');
+  } catch {
+    return false;
+  }
+};
+
+const orders = new Map();
+for (const abs of codeFiles) {
+  const rel = path.relative(SRC, abs).split(path.sep).join('/');
+  const raw = await readFile(abs, 'utf8');
+  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
+  if (!m) { err(rel, 'missing front matter'); continue; }
+  const [, fm, body] = m;
+  /* Local, and `\\s` rather than `\s`: inside a template literal `\s` is not an escape
+     sequence, so the backslash is dropped and the class becomes a literal `s*`. */
+  const get = (k) => (new RegExp(`^${k}:\\s*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
+
+  if (!get('name')) err(rel, 'missing name');
+
+  const url = get('url');
+  if (!url) err(rel, 'missing url');
+  else if (!isProjectUrl(url))
+    err(rel, `url is not an absolute http(s) URL with a dotted host, nor a site path ending in "/": ${url}`);
+
+  /* The description IS the body on this collection, so an empty file is an entry that
+     renders as a bare link with nothing under it. */
+  if (!body.trim()) err(rel, 'description is empty — the body of the file is the sentence the page prints');
+
+  /* A duplicate `order` is a hard error rather than a warning. The page does break the
+     tie — by name, then by id, so the build is deterministic either way — but on a list
+     whose entire order is hand-written, two entries claiming one position means the page
+     is not in the order its author wrote, and the fix is one character. Keyed through
+     Number() so `07` and `7` collide, which is what YAML will hand the schema anyway. */
+  const rawOrder = get('order');
+  if (!/^-?\d+$/.test(rawOrder)) err(rel, `order is not an integer: ${rawOrder || '(none)'}`);
+  else {
+    const key = String(Number(rawOrder));
+    if (orders.has(key)) err(rel, `order ${key} is already taken by ${orders.get(key)}`);
+    else orders.set(key, rel);
+  }
+}
+
+console.log(`check-content: ${files.length} documents, ${seen.size} URLs, ${codeFiles.length} code entries, STRICT=${[...STRICT].join(',')}`);
 for (const w of warns) console.log(`  warn  ${w}`);
 for (const e of errors) console.error(`  ERROR ${e}`);
 if (errors.length) { console.error(`\n${errors.length} error(s) — stopping the build.`); process.exit(1); }
diff --git a/scripts/render-diagrams.mjs b/scripts/render-diagrams.mjs
@@ -0,0 +1,355 @@
+/**
+ * render-diagrams.mjs — bakes every mermaid fence in the corpus to a committed SVG.
+ *
+ * Mermaid cannot be rendered without a browser. Under jsdom it does not merely look wrong,
+ * it lays out wrong: with CSSOM, getBBox, getComputedTextLength and getScreenCTM all
+ * shimmed it *succeeds* and hands back a flowchart 30x too wide (viewBox 42256 against the
+ * real 1424) and sequence diagrams ~10% short, because text layout is diagram layout.
+ *
+ * So the browser is the one on the machine, driven over CDP with no dependency at all:
+ * Node 22+ ships a global WebSocket, which is the entire protocol client. ubuntu-latest
+ * preinstalls Google Chrome, and withastro/action is a composite action running on the
+ * host, so CI has one in scope without a Puppeteer download.
+ *
+ * And it almost never runs. Each fence is keyed by a hash over its source and everything
+ * that could move a pixel (src/lib/diagrams.mjs), the SVG is committed under that name, and
+ * a build with every key present exits here before a browser is ever looked for. A cache
+ * miss means an author changed a diagram: it renders locally, or on a machine with a
+ * browser, and the result is committed. A miss with no browser anywhere is a hard error —
+ * never a silently stale picture, which is the one failure mode a committed cache invites.
+ *
+ *   node scripts/render-diagrams.mjs            render what is missing, prune what is not used
+ *   node scripts/render-diagrams.mjs --check     report only; exit 1 if anything is missing
+ *   node scripts/render-diagrams.mjs --force     re-render everything (palette work)
+ */
+import { readdir, readFile, writeFile, mkdir, unlink, rm } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { spawn } from 'node:child_process';
+import { createRequire } from 'node:module';
+import os from 'node:os';
+import path from 'node:path';
+import {
+  DIAGRAM_DIR,
+  DIAGRAM_FONTS,
+  MERMAID_CONFIG,
+  MERMAID_VERSION,
+  diagramFile,
+  diagramKey,
+  findMermaidFences,
+  finishDiagram,
+} from '../src/lib/diagrams.mjs';
+
+const SRC = process.env.CONTENT_DIR ?? '.';
+const CHECK = process.argv.includes('--check');
+const FORCE = process.argv.includes('--force');
+const RENDER_TIMEOUT_MS = 120_000;
+
+/* ── 1. every fence in the corpus ─────────────────────────────────────────── */
+
+/** content/ is prose and is never written here — only read. */
+async function markdownFiles(dir) {
+  const out = [];
+  for (const entry of await readdir(dir, { withFileTypes: true })) {
+    const abs = path.join(dir, entry.name);
+    if (entry.isDirectory()) out.push(...await markdownFiles(abs));
+    else if (entry.name.endsWith('.md') && !entry.name.startsWith('_')) out.push(abs);
+  }
+  return out;
+}
+
+const contentDir = path.join(SRC, 'content');
+if (!existsSync(contentDir)) {
+  console.error(`render-diagrams: no content directory at ${contentDir} — the checkout may have failed`);
+  process.exit(1);
+}
+
+/** key -> { source, where }. Identical fences in two posts share one file. */
+const wanted = new Map();
+for (const abs of (await markdownFiles(contentDir)).sort()) {
+  const rel = path.relative(SRC, abs).split(path.sep).join('/');
+  for (const fence of findMermaidFences(await readFile(abs, 'utf8'))) {
+    const key = diagramKey(fence.source);
+    if (!wanted.has(key)) wanted.set(key, { source: fence.source, where: `${rel}:${fence.line}` });
+  }
+}
+
+await mkdir(DIAGRAM_DIR, { recursive: true });
+const onDisk = new Set(
+  (await readdir(DIAGRAM_DIR).catch(() => [])).filter((f) => f.endsWith('.svg')).map((f) => f.slice(0, -4)),
+);
+
+const missing = [...wanted].filter(([key]) => FORCE || !onDisk.has(key));
+const orphans = [...onDisk].filter((key) => !wanted.has(key));
+
+/* ── 2. prune, then decide whether a browser is needed at all ─────────────── */
+
+if (!CHECK) {
+  for (const key of orphans) {
+    await unlink(diagramFile(key));
+    console.log(`  pruned ${key}.svg — no fence hashes to it any more`);
+  }
+  if (orphans.length) await invalidateRenderedMarkdown();
+}
+
+const summary = `render-diagrams: ${wanted.size} fence(s), ${wanted.size - missing.length} cached`;
+
+if (missing.length === 0) {
+  console.log(`${summary}, 0 to render`);
+  process.exit(0);
+}
+
+if (CHECK) {
+  console.error(`${summary}, ${missing.length} MISSING:`);
+  for (const [key, { where }] of missing) console.error(`  ${where} -> ${diagramFile(key)}`);
+  console.error('\nRun: npm run diagrams');
+  process.exit(1);
+}
+
+/* ── 3. a browser, or a loud failure ──────────────────────────────────────── */
+
+/** CHROME_PATH is the variable Lighthouse, karma and chrome-launcher all read. */
+function findBrowser() {
+  const listed = [process.env.CHROME_PATH, process.env.CHROME_BIN].filter(Boolean);
+  const byPlatform = {
+    win32: [
+      'C:/Program Files/Google/Chrome/Application/chrome.exe',
+      'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
+      path.join(process.env.LOCALAPPDATA ?? '', 'Google/Chrome/Application/chrome.exe'),
+      'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
+      'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
+    ],
+    darwin: [
+      '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+      '/Applications/Chromium.app/Contents/MacOS/Chromium',
+      '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
+    ],
+    linux: [
+      '/usr/bin/google-chrome',
+      '/usr/bin/google-chrome-stable',
+      '/usr/bin/chromium',
+      '/usr/bin/chromium-browser',
+      '/snap/bin/chromium',
+      '/usr/bin/microsoft-edge',
+    ],
+  };
+  for (const candidate of [...listed, ...(byPlatform[process.platform] ?? [])]) {
+    if (candidate && existsSync(candidate)) return candidate;
+  }
+  return null;
+}
+
+const browser = findBrowser();
+if (!browser) {
+  console.error(`${summary}, ${missing.length} to render — but no Chrome or Chromium was found.`);
+  for (const [key, { where }] of missing) console.error(`  ${where} -> ${diagramFile(key)}`);
+  console.error(
+    '\nA mermaid diagram is laid out by measuring text, so rendering one needs a real browser.\n' +
+    'Install Chrome, or point CHROME_PATH at one, then run `npm run diagrams` and commit\n' +
+    `the files it writes to ${DIAGRAM_DIR}/.`,
+  );
+  process.exit(1);
+}
+
+const require = createRequire(import.meta.url);
+let mermaidBundle;
+try {
+  const installed = require('mermaid/package.json').version;
+  if (installed !== MERMAID_VERSION) {
+    console.error(
+      `render-diagrams: mermaid ${installed} is installed but src/lib/diagrams.mjs keys diagrams to ` +
+      `${MERMAID_VERSION}. Update MERMAID_VERSION and re-render, or reinstall ${MERMAID_VERSION}.`,
+    );
+    process.exit(1);
+  }
+  // The UMD build, deliberately: it is one self-contained file with no dynamic imports,
+  // which is what makes it injectable into a page over CDP with nothing to serve.
+  mermaidBundle = await readFile(require.resolve('mermaid/dist/mermaid.min.js'), 'utf8');
+} catch {
+  console.error('render-diagrams: mermaid is not installed. It is a devDependency, used only here — run `npm install`.');
+  process.exit(1);
+}
+
+/* ── 4. CDP, with a global WebSocket and nothing else ─────────────────────── */
+
+async function connect(wsUrl) {
+  const socket = new WebSocket(wsUrl);
+  await new Promise((resolve, reject) => {
+    socket.onopen = resolve;
+    socket.onerror = () => reject(new Error(`cannot attach to ${wsUrl}`));
+  });
+  let seq = 0;
+  const pending = new Map();
+  socket.onmessage = (event) => {
+    const message = JSON.parse(event.data);
+    const waiter = message.id && pending.get(message.id);
+    if (!waiter) return;
+    pending.delete(message.id);
+    if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
+    else waiter.resolve(message.result);
+  };
+  return {
+    send: (method, params = {}) => new Promise((resolve, reject) => {
+      const id = ++seq;
+      pending.set(id, { resolve, reject });
+      socket.send(JSON.stringify({ id, method, params }));
+    }),
+    close: () => socket.close(),
+  };
+}
+
+const started = Date.now();
+// A throwaway profile per run, so a Chrome the author already has open cannot be joined
+// (which would hand back a browser whose fonts and flags are not the ones set here).
+const profile = path.join(os.tmpdir(), `render-diagrams-${process.pid}`);
+await mkdir(profile, { recursive: true });
+const child = spawn(browser, [
+  '--headless=new',
+  '--disable-gpu',
+  '--no-sandbox',              // the CI runner is already a throwaway container-shaped VM
+  '--no-first-run',
+  '--no-default-browser-check',
+  '--disable-extensions',
+  '--remote-debugging-port=0', // the port is read back off stderr, so parallel runs cannot clash
+  `--user-data-dir=${profile}`,
+  'about:blank',
+], { stdio: ['ignore', 'ignore', 'pipe'] });
+
+const fail = (message) => {
+  try { child.kill(); } catch { /* already gone */ }
+  console.error(`render-diagrams: ${message}`);
+  process.exit(1);
+};
+// Nothing below may block a deploy forever: a dead socket would otherwise leave every
+// `page.send` waiting on a reply that never comes, with no output and no exit.
+const watchdog = setTimeout(() => fail('the browser stopped responding'), RENDER_TIMEOUT_MS * 2);
+child.on('error', (error) => fail(`could not start ${browser} — ${error.message}`));
+
+const wsUrl = await Promise.race([
+  new Promise((resolve) => {
+    let buffered = '';
+    child.stderr.on('data', (chunk) => {
+      buffered += chunk;
+      const match = buffered.match(/ws:\/\/\S+/);
+      if (match) resolve(match[0]);
+    });
+  }),
+  new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30_000)),
+]).catch(() => fail(`${browser} started but never announced a debugging endpoint`));
+
+const origin = wsUrl.replace('ws://', 'http://').replace(/\/devtools.*/, '');
+const targets = await (await fetch(`${origin}/json/list`)).json();
+const page = await connect(targets.find((t) => t.type === 'page').webSocketDebuggerUrl);
+
+// A data: URL rather than a file: one, so nothing on disk is implied and no server is needed.
+await page.send('Page.navigate', {
+  url: 'data:text/html;charset=utf-8,' + encodeURIComponent('<!doctype html><meta charset="utf-8"><body></body>'),
+});
+await new Promise((resolve) => setTimeout(resolve, 200));
+
+// Injected as an expression, not as a <script> in the page's markup: the minified bundle
+// contains character sequences the HTML parser ends a script element on, and the page then
+// reports `mermaid is not defined`. mermaid.min.js assigns globalThis.mermaid on its last line.
+const loaded = await page.send('Runtime.evaluate', { expression: mermaidBundle, returnByValue: false });
+if (loaded.exceptionDetails) fail(`mermaid failed to load: ${loaded.exceptionDetails.text}`);
+
+const faces = [];
+for (const font of DIAGRAM_FONTS) {
+  const file = path.join(SRC, font.file);
+  if (!existsSync(file)) fail(`${font.file} is missing — diagram text would be measured in a system font`);
+  faces.push({ ...font, data: (await readFile(file)).toString('base64') });
+}
+
+const job = {
+  config: MERMAID_CONFIG,
+  faces: faces.map((f) => ({ family: f.family, data: f.data, descriptors: f.descriptors })),
+  fences: missing.map(([key, { source, where }]) => ({ id: `mmd-${key}`, source, where })),
+};
+
+const expression = `(async () => {
+  const job = ${JSON.stringify(job)};
+  for (const face of job.faces) {
+    const f = new FontFace(face.family, 'url(data:font/woff2;base64,' + face.data + ') format("woff2")', face.descriptors);
+    await f.load();
+    document.fonts.add(f);
+  }
+  await document.fonts.ready;
+  mermaid.initialize(job.config);
+  const out = [];
+  for (const fence of job.fences) {
+    document.body.innerHTML = '';
+    try {
+      const { svg } = await mermaid.render(fence.id, fence.source);
+      out.push({ id: fence.id, svg });
+    } catch (error) {
+      out.push({ id: fence.id, error: String(error && error.message || error) });
+    }
+  }
+  return JSON.stringify(out);
+})()`;
+
+const result = await Promise.race([
+  page.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }),
+  new Promise((_, reject) => setTimeout(() => reject(new Error('render timed out')), RENDER_TIMEOUT_MS)),
+]).catch((error) => fail(error.message));
+
+if (result.exceptionDetails) fail(`render failed: ${JSON.stringify(result.exceptionDetails).slice(0, 800)}`);
+
+clearTimeout(watchdog);
+page.close();
+child.kill();
+await rm(profile, { recursive: true, force: true }).catch(() => { /* Chrome may still hold it */ });
+
+/* ── 5. write, and account for it ─────────────────────────────────────────── */
+
+const rendered = JSON.parse(result.result.value);
+const failures = [];
+let bytes = 0;
+
+for (let i = 0; i < missing.length; i++) {
+  const [key, { where }] = missing[i];
+  const svg = rendered[i];
+  if (svg.error || !svg.svg?.startsWith('<svg')) {
+    failures.push(`  ${where}: ${svg.error ?? 'mermaid returned something that is not an SVG'}`);
+    continue;
+  }
+  // LF and a final newline: .gitattributes normalises the working tree to LF, and a file
+  // that ends without one shows up as a whole-file diff on the next platform that touches it.
+  const file = diagramFile(key);
+  const text = finishDiagram(svg.svg, `mmd-${key}`).replace(/\r\n?/g, '\n').trimEnd() + '\n';
+  await writeFile(file, text, 'utf8');
+  bytes += Buffer.byteLength(text);
+  console.log(`  rendered ${where} -> ${file} (${Buffer.byteLength(text).toLocaleString()} B)`);
+}
+
+if (failures.length) {
+  console.error(`\nrender-diagrams: ${failures.length} fence(s) did not render:`);
+  for (const line of failures) console.error(line);
+  process.exit(1);
+}
+
+await invalidateRenderedMarkdown();
+
+console.log(
+  `${summary}, ${missing.length} rendered in ${Date.now() - started} ms ` +
+  `(${bytes.toLocaleString()} B of SVG) using ${browser}`,
+);
+if (process.env.CI) {
+  console.log('  note: these were rendered on CI, so the committed cache is behind — run `npm run diagrams` and commit.');
+}
+
+/**
+ * Astro's content layer caches rendered Markdown keyed on the *Markdown* file's digest, and
+ * the SVG is inlined into that HTML. Re-bake a diagram without touching the prose and the
+ * next build happily serves the previous picture — the one failure this whole design exists
+ * to prevent. So a bake drops the store: it is a cache, the rebuild costs a second, and it
+ * only happens on the rare run that changed a diagram in the first place.
+ */
+async function invalidateRenderedMarkdown() {
+  for (const store of ['node_modules/.astro/data-store.json', '.astro/data-store.json']) {
+    const file = path.join(SRC, store);
+    if (!existsSync(file)) continue;
+    await unlink(file);
+    console.log(`  dropped ${store} so the pages holding these diagrams re-render`);
+    if (!process.env.CI) console.log('  (a dev server that is already running keeps its own copy — restart it)');
+  }
+}
diff --git a/scripts/subset-fonts.py b/scripts/subset-fonts.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+"""Rebuild the shipped web fonts from the variable masters in fonts-src/.
+
+fonts-src/ is deliberately outside public/: Astro copies public/ verbatim, so the
+masters used to be published alongside the subsets - 294,772B of fonts in dist/fonts/
+for a site that references two files totalling 140,436B.
+
+Maintenance tool, not a build step: the outputs are committed to public/fonts/ and the
+GitHub Actions build never runs this. Re-run it when the corpus gains characters the
+subsets do not cover, then commit the new files and the hashes printed at the end.
+
+    python -m pip install "fonttools[woff]" brotli
+    python scripts/subset-fonts.py
+
+Two faces, two scripts. Lora is the only Latin face on the site - body, headings, links,
+nav and the wordmark all resolve to it - and Pretendard covers Hangul. Lora has no
+Hangul and Pretendard's Latin is switched off by unicode-range, so the two never compete
+for a codepoint.
+
+Why each flag is here, measured on this repo's masters by removing one flag at a time
+from the recipe below and re-encoding in a single session, so the pairs below compare
+like with like:
+
+  unicodes   The dominant saving on Lora, and the only large one. The master carries 778
+             codepoints - 318 Latin Extended and Vietnamese, 242 Cyrillic, 20 combining
+             marks, 6 Greek - against the 105 the whole built corpus paints. Cutting to
+             LATIN takes 71,504 -> 22,288. On Pretendard the same idea drops the Latin the
+             unicode-range gate had already made unreachable: 130,528 -> 118,212.
+
+  instancer  Worth 49,552B on Pretendard, whose wght axis runs 45-930 against the 400-700
+             the design uses, and whose gvar is 226,842B of the 466,492B sfnt. On Lora it
+             is worth nothing: the axis already *is* 400-700, so there is no range to trim
+             and the two encodings differ by 4B, which is inside woff2's own noise. The
+             call stays because it is what pins the design's range in one readable place,
+             not because it pays for itself on the Latin face.
+
+  features   kern + liga, nothing else. GPOS also carries mark attachment and script
+             shaping this site never invokes; dropping the rest takes Lora 23,268 ->
+             22,288. On Pretendard it is now worth 28B, i.e. nothing: once the Latin is
+             gone, Hangul has no other features left to drop. Kept for both so the recipe
+             is one recipe.
+
+             liga is the one the outgoing sans had no use for. Lora's f has a prominent
+             hook that runs into the dot of a following i, and the built corpus renders 61
+             `fi`, 52 `fl` and 17 `ffl` sequences. Lora's liga covers f+i and f+l and
+             costs 396B over kern alone. Dropping it would save a third of a kilobyte by
+             breaking 113 words.
+
+Measured results (bytes), as shipped:
+    lora-var.woff2          84,764 -> latin-9bba8f25.woff2    22,300   (-74%)
+    pretendard-var.woff2   210,008 -> ko-02f8cebd.woff2      118,136   (-44%)
+                                      english route           22,300
+                                      korean route           140,436
+
+woff2 encoding is not byte-deterministic. Re-running this on unchanged masters moves the
+output by a hundred bytes or so - Pretendard has come out at 118,136, 118,212 and 118,276
+from identical input - which is why every filename is content-hashed and why the figures
+above are quoted to compare against each other, never as a checksum.
+
+Not shipped, deliberately:
+
+    Lora-Italic (wght 400-700, same recipe)   ~23,400   0 renders in the whole corpus.
+        The corpus holds 25 markdown emphasis runs. satteri-figure.mjs lifts 15 of them
+        out as figure captions, which are upright by design, and the 10 that survive as
+        <em> are all on documents that resolve to lang="ko", where [slug].astro sets
+        `.post[lang='ko'] .prose em { font-style: normal; font-weight: 500 }` because
+        Pretendard has no italic. Nothing on the site requests font-style: italic, so
+        nothing synthesises one either - this is 23KB that would never be fetched.
+        If an English post ever uses emphasis, put Lora-Italic-wght back in fonts-src/ and
+        add one JOBS row with font-style: italic in fonts.css; the recipe is unchanged.
+
+    Three pinned weights instead of one axis   11,872 + 12,396 + 11,912 = 36,180, against
+        22,288 for the axis. The design reaches 400 (body), 500 (headings, Korean
+        emphasis) and 700 (links, kickers, nav, wordmark), and Lora's axis is exactly
+        400-700, so all three are real instances. One variable file carrying all three is
+        13,892B cheaper than three static ones and is one request instead of three.
+"""
+import hashlib, os, pathlib, sys
+from fontTools.ttLib import TTFont
+from fontTools.varLib import instancer
+from fontTools import subset
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent
+SRC = ROOT / "fonts-src"          # variable masters, never published
+OUT = ROOT / "public" / "fonts"    # the two files the site actually serves
+TMP = ROOT / ".fonts-work"
+
+# The five Hangul blocks, identical to the unicode-range in fonts.css.
+KO = "U+1100-11FF,U+3130-318F,U+A960-A97F,U+AC00-D7A3,U+D7B0-D7FF"
+
+# Printable ASCII plus the ten non-ASCII marks the built corpus actually paints. This is
+# the exact set, not an estimate: every .html in dist/ plus every .md in content/ was
+# scanned, and the non-Hangul result is these 105 codepoints. The retired Inter and
+# Newsreader masters had already been cut the same way, to 107 and 104, so nothing the
+# site renders today is lost - and U+2022 and U+2248 are gained, because Newsreader
+# carried no bullet and neither face carried an approximation sign.
+#
+# 27 further non-Hangul codepoints appear in the corpus and are in no shipped face:
+# arrows, box drawing, circled digits, kana and emoji. They fall to a system font, which
+# is what they do today as well. Adding them is not an option - Lora has none of them.
+LATIN = "U+0020-007E,U+00B7,U+2013-2014,U+2018-2019,U+201C-201D,U+2022,U+2026,U+2248"
+
+JOBS = [
+    # master,                  stem,      axis limits,           unicodes
+    ("lora-var.woff2",        "latin",   {"wght": (400, 700)},  LATIN),
+    ("pretendard-var.woff2",  "ko",      {"wght": (400, 700)},  KO),
+]
+
+
+def build(master, stem, limits, unicodes):
+    src = SRC / master
+    if not src.exists():
+        sys.exit(f"missing master: {src}")
+    font = TTFont(src)
+    font = instancer.instantiateVariableFont(
+        font, limits, updateFontNames=False, optimize=True
+    )
+    TMP.mkdir(exist_ok=True)
+    flat = TMP / f"_{stem}.ttf"
+    font.flavor = None
+    font.save(flat)
+
+    out = TMP / f"{stem}.woff2"
+    subset.main([
+        str(flat),
+        f"--output-file={out}",
+        "--flavor=woff2",
+        "--no-hinting",
+        "--desubroutinize",
+        "--layout-features=kern,liga",
+        "--drop-tables+=DSIG",
+        f"--unicodes={unicodes}",
+    ])
+    flat.unlink()
+
+    digest = hashlib.sha256(out.read_bytes()).hexdigest()[:8]
+    # public/ is copied verbatim by Vite, so nothing hashes these filenames for us.
+    # GitHub Pages pins Cache-Control at max-age=600 and cannot be configured, which
+    # makes the filename the only cache-invalidation handle we have.
+    final = OUT / f"{stem}-{digest}.woff2"
+    OUT.mkdir(parents=True, exist_ok=True)
+    for stale in OUT.glob(f"{stem}-*.woff2"):
+        if stale != final:
+            stale.unlink()
+    final.write_bytes(out.read_bytes())
+    out.unlink()
+    return final.name, final.stat().st_size
+
+
+def rewrite(names):
+    """Point fonts.css, Base.astro and tokens.css at the files that were just written.
+
+    woff2 encoding is not byte-deterministic, so the digest moves on every run even when
+    the input does not. Hand-syncing filenames across three files after each rebuild is a
+    guaranteed source of a silent 404 and a page rendered entirely in fallback, so the
+    script owns the references instead of documenting them.
+    """
+    import re
+    targets = [
+        ROOT / "src" / "styles" / "fonts.css",
+        ROOT / "src" / "layouts" / "Base.astro",
+        # tokens.css cites the shipped Latin file by its hashed name in the type-scale
+        # derivation comment, so it goes stale on every rebuild exactly like the other
+        # two. It carries no url(), only prose, so a stale name here is a misleading
+        # citation rather than a 404 - but it is the same hand-sync this function exists
+        # to remove.
+        ROOT / "src" / "styles" / "tokens.css",
+    ]
+    for t in targets:
+        text = t.read_text(encoding="utf-8")
+        for stem, name in names.items():
+            text = re.sub(rf"/fonts/{stem}-[0-9a-f]{{8}}\.woff2", f"/fonts/{name}", text)
+        t.write_text(text, encoding="utf-8")
+        print(f"  updated {t.relative_to(ROOT)}")
+
+
+def main():
+    print(f"{'file':<34}{'bytes':>9}")
+    names, sizes = {}, {}
+    for master, stem, limits, unicodes in JOBS:
+        name, size = build(master, stem, limits, unicodes)
+        names[stem], sizes[stem] = name, size
+        print(f"{name:<34}{size:>9,}")
+    print("-" * 43)
+    print(f"{'latin only (english route)':<34}{sizes['latin']:>9,}")
+    print(f"{'+ korean face (31 of 35 pages)':<34}{sizes['latin'] + sizes['ko']:>9,}")
+    print()
+    rewrite(names)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/content.config.ts b/src/content.config.ts
@@ -38,9 +38,79 @@ const writing = defineCollection({
   }),
 });
 
+/**
+ * A project link. Either an absolute http(s) URL or a root-relative path on this site:
+ * the reference's own code page points some entries at GitHub and others at a page it
+ * hosts itself, so both shapes stay open.
+ *
+ * This is the one field on the page whose defect a schema can actually catch. `new URL()`
+ * rejects `htps://…`, a missing slash after the scheme and a host with no dot; what it
+ * cannot see is a well-formed URL pointing at the wrong repository, so the check is a
+ * floor, not a guarantee. Internal paths are required to end in a slash because
+ * astro.config.mjs builds with `trailingSlash: 'always'` — without one the link takes a
+ * redirect, or 404s on a host that does not add it.
+ *
+ * Written as a single `.refine()` with a boolean predicate rather than `superRefine` +
+ * `ctx.addIssue`, whose issue shape differs between Zod majors; this form reads the same
+ * whichever one `astro:content` re-exports.
+ */
+const isProjectUrl = (v: string): boolean => {
+  if (v.startsWith('/')) return v.endsWith('/');
+  try {
+    const u = new URL(v);
+    return (u.protocol === 'https:' || u.protocol === 'http:') && u.hostname.includes('.');
+  } catch {
+    return false;
+  }
+};
+
+const projectUrl = z.string().refine(isProjectUrl, {
+  message:
+    'must be an absolute http(s) URL with a dotted hostname, or a path on this site ending in "/"',
+});
+
+/**
+ * The Code index is a hand-ranked list, so it carries no date: `order` ascending is the
+ * whole sort, and the author changes the page by changing one number in front matter.
+ * Numbered in tens in content/code so a project can be slotted between two others
+ * without renumbering the tail.
+ *
+ * Deliberately NOT derived from anything: sorting by date would put the list in an order
+ * nobody chose, and sorting by stars would let GitHub reorder the page.
+ */
+const code = defineCollection({
+  loader: glob({ base: './content/code', pattern: '*.md', generateId: slugOf }),
+  schema: z.object({
+    /**
+     * The link text, and the project's own name rather than its repo slug — `UAF —
+     * Ultimate AP Finder`, not `uaf`. The slug still comes from the filename, so the two
+     * are free to differ.
+     */
+    name: z.string(),
+    url: projectUrl,
+    order: z.number().int(),
+    draft: z.boolean().optional(),
+  }),
+  /*
+   * No `description` field. The one-sentence description is the markdown BODY of each
+   * file, which is what a markdown file is for: it needs no YAML quoting, so a colon or
+   * an apostrophe in a sentence cannot break the parse, and an author who wants to put a
+   * link or an emphasis inside the sentence later can.
+   *
+   * Also no `language` and no `licence`, though both were to hand. Three reasons, in
+   * order of weight: the reference prints neither and this page is modelled on it; both
+   * are one click away at the other end of the link that is already the entry's headline;
+   * and a field this site cannot keep true should not be printed — `uaf` already
+   * disagrees with itself, carrying no licence in its repository metadata and
+   * `GPL-2.0-or-later` in its README. Stars are out for the same reason twice over: the
+   * reference shows none, and a number that changes without a commit does not belong in
+   * a git-based site's content.
+   */
+});
+
 const about = defineCollection({
   loader: glob({ base: './content', pattern: 'about.md', generateId: () => 'about' }),
   schema: z.object({ title: z.string(), date: dateString.optional() }),
 });
 
-export const collections = { writing, about };
+export const collections = { writing, code, about };
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
@@ -2,9 +2,35 @@
 import '../styles/fonts.css';
 import '../styles/tokens.css';
 import '../styles/base.css';
+import { hasHangul } from '../lib/hangul';
 
-interface Props { title: string; description?: string; section?: string }
-const { title, description, section = 'slate' } = Astro.props;
+interface Props {
+  title: string;
+  description?: string;
+  section?: string;
+  /**
+   * Does this page put Hangul on screen? It decides which font is preloaded, and it is
+   * NOT the same question as the document language. /writing/ is an English document —
+   * heading "Writing", year kickers, "30 posts" — that renders 21 Korean titles above
+   * the fold, and `/` is an English document whose one tagline is Korean. 31 of the 35
+   * built pages need the Korean face; only /papers/, /talk/, /code/ and /404.html do not.
+   * Pass it explicitly wherever the Korean text is not already in title or description.
+   */
+  ko?: boolean;
+}
+const { title, description, section = 'slate', ko } = Astro.props;
+
+/**
+ * Preload the face that blocks first paint on THIS route, not a fixed pair.
+ *
+ * Measured: the Korean face is 118,240B against 21,816 + 21,308 for the Latin pair, and
+ * it is discovered last — HTML, then CSS, then layout, then the request. On a Korean
+ * article the display face sets exactly one string above the fold, the wordmark, so
+ * preloading it ahead of the Korean body text inverts the priority.
+ *
+ * woff2 is already Brotli-compressed, so none of these numbers move on the wire.
+ */
+const needsKo = ko ?? hasHangul(title, description);
 const NAV = [
   { label: 'About',   href: '/' },
   { label: 'Papers',  href: '/papers/' },
@@ -16,6 +42,36 @@ const here = Astro.url.pathname;
 // An exact match would leave every nav item unmarked on /writing/2026/vtzero/.
 // Only '/' compares exactly, so that About is not current on every page.
 const isCurrent = (href: string) => (href === '/' ? here === '/' : here.startsWith(href));
+
+/**
+ * The header's left slot is the name of the page you are on, not a wordmark. That is the
+ * whole state mechanic of the reference header, and the reason its nav carries no
+ * current-item marker at all: /papers reads "Papers", /code reads "Code", and an article
+ * reads its SECTION — measured on the live reference, /writing/2025/… shows "Writing",
+ * never the article's own title, which already has an h1 below.
+ *
+ * It is derived from NAV and isCurrent rather than from a new prop, so it needs nothing
+ * of the pages: startsWith already carries /writing/2026/vtzero/ to "Writing". Root is
+ * excluded from the match because its nav label is "About", which names a page, not the
+ * site; / and anything unrouted (404) fall through to the site name instead — the
+ * reference does the same, labelling its own root nav item "Main" while the slot reads
+ * the site owner's name.
+ *
+ * Note this is NOT the `section` prop above: that one is the palette key (is-slate,
+ * is-writing) and only article pages pass it.
+ */
+const currentNav = NAV.find((n) => n.href !== '/' && isCurrent(n.href));
+/* The site owner's name, not the domain. The reference's own root slot reads "Nicholas
+ * Carlini" where its nav item reads "Main"; this reads the same way against "About". */
+const SITE_OWNER = 'Hyukjin An';
+const pageName = currentNav?.label ?? SITE_OWNER;
+
+/* Copyright range. The opening year is fixed; the closing year is the build's, in UTC like
+   every other date on this site. Stamping it at build time rather than typing 2026 means the
+   notice cannot quietly go stale — and if a year passes with no push, it correctly keeps
+   showing the year of the last publication rather than a year nothing was published in. */
+const SITE_YEAR_FROM = 2009;
+const buildYear = new Date().getUTCFullYear();
 ---
 <!doctype html>
 <html lang="en">
@@ -26,13 +82,22 @@ const isCurrent = (href: string) => (href === '/' ? here === '/' : here.startsWi
     {description && <meta name="description" content={description} />}
     <link rel="canonical" href={new URL(here, Astro.site)} />
     <link rel="alternate" type="application/rss+xml" title="sqzer" href="/index.xml" />
-    <link rel="preload" href="/fonts/newsreader-var.woff2" as="font" type="font/woff2" crossorigin />
-    <link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
+    {/* One Latin face now, so this is unconditional: every page paints Lora, and the
+        old branch between a display face and the Korean one has nothing left to pick. */}
+    <link rel="preload" href="/fonts/latin-9bba8f25.woff2" as="font" type="font/woff2" crossorigin />
+    {needsKo && <link rel="preload" href="/fonts/ko-02f8cebd.woff2" as="font" type="font/woff2" crossorigin />}
   </head>
   <body class={`is-${section}`}>
     <a class="skip" href="#main">Skip to content</a>
     <header>
-      <a href="/">sqzer</a>
+      {/* A span, not a link and not a heading. Once the slot reads "Papers" an href="/"
+          on it is a link whose visible name is not its destination, and the same control
+          would take five different names across the site; the reference's own slot is a
+          plain div for that reason. / is already in the nav as "About". Not a heading
+          either: /papers/, /talk/, /code/ and /writing/ each already carry an h1 holding
+          exactly this word, and a second one inside the banner landmark would duplicate
+          it. */}
+      <span class="page-name">{pageName}</span>
       <nav>
         {NAV.map((n) => (
           <a href={n.href} aria-current={isCurrent(n.href) ? 'page' : undefined}>{n.label}</a>
@@ -40,5 +105,8 @@ const isCurrent = (href: string) => (href === '/' ? here === '/' : here.startsWi
       </nav>
     </header>
     <main id="main"><slot /></main>
+    <footer class="site-foot">
+      <p>Copyright &copy; {SITE_YEAR_FROM}&ndash;{buildYear} {SITE_OWNER}</p>
+    </footer>
   </body>
 </html>
diff --git a/src/lib/diagrams.mjs b/src/lib/diagrams.mjs
@@ -0,0 +1,284 @@
+/**
+ * diagrams — the contract between the step that bakes mermaid fences into SVG
+ * (scripts/render-diagrams.mjs) and the plugin that puts the result back into the page
+ * (src/lib/satteri-diagram.mjs). Both sides import this file, so a diagram is keyed the
+ * same way from either end.
+ *
+ * Why bake at all. Mermaid lays a diagram out by *measuring text*, so the geometry it
+ * emits belongs to whatever font did the measuring. Measured in a browser, the string
+ * "클라이언트 검열 프로브 위장 핸드셰이크" at 16px is 251.1px in this site's own KoBody,
+ * 266.3px in a default serif and 294.5px in Malgun Gothic — a 17.3% spread, and every box
+ * in the picture is sized around that number. Loading public/fonts before rendering is
+ * therefore not a nicety: it is what makes a Windows dev box and an Ubuntu runner produce
+ * identical SVG, because no system font is ever consulted.
+ *
+ * The cache key is a hash over the fence source *and* everything else that could move a
+ * pixel — schema version, mermaid config, palette, and the content-hashed font filenames.
+ * Re-subset a face and its filename changes, so every diagram invalidates itself. Nothing
+ * here needs a manifest: the key *is* the filename.
+ */
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+
+/** Committed, because a build must never need a browser. See scripts/render-diagrams.mjs. */
+export const DIAGRAM_DIR = 'src/lib/diagrams';
+
+/** Bump when the baked SVG's own markup changes shape (ids, root attributes, wrapper). */
+export const DIAGRAM_SCHEMA = 2;
+
+/**
+ * The renderer is part of the geometry, so it is part of the key: a mermaid upgrade
+ * re-renders every diagram rather than leaving new ones drawn by a different version than
+ * their neighbours. Stated here rather than read from the package so the plugin side never
+ * has to resolve a devDependency; scripts/render-diagrams.mjs asserts the two agree.
+ */
+export const MERMAID_VERSION = '12.0.0';
+
+/**
+ * The faces to load into the render browser, matching the @font-face rules in
+ * src/styles/fonts.css exactly — including KoBody's size-adjust, without which every
+ * Hangul label is measured 6% too wide and the boxes are drawn to match.
+ */
+export const DIAGRAM_FONTS = [
+  { family: 'Lora', file: 'public/fonts/latin-9bba8f25.woff2', descriptors: { weight: '400 700' } },
+  {
+    family: 'KoBody',
+    file: 'public/fonts/ko-02f8cebd.woff2',
+    descriptors: {
+      weight: '400 700',
+      sizeAdjust: '94.29%',
+      unicodeRange: 'U+1100-11FF, U+3130-318F, U+A960-A97F, U+AC00-D7A3, U+D7B0-D7FF',
+    },
+  },
+];
+
+/**
+ * --font-body, spelled out. The stack is baked into the SVG's own style block, so the
+ * fallback names have to be here too: a reader whose webfont has not landed yet gets
+ * KoFallback, whose metric overrides in fonts.css are tuned to KoBody, rather than a raw
+ * Malgun Gothic that would not fit the boxes this file measured.
+ */
+const FONT_STACK = "'Lora', 'KoBody', 'KoFallback', 'LatinFallback', Georgia, 'Times New Roman', serif";
+
+/* tokens.css, resolved. SVG cannot carry a var(), so these are the literals it computes to. */
+const INK = '#000000';         /* --page-fg */
+const PLATE = '#faedda';       /* --sand */
+const PAGE = '#fffbf4';        /* --sand-light */
+const BORDER = '#eed6aa';      /* --sand-dark */
+const HAIRLINE = '#b9b8ad';    /* --grey */
+const RULE = '#605a4e';        /* --slate */
+const WASH = '#f5eee0';        /* --slate-wash */
+const ACCENT = '#a51c30';      /* --crimson */
+const ACCENT_WASH = '#f7ebeb'; /* --crimson-wash */
+
+/**
+ * The config every fence is rendered with.
+ *
+ * htmlLabels: false is the load-bearing line. With HTML labels mermaid puts a <p> inside a
+ * <foreignObject>, which is live HTML sitting inside .prose: every `.prose p` rule would
+ * apply to it, and the reader's browser would re-wrap that text at read time inside a box
+ * whose size was frozen at build time. SVG text is measured once, here, and cannot reflow.
+ * A <br/> inside a label still breaks the line.
+ *
+ * useMaxWidth: false keeps the width/height attributes mermaid measured on the root, which
+ * is what gives the baked SVG an intrinsic ratio. The fitting itself is CSS's - base.css
+ * scales the picture into the column with `max-inline-size: 100%` - so mermaid's own
+ * `width="100%" style="max-width:Npx"` variant would only be a second, weaker way of saying
+ * the same thing, and it would take the measurement off the file on its way out.
+ *
+ * sequence.wrap: true is what makes that fitting affordable, and it is the one setting here
+ * that changes a drawing rather than its colours. A sequence diagram's width is set by its
+ * widest message label: unwrapped, "FakeIP 198.18.x.x (실제 해석 안 함 → 유출 0)" alone
+ * holds two lifelines ~380px apart, and five actors of that turn into 1,540px that has to be
+ * scaled 0.58 to reach a 928px column - 16px labels at 9.3px. Wrapped, the same two diagrams
+ * come out 660x780 (fits with no scaling at all) and 1,194x958 (scales 0.75, labels at 12px).
+ * Height is the price, and it is the right one: a page scrolls down already.
+ *
+ * It does re-break text the author did not break, which is the thing flowchart.wrappingWidth
+ * above is set to 400 to avoid. The two are not the same call. A <br/> in a flowchart label
+ * is the author drawing a box; a long sentence on an arrow is prose, and mermaid breaks it on
+ * spaces, so the Korean labels here wrap where their words already end.
+ */
+export const MERMAID_CONFIG = {
+  startOnLoad: false,
+  // The SVG is injected as markup, so mermaid's own DOMPurify pass stays in the loop.
+  securityLevel: 'strict',
+  theme: 'base',
+  // mermaid 12 defaults to look: 'neo', which hangs a drop shadow off notes and label
+  // boxes. This page's whole graphic vocabulary is a 4px rule and a 1px hairline.
+  look: 'classic',
+  fontFamily: FONT_STACK,
+  htmlLabels: false,
+  // wrappingWidth is 120px by default, which re-breaks lines the author already broke with
+  // <br/>: "VLESS + Reality :443" came back as two lines. Raised past the longest explicit
+  // line in the corpus (~270px), so a break in the picture is a break in the source.
+  flowchart: { htmlLabels: false, useMaxWidth: false, wrappingWidth: 400 },
+  sequence: { useMaxWidth: false, wrap: true },
+  er: { useMaxWidth: false },
+  gantt: { useMaxWidth: false },
+  class: { useMaxWidth: false },
+  state: { useMaxWidth: false },
+  journey: { useMaxWidth: false },
+  pie: { useMaxWidth: false },
+  themeVariables: {
+    fontFamily: FONT_STACK,
+    fontSize: '15px',
+    background: PAGE,
+    // Nodes take the surface token — the same tint as inline code and the figure plate.
+    primaryColor: PLATE,
+    primaryTextColor: INK,
+    primaryBorderColor: BORDER,
+    secondaryColor: WASH,
+    secondaryTextColor: INK,
+    secondaryBorderColor: BORDER,
+    // Clusters sit on the page ground behind a hairline, so a subgraph reads as a frame
+    // around its nodes rather than a second filled box behind them.
+    tertiaryColor: PAGE,
+    tertiaryTextColor: INK,
+    tertiaryBorderColor: HAIRLINE,
+    mainBkg: PLATE,
+    nodeBorder: BORDER,
+    clusterBkg: PAGE,
+    clusterBorder: HAIRLINE,
+    titleColor: INK,
+    textColor: INK,
+    lineColor: RULE,
+    edgeLabelBackground: PAGE,
+    nodeTextColor: INK,
+    /* Sequence. */
+    actorBkg: PLATE,
+    actorBorder: BORDER,
+    actorTextColor: INK,
+    actorLineColor: RULE,
+    signalColor: INK,
+    signalTextColor: INK,
+    labelBoxBkgColor: PLATE,
+    labelBoxBorderColor: BORDER,
+    labelTextColor: INK,
+    loopTextColor: INK,
+    altBackground: WASH,
+    activationBkgColor: WASH,
+    activationBorderColor: BORDER,
+    sequenceNumberColor: PAGE,
+    // A note is an aside, so it borrows the accent the way a blockquote's bar does.
+    noteBkgColor: ACCENT_WASH,
+    noteTextColor: INK,
+    noteBorderColor: ACCENT,
+  },
+};
+
+/**
+ * Rules appended to each baked SVG's own style block, scoped to that diagram's id.
+ *
+ * With SVG text labels mermaid paints the rect behind an edge label at opacity 0.5, so the
+ * edge line shows straight through the words — visible on the flowchart, where a three-line
+ * label sits on top of the arrow between two subgraphs. The HTML-label path gets an opaque
+ * box; this gives the SVG path the same one. Part of the cache key, so editing this line
+ * re-bakes every diagram.
+ */
+export const DIAGRAM_CSS = ['.edgeLabel rect{opacity:1;}'];
+
+/**
+ * Make one rendered SVG fit to be *inlined into HTML*, which is not the same document type
+ * mermaid serialised it for, and append the rules above.
+ *
+ * The bug this fixes is silent. Mermaid serialises as XML, so a child combinator inside its
+ * stylesheet comes out escaped: `.noteText&gt;tspan`. Re-parsed by an HTML parser, a <style>
+ * element's content is raw text — nothing decodes the entity, the selector is invalid, and
+ * because one bad selector voids the whole comma-separated group it takes `.noteText` down
+ * with it. Five rules per sequence diagram, covering note, loop, label and actor text. They
+ * happen to be black on black here, so it cost nothing this time; it would not stay that way.
+ */
+export function finishDiagram(svg, id) {
+  const rules = DIAGRAM_CSS.map((rule) => `#${id} ${rule}`).join('');
+  if (!svg.includes('</style>')) return svg.replace(/^(<svg[^>]*>)/, `$1<style>${rules}</style>`);
+  let appended = false;
+  // Every style block is decoded, not just the first: today mermaid emits one per diagram,
+  // and a version that emits two would otherwise leave the second one's selectors broken.
+  return svg.replace(/(<style>)([\s\S]*?)(<\/style>)/g, (_all, open, css, close) => {
+    const decoded = css.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
+    // A decoded `</style` would end the element early and spill CSS into the page as text.
+    if (/<\/style/i.test(decoded)) throw new Error('diagram stylesheet contains a closing style tag');
+    const tail = appended ? '' : rules;
+    appended = true;
+    return `${open}${decoded}${tail}${close}`;
+  });
+}
+
+/**
+ * Both ends have to hash the same bytes. The highlight plugin in @astrojs/markdown-satteri
+ * strips one trailing newline from a fence's text; this strips every trailing newline and
+ * normalises CRLF, so a checkout with other line endings still finds its cached diagram.
+ */
+export const normalizeDiagram = (source) => String(source).replace(/\r\n?/g, '\n').replace(/\n+$/, '');
+
+/** 16 hex characters = 64 bits. A collision needs billions of diagrams; the corpus has 3. */
+export function diagramKey(source) {
+  const material = JSON.stringify({
+    v: DIAGRAM_SCHEMA,
+    mermaid: MERMAID_VERSION,
+    config: MERMAID_CONFIG,
+    css: DIAGRAM_CSS,
+    fonts: DIAGRAM_FONTS,
+    source: normalizeDiagram(source),
+  });
+  return createHash('sha256').update(material, 'utf8').digest('hex').slice(0, 16);
+}
+
+export const diagramFile = (key) => `${DIAGRAM_DIR}/${key}.svg`;
+
+/**
+ * The baked SVG, or null when this fence has not been rendered yet. Synchronous by design:
+ * the plugin that calls it is a cache lookup, with no browser and no lifecycle to manage.
+ */
+export function readDiagram(key) {
+  try {
+    return readFileSync(diagramFile(key), 'utf8');
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Every mermaid fence in a markdown document, in source order.
+ *
+ * A line-by-line CommonMark scan rather than one regex: a fence body can contain anything,
+ * including shorter backtick runs, and a regex that matched the first closing run it saw
+ * would end a mermaid block on a nested one. Tildes, indented openers (up to three spaces)
+ * and longer closing runs are all part of the format, so they are all handled.
+ */
+export function findMermaidFences(markdown) {
+  const lines = String(markdown).replace(/\r\n?/g, '\n').split('\n');
+  const out = [];
+  let open = null;
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i];
+    const fence = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
+    if (open) {
+      // A closing fence is the same character, at least as long, and carries nothing else.
+      if (fence && fence[2][0] === open.char && fence[2].length >= open.length && fence[3].trim() === '') {
+        if (open.mermaid) out.push({ source: open.body.join('\n'), line: open.line });
+        open = null;
+        continue;
+      }
+      // The opener's indent comes off every body line, as CommonMark requires.
+      if (open.mermaid) open.body.push(line.replace(new RegExp(`^ {0,${open.indent}}`), ''));
+      continue;
+    }
+    if (!fence) continue;
+    const info = fence[3].trim();
+    // A backtick fence cannot carry a backtick in its info string.
+    if (fence[2][0] === '`' && info.includes('`')) continue;
+    open = {
+      char: fence[2][0],
+      length: fence[2].length,
+      indent: fence[1].length,
+      mermaid: info.split(/\s+/)[0].toLowerCase() === 'mermaid',
+      line: i + 1,
+      body: [],
+    };
+  }
+  // An unclosed fence still runs to the end of the document.
+  if (open?.mermaid) out.push({ source: open.body.join('\n'), line: open.line });
+  return out;
+}
diff --git a/src/lib/diagrams/48b6ca45b842c6d7.svg b/src/lib/diagrams/48b6ca45b842c6d7.svg
@@ -0,0 +1 @@
+<svg id="mmd-48b6ca45b842c6d7" width="660" xmlns="http://www.w3.org/2000/svg" height="779.5" viewBox="-60 -10 660 779.5" role="graphics-document document" aria-roledescription="sequence"><g><rect x="400" y="693.5" fill="#eaeaea" stroke="#666" width="150" height="65" name="N" rx="3" ry="3" class="actor actor-bottom"></rect><text x="475" y="726" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="475" dy="-8">진짜</tspan></text><text x="475" y="726" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="475" dy="8">www.example.jp</tspan></text></g><g><rect x="200" y="693.5" fill="#eaeaea" stroke="#666" width="150" height="65" name="R" rx="3" ry="3" class="actor actor-bottom"></rect><text x="275" y="726" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="275" dy="-8">Xray Reality 서버</tspan></text><text x="275" y="726" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="275" dy="8">:443</tspan></text></g><g><rect x="0" y="693.5" fill="#eaeaea" stroke="#666" width="150" height="65" name="P" rx="3" ry="3" class="actor actor-bottom"></rect><text x="75" y="726" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="75" dy="-8">클라이언트 / 검열</tspan></text><text x="75" y="726" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="75" dy="8">프로브</tspan></text></g><g><line id="actor2" x1="475" y1="65" x2="475" y2="693.5" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="N" data-et="life-line" data-id="N"></line><g id="root-2" data-et="participant" data-type="participant" data-id="N"><rect x="400" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="N" rx="3" ry="3" class="actor actor-top"></rect><text x="475" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="475" dy="-8">진짜</tspan></text><text x="475" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="475" dy="8">www.example.jp</tspan></text></g></g><g><line id="actor1" x1="275" y1="65" x2="275" y2="693.5" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="R" data-et="life-line" data-id="R"></line><g id="root-1" data-et="participant" data-type="participant" data-id="R"><rect x="200" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="R" rx="3" ry="3" class="actor actor-top"></rect><text x="275" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="275" dy="-8">Xray Reality 서버</tspan></text><text x="275" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="275" dy="8">:443</tspan></text></g></g><g><line id="actor0" x1="75" y1="65" x2="75" y2="693.5" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="P" data-et="life-line" data-id="P"></line><g id="root-0" data-et="participant" data-type="participant" data-id="P"><rect x="0" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="P" rx="3" ry="3" class="actor actor-top"></rect><text x="75" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="75" dy="-8">클라이언트 / 검열</tspan></text><text x="75" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="75" dy="8">프로브</tspan></text></g></g><style>#mmd-48b6ca45b842c6d7{font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;font-size:15px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mmd-48b6ca45b842c6d7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mmd-48b6ca45b842c6d7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mmd-48b6ca45b842c6d7 .error-icon{fill:#fffbf4;}#mmd-48b6ca45b842c6d7 .error-text{fill:#000000;stroke:#000000;}#mmd-48b6ca45b842c6d7 .edge-thickness-normal{stroke-width:1px;}#mmd-48b6ca45b842c6d7 .edge-thickness-thick{stroke-width:3.5px;}#mmd-48b6ca45b842c6d7 .edge-pattern-solid{stroke-dasharray:0;}#mmd-48b6ca45b842c6d7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mmd-48b6ca45b842c6d7 .edge-pattern-dashed{stroke-dasharray:3;}#mmd-48b6ca45b842c6d7 .edge-pattern-dotted{stroke-dasharray:2;}#mmd-48b6ca45b842c6d7 .marker{fill:#605a4e;stroke:#605a4e;}#mmd-48b6ca45b842c6d7 .marker.cross{stroke:#605a4e;}#mmd-48b6ca45b842c6d7 svg{font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;font-size:15px;}#mmd-48b6ca45b842c6d7 p{margin:0;}#mmd-48b6ca45b842c6d7 .actor{stroke:#eed6aa;fill:#faedda;stroke-width:1;}#mmd-48b6ca45b842c6d7 rect.actor.outer-path[data-look="neo"]{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-48b6ca45b842c6d7 rect.note[data-look="neo"]{stroke:#a51c30;fill:#f7ebeb;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-48b6ca45b842c6d7 text.actor>tspan{fill:#000000;stroke:none;}#mmd-48b6ca45b842c6d7 .actor-line{stroke:#605a4e;}#mmd-48b6ca45b842c6d7 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mmd-48b6ca45b842c6d7 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#000000;}#mmd-48b6ca45b842c6d7 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#000000;}#mmd-48b6ca45b842c6d7 [id$="-arrowhead"] path{fill:#000000;stroke:#000000;}#mmd-48b6ca45b842c6d7 .sequenceNumber{fill:#fffbf4;}#mmd-48b6ca45b842c6d7 [id$="-sequencenumber"]{fill:#000000;}#mmd-48b6ca45b842c6d7 [id$="-crosshead"] path{fill:#000000;stroke:#000000;}#mmd-48b6ca45b842c6d7 .messageText{fill:#000000;stroke:none;}#mmd-48b6ca45b842c6d7 .labelBox{stroke:#eed6aa;fill:#faedda;filter:none;}#mmd-48b6ca45b842c6d7 .labelText,#mmd-48b6ca45b842c6d7 .labelText>tspan{fill:#000000;stroke:none;}#mmd-48b6ca45b842c6d7 .loopText,#mmd-48b6ca45b842c6d7 .loopText>tspan{fill:#000000;stroke:none;}#mmd-48b6ca45b842c6d7 .sectionTitle,#mmd-48b6ca45b842c6d7 .sectionTitle>tspan{fill:#000000;stroke:none;}#mmd-48b6ca45b842c6d7 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#eed6aa;fill:#eed6aa;}#mmd-48b6ca45b842c6d7 .note{stroke:#a51c30;fill:#f7ebeb;}#mmd-48b6ca45b842c6d7 .noteText,#mmd-48b6ca45b842c6d7 .noteText>tspan{fill:#000000;stroke:none;}#mmd-48b6ca45b842c6d7 .activation0{fill:#f5eee0;stroke:#eed6aa;}#mmd-48b6ca45b842c6d7 .activation1{fill:#f5eee0;stroke:#eed6aa;}#mmd-48b6ca45b842c6d7 .activation2{fill:#f5eee0;stroke:#eed6aa;}#mmd-48b6ca45b842c6d7 .actorPopupMenu{position:absolute;}#mmd-48b6ca45b842c6d7 .actorPopupMenuPanel{position:absolute;fill:#faedda;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mmd-48b6ca45b842c6d7 .actor-man circle,#mmd-48b6ca45b842c6d7 line{fill:#faedda;stroke-width:2px;}#mmd-48b6ca45b842c6d7 g rect.rect{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));stroke:#eed6aa;}#mmd-48b6ca45b842c6d7 .node .neo-node{stroke:#eed6aa;}#mmd-48b6ca45b842c6d7 [data-look="neo"].node rect,#mmd-48b6ca45b842c6d7 [data-look="neo"].cluster rect,#mmd-48b6ca45b842c6d7 [data-look="neo"].node polygon{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-48b6ca45b842c6d7 [data-look="neo"].swimlane.cluster rect{filter:none;}#mmd-48b6ca45b842c6d7 [data-look="neo"].node path{stroke:#eed6aa;stroke-width:1px;}#mmd-48b6ca45b842c6d7 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-48b6ca45b842c6d7 [data-look="neo"].node .neo-line path{stroke:#eed6aa;filter:none;}#mmd-48b6ca45b842c6d7 [data-look="neo"].node circle{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-48b6ca45b842c6d7 [data-look="neo"].node circle .state-start{fill:#000000;}#mmd-48b6ca45b842c6d7 [data-look="neo"].icon-shape .icon{fill:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-48b6ca45b842c6d7 [data-look="neo"].icon-shape .icon-neo path{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-48b6ca45b842c6d7 :root{--mermaid-font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;}#mmd-48b6ca45b842c6d7 .edgeLabel rect{opacity:1;}</style><g></g><defs><symbol id="mmd-48b6ca45b842c6d7-computer" width="24" height="24"><path transform="scale(.5)" d="M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z"></path></symbol></defs><defs><symbol id="mmd-48b6ca45b842c6d7-database" fill-rule="evenodd" clip-rule="evenodd"><path transform="scale(.5)" d="M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z"></path></symbol></defs><defs><symbol id="mmd-48b6ca45b842c6d7-clock" width="24" height="24"><path transform="scale(.5)" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z"></path></symbol></defs><defs><marker id="mmd-48b6ca45b842c6d7-arrowhead" refX="7.9" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M -1 0 L 10 5 L 0 10 z"></path></marker></defs><defs><marker id="mmd-48b6ca45b842c6d7-crosshead" markerWidth="15" markerHeight="8" orient="auto" refX="4" refY="4.5"><path fill="none" stroke="#000000" stroke-width="1pt" d="M 1,2 L 6,7 M 6,2 L 1,7" style="stroke-dasharray: 0, 0;"></path></marker></defs><defs><marker id="mmd-48b6ca45b842c6d7-filled-head" refX="15.5" refY="7" markerWidth="20" markerHeight="28" orient="auto"><path d="M 18,7 L9,13 L14,7 L9,1 Z"></path></marker></defs><defs><marker id="mmd-48b6ca45b842c6d7-sequencenumber" refX="15" refY="15" markerWidth="60" markerHeight="40" orient="auto"><circle cx="15" cy="15" r="6"></circle></marker></defs><defs><marker id="mmd-48b6ca45b842c6d7-solidTopArrowHead" refX="7.9" refY="7.25" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 8 L 0 8 z"></path></marker></defs><defs><marker id="mmd-48b6ca45b842c6d7-solidBottomArrowHead" refX="7.9" refY="0.75" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 0 L 0 8 z"></path></marker></defs><defs><marker id="mmd-48b6ca45b842c6d7-stickTopArrowHead" refX="7.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 7 7" stroke="black" stroke-width="1.5" fill="none"></path></marker></defs><defs><marker id="mmd-48b6ca45b842c6d7-stickBottomArrowHead" refX="7.5" refY="0" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 7 L 7 0" stroke="black" stroke-width="1.5" fill="none"></path></marker></defs><g data-et="note" data-id="i9"><rect x="0" y="582.5" fill="#EDF2AE" stroke="#666" width="150" height="81" class="note"></rect><text x="75" y="588" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="noteText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;"><tspan x="75">결론 "그냥</tspan></text><text x="75" y="608" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="noteText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;"><tspan x="75">example.jp 서버네"</tspan></text><text x="75" y="629" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="noteText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;"><tspan x="75">→ 탐지 실패</tspan></text></g><g data-et="control-structure" data-id="i10"><line x1="-10" y1="75" x2="486" y2="75" class="loopLine"></line><line x1="486" y1="75" x2="486" y2="673.5" class="loopLine"></line><line x1="-10" y1="673.5" x2="486" y2="673.5" class="loopLine"></line><line x1="-10" y1="75" x2="-10" y2="673.5" class="loopLine"></line><line x1="-10" y1="366" x2="486" y2="366" class="loopLine" style="stroke-dasharray: 3, 3;"></line><polygon points="-10,75 40,75 40,88 31.6,95 -10,95" class="labelBox"></polygon><text x="15" y="88" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="labelText" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">alt</text><text x="263" y="93" text-anchor="middle" class="loopText" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;"><tspan x="263">[올바른 키 보유 (정당한 사용자)]</tspan></text><text x="238" y="384" text-anchor="middle" class="sectionTitle" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">[키 없음 (검열자의 능동 탐지 프로브)]</text></g><text x="174" y="126" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">ClientHello + x25519 · shortId</text><text x="174" y="146" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">· UUID 임베드</text><line x1="82" y1="181" x2="271" y2="181" class="messageLine0" data-et="message" data-id="i2" data-from="P" data-to="R" stroke-width="2" stroke="none" marker-end="url(#mmd-48b6ca45b842c6d7-arrowhead)" style="fill: none;"></line><line x1="75" y1="181" x2="75" y2="181" stroke-width="0" marker-start="url(#mmd-48b6ca45b842c6d7-sequencenumber)"></line><text x="75" y="185" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">1</text><text x="276" y="196" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">키 검증 성공 → 프록시</text><text x="276" y="216" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">모드</text><path d="M 276,251 C 336,241 336,281 276,271" class="messageLine0" data-et="message" data-id="i3" data-from="R" data-to="R" stroke-width="2" stroke="none" marker-end="url(#mmd-48b6ca45b842c6d7-arrowhead)" x1="282" style="fill: none;"></path><line x1="275" y1="251" x2="275" y2="251" stroke-width="0" marker-start="url(#mmd-48b6ca45b842c6d7-sequencenumber)"></line><text x="275" y="255" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">2</text><text x="177" y="296" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">위장 TLS 세션 수립 → 프록시 터널</text><text x="177" y="316" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">개통</text><line x1="280" y1="351" x2="79" y2="351" class="messageLine1" data-et="message" data-id="i4" data-from="R" data-to="P" stroke-width="2" stroke="none" marker-end="url(#mmd-48b6ca45b842c6d7-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"></line><line x1="275" y1="351" x2="275" y2="351" stroke-width="0" marker-start="url(#mmd-48b6ca45b842c6d7-sequencenumber)"></line><text x="275" y="355" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">3</text><text x="174" y="412" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">ClientHello (유효 키 없음)</text><line x1="82" y1="449" x2="271" y2="449" class="messageLine0" data-et="message" data-id="i6" data-from="P" data-to="R" stroke-width="2" stroke="none" marker-end="url(#mmd-48b6ca45b842c6d7-arrowhead)" style="fill: none;"></line><line x1="75" y1="449" x2="75" y2="449" stroke-width="0" marker-start="url(#mmd-48b6ca45b842c6d7-sequencenumber)"></line><text x="75" y="453" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">4</text><text x="374" y="464" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">핸드셰이크를 진짜</text><text x="374" y="484" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">example.jp으로 그대로 포워딩</text><line x1="282" y1="520.5" x2="471" y2="520.5" class="messageLine0" data-et="message" data-id="i7" data-from="R" data-to="N" stroke-width="2" stroke="none" marker-end="url(#mmd-48b6ca45b842c6d7-arrowhead)" style="fill: none;"></line><line x1="275" y1="520.5" x2="275" y2="520.5" stroke-width="0" marker-start="url(#mmd-48b6ca45b842c6d7-sequencenumber)"></line><text x="275" y="524.5" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">5</text><text x="277" y="536" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">진짜 example.jp 인증서 (Verification OK)</text><line x1="480" y1="572.5" x2="79" y2="572.5" class="messageLine1" data-et="message" data-id="i8" data-from="N" data-to="P" stroke-width="2" stroke="none" marker-end="url(#mmd-48b6ca45b842c6d7-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"></line><line x1="475" y1="572.5" x2="475" y2="572.5" stroke-width="0" marker-start="url(#mmd-48b6ca45b842c6d7-sequencenumber)"></line><text x="475" y="576.5" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">6</text></svg>
diff --git a/src/lib/diagrams/dc1c6779ce6337ef.svg b/src/lib/diagrams/dc1c6779ce6337ef.svg
@@ -0,0 +1 @@
+<svg id="mmd-dc1c6779ce6337ef" width="1526.07080078125" xmlns="http://www.w3.org/2000/svg" class="flowchart" height="173.07046508789062" viewBox="4 4 1526.07080078125 173.07046508789062" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#mmd-dc1c6779ce6337ef{font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;font-size:15px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mmd-dc1c6779ce6337ef .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mmd-dc1c6779ce6337ef .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mmd-dc1c6779ce6337ef .error-icon{fill:#fffbf4;}#mmd-dc1c6779ce6337ef .error-text{fill:#000000;stroke:#000000;}#mmd-dc1c6779ce6337ef .edge-thickness-normal{stroke-width:1px;}#mmd-dc1c6779ce6337ef .edge-thickness-thick{stroke-width:3.5px;}#mmd-dc1c6779ce6337ef .edge-pattern-solid{stroke-dasharray:0;}#mmd-dc1c6779ce6337ef .edge-thickness-invisible{stroke-width:0;fill:none;}#mmd-dc1c6779ce6337ef .edge-pattern-dashed{stroke-dasharray:3;}#mmd-dc1c6779ce6337ef .edge-pattern-dotted{stroke-dasharray:2;}#mmd-dc1c6779ce6337ef .marker{fill:#605a4e;stroke:#605a4e;}#mmd-dc1c6779ce6337ef .marker.cross{stroke:#605a4e;}#mmd-dc1c6779ce6337ef svg{font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;font-size:15px;}#mmd-dc1c6779ce6337ef p{margin:0;}#mmd-dc1c6779ce6337ef .label{font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;color:#000000;}#mmd-dc1c6779ce6337ef .cluster-label text{fill:#000000;}#mmd-dc1c6779ce6337ef .cluster-label span{color:#000000;}#mmd-dc1c6779ce6337ef .cluster-label span p{background-color:transparent;}#mmd-dc1c6779ce6337ef .label text,#mmd-dc1c6779ce6337ef span{fill:#000000;color:#000000;}#mmd-dc1c6779ce6337ef .node rect,#mmd-dc1c6779ce6337ef .node circle,#mmd-dc1c6779ce6337ef .node ellipse,#mmd-dc1c6779ce6337ef .node polygon,#mmd-dc1c6779ce6337ef .node path{fill:#faedda;stroke:#eed6aa;stroke-width:1px;}#mmd-dc1c6779ce6337ef .rough-node .label text,#mmd-dc1c6779ce6337ef .node .label text,#mmd-dc1c6779ce6337ef .image-shape .label,#mmd-dc1c6779ce6337ef .icon-shape .label{text-anchor:middle;}#mmd-dc1c6779ce6337ef .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mmd-dc1c6779ce6337ef .rough-node .label,#mmd-dc1c6779ce6337ef .node .label,#mmd-dc1c6779ce6337ef .image-shape .label,#mmd-dc1c6779ce6337ef .icon-shape .label{text-align:center;}#mmd-dc1c6779ce6337ef .node.clickable{cursor:pointer;}#mmd-dc1c6779ce6337ef .root .anchor path{fill:#605a4e!important;stroke-width:0;stroke:#605a4e;}#mmd-dc1c6779ce6337ef .arrowheadPath{fill:#00040b;}#mmd-dc1c6779ce6337ef .edgePaths .path{stroke:#605a4e;stroke-width:1px;}#mmd-dc1c6779ce6337ef .flowchart-link{stroke:#605a4e;fill:none;}#mmd-dc1c6779ce6337ef .edgeLabel{background-color:#fffbf4;text-align:center;}#mmd-dc1c6779ce6337ef .edgeLabel p{background-color:#fffbf4;}#mmd-dc1c6779ce6337ef .edgeLabel rect{opacity:0.5;background-color:#fffbf4;fill:#fffbf4;}#mmd-dc1c6779ce6337ef .labelBkg{background-color:rgba(255, 251, 244, 0.5);}#mmd-dc1c6779ce6337ef .cluster rect{fill:#fffbf4;stroke:#b9b8ad;stroke-width:1px;}#mmd-dc1c6779ce6337ef .cluster text{fill:#000000;}#mmd-dc1c6779ce6337ef .cluster span{color:#000000;}#mmd-dc1c6779ce6337ef .node .collapsed-indicator{fill:#b9b8ad;stroke:none;opacity:0.6;}#mmd-dc1c6779ce6337ef .node .collapsed-separator{stroke:#b9b8ad;stroke-width:0.75px;}#mmd-dc1c6779ce6337ef div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;font-size:12px;background:#fffbf4;border:1px solid #b9b8ad;border-radius:2px;pointer-events:none;z-index:100;}#mmd-dc1c6779ce6337ef .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#000000;}#mmd-dc1c6779ce6337ef rect.text{fill:none;stroke-width:0;}#mmd-dc1c6779ce6337ef .icon-shape,#mmd-dc1c6779ce6337ef .image-shape{background-color:#fffbf4;text-align:center;}#mmd-dc1c6779ce6337ef .icon-shape p,#mmd-dc1c6779ce6337ef .image-shape p{background-color:#fffbf4;padding:2px;}#mmd-dc1c6779ce6337ef .icon-shape .label rect,#mmd-dc1c6779ce6337ef .image-shape .label rect{opacity:0.5;background-color:#fffbf4;fill:#fffbf4;}#mmd-dc1c6779ce6337ef .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mmd-dc1c6779ce6337ef .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mmd-dc1c6779ce6337ef .node .neo-node{stroke:#eed6aa;}#mmd-dc1c6779ce6337ef [data-look="neo"].node rect,#mmd-dc1c6779ce6337ef [data-look="neo"].cluster rect,#mmd-dc1c6779ce6337ef [data-look="neo"].node polygon{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-dc1c6779ce6337ef [data-look="neo"].swimlane.cluster rect{filter:none;}#mmd-dc1c6779ce6337ef [data-look="neo"].node path{stroke:#eed6aa;stroke-width:1px;}#mmd-dc1c6779ce6337ef [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-dc1c6779ce6337ef [data-look="neo"].node .neo-line path{stroke:#eed6aa;filter:none;}#mmd-dc1c6779ce6337ef [data-look="neo"].node circle{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-dc1c6779ce6337ef [data-look="neo"].node circle .state-start{fill:#000000;}#mmd-dc1c6779ce6337ef [data-look="neo"].icon-shape .icon{fill:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-dc1c6779ce6337ef [data-look="neo"].icon-shape .icon-neo path{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-dc1c6779ce6337ef :root{--mermaid-font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;}#mmd-dc1c6779ce6337ef .censored rect{stroke:rgb(225, 29, 72)!important;stroke-width:3px!important;}#mmd-dc1c6779ce6337ef .censored polygon{stroke:rgb(225, 29, 72)!important;stroke-width:3px!important;}#mmd-dc1c6779ce6337ef .censored ellipse{stroke:rgb(225, 29, 72)!important;stroke-width:3px!important;}#mmd-dc1c6779ce6337ef .censored circle{stroke:rgb(225, 29, 72)!important;stroke-width:3px!important;}#mmd-dc1c6779ce6337ef .censored path{stroke:rgb(225, 29, 72)!important;stroke-width:3px!important;}#mmd-dc1c6779ce6337ef .edgeLabel rect{opacity:1;}</style><g><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></path></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></polygon></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"></circle></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"></path></marker><marker id="mmd-dc1c6779ce6337ef_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"></path></marker><g class="root"><g class="clusters"><g class="cluster" id="mmd-dc1c6779ce6337ef-WP" data-look="classic"><rect style="" x="1079.8361358642578" y="12" width="252.09375" height="152"></rect><g class="cluster-label" transform="translate(1134.0001983642578, 12)"><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">☁️</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Cloudflare</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> WARP</tspan></tspan></text></g></g></g><g class="cluster" id="mmd-dc1c6779ce6337ef-JP" data-look="classic"><rect style="" x="527.640625" y="12" width="291.9455108642578" height="152"></rect><g class="cluster-label" transform="translate(576.9343795776367, 12)"><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">🇯🇵</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> AWS</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 도쿄</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ap-northeast-1</tspan></tspan></text></g></g></g><g class="cluster" id="mmd-dc1c6779ce6337ef-KR" data-look="classic"><rect style="" x="12" y="12" width="228" height="152"></rect><g class="cluster-label" transform="translate(27.181907653808594, 12)"><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">🇰🇷</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 한국</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> KT</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 회선</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> (DPI</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 노출</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 구간)</tspan></tspan></text></g></g></g></g><g class="edges edgePaths"><path d="M216,99L250,99L517.640625,99L547.640625,99" id="mmd-dc1c6779ce6337ef-L_C_X_0" class="edge-thickness-thick edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_X_0" data-points="W3sieCI6MjE2LCJ5Ijo5OX0seyJ4IjoyNTAsInkiOjk5fSx7IngiOjUxNy42NDA2MjUsInkiOjk5fSx7IngiOjU1MS42NDA2MjUsInkiOjk5fV0=" data-look="classic" marker-end="url(#mmd-dc1c6779ce6337ef_flowchart-v2-pointEnd)"></path><path d="M795.5861358642578,99L829.5861358642578,99L1069.8361358642578,99L1099.8361358642578,99" id="mmd-dc1c6779ce6337ef-L_X_W_0" class="edge-thickness-thick edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_X_W_0" data-points="W3sieCI6Nzk1LjU4NjEzNTg2NDI1NzgsInkiOjk5fSx7IngiOjgyOS41ODYxMzU4NjQyNTc4LCJ5Ijo5OX0seyJ4IjoxMDY5LjgzNjEzNTg2NDI1NzgsInkiOjk5fSx7IngiOjExMDMuODM2MTM1ODY0MjU3OCwieSI6OTl9XQ==" data-look="classic" marker-end="url(#mmd-dc1c6779ce6337ef_flowchart-v2-pointEnd)"></path><path d="M1307.9298858642578,99L1341.9298858642578,99L1377.9298858642578,99" id="mmd-dc1c6779ce6337ef-L_W_NET_0" class="edge-thickness-thick edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_W_NET_0" data-points="W3sieCI6MTMwNy45Mjk4ODU4NjQyNTc4LCJ5Ijo5OX0seyJ4IjoxMzQxLjkyOTg4NTg2NDI1NzgsInkiOjk5fSx7IngiOjEzODEuOTI5ODg1ODY0MjU3OCwieSI6OTl9XQ==" data-look="classic" marker-end="url(#mmd-dc1c6779ce6337ef_flowchart-v2-pointEnd)"></path></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(383.8203125, 98.5)"><g class="label" data-id="L_C_X_0" transform="translate(0, -26)"><g><rect class="background" style="" x="-93.8203125" y="-2" width="187.640625" height="56"></rect><text y="-10.1" text-anchor="middle" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">①</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Reality</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> TLS</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 1.3</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 핸드셰이크</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">DPI엔</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 'example.jp</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 보는</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">크롬'으로</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 보임</tspan></tspan></text></g></g></g><g class="edgeLabel" transform="translate(949.7111358642578, 98.25)"><g class="label" data-id="L_X_W_0" transform="translate(0, -17.75)"><g><rect class="background" style="" x="-80.125" y="-2" width="160.25" height="39.5"></rect><text y="-10.1" text-anchor="middle" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">②</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> WireGuard</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> /</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> WARP</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em" text-anchor="middle"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">(기본</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 경로)</tspan></tspan></text></g></g></g></g><g class="nodes"><g class="node default censored" id="mmd-dc1c6779ce6337ef-flowchart-C-0" data-look="classic" transform="translate(126, 99)"><rect class="basic label-container" style="stroke:#e11d48 !important;stroke-width:3px !important" x="-90" y="-41" width="180" height="82"></rect><g class="label" style="" transform="translate(0, -26)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">클라이언트</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">sing-box</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> TUN</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">uTLS</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> =</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> chrome</tspan></tspan></text></g></g></g><g class="node default censored" id="mmd-dc1c6779ce6337ef-flowchart-X-1" data-look="classic" transform="translate(673.6133804321289, 99)"><rect class="basic label-container" style="stroke:#e11d48 !important;stroke-width:3px !important" x="-121.9727554321289" y="-41" width="243.9455108642578" height="82"></rect><g class="label" style="" transform="translate(0, -26)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">Xray</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 서버</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">VLESS</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> +</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> Reality</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> :443</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">위장</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> SNI</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> =</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> www.example.jp</tspan></tspan></text></g></g></g><g class="node default" id="mmd-dc1c6779ce6337ef-flowchart-W-2" data-look="classic" transform="translate(1205.8830108642578, 99)"><rect class="basic label-container" style="" x="-102.046875" y="-41" width="204.09375" height="82"></rect><g class="label" style="" transform="translate(0, -26)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">WARP</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 엣지</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> ·</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> NRT</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">AS13335</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="2.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">출구</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> IP</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 104.28.243.105</tspan></tspan></text></g></g></g><g class="node default" id="mmd-dc1c6779ce6337ef-flowchart-NET-3" data-look="classic" transform="translate(1452.000343322754, 99)"><circle class="basic label-container" style="" r="70.07046028278839" cx="0" cy="0"></circle><g class="label" style="" transform="translate(0, -17.75)"><rect></rect><g><rect class="background" style="stroke: none"></rect><text y="-10.1" style=""><tspan class="text-outer-tspan row" x="0" y="-0.1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">🌐</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 인터넷</tspan></tspan><tspan class="text-outer-tspan row" x="0" y="1em" dy="1.1em"><tspan font-style="normal" class="text-inner-tspan" font-weight="normal">일반</tspan><tspan font-style="normal" class="text-inner-tspan" font-weight="normal"> 목적지</tspan></tspan></text></g></g></g></g></g></g><defs><filter id="mmd-dc1c6779ce6337ef-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs><defs><filter id="mmd-dc1c6779ce6337ef-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"></feDropShadow></filter></defs></svg>
diff --git a/src/lib/diagrams/e4164af5ea9dc19d.svg b/src/lib/diagrams/e4164af5ea9dc19d.svg
@@ -0,0 +1 @@
+<svg id="mmd-e4164af5ea9dc19d" width="1194" xmlns="http://www.w3.org/2000/svg" height="958" viewBox="-50 -10 1194 958" role="graphics-document document" aria-roledescription="sequence"><g><rect x="944" y="872" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-bottom"></rect><text x="1019" y="904.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="1019" dy="0">example.com</tspan></text></g><g><rect x="744" y="872" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-bottom"></rect><text x="819" y="904.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="819" dy="-8">WARP /</tspan></text><text x="819" y="904.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="819" dy="8">Cloudflare</tspan></text></g><g><rect x="544" y="872" fill="#eaeaea" stroke="#666" width="150" height="65" name="X" rx="3" ry="3" class="actor actor-bottom"></rect><text x="619" y="904.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="619" dy="0">Xray 도쿄 :443</tspan></text></g><g><rect x="200" y="872" fill="#eaeaea" stroke="#666" width="150" height="65" name="S" rx="3" ry="3" class="actor actor-bottom"></rect><text x="275" y="904.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="275" dy="0">sing-box (TUN)</tspan></text></g><g><rect x="0" y="872" fill="#eaeaea" stroke="#666" width="150" height="65" name="A" rx="3" ry="3" class="actor actor-bottom"></rect><text x="75" y="904.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="75" dy="0">curl / 앱</tspan></text></g><g><line id="actor7" x1="1019" y1="65" x2="1019" y2="872" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="D" data-et="life-line" data-id="D"></line><g id="root-7" data-et="participant" data-type="participant" data-id="D"><rect x="944" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-top"></rect><text x="1019" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="1019" dy="0">example.com</tspan></text></g></g><g><line id="actor6" x1="819" y1="65" x2="819" y2="872" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="W" data-et="life-line" data-id="W"></line><g id="root-6" data-et="participant" data-type="participant" data-id="W"><rect x="744" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-top"></rect><text x="819" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="819" dy="-8">WARP /</tspan></text><text x="819" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="819" dy="8">Cloudflare</tspan></text></g></g><g><line id="actor5" x1="619" y1="65" x2="619" y2="872" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="X" data-et="life-line" data-id="X"></line><g id="root-5" data-et="participant" data-type="participant" data-id="X"><rect x="544" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="X" rx="3" ry="3" class="actor actor-top"></rect><text x="619" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="619" dy="0">Xray 도쿄 :443</tspan></text></g></g><g><line id="actor4" x1="275" y1="65" x2="275" y2="872" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="S" data-et="life-line" data-id="S"></line><g id="root-4" data-et="participant" data-type="participant" data-id="S"><rect x="200" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="S" rx="3" ry="3" class="actor actor-top"></rect><text x="275" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="275" dy="0">sing-box (TUN)</tspan></text></g></g><g><line id="actor3" x1="75" y1="65" x2="75" y2="872" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="A" data-et="life-line" data-id="A"></line><g id="root-3" data-et="participant" data-type="participant" data-id="A"><rect x="0" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="A" rx="3" ry="3" class="actor actor-top"></rect><text x="75" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400; font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif;"><tspan x="75" dy="0">curl / 앱</tspan></text></g></g><style>#mmd-e4164af5ea9dc19d{font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;font-size:15px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mmd-e4164af5ea9dc19d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mmd-e4164af5ea9dc19d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mmd-e4164af5ea9dc19d .error-icon{fill:#fffbf4;}#mmd-e4164af5ea9dc19d .error-text{fill:#000000;stroke:#000000;}#mmd-e4164af5ea9dc19d .edge-thickness-normal{stroke-width:1px;}#mmd-e4164af5ea9dc19d .edge-thickness-thick{stroke-width:3.5px;}#mmd-e4164af5ea9dc19d .edge-pattern-solid{stroke-dasharray:0;}#mmd-e4164af5ea9dc19d .edge-thickness-invisible{stroke-width:0;fill:none;}#mmd-e4164af5ea9dc19d .edge-pattern-dashed{stroke-dasharray:3;}#mmd-e4164af5ea9dc19d .edge-pattern-dotted{stroke-dasharray:2;}#mmd-e4164af5ea9dc19d .marker{fill:#605a4e;stroke:#605a4e;}#mmd-e4164af5ea9dc19d .marker.cross{stroke:#605a4e;}#mmd-e4164af5ea9dc19d svg{font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;font-size:15px;}#mmd-e4164af5ea9dc19d p{margin:0;}#mmd-e4164af5ea9dc19d .actor{stroke:#eed6aa;fill:#faedda;stroke-width:1;}#mmd-e4164af5ea9dc19d rect.actor.outer-path[data-look="neo"]{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-e4164af5ea9dc19d rect.note[data-look="neo"]{stroke:#a51c30;fill:#f7ebeb;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-e4164af5ea9dc19d text.actor>tspan{fill:#000000;stroke:none;}#mmd-e4164af5ea9dc19d .actor-line{stroke:#605a4e;}#mmd-e4164af5ea9dc19d .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mmd-e4164af5ea9dc19d .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#000000;}#mmd-e4164af5ea9dc19d .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#000000;}#mmd-e4164af5ea9dc19d [id$="-arrowhead"] path{fill:#000000;stroke:#000000;}#mmd-e4164af5ea9dc19d .sequenceNumber{fill:#fffbf4;}#mmd-e4164af5ea9dc19d [id$="-sequencenumber"]{fill:#000000;}#mmd-e4164af5ea9dc19d [id$="-crosshead"] path{fill:#000000;stroke:#000000;}#mmd-e4164af5ea9dc19d .messageText{fill:#000000;stroke:none;}#mmd-e4164af5ea9dc19d .labelBox{stroke:#eed6aa;fill:#faedda;filter:none;}#mmd-e4164af5ea9dc19d .labelText,#mmd-e4164af5ea9dc19d .labelText>tspan{fill:#000000;stroke:none;}#mmd-e4164af5ea9dc19d .loopText,#mmd-e4164af5ea9dc19d .loopText>tspan{fill:#000000;stroke:none;}#mmd-e4164af5ea9dc19d .sectionTitle,#mmd-e4164af5ea9dc19d .sectionTitle>tspan{fill:#000000;stroke:none;}#mmd-e4164af5ea9dc19d .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#eed6aa;fill:#eed6aa;}#mmd-e4164af5ea9dc19d .note{stroke:#a51c30;fill:#f7ebeb;}#mmd-e4164af5ea9dc19d .noteText,#mmd-e4164af5ea9dc19d .noteText>tspan{fill:#000000;stroke:none;}#mmd-e4164af5ea9dc19d .activation0{fill:#f5eee0;stroke:#eed6aa;}#mmd-e4164af5ea9dc19d .activation1{fill:#f5eee0;stroke:#eed6aa;}#mmd-e4164af5ea9dc19d .activation2{fill:#f5eee0;stroke:#eed6aa;}#mmd-e4164af5ea9dc19d .actorPopupMenu{position:absolute;}#mmd-e4164af5ea9dc19d .actorPopupMenuPanel{position:absolute;fill:#faedda;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mmd-e4164af5ea9dc19d .actor-man circle,#mmd-e4164af5ea9dc19d line{fill:#faedda;stroke-width:2px;}#mmd-e4164af5ea9dc19d g rect.rect{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));stroke:#eed6aa;}#mmd-e4164af5ea9dc19d .node .neo-node{stroke:#eed6aa;}#mmd-e4164af5ea9dc19d [data-look="neo"].node rect,#mmd-e4164af5ea9dc19d [data-look="neo"].cluster rect,#mmd-e4164af5ea9dc19d [data-look="neo"].node polygon{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-e4164af5ea9dc19d [data-look="neo"].swimlane.cluster rect{filter:none;}#mmd-e4164af5ea9dc19d [data-look="neo"].node path{stroke:#eed6aa;stroke-width:1px;}#mmd-e4164af5ea9dc19d [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-e4164af5ea9dc19d [data-look="neo"].node .neo-line path{stroke:#eed6aa;filter:none;}#mmd-e4164af5ea9dc19d [data-look="neo"].node circle{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-e4164af5ea9dc19d [data-look="neo"].node circle .state-start{fill:#000000;}#mmd-e4164af5ea9dc19d [data-look="neo"].icon-shape .icon{fill:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-e4164af5ea9dc19d [data-look="neo"].icon-shape .icon-neo path{stroke:#eed6aa;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#mmd-e4164af5ea9dc19d :root{--mermaid-font-family:'Lora','KoBody','KoFallback','LatinFallback',Georgia,'Times New Roman',serif;}#mmd-e4164af5ea9dc19d .edgeLabel rect{opacity:1;}</style><g></g><defs><symbol id="mmd-e4164af5ea9dc19d-computer" width="24" height="24"><path transform="scale(.5)" d="M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z"></path></symbol></defs><defs><symbol id="mmd-e4164af5ea9dc19d-database" fill-rule="evenodd" clip-rule="evenodd"><path transform="scale(.5)" d="M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z"></path></symbol></defs><defs><symbol id="mmd-e4164af5ea9dc19d-clock" width="24" height="24"><path transform="scale(.5)" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z"></path></symbol></defs><defs><marker id="mmd-e4164af5ea9dc19d-arrowhead" refX="7.9" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M -1 0 L 10 5 L 0 10 z"></path></marker></defs><defs><marker id="mmd-e4164af5ea9dc19d-crosshead" markerWidth="15" markerHeight="8" orient="auto" refX="4" refY="4.5"><path fill="none" stroke="#000000" stroke-width="1pt" d="M 1,2 L 6,7 M 6,2 L 1,7" style="stroke-dasharray: 0, 0;"></path></marker></defs><defs><marker id="mmd-e4164af5ea9dc19d-filled-head" refX="15.5" refY="7" markerWidth="20" markerHeight="28" orient="auto"><path d="M 18,7 L9,13 L14,7 L9,1 Z"></path></marker></defs><defs><marker id="mmd-e4164af5ea9dc19d-sequencenumber" refX="15" refY="15" markerWidth="60" markerHeight="40" orient="auto"><circle cx="15" cy="15" r="6"></circle></marker></defs><defs><marker id="mmd-e4164af5ea9dc19d-solidTopArrowHead" refX="7.9" refY="7.25" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 8 L 0 8 z"></path></marker></defs><defs><marker id="mmd-e4164af5ea9dc19d-solidBottomArrowHead" refX="7.9" refY="0.75" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 0 L 0 8 z"></path></marker></defs><defs><marker id="mmd-e4164af5ea9dc19d-stickTopArrowHead" refX="7.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 7 7" stroke="black" stroke-width="1.5" fill="none"></path></marker></defs><defs><marker id="mmd-e4164af5ea9dc19d-stickBottomArrowHead" refX="7.5" refY="0" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 7 L 7 0" stroke="black" stroke-width="1.5" fill="none"></path></marker></defs><g data-et="note" data-id="i4"><rect x="200" y="271" fill="#EDF2AE" stroke="#666" width="150" height="62" class="note"></rect><text x="275" y="276" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="noteText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;"><tspan x="275">라우팅 판정 = proxy</tspan></text><text x="275" y="297" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="noteText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;"><tspan x="275">(전량 프록시)</tspan></text></g><g data-et="note" data-id="i6"><rect x="250" y="416" fill="#EDF2AE" stroke="#666" width="394" height="61" class="note"></rect><text x="447" y="421" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="noteText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;"><tspan x="447">⚠ DPI가 보는 유일한 장면</tspan></text><text x="447" y="441" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="noteText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;"><tspan x="447">'example.jp 보는 크롬 사용자'</tspan></text></g><text x="174" y="80" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">DNS 조회 example.com</text><line x1="82" y1="115" x2="271" y2="115" class="messageLine0" data-et="message" data-id="i1" data-from="A" data-to="S" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="fill: none;"></line><line x1="75" y1="115" x2="75" y2="115" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="75" y="119" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">1</text><text x="177" y="130" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">FakeIP 198.18.x.x (실제 해석 안</text><text x="177" y="151" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">함 → 유출 0)</text><line x1="280" y1="188" x2="79" y2="188" class="messageLine1" data-et="message" data-id="i2" data-from="S" data-to="A" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"></line><line x1="275" y1="188" x2="275" y2="188" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="275" y="192" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">2</text><text x="174" y="203" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">TCP → 198.18.x.x:443 (TUN이</text><text x="174" y="224" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">흡수)</text><line x1="82" y1="261" x2="271" y2="261" class="messageLine0" data-et="message" data-id="i3" data-from="A" data-to="S" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="fill: none;"></line><line x1="75" y1="261" x2="75" y2="261" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="75" y="265" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">3</text><text x="446" y="348" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">Reality 핸드셰이크</text><text x="446" y="369" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">SNI=www.example.jp · uTLS=chrome</text><line x1="282" y1="406" x2="615" y2="406" class="messageLine0" data-et="message" data-id="i5" data-from="S" data-to="X" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="fill: none;"></line><line x1="275" y1="406" x2="275" y2="406" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="275" y="410" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">4</text><text x="446" y="492" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">VLESS 헤더: 진짜 목적지 = example.com:443</text><line x1="282" y1="527" x2="615" y2="527" class="messageLine0" data-et="message" data-id="i7" data-from="S" data-to="X" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="fill: none;"></line><line x1="275" y1="527" x2="275" y2="527" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="275" y="531" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">5</text><text x="718" y="542" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">warp-out (Xray 내부</text><text x="718" y="563" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">WireGuard)</text><line x1="626" y1="600" x2="815" y2="600" class="messageLine0" data-et="message" data-id="i8" data-from="X" data-to="W" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="fill: none;"></line><line x1="619" y1="600" x2="619" y2="600" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="619" y="604" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">6</text><text x="918" y="615" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">출구 104.28.243.105 로 접속</text><line x1="826" y1="650" x2="1015" y2="650" class="messageLine0" data-et="message" data-id="i9" data-from="W" data-to="D" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="fill: none;"></line><line x1="819" y1="650" x2="819" y2="650" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="819" y="654" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">7</text><text x="921" y="665" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">응답</text><line x1="1024" y1="700" x2="823" y2="700" class="messageLine1" data-et="message" data-id="i10" data-from="D" data-to="W" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"></line><line x1="1019" y1="700" x2="1019" y2="700" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="1019" y="704" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">8</text><text x="721" y="715" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">응답</text><line x1="824" y1="750" x2="623" y2="750" class="messageLine1" data-et="message" data-id="i11" data-from="W" data-to="X" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"></line><line x1="819" y1="750" x2="819" y2="750" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="819" y="754" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">9</text><text x="449" y="765" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">응답</text><line x1="624" y1="800" x2="279" y2="800" class="messageLine1" data-et="message" data-id="i12" data-from="X" data-to="S" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"></line><line x1="619" y1="800" x2="619" y2="800" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="619" y="804" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">10</text><text x="177" y="815" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-family: Lora, KoBody, KoFallback, LatinFallback, Georgia, &quot;Times New Roman&quot;, serif; font-size: 16px; font-weight: 400;">응답 (curl 완료)</text><line x1="280" y1="852" x2="79" y2="852" class="messageLine1" data-et="message" data-id="i13" data-from="S" data-to="A" stroke-width="2" stroke="none" marker-end="url(#mmd-e4164af5ea9dc19d-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"></line><line x1="275" y1="852" x2="275" y2="852" stroke-width="0" marker-start="url(#mmd-e4164af5ea9dc19d-sequencenumber)"></line><text x="275" y="856" font-family="sans-serif" font-size="12px" text-anchor="middle" class="sequenceNumber">11</text></svg>
diff --git a/src/lib/hangul.ts b/src/lib/hangul.ts
@@ -0,0 +1,16 @@
+/** Jamo, compatibility jamo, extended jamo and the precomposed syllable block. */
+const HANGUL = /[\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uAC00-\uD7A3\uD7B0-\uD7FF]/;
+
+/** True when any of the strings would put a Hangul glyph on the page. */
+export const hasHangul = (...parts: (string | null | undefined)[]): boolean =>
+  parts.some((s) => !!s && HANGUL.test(s));
+
+/**
+ * `lang` for a single string, returned only when it differs from the document.
+ * Re-declaring the document language on a child makes a screen reader switch voices
+ * for no reason, so an agreeing value is dropped.
+ */
+export const langOf = (s: string, doc: 'en' | 'ko'): 'en' | 'ko' | undefined => {
+  const l: 'en' | 'ko' = HANGUL.test(s) ? 'ko' : 'en';
+  return l === doc ? undefined : l;
+};
diff --git a/src/lib/prose.ts b/src/lib/prose.ts
@@ -28,17 +28,13 @@ function outsideFences(md: string): string {
   return out.join('\n');
 }
 
-/**
- * Reading time: 500 Korean characters per minute plus 230 Latin words per minute.
- * A Korean syllable carries less than a Latin word, so Korean is counted by character.
- * Across the corpus this yields 1 minute (work-is-finding) to 24 (how-to-win-best-paper-ko).
+/*
+ * `readingMinutes` stood here — 500 Korean characters plus 230 Latin words per minute,
+ * printed as "N min read" in the article header. The header no longer carries a reading
+ * estimate (the reference prints a title, a byline and a date and nothing else), and the
+ * article template was its only caller, so it is deleted rather than left as an export
+ * with no importer. `plain()` above is still read by both functions below.
  */
-export function readingMinutes(md: string): number {
-  const t = plain(md);
-  const kor = (t.match(/[가-힣]/g) ?? []).length;
-  const lat = (t.match(/[A-Za-z0-9][A-Za-z0-9'’-]*/g) ?? []).length;
-  return Math.max(1, Math.ceil(kor / 500 + lat / 230));
-}
 
 export type ProseShape = 'essay' | 'technical';
 export type ProseRhythm = 'flow' | 'staccato';
diff --git a/src/lib/satteri-diagram.mjs b/src/lib/satteri-diagram.mjs
@@ -0,0 +1,116 @@
+/**
+ * satteri-diagram — swaps a mermaid fence for the SVG that was baked from it.
+ *
+ * This is a cache lookup and nothing else. `mermaid` is in syntaxHighlight.excludeLangs, so
+ * the highlight plugin returns before Shiki ever sees the block and the original
+ * <pre><code data-lang="mermaid"> arrives here intact; the picture itself was drawn once, in
+ * a browser, by scripts/render-diagrams.mjs, and committed under a hash of its source. No
+ * browser, no async, no mermaid import, and nothing at all for the reader to download.
+ *
+ * The output is one <figure class="diagram"> holding the SVG. It is still focusable, but it
+ * is no longer a scroll region: base.css scales the picture into the column now, and the
+ * baked SVG keeps the width/height attributes that give it a ratio to scale by (see the
+ * useMaxWidth note in diagrams.mjs). What the tabindex buys is a keyboard reader landing on
+ * the figure - the drawing is vector, so a reader who lands on it can zoom it losslessly.
+ */
+import { diagramFile, diagramKey, normalizeDiagram, readDiagram } from './diagrams.mjs';
+
+/**
+ * A build with a fence that was never rendered is a broken build, not a warning: the
+ * prebuild step renders every fence in the corpus, so a miss here means it did not run.
+ * `astro dev` skips prebuild, though, and an author who has just typed a new diagram wants
+ * to keep typing — so the dev server shows the source with a note instead of dying.
+ */
+const DEV = process.env.NODE_ENV !== 'production';
+
+/** mermaid's own word for what it drew, from aria-roledescription, turned into a label. */
+const LABELS = {
+  'flowchart-v2': 'Flowchart',
+  flowchart: 'Flowchart',
+  sequence: 'Sequence diagram',
+  class: 'Class diagram',
+  state: 'State diagram',
+  er: 'Entity relationship diagram',
+  gantt: 'Gantt chart',
+  journey: 'User journey',
+  pie: 'Pie chart',
+  mindmap: 'Mind map',
+  timeline: 'Timeline',
+  quadrantChart: 'Quadrant chart',
+  gitGraph: 'Git graph',
+};
+
+export default function satteriDiagram() {
+  return {
+    name: 'diagram',
+    before(root, ctx) {
+      // Per document: the id inside a baked SVG is its hash, so the same diagram twice on
+      // one page would be the same id twice. Only the repeats are renamed.
+      ctx.data.diagramSeen = new Map();
+    },
+    element: {
+      filter: ['pre'],
+      visit(node, ctx) {
+        const code = node.children?.find((c) => c.type === 'element' && c.tagName === 'code');
+        if (!code || code.type !== 'element') return;
+        // data.lang is what the highlight plugin keys on, so it is the same contract; the
+        // class is the fallback for a tree that arrived some other way.
+        const lang = code.data?.lang
+          ?? (Array.isArray(code.properties?.className)
+            ? String(code.properties.className.find((c) => String(c).startsWith('language-')) ?? '').slice(9)
+            : '');
+        if (lang !== 'mermaid') return;
+
+        const source = normalizeDiagram(ctx.textContent(code));
+        const key = diagramKey(source);
+        const svg = readDiagram(key);
+
+        if (!svg) {
+          const message =
+            `No baked diagram for this mermaid fence (${diagramFile(key)}). Run: npm run diagrams`;
+          // Measured, not assumed: Astro's glob loader catches a render error, logs it as
+          // [glob-loader] ERROR, and finishes the build with exit 0 and that one post's body
+          // empty. `process.exitCode = 1` here does not survive the CLI either. So this throw
+          // is a loud line in the log, not a gate — the gate is `npm run prebuild`, which
+          // exits 1 when a fence has no SVG and no browser to make one, and npm then never
+          // reaches `astro build`. That is why the workflow calls `npm run build`.
+          if (!DEV) throw new Error(`satteri-diagram: ${message}`);
+          return {
+            type: 'element',
+            tagName: 'figure',
+            properties: { className: ['diagram', 'diagram--unbaked'] },
+            children: [
+              { type: 'element', tagName: 'p', properties: {}, children: [{ type: 'text', value: message }] },
+              node,
+            ],
+          };
+        }
+
+        const seen = ctx.data.diagramSeen;
+        const repeat = seen.get(key) ?? 0;
+        seen.set(key, repeat + 1);
+        // Every id inside the file — the root, and the arrowhead markers that reference it —
+        // starts from this one string, so one replacement keeps the SVG self-consistent.
+        const markup = repeat === 0 ? svg : svg.replaceAll(`mmd-${key}`, `mmd-${key}-${repeat}`);
+
+        const kind = /aria-roledescription="([^"]+)"/.exec(svg)?.[1] ?? '';
+        const label = LABELS[kind] ?? 'Diagram';
+
+        return {
+          type: 'element',
+          tagName: 'figure',
+          properties: {
+            className: ['diagram'],
+            'data-diagram': kind || 'diagram',
+            // Not for scrolling any more - see the header. A labelled region a reader can
+            // reach, and stop on, before zooming into the one thing on the page that rewards it.
+            tabindex: '0',
+            role: 'region',
+            'aria-label': label,
+          },
+          children: [{ type: 'raw', value: markup.trim() }],
+        };
+      },
+    },
+  };
+}
diff --git a/src/lib/satteri-figure.mjs b/src/lib/satteri-figure.mjs
@@ -0,0 +1,97 @@
+/**
+ * satteri-figure — promotes the corpus's own caption convention to real markup.
+ *
+ * The author already writes captions as an italic paragraph directly beneath a standalone
+ * image; 11 of the 22 standalone images in the corpus carry one (arp-spoofing 8, doh 2,
+ * bind9 1). Without this they render as ordinary italic paragraphs and every image ships
+ * as a bare <img> with no <figure>.
+ *
+ * It also repairs alt text: 20 of 26 images have an alt that is empty or filename-shaped
+ * ("image", "image-1", "img_4305", "2026-08-13-165932"), which a screen reader spells out
+ * letter by letter. Junk alt is cleared, and where a caption exists it becomes the alt.
+ *
+ * The repair is not limited to the images this plugin lifts. The figure path only takes a
+ * paragraph containing nothing BUT images, so an image with prose beside it, a linked
+ * image [![](a)](b), or an image in a list or a table cell never reaches it -- and those
+ * carry exactly the same "image-1" the corpus produces everywhere else. None of the 26
+ * corpus images are in that position today, which is the only reason this has not been
+ * audible; the `image` visitor below makes the repair total rather than positional.
+ */
+
+const JUNK_ALT =
+  /^(?:images?|imgs?|photos?|screenshots?|figures?)?[-_ ]?\d*$|^\d{4}-\d{2}-\d{2}[-_\d]*$|^[a-z]+_\d+$/i;
+
+const meaningful = (kids = []) =>
+  kids.filter((k) => !(k.type === 'text' && k.value.trim() === ''));
+
+/** The single meaningful child of a paragraph, or null. */
+function sole(node) {
+  if (!node || node.type !== 'paragraph') return null;
+  const kids = meaningful(node.children);
+  return kids.length === 1 ? kids[0] : null;
+}
+
+/**
+ * Every image in a paragraph that holds nothing but images, or null.
+ * 인지부조화이론.md places three portrait scans on three consecutive lines, which
+ * CommonMark folds into one paragraph — they are a group, not three loose images.
+ */
+function imageOnly(node) {
+  if (!node || node.type !== 'paragraph') return null;
+  const kids = meaningful(node.children);
+  return kids.length > 0 && kids.every((k) => k.type === 'image') ? kids : null;
+}
+
+function textOf(nodes) {
+  let out = '';
+  for (const n of nodes ?? []) out += n.value ?? textOf(n.children);
+  return out.trim();
+}
+
+export default function satteriFigure() {
+  return {
+    name: 'figure',
+    /*
+     * Runs after `before` has built the figures, so every image it sees is one the figure
+     * path did not claim -- plus the plates' own images, whose alt is already either a
+     * caption or empty, and clearing an empty alt again is a no-op. An alt is only ever
+     * cleared, never invented: a filename is worse than silence to a screen reader, and
+     * this plugin has no way to know what the picture shows.
+     */
+    image(node, ctx) {
+      const alt = node.alt ?? '';
+      if (alt !== '' && JUNK_ALT.test(alt)) ctx.setProperty(node, 'alt', '');
+    },
+    before(root, ctx) {
+      const kids = root.children ?? [];
+      for (let i = 0; i < kids.length; i++) {
+        const images = imageOnly(kids[i]);
+        if (!images) continue;
+
+        const next = sole(kids[i + 1]);
+        const caption = next && next.type === 'emphasis' ? next.children : null;
+
+        const plate = images.map((image, n) => {
+          const alt = JUNK_ALT.test(image.alt ?? '') ? '' : (image.alt ?? '');
+          // With a caption present the alt stays empty: the figcaption is real text in the
+          // DOM and is announced, so repeating it in alt only makes it read twice.
+          return { type: 'image', url: image.url, title: image.title ?? null, alt: caption ? '' : alt };
+        });
+
+        const body = [{
+          type: 'paragraph',
+          data: { hName: 'span', hProperties: { className: ['fig__plate'] } },
+          children: plate,
+        }];
+        if (caption) body.push({ type: 'paragraph', data: { hName: 'figcaption' }, children: [...caption] });
+
+        ctx.replaceNode(kids[i], {
+          type: 'figure',
+          data: { hName: 'figure', hProperties: { className: ['fig'], 'data-count': String(plate.length) } },
+          children: body,
+        });
+        if (caption) { ctx.removeNode(kids[i + 1]); i++; }
+      }
+    },
+  };
+}
diff --git a/src/lib/satteri-korean.mjs b/src/lib/satteri-korean.mjs
@@ -0,0 +1,144 @@
+/**
+ * satteri-korean — two CommonMark inline rules that misfire on Korean prose.
+ *
+ * Both are the same kind of bug: a rule written for English, applied to a script whose
+ * word boundaries and punctuation habits are different, producing markup the author never
+ * asked for. Both were live on the built site, not hypothetical. Neither is fixable with a
+ * parser flag — Sätteri's `gfm` option exposes footnotes and nothing else — so they are
+ * repaired on the mdast, from the source text, before anything renders.
+ *
+ * ── 1. A single `~` is a range, not a deletion ───────────────────────────────────────
+ * GFM strikethrough pairs single tildes as well as double ones, and Korean writes ranges
+ * with a tilde constantly: `그림 3~4`, `5~8`, `25~50바이트`, `1Gbps ~ 100Gbps`. Two on one
+ * line are a matched pair, so everything between them is struck through. Measured in
+ * dist/ before this plugin — content/writing/how-to-win-best-paper-ko.md:231 shipped as
+ *
+ *     그림 3<del>4는 … 그림 5</del>8에는 결과와 분석이 담겨 있을 수 잇다.
+ *
+ * one sentence of the author's prose crossed out because it named two figure ranges.
+ *
+ * The rule installed here is: **`~~` strikes through, `~` is a literal tilde.** That is the
+ * rule an author already carries — `~~` is what GitHub's own documentation shows and what
+ * anyone reaching for strikethrough types — and it is decided on the source text, not
+ * guessed from the content: the delimiter run is read straight out of ctx.source, so a
+ * range and a deletion are told apart by what was written rather than by what it looks
+ * like.
+ *
+ * ── 2. Emphasis that closes on a quote mark ──────────────────────────────────────────
+ * CommonMark closes `**…**` only on a right-flanking delimiter run, and a run preceded by
+ * punctuation is right-flanking only if what FOLLOWS it is whitespace or punctuation. In
+ * English that rule earns its keep (it is what stops `a**b**c` from bolding mid-word). In
+ * Korean it fires constantly, because a quoted phrase is followed immediately by a
+ * particle with no space: `**‘직관적으로 옳다’**고`, `**TSO (TCP Segmentation Offload)**와`.
+ * The closing run is preceded by `’`, followed by `고` — a letter, so not right-flanking —
+ * so it cannot close, and the asterisks ship as literal text in the middle of the prose.
+ *
+ * Measured across the built site before this plugin: **12 runs in 3 posts**, and every one
+ * of them had the identical shape — content ending in Unicode punctuation (Pe or Pf, a
+ * closing bracket or a closing quote) with a CJK letter (Lo) immediately after the closing
+ * run. That is not a coincidence, it is the flanking rule's blind spot, and it is what the
+ * `cjk-friendly` CommonMark proposals exist to close upstream.
+ *
+ * So the repair is written as that exact shape rather than as a general "re-emphasise
+ * stray asterisks" pass. It fires only where a CJK letter sits against the delimiter, i.e.
+ * only where the flanking rule was answering a question about a script it does not model.
+ * On the same corpus the one other literal-asterisk pair — bind9's `* port *`, whitespace
+ * on both sides of the content — is left alone, which is CommonMark being right.
+ *
+ * Known limits, stated rather than hidden. Both are cases where the pair never reaches this
+ * plugin intact, and both need a parser, not a visitor:
+ *   - The repair reads one text node, so it cannot rejoin a run containing other inline
+ *     markup: `**‘가’ [링크](x)**고` is three nodes and stays literal.
+ *   - Two such runs on ONE line can be mispaired by the parser before anything gets here.
+ *     Measured on `앞**‘강조’**뒤 그리고 앞**‘강조’** 뒤.`: the first `**` cannot open and the
+ *     second can, so the parser bolds from the second to the third and leaves the outer two
+ *     asterisks stranded. One run on a line — which is all 12 of the live ones, and the
+ *     normal way anyone writes — leaves the whole pair literal, and that is repaired.
+ */
+
+/**
+ * CJK letters, for the flanking question only: Hangul jamo and syllables, CJK ideographs
+ * and their extensions, kana, and the halfwidth/fullwidth forms. This is deliberately
+ * wider than the Hangul-only class in src/lib/hangul.ts — that one measures how Korean a
+ * document is, this one asks whether a character is the kind of letter CommonMark's
+ * flanking rule was not written for, and Japanese and Chinese have the same problem.
+ */
+const CJK = /[ᄀ-ᇿ⺀-鿿ꥠ-꥿가-힣ힰ-퟿豈-﫿︰-﹏＀-⦆¢-₩]/;
+
+/** Unicode punctuation — the character class that broke the flanking test. */
+const PUNCT = /\p{P}/u;
+
+/**
+ * A `*` or `**` pair with no whitespace just inside either delimiter and no `*` in the
+ * content. Those two constraints are CommonMark's own (a run cannot open on trailing
+ * whitespace or close on leading whitespace); repeating them here is what keeps the pass
+ * from touching `2 * 3 * 4` or bind9's `* port *`.
+ */
+const PAIR = /(\*\*|\*)(?![\s*])([^*]*[^\s*])\1/g;
+
+export default function satteriKorean() {
+  return {
+    name: 'korean',
+    // Read by the `delete` visitor only; the emphasis repair works off the node's own text.
+    options: { position: true },
+
+    delete(node, ctx) {
+      const at = node.position;
+      // Defensive: a `delete` built by another plugin carries no position, and a node whose
+      // delimiter cannot be read is left exactly as the parser made it.
+      if (!at) return;
+      if (ctx.source[at.start.offset + 1] === '~') return;   // ~~ … ~~ — a real deletion
+
+      /*
+       * Unwrap in place: the node's own children are handed back as replacement content,
+       * between the two literal tildes that were the delimiters. Passing the existing
+       * children rather than copies is what keeps inner markup intact — `~a **b** `c`~`
+       * comes back as `~a <strong>b</strong> <code>c</code>~`, which a deep copy could not
+       * do, because a child's own children resolve lazily and are not present to copy.
+       *
+       * Not `{ raw: … }`: a raw string is re-parsed as a document of its own, and measured,
+       * that splices a block-level <p> into the middle of the paragraph.
+       */
+      ctx.replaceNode(node, [
+        { type: 'text', value: '~' },
+        ...node.children,
+        { type: 'text', value: '~' },
+      ]);
+    },
+
+    text(node, ctx) {
+      const src = node.value;
+      if (!src.includes('*')) return;
+
+      const out = [];
+      let cut = 0;
+      PAIR.lastIndex = 0;
+      for (let m = PAIR.exec(src); m; m = PAIR.exec(src)) {
+        const [whole, run, content] = m;
+        const before = src[m.index - 1] ?? '';
+        const after = src[m.index + whole.length] ?? '';
+
+        /*
+         * Only the two shapes the flanking rule gets wrong, one per side:
+         *   closing  …옳다’**고   content ends in punctuation, a CJK letter follows
+         *   opening  앞**‘강조’…   content starts with punctuation, a CJK letter precedes
+         * Anything else that survived as literal asterisks survived on purpose.
+         */
+        const closingCase = PUNCT.test(content.at(-1)) && CJK.test(after);
+        const openingCase = PUNCT.test(content[0]) && CJK.test(before);
+        if (!closingCase && !openingCase) continue;
+
+        if (m.index > cut) out.push({ type: 'text', value: src.slice(cut, m.index) });
+        out.push({
+          type: run === '**' ? 'strong' : 'emphasis',
+          children: [{ type: 'text', value: content }],
+        });
+        cut = m.index + whole.length;
+      }
+
+      if (!out.length) return;
+      if (cut < src.length) out.push({ type: 'text', value: src.slice(cut) });
+      ctx.replaceNode(node, out);
+    },
+  };
+}
diff --git a/src/lib/satteri-prose.mjs b/src/lib/satteri-prose.mjs
@@ -0,0 +1,108 @@
+/**
+ * satteri-prose — three repairs to the rendered tree, in one plugin.
+ *
+ * 1. Demote headings. 8 of 30 posts use `#` as a section heading (VTZero 4,
+ *    인지부조화이론 5, how-to-win-best-paper-ko 4, AIOdyssey 2, We_need_to_talk 2,
+ *    journalctl 1, sysmon 1, tso 1), each of which would emit an <h1> competing with the
+ *    article title. When a document contains any h1, every heading drops one level.
+ *    This runs before Sätteri's heading-id plugin, so `headings` from render() reports the
+ *    shifted depths and the table of contents stays truthful.
+ *    Unless the document also contains an h6, which is where +1 stops being a shift and
+ *    starts being a collision: h6 is the floor, so h5 would land on top of it and the
+ *    document would lose a level it was using. --fs-h5 and --fs-h6 are different sizes, so
+ *    that is visible, not just semantic. No corpus post has an h6 today, so this guard has
+ *    never fired; it is here because the premise of the contents rail is that every post
+ *    grows headings, and a post deep enough to reach h6 is exactly the one that would.
+ * 2. Stamp intrinsic image size. The 26 images live in public/uploads and so bypass
+ *    Astro's image service: no width/height, and every one shifts layout while loading.
+ *    With the attributes present and no CSS `width`, the 13 images narrower than the 728px
+ *    column render at their own size instead of being upscaled.
+ * 3. Wrap tables. 4 posts contain tables; each gets a focusable scroll container so a wide
+ *    table never makes the page itself scroll sideways.
+ */
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+
+/** PNG / WebP / JPEG intrinsic size straight from the file header — no dependency. */
+function intrinsicSize(file) {
+  let b;
+  try { b = readFileSync(file); } catch { return null; }
+  if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47)
+    return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
+  if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
+    const tag = b.toString('latin1', 12, 16);
+    if (tag === 'VP8X') return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
+    if (tag === 'VP8 ') return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff };
+    if (tag === 'VP8L') { const n = b.readUInt32LE(21);
+      return { width: (n & 0x3fff) + 1, height: ((n >> 14) & 0x3fff) + 1 }; }
+  }
+  if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
+    let o = 2;
+    while (o + 9 < b.length) {
+      if (b[o] !== 0xff) { o++; continue; }
+      const m = b[o + 1];
+      if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc)
+        return { height: b.readUInt16BE(o + 5), width: b.readUInt16BE(o + 7) };
+      o += 2 + b.readUInt16BE(o + 2);
+    }
+  }
+  return null;
+}
+
+const sizes = new Map();
+
+export default function satteriProse({ publicDir = 'public' } = {}) {
+  return {
+    name: 'prose',
+    before(root, ctx) {
+      // One pre-scan so every heading visit shares the same answer. Both questions are
+      // answered in the same walk: is there an h1 to get out of the title's way, and is
+      // there an h6 already at the floor that a shift would collide with.
+      let hasH1 = false;
+      let hasH6 = false;
+      (function walk(n) {
+        if (n.type === 'element') {
+          if (n.tagName === 'h1') hasH1 = true;
+          else if (n.tagName === 'h6') hasH6 = true;
+        }
+        for (const child of n.children ?? []) walk(child);
+      })(root);
+      ctx.data.proseDemote = hasH1 && !hasH6;
+    },
+    element: [
+      {
+        filter: ['h1', 'h2', 'h3', 'h4', 'h5'],
+        visit(node, ctx) {
+          if (!ctx.data.proseDemote) return;
+          return { ...node, tagName: `h${Number(node.tagName[1]) + 1}` };
+        },
+      },
+      {
+        filter: ['img'],
+        visit(node, ctx) {
+          const src = String(node.properties?.src ?? '');
+          if (src.startsWith('/') && node.properties?.width == null) {
+            const file = path.join(publicDir, decodeURIComponent(src));
+            if (!sizes.has(file)) sizes.set(file, intrinsicSize(file));
+            const s = sizes.get(file);
+            if (s) { ctx.setProperty(node, 'width', s.width); ctx.setProperty(node, 'height', s.height); }
+          }
+          if (node.properties?.loading == null) ctx.setProperty(node, 'loading', 'lazy');
+          if (node.properties?.decoding == null) ctx.setProperty(node, 'decoding', 'async');
+        },
+      },
+      {
+        filter: ['table'],
+        visit(node, ctx) {
+          const parent = ctx.parent(node);
+          if (parent?.type === 'element' && parent.tagName === 'div') return;
+          ctx.wrapNode(node, {
+            type: 'element', tagName: 'div',
+            properties: { className: ['scroller'], tabindex: '0', role: 'region', 'aria-label': 'Table' },
+            children: [],
+          });
+        },
+      },
+    ],
+  };
+}
diff --git a/src/pages/code/index.astro b/src/pages/code/index.astro
@@ -1,7 +1,156 @@
 ---
 import Base from '../../layouts/Base.astro';
+import { getCollection, render } from 'astro:content';
+import { hasHangul, langOf } from '../../lib/hangul';
+
+/*
+ * Authorial order, and nothing else. `order` ascending is the whole sort: the reference's
+ * own code page is hand-ranked, and the author reorders this page by changing one number
+ * in front matter. Name and id break a tie only so the output is stable rather than
+ * filesystem-ordered — the same reason src/lib/order.ts refuses to delegate that. There
+ * is no date on this collection to sort by and no star count to let GitHub sort it.
+ */
+const collator = new Intl.Collator('en');
+const projects = (await getCollection('code'))
+  .filter((p) => !p.data.draft)
+  .sort(
+    (a, b) =>
+      a.data.order - b.data.order ||
+      collator.compare(a.data.name, b.data.name) ||
+      (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
+  );
+
+/* The description is each file's markdown body, so every entry has to be rendered before
+   the template can lay it out. Seven `render()` calls, resolved together. */
+const entries = await Promise.all(
+  projects.map(async (p) => ({ entry: p, Content: (await render(p)).Content })),
+);
+
+/* The same question src/pages/index.astro asks of about.md, and for the same reason: the
+   metadata on this route is English whatever the content says, so only the content can
+   answer whether the 118KB Korean face blocks first paint. It is false for all seven
+   projects today — which is what the note in Base.astro claims about /code/ — and it
+   stops being a lie the day one of these descriptions is written in Korean. */
+const ko = projects.some((p) => hasHangul(p.data.name, p.body));
 ---
-<Base title="Code :: sqzer">
-  <h1>Code</h1>
-  <p>Nothing here yet.</p>
+<Base
+  title="Code :: sqzer"
+  description="Tools and small programs I have written and published."
+  ko={ko}
+>
+  <div class="code-index">
+    {/* Flush with the page box, exactly as .index__head is on /writing/: the h1 is the
+        page's own name, so it takes the shell's edge rather than a column's. Left as a
+        bare <h1> with no class and no font rules of its own, which is what puts it on the
+        same left edge and at the same size as the h1 on /papers/ and /talk/. */}
+      {/* The reference carries no page title on its section pages: the bar's left slot
+          already prints the page name, so an <h1> repeating it is the same word twice.
+          Kept in the document as .sr-only rather than deleted — the page still needs one
+          top-level heading for assistive tech and for the document outline, and the bar's
+          slot is a <span> precisely because it is not one. */}
+      <h1 class="sr-only">Code</h1>
+
+    {entries.length === 0 ? (
+      <p class="code-index__empty">Nothing here yet.</p>
+    ) : (
+      <ul class="projects">
+        {entries.map(({ entry, Content }) => (
+          <li class="project">
+            <h2 class="project__name" lang={langOf(entry.data.name, 'en')}>
+              <a href={entry.data.url}>{entry.data.name}</a>
+            </h2>
+            <div class="project__about" lang={hasHangul(entry.body) ? 'ko' : undefined}>
+              <Content />
+            </div>
+          </li>
+        ))}
+      </ul>
+    )}
+  </div>
 </Base>
+
+<style>
+  /* Capped at the site's measure, the same 928px an article's prose and the Writing
+     index's entry column get.
+
+     Capped WITHOUT `margin-inline: auto`, and that is the load-bearing half. `main` caps
+     at --w-page (1024px) and centres itself; centring a narrower box inside it again
+     would step this page's <h1> ~48px right of the <h1> on /papers/ and /talk/, which is
+     the edge that was just measured to the pixel (330.5px at a 1700px window, identical
+     on all four section indexes). With margin-inline at its 0 default the box keeps
+     `main`'s left edge and gives the 96px back on the right — the mirror of /writing/,
+     where that same 96px is the date rail on the left. */
+  .code-index {
+    max-width: var(--w-content);
+  }
+
+  /* --sp-48 is the block rhythm token, and the same clamp(2rem, …, 3rem) that /writing/
+     spends between its masthead and its first year. */
+
+  .code-index__empty {
+    font-size: var(--fs-body);
+  }
+
+  .projects {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+  }
+
+  /* The reference separates its entries with a single blank line and nothing else: no
+     rule, no wash, no row-sized hit area. Copied as it stands rather than borrowing the
+     Writing index's row treatment, because that treatment is answering a different
+     question — a row on /writing/ is one whole link to a page of this site, so the row
+     can be the control and the underline can wait for hover. A row here is a name that
+     leaves the site, so the name is the control, it carries the underline base.css gives
+     every link, and there is nothing else on the row to make clickable. Hairlines
+     between seven entries would be furniture the reference does not have and this page
+     does not need. */
+  .project + .project {
+    margin-top: var(--sp-32);
+  }
+
+  /* h2 is the right element — this is a list of seven named things directly under the
+     page's h1, and a screen reader should be able to walk it — but not the right size.
+     --fs-h2 is 28.5px, which reads as seven section headings; --fs-h5 is what the
+     Writing index already sets its entry titles at, and tokens.css says so in as many
+     words: "h5 is really 'index entry title'". Family, weight and colour come from
+     base.css's heading rule, and the link keeps the heading's 500 through
+     `:is(h1…h6) a { font-weight: inherit }`. */
+  .project__name {
+    font-size: var(--fs-h5);
+    line-height: var(--lh-medium);
+    text-wrap: balance;
+  }
+
+  /* Crimson, no underline. base.css underlines every <a> because prose links have to be
+     findable inside running text; here the link IS the entry's headline, so the colour is
+     already the whole affordance and the rule under it only adds noise. --crimson measures
+     7.25:1 against --page-bg, over the 4.5:1 normal text needs, so the colour is not
+     carrying meaning at a contrast that fails — and it lands within 0.23 of the 7.48:1 the
+     reference sets its own outbound links at. The underline comes back on hover and on
+     keyboard focus, where a second signal is cheap and colour alone would be the only cue
+     that the pointer is over a target. */
+  .project__name a { color: var(--crimson); text-decoration: none; }
+  .project__name a:hover,
+  .project__name a:focus-visible { color: var(--crimson-dark); text-decoration: underline; }
+
+  /* margin-top is 0.375rem, matching .entry__deck on /writing/ — a description belongs
+     to the name above it, so the gap has to read as smaller than the --sp-32 between
+     entries.
+
+     The other two are set here and not on a descendant selector because <Content />
+     emits elements with no scope id, so `.project__about p` would miss every one of
+     them. Both properties inherit, which is what lets the wrapper carry them.
+
+     keep-all is a no-op on the English descriptions here and is the house rule
+     everywhere Korean can appear (.prose, .about, .entry__title); `ko` above already
+     anticipates a Korean description, so this does too. break-word is the guard against
+     a long unbroken token — the defect that made `/` scroll sideways on a phone when
+     about.md printed a 40-character URL with no space in it. */
+  .project__about {
+    margin-top: 0.375rem;
+    word-break: keep-all;
+    overflow-wrap: break-word;
+  }
+</style>
diff --git a/src/pages/index.astro b/src/pages/index.astro
@@ -1,9 +1,76 @@
 ---
 import Base from '../layouts/Base.astro';
 import { getEntry, render } from 'astro:content';
+import { hasHangul } from '../lib/hangul';
+
 const entry = await getEntry('about', 'about');
 const { Content } = await render(entry!);
+
+/* Ask the body, not the metadata. This used to matter because about.md was an English
+   document with one Korean line in it — 14 Hangul syllables that no title or description
+   would have revealed, against a 118KB Korean face. The rewritten about.md is English
+   throughout, so the answer is false today and the page fetches no Korean font. The test
+   stays because the answer is the body's to give: one Korean sentence added here starts
+   needing that face again, and nothing else in this file would notice. */
+const ko = hasHangul(entry!.body);
 ---
-<Base title="sqzer" description="Notes on systems, networks, and the things around them.">
-  <article><Content /></article>
+<Base
+  title="sqzer"
+  description="Hyukjin An — software and security engineer, and a founder, in South Korea."
+  ko={ko}
+>
+  {/* about.md is running prose with no headings, the shape the reference's own home page
+      uses, so nothing in the body is a top-level heading any more. The bar's left slot
+      prints the name but it is a <span>, not a heading — so the page needs one here or it
+      has no h1 at all, which was already true when the body opened with `# whoami`
+      (satteriProse demotes it to h2). Visually hidden for the same reason as the other
+      section pages: the reference prints no page title either. */}
+  <h1 class="sr-only">About</h1>
+  <article class="about"><Content /></article>
 </Base>
+
+<!-- is:global for the same reason the article page uses it: the elements <Content />
+     emits carry no scope id, so a scoped selector would miss every one of them. -->
+<style is:global>
+  /* about.md used to print three bare URLs, the longest 40 characters with no space
+     in it. Left unbroken it set the page's minimum width and `/` scrolled sideways on
+     every phone - 383px of content in a 320px viewport. The article page already
+     carries this pair on .prose; the home page had no equivalent rule because it is
+     the one page that renders <Content /> without that wrapper. */
+  .about { word-break: keep-all; }
+
+  /* The portrait, set the way the reference sets its own: floated to the inline end with
+     the prose running beside it, not a banner above the text. It is the first figure in
+     the document and the only one on this page, so the selector is :first-child rather
+     than a class the author would have to remember to type in markdown.
+
+     Two things it buys beyond looking composed. It pulls the reading measure in from the
+     full 1024px this page gets — 4.3 words a line narrower over the paragraphs it passes,
+     which is the direction this page's line length wanted to move anyway. And the 4:5
+     portrait keeps its own aspect from the width/height satteriProse stamps on the <img>,
+     so nothing reflows when it loads.
+
+     Below 40rem the float comes off: a 232px picture beside a 300px column leaves neither
+     enough room, and the reference drops its own block to full width at a similar point. */
+  .about > .fig:first-child {
+    float: inline-end;
+    inline-size: min(14.5rem, 34%);
+    margin: 0 0 var(--sp-40) var(--sp-48);
+    shape-margin: var(--sp-40);
+  }
+  .about > .fig:first-child img { inline-size: 100%; block-size: auto; }
+
+  /* Clears the float so the footer's rule and anything after the prose cannot ride up
+     beside the picture on a short viewport. */
+  .about::after { content: ""; display: block; clear: both; }
+
+  @media (max-width: 40rem) {
+    .about > .fig:first-child {
+      float: none;
+      inline-size: min(12rem, 45%);
+      margin: 0 0 var(--sp-40);
+    }
+  }
+  .about :is(p, li, h1, h2, h3, h4) { overflow-wrap: break-word; }
+  .about a { overflow-wrap: anywhere; }
+</style>
diff --git a/src/pages/papers/index.astro b/src/pages/papers/index.astro
@@ -2,6 +2,11 @@
 import Base from '../../layouts/Base.astro';
 ---
 <Base title="Papers :: sqzer">
-  <h1>Papers</h1>
+  {/* The reference carries no page title on its section pages: the bar's left slot
+        already prints the page name, so an <h1> repeating it is the same word twice.
+        Kept in the document as .sr-only rather than deleted — the page still needs one
+        top-level heading for assistive tech and for the document outline, and the bar's
+        slot is a <span> precisely because it is not one. */}
+  <h1 class="sr-only">Papers</h1>
   <p>Nothing here yet.</p>
 </Base>
diff --git a/src/pages/talk/index.astro b/src/pages/talk/index.astro
@@ -2,6 +2,11 @@
 import Base from '../../layouts/Base.astro';
 ---
 <Base title="Talk :: sqzer">
-  <h1>Talk</h1>
+  {/* The reference carries no page title on its section pages: the bar's left slot
+        already prints the page name, so an <h1> repeating it is the same word twice.
+        Kept in the document as .sr-only rather than deleted — the page still needs one
+        top-level heading for assistive tech and for the document outline, and the bar's
+        slot is a <span> precisely because it is not one. */}
+  <h1 class="sr-only">Talk</h1>
   <p>Nothing here yet.</p>
 </Base>
diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -2,23 +2,1833 @@
 import Base from '../../../layouts/Base.astro';
 import { getCollection, render } from 'astro:content';
 import type { GetStaticPaths } from 'astro';
+import { byHugoOrder, postUrl } from '../../../lib/order';
+import { proseShape, excerpt, longDate } from '../../../lib/prose';
+import { hasHangul, langOf } from '../../../lib/hangul';
 
 export const getStaticPaths: GetStaticPaths = async () => {
-  const posts = await getCollection('writing');
-  return posts
-    .filter((p) => !p.data.draft)
+  const posts = (await getCollection('writing')).filter((p) => !p.data.draft).sort(byHugoOrder);
+  return posts.map((p, i) => ({
     // params must be the decoded form — Korean slugs are passed through verbatim.
-    .map((p) => ({ params: { year: p.data.date.slice(0, 4), slug: p.id }, props: { post: p } }));
+    params: { year: p.data.date.slice(0, 4), slug: p.id },
+    // byHugoOrder is date DESC, so i-1 is the newer neighbour and i+1 the older one.
+    props: { post: p, newer: posts[i - 1] ?? null, older: posts[i + 1] ?? null },
+  }));
 };
 
-const { post } = Astro.props;
-const { Content } = await render(post);
+const { post, newer, older } = Astro.props;
+const { Content, headings } = await render(post);
+
+const md = post.body ?? '';
+const { shape, rhythm } = proseShape(md);
+
+/**
+ * The byline, and it is a site-level constant rather than a per-post field on purpose.
+ *
+ * The reference prints one on every article — `by <strong>Nicholas Carlini</strong>`
+ * followed by the date, checked on three of its posts across 2024, 2025 and 2026 — so a
+ * byline belongs here. A byline names a person, so this is the person: the same string
+ * Base.astro holds as SITE_OWNER and paints as the wordmark in the page header of all 36
+ * pages. NOT 'sqzer' — that is the domain (blog.sqzer.com), which Base.astro spends its
+ * own comment distinguishing from the owner's name, and which only ever appears in
+ * <title> and the feed title. Printing it here would have the header say one name and the
+ * byline three lines below it say another, where the reference says the same name twice.
+ *
+ * Duplicated rather than imported because SITE_OWNER is a module-scoped const in
+ * Base.astro's frontmatter with no export, and Base.astro is not this run's to edit.
+ * Keep the two in sync: src/layouts/Base.astro, `const SITE_OWNER`.
+ *
+ * Site-level rather than per-post because the writing schema has no `author` field and
+ * all 30 posts' front matter is title/date/tags. A per-post byline — a guest post, a
+ * co-author — would need that field on the collection, which is a schema change.
+ */
+const AUTHOR = 'Hyukjin An';
+
+const year = post.data.date.slice(0, 4);
+
+/**
+ * lang follows the body, not the title, and is measured on prose only.
+ *
+ * The label drives leading, line breaking and speech, so it has to describe what is read.
+ * Keying off the title would mislabel AI Odyssey and VTZero; counting Hangul against every
+ * character would mislabel the Korean technical posts, whose shell transcripts and URLs
+ * swamp the prose \u2014 measured that way sysmon lands at 0.14 and journalctl at 0.29, both
+ * plainly Korean documents. With fences and link targets dropped and Hangul weighed
+ * against Latin letters, the corpus splits with nothing near the line: the two English
+ * essays sit at 0.25 and 0.28, and the other 28 posts all sit at 0.50 or above.
+ */
+const HANGUL = /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7A3]/g;
+const prose = md.replace(/```[\s\S]*?```/g, '').replace(/\[[^\]]*\]\([^)]*\)/g, '');
+const kor = (prose.match(HANGUL) ?? []).length;
+const lat = (prose.match(/[A-Za-z]/g) ?? []).length;
+const lang = kor / Math.max(1, kor + lat) > 0.4 ? 'ko' : 'en';
+
+/*
+ * Which font to preload is a different question from which language to declare, and the
+ * two answers differ on real posts. `lang` is a ratio over prose with code fences removed,
+ * so We_need_to_talk is correctly an English document at 0.28 - and it still paints 760
+ * Hangul syllables, because the English essays quote Korean. Preloading by `lang` would
+ * leave those in fallback behind a third-wave request. One Hangul character anywhere in
+ * the rendered body is enough to need the whole 118KB face, so the test is presence, not
+ * proportion, and it runs on the raw source including fences: 20 of the 55 code blocks in
+ * the corpus contain Hangul comments.
+ */
+const ko = hasHangul(md, post.data.title, post.data.deck);
+
+/** deck is empty on all 30 posts, so the description falls back to the opening sentence. */
+const description = post.data.deck ?? excerpt(md);
+
+/**
+ * Contents are the default, not a reward for being technical.
+ *
+ * The premise is that every post gets headings. 14 of the 30 have none today and 2 have
+ * one; that is a gap in the prose, not a property of those documents, and the gate is
+ * written for the corpus they are becoming rather than the one they are. So it asks a
+ * single structural question — is there a heading structure here a reader could navigate
+ * — and nothing at all about genre.
+ *
+ * Three levels deep, counted from the document's own top heading rather than from <h1>.
+ * The absolute `depth <= 3` this used to be looked equivalent and was not, because
+ * satteri-prose demotes every heading by one in any document that contains an h1 — so
+ * "depth 3" is the author's ### in 안전하고 and the author's ## in tso, and the filter
+ * silently meant a different thing on either side of that. Counted in the built DOM:
+ *
+ *                            absolute <= 3      relative to top
+ *     tso                        5                  11     six ### subsections restored
+ *     journalctl                 2                   6     four ### sections restored
+ *     sysmon                    11                  18
+ *
+ * tso fell under the six-entry gate on that arithmetic alone and shipped no contents at
+ * all, and journalctl — a command reference, the single most jumped-around document in
+ * the corpus — showed two entries. Every post in the corpus starts at h2 after demotion,
+ * so today the two readings differ only in where they stop; the relative form is what
+ * keeps them in agreement if a document ever opens at h3 or the demotion rule moves.
+ *
+ * The distribution over the 30 posts, entries per document:
+ *
+ *     28 19 19 18 11 10 6 6 6 4 3 2 2 2 | 1 1 | 0 x14
+ *      ^ the 14 that get contents today ^  ^ nothing to navigate ^
+ *
+ *     >= 2 -> 14    >= 4 -> 10    >= 6 -> 9
+ *     >= 3 -> 11    >= 5 ->  9    >= 7 -> 6
+ *
+ * 2, and it is ONE gate for both presentations. What decided it:
+ *   - 1 is not a list. A single entry offers no choice — it is a link to a point in the
+ *     middle of the page, filed under a heading that says "Contents" — and the rail would
+ *     spend 240px of gutter and a full-height rule stating it. Two is the smallest list a
+ *     reader can navigate WITH. It is the only threshold that follows from the premise
+ *     rather than from this corpus's accidents, which is what makes it safe to leave in
+ *     place while the 16 documents above are being written into.
+ *   - the old threshold was 6, picked out of a gap in this distribution, and the gap is
+ *     exactly the artefact the premise dissolves: every one of those documents is one
+ *     editing pass away from moving across it. 6 also selected nine documents while the
+ *     page box was already reserving room for a rail on all thirty (see the rail block).
+ *   - `shape === 'technical'` is gone. It existed to keep a long list of decorative
+ *     headings out of an essay, and at two entries there is no long list to keep out. An
+ *     essay with sections is a document with sections: this admits AIOdyssey and
+ *     We_need_to_talk, both essay-shaped, both two real sections.
+ *   - one gate, not two. A rail-only gate wider than the inline one would leave those
+ *     posts with no contents at all below the width where the rail parks.
+ *
+ * What the 16 below the line get: nothing painted — no rail, no inline list, no rule.
+ * What does not change for them is the page box, which reserves the rail's gutter on
+ * every article whether or not a rail is painted in it; see `main:has(.post-shell)` in
+ * the rail block below for why that is the steady state rather than a concession.
+ */
+/*
+ * One heading in `headings` was not written by the author. GFM footnotes close the document
+ * with <section class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>,
+ * and Sätteri's heading-id plugin collects it like any other — measured, depth 2, slug
+ * "footnote-label". It is drawn nowhere (.sr-only in base.css), so leaving it in puts an
+ * entry in the rail that jumps to an invisible target, and at a two-entry gate a post with
+ * one real heading and one footnote would get a whole rail for that. It also joins the
+ * Math.min below, so a footnoted document whose headings all start at h3 would take its
+ * base from the footnote label instead and mis-level every entry under it.
+ * No post uses footnotes today; two have exactly one heading, which is the case this
+ * protects. Filtered by slug rather than by text because the text is translatable.
+ */
+const authored = headings.filter((h) => h.slug !== 'footnote-label');
+const tocBase = authored.length ? Math.min(...authored.map((h) => h.depth)) : 2;
+const toc = authored.filter((h) => h.depth <= tocBase + 2);
+const showToc = toc.length >= 2;
+
+/**
+ * Indent by position in this document's own hierarchy, not by absolute depth — the same
+ * reason the filter above is relative. 0, 1 or 2 by construction.
+ */
+const tocLevel = (depth: number) => depth - tocBase;
+
+const ld = {
+  '@context': 'https://schema.org',
+  '@type': 'BlogPosting',
+  headline: post.data.title,
+  datePublished: post.data.date,
+  inLanguage: lang,
+  description,
+  mainEntityOfPage: new URL(postUrl(post), Astro.site).href,
+};
 ---
-<Base title={`${post.data.title} :: sqzer`} description={post.data.deck}>
-  <article>
-    <h1>{post.data.title}</h1>
-    {post.data.deck && <p>{post.data.deck}</p>}
-    <time datetime={post.data.date}>{post.data.date}</time>
-    <Content />
+<Base
+  title={`${post.data.title} :: sqzer`}
+  description={description}
+  section="writing"
+  ko={ko}
+>
+  {/* Outside .post-shell, and that placement is load-bearing rather than tidy. The shell
+      carries `container-type: inline-size`, which implies layout containment, which makes
+      it a containing block for position:fixed descendants. Put the rail inside it and
+      `right: 50% + …` resolves against the 1024px shell instead of the viewport, `top`
+      measures from the shell, and the whole thing scrolls away with the article. Here in
+      <main>, which sets no containment, it is fixed to the viewport as intended.
+      In reading order it precedes the article, which is where a contents list belongs. */}
+  {showToc && (
+    <nav class="toc-rail" aria-labelledby="toc-rail-title">
+      <p class="toc-rail__title" id="toc-rail-title">Contents</p>
+      <ol class="toc-rail__list">
+        {toc.map((h) => (
+          <li class="toc-rail__item" data-level={tocLevel(h.depth)}>
+            {/* data-target, not the href, is what the scroll-spy resolves: 21 of the 30
+                posts are Korean and `a.hash` comes back percent-encoded in some engines,
+                so reading the slug straight out of the attribute skips a decode step
+                that has to be right for every one of them. */}
+            {/* lang per entry, and it is this rail's price for living outside .post.
+                The article declares lang="ko" on <article class="post">, and the inline
+                <details class="toc"> below sits inside it, so the collapsed list inherits
+                the right language for free. The rail cannot: it is hoisted out to <main>
+                so that .post-shell's `container-type` does not become its containing
+                block, and out there the nearest [lang] is <html lang="en">. Measured in
+                the built DOM before this line existed: 15 of sysmon's 18 entries, all 10
+                of bind9's and 8 of tso's 11 were Hangul announced as English — the same
+                strings the inline list announces correctly at the same widths. Per entry
+                rather than one lang on the <nav>, because out here `en` really is the
+                document: how-to-win's list is 25 English headings to 3 Korean, and
+                langOf() drops an agreeing value so a reader is not switched for them.
+                Same call the Writing index makes on the same kind of string. */}
+            <a class="toc-rail__link" href={`#${h.slug}`} data-target={h.slug} lang={langOf(h.text, 'en')}>{h.text}</a>
+          </li>
+        ))}
+      </ol>
+    </nav>
+  )}
+  <div class="post-shell">
+  <article class="post" data-shape={shape} data-rhythm={rhythm} lang={lang}>
+    <header class="post__head">
+      <p class="post__kicker">
+        <a href="/writing/">Writing</a>
+        <span class="post__sep" aria-hidden="true">·</span>
+        <a href={`/writing/#${year}`}>{year}</a>
+      </p>
+      <h1 class="post__title">{post.data.title}</h1>
+      {post.data.deck && <p class="post__deck">{post.data.deck}</p>}
+      {/* What stood here was `{minutes} min read`. It is gone at the reader's request, and
+          the reference is the reason it has nothing to be replaced by: its header is a
+          title, a byline and a date, and it prints no estimate of how long the reading
+          will take. The byline takes the slot. lang="en" because the word "by" and the
+          Latin-script name are the same on all 30 posts, 21 of which declare lang="ko" on
+          <article> — the same reason the contents rail labels its entries one by one. */}
+      <p class="post__byline" lang="en">by <span class="post__author">{AUTHOR}</span><time class="post__date" datetime={post.data.date}>{longDate(post.data.date)}</time></p>
+    </header>
+
+    {showToc && (
+      <details class="toc">
+        <summary class="toc__summary">Contents</summary>
+        <ol class="toc__list">
+          {toc.map((h) => (
+            <li class="toc__item" data-level={tocLevel(h.depth)}><a href={`#${h.slug}`}>{h.text}</a></li>
+          ))}
+        </ol>
+      </details>
+    )}
+
+    <div class="prose"><Content /></div>
+
+    <footer class="post__foot">
+      {(older || newer) && (
+        <nav class="adj" aria-label="Nearby posts">
+          {older && (
+            <a class="adj__item" href={postUrl(older)} rel="prev">
+              <span class="adj__label">Older</span>
+              <span class="adj__title">{older.data.title}</span>
+            </a>
+          )}
+          {newer && (
+            <a class="adj__item" href={postUrl(newer)} rel="next">
+              <span class="adj__label">Newer</span>
+              <span class="adj__title">{newer.data.title}</span>
+            </a>
+          )}
+        </nav>
+      )}
+      <p class="post__back"><a href={`/writing/#${year}`}>All {year} posts</a></p>
+    </footer>
   </article>
+  </div>
+  <script type="application/ld+json" set:html={JSON.stringify(ld)} />
 </Base>
+
+<script>
+  /*
+   * Current-section marking for the contents rail.
+   *
+   * Enhancement only. Without it every entry in the rail is still a working anchor, and
+   * Astro hoists this script onto all 30 article pages, so the first line is the guard
+   * that makes it a no-op on the 16 that have no rail today.
+   *
+   * No scroll handler, and nothing measured to decide which entry is current — one
+   * IntersectionObserver, with a root shaped so that `isIntersecting` is already the
+   * answer. (The single property read further down runs only when the section changes,
+   * and it is about the rail's own scrollport, not about where the reader is.)
+   *
+   * The reading line is 15% of the way down the viewport, and a heading counts as current
+   * once it has crossed it. Expressing that as "is the heading inside a band at the top of
+   * the viewport" does not work, and the failure is not theoretical: a band is a single
+   * boolean, so above-the-band and below-the-band are the same state, and an instant jump
+   * over the band — Home, a restored scroll position, window.scrollTo — changes nothing
+   * and fires no callback. Measured on how-to-win: after scrollTo(0, 0) from the foot of
+   * the document the rail still marked "Conclusion".
+   *
+   * So the root is not a band but a half-plane: 100,000px of margin above the viewport and
+   * -85% below, giving a box that runs from far above the document to the reading line.
+   * A heading intersects it exactly while it is above that line, "above" and "below" are
+   * now different states, and every crossing in either direction fires — instant jumps
+   * included. 100,000px against a tallest article of 14,631px is roughly seven times the
+   * document, so nothing can drift out of the top of the root.
+   *
+   * 15% is 135px on a laptop, against the 16px (--sp-40) of scroll-margin the headings
+   * carry — so following a rail entry always lands its own heading above the line and
+   * marks the entry that was clicked. Where an h2 is followed immediately by its first
+   * h3, both clear the line at once and the h3 wins; that is the rule working, not an
+   * off-by-one, since the h2 has no text of its own at that point.
+   *
+   * The current section is then the LAST heading that has crossed, in document order.
+   * None crossed means the reader is above the first heading — in the lede, or back at the
+   * top of the document — and the answer there is the first entry, not nothing. Marking
+   * nothing is what a reader reported as the rail "not going back to the start": scrolling
+   * to the foot of how-to-win and then Home left the rail with no mark at all AND left it
+   * scrolled 175px down its own 917px list, so the list opened mid-entry with CONTENTS off
+   * the top. Both halves of that are fixed here, and the second half is the one that was
+   * actually visible: whenever the answer is entry one, the rail is returned to its own
+   * scrollTop 0 so the title and the first entry are whole.
+   *
+   * One correction to that rule, at the far end. A heading can only reach the line if the
+   * document is tall enough to push it there, so a final section shorter than 85% of the
+   * viewport can never be marked at all: measured on how-to-win, scrolling to the very
+   * bottom left "Conclusion" 400px down the screen, in full view, with the rail still
+   * pointing at the section above it. A second observer watches the article footer against
+   * the plain viewport; while the footer is on screen the reader is past the last heading
+   * by construction, so the last entry is the answer. It can only ever fire in the case it
+   * exists for — in a document whose last section is long, the footer appears well after
+   * that heading has crossed the line on its own.
+   */
+  const rail = document.querySelector('.toc-rail');
+  if (rail) {
+    const links = new Map();
+    const targets = [];
+    for (const a of rail.querySelectorAll('.toc-rail__link')) {
+      const el = document.getElementById(a.dataset.target);
+      if (!el) continue;
+      links.set(el.id, a);
+      targets.push(el);   // rail order, which is heading order, which is document order
+    }
+
+    if (targets.length) {
+      const passed = new Set();
+      let atEnd = false;
+      let marked = null;
+
+      const apply = () => {
+        // Entry one is the floor, not null: above the first heading the reader is in the
+        // lede of section one, and that is what the rail should say.
+        let next = targets[0].id;
+        for (const el of targets) if (passed.has(el.id)) next = el.id;
+        // `passed.size` keeps this off an article short enough to show its own footer
+        // before a single heading has been read past.
+        if (atEnd && passed.size) next = targets[targets.length - 1].id;
+
+        /* The one place the rail's own scroll position is set outright rather than nudged.
+           scrollIntoView({ block: 'nearest' }) is a no-op once the entry is already in
+           view, so coming back up to entry one from the foot of the article would leave
+           the list wherever the last nudge had put it — 175px down, on how-to-win at
+           1440x900. At the top of the document the rail belongs at the top of itself.
+           Written on every callback that lands here rather than only on a change: it is
+           one property write, the callbacks are section crossings and not frames, and it
+           also recovers a rail the reader had scrolled by hand. */
+        const atStart = next === targets[0].id;
+        if (atStart) rail.scrollTop = 0;
+
+        if (next === marked) return;
+
+        if (marked) links.get(marked).removeAttribute('aria-current');
+        marked = next;
+
+        const link = links.get(marked);
+        link.setAttribute('aria-current', 'location');
+        /* Keep the mark inside the rail's own scrollport — one of the 14 lists is taller
+           than it at 1440x900, and the count only rises as posts gain headings. One
+           property read, and only when the section changes, which is a handful of times
+           over an article rather than once a frame. Skipped at the start, where the line
+           above has already put the whole head of the list on screen. */
+        if (!atStart && rail.scrollHeight > rail.clientHeight) link.scrollIntoView({ block: 'nearest' });
+      };
+
+      const reading = new IntersectionObserver(
+        (entries) => {
+          for (const entry of entries) {
+            if (entry.isIntersecting) passed.add(entry.target.id);
+            else passed.delete(entry.target.id);
+          }
+          apply();
+        },
+        { rootMargin: '100000px 0px -85% 0px' },
+      );
+      for (const el of targets) reading.observe(el);
+
+      const foot = document.querySelector('.post__foot');
+      if (foot) {
+        new IntersectionObserver((entries) => {
+          atEnd = entries[entries.length - 1].isIntersecting;
+          apply();
+        }).observe(foot);
+      }
+    }
+  }
+</script>
+
+<script>
+  /*
+   * Copy control for code blocks.
+   *
+   * Enhancement, and built here rather than emitted by the `sqzer:code-block` transformer
+   * on purpose: a button that cannot copy is worse than no button, so the control exists
+   * only where the script that gives it a job has run. What the transformer must not do is
+   * grow a second child — Astro reads `root.children[0]` and drops the rest — and what it
+   * does emit is the contract this reads: figure.code[data-language] > pre > code.
+   *
+   * The row the button lands in is NOT made here. base.css reserves it on every plate, so
+   * nothing reflows when this runs and the band is the same with a figcaption (where the
+   * label and the button share one row) and without (7 of the corpus's 52 plates, the ones
+   * whose fence named no language, get no figcaption at all).
+   *
+   * What gets copied is `code.textContent`. Shiki's per-token <span>s contribute their text
+   * and nothing else, the break between two rows is a real newline text node, and the
+   * language label lives in a <figcaption> outside the <pre> — so the string is the fence's source
+   * back, exactly, with no markup and no label in it. There is no trailing newline, which is
+   * the fence's own shape and also the safer paste: a shell runs the last line when it
+   * arrives with one, and does not when it does not.
+   *
+   * Insertion is `prepend`, not `append`, so the tab order matches the reading order: the
+   * button at the top right of the plate, then the <pre>, which carries Shiki's own
+   * tabindex="0" as a scroll region.
+   */
+  const plates = document.querySelectorAll('.prose figure.code');
+  if (plates.length) {
+    /*
+     * One polite live region for the page, created up front. A region that is inserted
+     * and filled in the same task is missed by most screen readers, so it has to be in
+     * the tree before there is anything to say.
+     *
+     * role="status" is aria-live="polite" plus aria-atomic="true" without spelling either
+     * out. Assigning the same string twice is commonly swallowed as "no change", and
+     * copying the same block twice is a normal thing to do, so the region is emptied and
+     * refilled on the next frame instead.
+     */
+    const status = document.createElement('p');
+    status.className = 'sr-only';
+    status.setAttribute('role', 'status');
+    document.body.append(status);
+
+    let frame = 0;
+    const announce = (message) => {
+      cancelAnimationFrame(frame);
+      status.textContent = '';
+      frame = requestAnimationFrame(() => { status.textContent = message; });
+    };
+
+    /*
+     * Three glyphs in one <svg>; CSS paints whichever data-state names. 16px box, 1.5px
+     * stroke, mitred joins and square caps — the page's own vocabulary is a 4px rule and a
+     * 1px hairline, so the icon is drawn in straight lines with no corner radius anywhere.
+     */
+    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>`;
+
+    /*
+     * navigator.clipboard needs a secure context. localhost and the deployed HTTPS site
+     * both are one, so the first branch is the one that runs in practice — but it can
+     * still reject (a permissions policy on an embedding page, a document that is not
+     * focused, a user denying the permission prompt some engines still show), and the
+     * whole point of a copy button is that the reader can tell whether it worked.
+     *
+     * So the fallback is also the failure affordance, in one gesture: select the block.
+     * execCommand('copy') is deprecated but implemented everywhere, and what it asks for is
+     * a user gesture rather than a permission — a click is one. If even that refuses, the
+     * selection stays up and the reader finishes the job with the keyboard, which is what
+     * the live region then tells them to do. Measured, with the async API forced to reject:
+     * a real click still copies through this path; a script-dispatched .click() does not,
+     * which is the gesture requirement doing its job.
+     */
+    const copy = async (code) => {
+      const text = code.textContent ?? '';
+      if (navigator.clipboard?.writeText) {
+        try {
+          await navigator.clipboard.writeText(text);
+          return 'done';
+        } catch { /* fall through to the selection path */ }
+      }
+      const selection = window.getSelection();
+      if (!selection) return 'fail';
+      const range = document.createRange();
+      range.selectNodeContents(code);
+      selection.removeAllRanges();
+      selection.addRange(range);
+      try {
+        if (document.execCommand('copy')) {
+          selection.removeAllRanges();
+          return 'done';
+        }
+      } catch { /* leave the selection standing — it is the fallback */ }
+      return 'fail';
+    };
+
+    for (const plate of plates) {
+      const code = plate.querySelector('pre > code');
+      if (!code) continue;
+
+      const lang = plate.dataset.language;
+      /* The control shows no text, so the name is the whole of it. It names the action and
+         the thing, never the outcome: a button whose accessible name changes to "Copied"
+         is a button that lies about what it does next. The outcome goes to the region. */
+      const name = lang && lang !== 'plaintext' ? `Copy ${lang} code` : 'Copy code';
+
+      const button = document.createElement('button');
+      button.type = 'button';
+      button.className = 'code__copy';
+      button.dataset.state = 'idle';
+      button.setAttribute('aria-label', name);
+      button.title = name;          // the same string as a tooltip, for a pointer user
+      button.innerHTML = GLYPHS;
+
+      let revert = 0;
+      button.addEventListener('click', async () => {
+        const state = await copy(code);
+        button.dataset.state = state;
+        announce(state === 'done'
+          ? 'Copied'
+          : 'Copy failed. The code is selected — copy it with the keyboard.');
+        clearTimeout(revert);
+        revert = setTimeout(() => { button.dataset.state = 'idle'; }, 2000);
+      });
+
+      plate.prepend(button);
+    }
+  }
+</script>
+
+<!-- is:global, not scoped. Astro rewrites a scoped `.prose p` to `.prose p[data-astro-cid-…]`,
+     and the elements <Content /> emits carry no cid, so every body rule would silently miss.
+     Only this page ships this sheet. Every selector is rooted at .post except the contents
+     rail, the hairline beside it (`main:has(.toc-rail)::before`) and the two rules that
+     move the page box out of its way — `:root` and `main:has(.post-shell)`. Those two are
+     the widening the rail asked for; they apply to every article rather than only the ones
+     carrying a rail, and outside 1440–1584px they compute exactly what base.css already
+     did. Nothing here touches the site header: it is the same bar in the same place on
+     every route, and base.css owns it. All of this is explained in the rail block below. -->
+<style is:global>
+  /* ══ Geometry ════════════════════════════════════════════════════════════
+     One left edge, with one bounded exception that is named here rather than left to be
+     discovered. The text column is the same 928px the index uses, hung off the same 72px
+     rail, so an article title starts exactly where the index entry title it was clicked
+     from started — measured 0.00px apart at 320 / 768 / 1024 / 1584 / 1700 / 1920, on
+     every article.
+
+     The exception is a width band, 1440 to 1584px, and it is now the same for all 30
+     articles. There the rail's 240px floor does not fit in a centred gutter, so
+     `main:has(.post-shell)` pushes the page box right far enough to make it fit and the
+     two edges part: measured 72px at 1440, 48 at 1488, 24 at 1536, 0 from 1584 up and 0
+     below 1440, where the rail is parked and the inline list is the contents. Those are
+     layout-viewport widths, so a classic 15px scrollbar shifts the whole band 15px out
+     and the true worst case is 79.5px, at a 1440px window. It was traded knowingly — the
+     alternative to the shift was to withhold the rail entirely across that 144px band —
+     and the cost is bounded by arithmetic rather than by a literal: --page-left is a
+     max() against the centred position, so the moment the gutter can seat the rail on its
+     own the shift is exactly zero and never negative. The gutter is reserved whether or
+     not a rail is painted in it, so Older/Newer no longer steps the left edge between a
+     post with contents and one without. See the rail block below for
+     the geometry, the --w-content note in tokens.css and the --content-w note in
+     src/pages/writing/index.astro for the other two ends of the same edge.
+
+     Inside that band the site header no longer follows the page box. It used to — the
+     article template restated base.css's header padding against --page-left — and the
+     price was a bar that slid ~50px right and went asymmetric on article pages and
+     nowhere else. The bar is fixed on every route now, so across the band the wordmark
+     stays on the centred edge while the page box leaves it. Measured, page box minus
+     wordmark, at a real window with a classic scrollbar: 79.5px at 1440, 49.5 at 1500,
+     7.5 at 1584, 0.00 at 1700 / 1920 / 2560 and 0.00 at 1262 and below. It is the same
+     shift already quoted above, now read against the bar instead of against the index.
+     The rail block below has the argument; base.css has the contract.
+
+     `scrollbar-gutter: stable` on the root does not change any number in this block — a
+     classic scrollbar still takes its 15px out of the layout viewport, which is why the
+     worst case is at 1440 and why the shift is not yet 0 at a 1584px window. What it
+     changes is that the four short pages now lose the same 15px, so the header's own
+     centring is computed from one width across the site instead of two.
+
+     Nothing breaks out of the edge on the page's own side: the rail lives outside .post
+     and the measure is never widened to reach it. */
+  .post {
+    --rail-w: 4.5rem;                 /* 72px, the index date rail */
+    --rail-gap: 1.5rem;               /* 24px */
+    --fs-kicker: clamp(0.63rem, 0.36vw + 0.54rem, 0.88rem);   /* 10.08→14.08 */
+
+    /* Korean leading. Measured from the shipped fonts: a Pretendard syllable advances
+       0.8643em, times KoBody's 94.29% size-adjust = 0.8149em, so the 928px column holds
+       59.9 syllables at 19px (Lora averages 100.4 Latin characters on a wrapped line
+       there). Hangul also fills a 0.840em ink band on every character, where Lora's
+       lowercase mostly occupies its 0.500em x-height and only the occasional ascender or
+       descender reaches the 1.031em extreme - so at 1.6 the Korean line loses about a
+       third of its interline white. 1.75 returns most of it. 1.90 would equalise it, at
+       the price of a 24px paragraph gap no longer reading as a gap. */
+    --lh-ko: 1.75;
+
+    display: grid;
+    grid-template-columns: var(--rail-w) minmax(0, 1fr);
+    column-gap: var(--rail-gap);
+    max-width: calc(var(--rail-w) + var(--rail-gap) + var(--w-content));  /* 1024px */
+    margin-inline: auto;
+  }
+  .post > * { grid-column: 2; }
+
+  /* The query container is this wrapper, not `.post` itself. An element cannot be
+     styled by a container query it establishes, so with `container-type` on `.post`
+     the rail-collapse block below reached every descendant but never `.post`: the
+     children moved to column 1 while the columns stayed `72px minmax(0, 1fr)`, and
+     the whole article rendered 72px wide on any viewport under ~623px. */
+  .post-shell { container: post / inline-size; }
+
+  /* ══ Header ══════════════════════════════════════════════════════════════
+     Every Gazette header style is built around a picture: .article-header is a grid
+     whose image slot is 316–500px tall (`height: clamp(19.75rem, 28.35vw + 13.11rem,
+     31.25rem)`) and the text is the narrow column beside it. Remove the image and
+     nothing holds the grid open, so the header has to be rebuilt, not emptied.
+
+     Three things stand in for the picture, and none of them is filler:
+       1. the 4px x 3rem rule, laid down as the page's first ink. The Gazette hangs it
+          off .article-header__meta::before; this site's index already hangs it above
+          the year kicker, and matching the index matters more than matching the
+          Gazette's internal placement — it is the mark you just clicked away from.
+       2. air above it and 64px below the meta. An opening gap wider than any gap inside
+          the body (48px) is what actually says "the header ended".
+       3. the title at --fs-h1. Not --fs-h1-large: measured over the real 30 titles,
+          88px puts 6 of them on three lines and 2 on four (387px of headline), and all
+          8 are Korean — the large step would systematically punish the 21 Korean
+          titles. At 64px the worst case is three lines and the median is two.
+
+     How much air, and why it is smaller than it was. The number that matters is not this
+     padding but the distance from the bar's bottom rule to the 4px mark, and this padding
+     is only the second half of it: `main` already opens with --sp-64 above anything an
+     article draws. At --sp-80 the two stacked to a measured 138.5px at 1700 and 133.5 at
+     1500, under a bar 59.9px tall — two large steps of nothing before the first ink,
+     which is what reads as a hole rather than as an opening.
+
+     Measured on the reference at 1700, from the bottom of the bar above it to the top of
+     its own article header: 0px on three of its five story styles (split-screen,
+     fullscreen, full-width-text-below — the header simply begins against the bar) and 94
+     to 158px on the two classic ones, which hang an eyebrow block in the gap. Its heading
+     lands 89.5 / 118.2 / 137.7 / 201.7px below the bar across those styles. So the
+     reference's own answer spans from flush to roughly what this page was already doing,
+     and the low end of it is where a header with no picture belongs.
+
+     --sp-32 puts the total at a measured 94.8px at 1700 and 93.8 at 1500 — the
+     reference's classic figure, a 43.7px lift, and still about twice the 48px block
+     rhythm inside the body, so point 2 above survives intact. It is not collapsed to
+     nothing: 94px of quiet above a 4px rule is an opening, 0px would make the rule read
+     as part of the bar. Measured across the range, header rule to the top of the kicker's
+     mark: 64.0 at 320, 74.7 at 768, 81.6 at 1024, 88.1 at 1262, 92.9 at 1440, 93.8 at
+     1500, 94.2 at 1584, 94.8 at 1700, 95.9 at 1920, 96.0 at 2560. The 320 end is both
+     clamps at their floor, which is right where the bar is 52px tall and the rail has
+     collapsed — the wrapped header wants less air above it, not the same. */
+  .post__head {
+    display: grid;
+    grid-template-columns: subgrid;
+    grid-column: 1 / -1;
+    padding-block: var(--sp-32) var(--sp-64);
+  }
+  .post__head > * { grid-column: 2; }
+
+  .post__kicker {
+    margin: 0;
+    font-family: var(--font-body);
+    font-size: var(--fs-kicker);
+    font-weight: 700;
+    line-height: var(--lh-medium);
+    letter-spacing: 0.105rem;         /* 1.68px, as measured on .article-header__category */
+    text-transform: uppercase;
+    font-variant-numeric: tabular-nums;
+    color: var(--ppc-text);
+  }
+  /* The signature motif. Twice per page at most: here, and on a thematic break. */
+  .post__kicker::before {
+    content: "";
+    display: block;
+    width: 3rem;
+    border-block-start: 4px solid var(--ppc-ui);
+    margin-block-end: var(--sp-40);
+  }
+  .post__kicker a { color: inherit; text-decoration: none; }
+  .post__kicker a:hover { text-decoration: underline; text-underline-offset: 0.3em; }
+  .post__sep { margin-inline: 0.35em; color: var(--page-hairline); }
+
+  /* The date sits in the byline now, not out in the rail.
+     It used to be `09.08` in the 72px date column with the year three lines above it in
+     the kicker, which split one date across two places and printed neither of them whole.
+     The reference puts the whole thing on the byline: `by <b>name</b>` then a 2em gap then
+     the date in a muted grey, and that is the shape reproduced here. The format is the
+     reader's, not the reference's — it writes 2025-01-05 where this writes
+     "September 8, 2026" — so longDate() supplies the text and `datetime` keeps the ISO
+     form for machines. longDate is pinned to UTC; parsing these in local time moves half
+     the corpus back a day.
+
+     tabular-nums is gone with the rail: it lined up a column of MM.DD, and there is no
+     column here. */
+  .post__date {
+    padding-inline-start: 2em;     /* the reference's own gap between name and date */
+    white-space: nowrap;
+  }
+
+  /* Stays in the measure column, on the entry title's edge — it does NOT follow the index's
+     own <h1>, which was just pulled out to the page box's left edge so that /writing/ opens
+     on the same line /papers/, /talk/ and /code/ open on. The two are different headings.
+     "Writing" names the page and answers to the word the bar prints above it; this one is
+     the entry you clicked, and it lands where that entry title was — measured 0.00px apart
+     at 320 / 768 / 1024 / 1700 / 1920, and apart only inside the rail's 1440–1584 band.
+     Moving it alone would also part it from its own kicker, byline and first paragraph,
+     which are all in this column; moving the whole header out would give the article two
+     left edges, one for the header and one for the body under it.
+
+     The cost, stated rather than hidden: this <h1> does not share the header's left edge,
+     where all four section index titles now do. Measured, wordmark to title: 96px at 1700,
+     and 175.5px at a 1440px window, where the page box is pushed right for the rail and
+     the bar stays put. */
+  .post__title {
+    grid-row: 2;
+    margin-block: var(--sp-24) 0;     /* .article-header h1 { margin: spacing-24 0 } */
+    font-size: var(--fs-h1);
+    word-break: keep-all;
+    text-wrap: balance;
+  }
+
+  /* Empty on all 30 posts today. The synthesised excerpt is deliberately NOT printed
+     here: it is the article's own first sentence, and showing it directly above that
+     same sentence is a duplication the reader sees at once. It goes to <meta> only. */
+  .post__deck {
+    margin-block-start: var(--sp-24);
+    font-family: var(--font-display);
+    font-size: var(--fs-h4);
+    line-height: var(--lh-close);
+    color: var(--page-fg);
+    word-break: keep-all;
+    text-wrap: pretty;
+  }
+
+  /* The retired .post__meta rule, minus its `font-variant-numeric: tabular-nums` — that
+     line was there for the digit in "10 min read" and a byline has no digits in it. The
+     rest is unchanged, so the line sits exactly where the reading time sat and nothing in
+     the header's geometry moved. The name carries the weight and the page's own ink, the
+     way the reference sets its byline (`by` plain, the name at 600 in its own colour);
+     700 rather than 600 because that is this site's one strong weight and Lora ships it.
+     `by` stays at --page-meta, so the two read as label and value. */
+  .post__byline {
+    margin-block-start: var(--sp-24);
+    font-size: var(--fs-small);
+    line-height: var(--lh-base);
+    letter-spacing: 0.02em;
+    color: var(--page-meta);
+  }
+  .post__author { font-weight: 700; color: var(--page-fg); }
+
+  /* ══ Contents ════════════════════════════════════════════════════════════
+     14 of the 30 posts carry one today and the design assumes all 30 eventually will (see
+     the note beside showToc). It has two presentations and exactly one of them is on at
+     any viewport width: the fixed rail in the left gutter above 1440px, this collapsed
+     inline list below it. The switch is a single media query further down, which turns one
+     on and the other off in the same block, so "both" and "neither" are not reachable
+     states — and the hairline beside the rail is generated in that same block, so it
+     cannot outlive the thing it marks either.
+
+     This is the narrow form: collapsed, so it costs one line of the column it sits in. */
+  .toc {
+    margin-block-end: var(--sp-64);
+    border-block: 1px solid var(--page-hairline);
+    padding-block: var(--sp-30);
+  }
+  .toc__summary {
+    cursor: pointer;
+    font-family: var(--font-body);
+    font-size: var(--fs-kicker);
+    font-weight: 700;
+    letter-spacing: 0.105rem;
+    text-transform: uppercase;
+    color: var(--ppc-text);
+  }
+  .toc__list { margin: var(--sp-40) 0 var(--sp-30); padding: 0; list-style: none; }
+  /* keep-all, for the same reason the rail below declares it: 12 of the 14 posts that
+     carry contents are Korean — it was all nine of them before the gate widened — and a
+     Korean line breaks between word units, never inside one. keep-all behaves as `normal`
+     for the Latin headings of the other two, so one declaration still covers both.
+     This list sits beside .prose rather than inside it, so it inherited nothing from the
+     .prose rule further down and defaulted to `word-break: normal`. Measured before this
+     line existed, over the 123 entries the nine posts then had: the longest heading (73
+     characters, on the cognitive-dissonance post) broke inside a Korean word at 320px and
+     again at 768px. Those are two of the three widths at which any entry wraps at all - 6
+     wrapping entries at 320, 3 at 375, 1 at 768, none from 1024 up - so the defect was
+     rare only because the wrapping was.
+
+     break-word is the escape valve and never has to fire: at 320, where the box is
+     narrowest, the widest segment keep-all refuses to break is 72.1px of Hangul, or
+     92.5px counting the one Latin filename among the 123, against a 256px text box.
+     Measured after: 0 in-word breaks and 0 overflow at 320 / 375 / 768 / 1024 / 1439. */
+  .toc__item {
+    margin-block-start: 0.4rem;
+    font-size: var(--fs-medium);
+    line-height: var(--lh-base);
+    word-break: keep-all;
+    overflow-wrap: break-word;
+  }
+  /* One step per level, off this document's own top level - see tocLevel(). The retired
+     rule keyed on `data-depth="3"` and was exactly equivalent while the filter stopped at
+     depth 3; now that it reaches one deeper it would not be, because h3 and h4 would share
+     an indent. That flattens tso's six ### subsections onto their ## parents, and
+     sysmon's seven and journalctl's four with them. */
+  .toc__item[data-level="1"] { padding-inline-start: var(--sp-40); }
+  .toc__item[data-level="2"] { padding-inline-start: calc(2 * var(--sp-40)); }
+  .toc__item a { font-weight: 400; text-decoration: none; color: var(--page-fg); }
+  .toc__item a:hover { text-decoration: underline; }
+
+  /* ── the rail ────────────────────────────────────────────────────────────
+     The reference site floats a fixed #leftnav at `left: calc(50% - 31em)` and slides it
+     to `left: -10em` under 80em. Same behaviour, positioned off this grid's own tokens
+     rather than a hand-fitted em value.
+
+     The geometry is declared once, on :root, and four rules read it: the rail's own
+     inline-size, the page box's start margin, the header's padding, and — through those
+     last two rather than directly — the hairline that runs beside the rail. Nothing below
+     restates any of it, which is the whole reason those
+     four cannot drift apart.
+
+     `100%`, never `100vw`, and that is a bug fix rather than a preference. Every consumer
+     of these properties resolves a percentage against the initial containing block — the
+     fixed rail and its rule directly, <main> and <header> through the body's content box,
+     which is the same width — and the ICB excludes a classic scrollbar where `100vw`
+     includes it. Measured against the shipped formula with a real 15px scrollbar at 1440:
+     the rail claimed 168px of a 160.5px gutter, so the 16px of --toc-edge this file
+     promised was 8.5px on screen. Written in `100%` it is 16.00px with the scrollbar and
+     16.00px without.
+
+     Where it sits. --page-left is the x of the page box's left edge and it is the larger
+     of two demands: the centred position every other page uses, and whatever the rail
+     needs in order to stand --toc-edge clear of the viewport. From 1584 up the centred
+     position wins and the max() is inert; from 1440 to 1583 the rail wins and the page
+     box is pushed right, which is the shift. The rail is still laid into the gutter
+     inline-end first, --toc-gap clear of the date rail's left edge, so it stays pinned to
+     the page box rather than to the viewport: widen --w-content again and the rail slides
+     outward with the text instead of drifting over it.
+
+     Its width is a floor now, not a leftover, and that is the change. The old formula
+     handed the rail whatever a centred gutter had spare, which is 168px at the switch —
+     the width every wrapping figure in this file used to be quoted at, and the reason the
+     rail was too narrow to read. 240px is guaranteed wherever the rail is painted (see
+     .toc-rail__link for what decided the number), the gutter grows it to 320px where the
+     gutter is wide enough to pay for the growth out of empty space, and it is nowhere
+     narrower than the 256px it used to reach at its widest.
+
+     The clearance it must never lose is still geometric rather than hoped for, and now on
+     both sides. Its inline-end edge is --toc-gap from the page box by construction, since
+     `right` is written off --page-left and nothing else; its inline-start edge is exactly
+     --toc-edge from the viewport whenever the rail is what set --page-left, and further in
+     when the centred position did. The rail cannot reach the 72px date rail, let alone the
+     measure, at any viewport. */
+  :root {
+    --toc-gap: 1.5rem;        /* 24px, = .post's --rail-gap: one column gutter */
+    --toc-edge: var(--sp-40); /* 16px of air at the viewport edge */
+    --toc-w-min: 15rem;       /* 240px, the floor  — reached at 1440, held to 1584 */
+    --toc-w-max: 20rem;       /* 320px, the cap    — reached at 1744 */
+
+    /* Three terms, and they are written in the order they bind: the cap past which more
+       width buys no reading, the floor the reading needs raised over what a centred gutter
+       happens to leave over, and the room that actually exists. The third is the one that
+       keeps the guarantee geometric rather than hopeful, and it is not theoretical - the
+       floor is in rem, so a reader on Chrome's 24px root asks for a 360px rail out of a
+       gutter that cannot seat one. Measured at 12 / 14 / 16 / 20 / 24px roots against
+       320 / 768 / 1024 / 1440 / 1584 / 1700 / 1920, it binds in exactly one of those
+       cells, 24px at 1440: at a 16px root it is 352.5px against a 240px ask at 1440 and
+       grows from there, so it is inert at every width the rail is painted at. Where it
+       does bind the page box keeps its full --w-page and ends up flush against --root-pad
+       on its right (measured 1072px and 27.22px), and the rail still keeps its --toc-edge
+       on the left; without it the rail would run off the left of the screen instead. */
+    --toc-rail-w: min(
+      var(--toc-w-max),
+      max(
+        var(--toc-w-min),
+        calc((100% - var(--w-page)) / 2 - var(--toc-gap) - var(--toc-edge))
+      ),
+      calc(100% - var(--w-page) - var(--root-pad) - var(--toc-gap) - var(--toc-edge))
+    );
+    --page-left: max(
+      calc((100% - var(--w-page)) / 2),
+      calc(var(--toc-edge) + var(--toc-rail-w) + var(--toc-gap))
+    );
+  }
+
+  .toc-rail {
+    /* The rail's inline-end coordinate and its top. Both are local to this box now — the
+       hairline beside it is no longer a pseudo-element of the rail and reads neither (see
+       the rule block inside the switch). Anything that moves the rail still moves --toc-x
+       rather than `right`, so that the slide has one coordinate to interpolate.
+
+       --toc-y is the rail's own top and nothing else's. It used to be the hairline's top
+       as well, which is what put the line 103-120px down a viewport whose header ends at
+       59.4px; the list keeps the inset because a list wants air under the header, and the
+       line does not because a line under a header is what the reader asked for. Measured
+       from the header's bottom border at 1440 / 1700 / 1920 / 2560: 43.8 / 52.7 / 60.4 /
+       60.4px of clearance to the cap height of CONTENTS. */
+    --toc-x: 100%;
+    --toc-y: var(--sp-120);
+
+    position: fixed;
+    z-index: 1;
+    top: var(--toc-y);
+    inline-size: var(--toc-rail-w);
+    /* Parked: right edge on x = 0, entirely left of the viewport. This is the off state
+       and it is also the default, so a page that never reaches 1440px never paints it.
+       Leftward overflow is not scrollable in an LTR document, so the parked box adds no
+       horizontal scroll - verified at 320 / 375 / 768 / 1024 / 1262 / 1366 / 1439, with
+       and without a classic scrollbar. */
+    right: var(--toc-x);
+    visibility: hidden;
+    /* Distance is --page-left - --toc-gap, 256px at the switch. `visibility` interpolates
+       as a step that counts as visible for any progress in (0,1), so one transition keeps
+       the box painted for the whole slide out and shows it immediately on the way in.
+       The hairline no longer rides this: it is generated by the switch rather than moved
+       by it, so it appears and disappears at the boundary instead of travelling.
+       prefers-reduced-motion is handled twice over: the global rule at the foot of
+       base.css kills every transition with !important, and the local rule below says so
+       where a reader of this block will look. */
+    transition: right 0.45s ease, visibility 0.45s;
+
+    /* The box stays exactly as tall as its list, capped at the viewport. Widening it to
+       240px took most of the overflow out: measured at 1440x900, where the box has 754px
+       of room, the lists that scroll inside themselves go from three at 168px to one at
+       240. The one left is how-to-win, 1,014px of list, and it still scrolls at every rail
+       width measured up to 392px — so this is not decoration and neither is the
+       scroll-spy's scrollIntoView. Capping the box rather than sizing it to the viewport
+       is also what keeps the gutter under the list clickable (hit-tested at x = 200,
+       y = 400 / 700 / 1000 / 1200 on journalctl at 1700: the page, not this nav) and keeps
+       `rail.scrollHeight > rail.clientHeight` — the guard the scroll-spy reads — meaning
+       what it says. */
+    max-block-size: calc(100dvh - var(--toc-y) - var(--sp-48));
+    overflow-y: auto;
+    overscroll-behavior: contain;
+
+    /* No scrollbar, and no gradient in its place either.
+
+       The bar itself first: `scrollbar-width: thin` paints an 11px track with a
+       --sand-dark thumb hard against this box's inline-end edge, which is exactly where
+       the hairline is. Measured on how-to-win at 1700x860 before this line: thumb at
+       x = 299-304 (#eed6aa), rule at x = 306 (#b9b8ad), two verticals 2px apart running
+       the same 700px of gutter. At a glance that is a double rule, and the thinner of the
+       two is the one the design means. The other candidates do not survive the same look.
+       Moving it (direction: rtl on the scrollport) parks the identical bar on the other
+       edge, 16px off the viewport, where it has even less to belong to; restyling cannot
+       make an 11px bar not a bar; and the need for it cannot be removed — how-to-win is
+       1,014px of list against a 754px box at 1440x900, and 28 entries is the corpus's own
+       worst case, not a hypothetical.
+
+       What stood here until now was .code pre's four-gradient overflow cue turned 90°: two
+       `local` masks in --page-bg that scroll with the list, two `scroll` shadows at
+       rgb(0 0 0 / 0.14) fixed to the box, the mask covering the shadow at whichever end
+       had no more list to show. It is gone because of what it looks like on a rail with no
+       plate under it. On .code pre the shadow falls on a --sand plate that is already a
+       surface, and it reads as depth; here it falls on the page ground, and 12px of black
+       at 14% across a 240px column reads as a grey band lying over the list — with the
+       list scrolled even a little, that band sits behind the word CONTENTS. Reported from
+       a screenshot, and the one complaint is enough: an overflow cue that has to be
+       explained is not one.
+
+       Nothing replaced it. The masks went with the shadows rather than staying on as a
+       softener, because they never softened anything: a background paints behind text, so
+       a --page-bg layer over --page-bg ground is invisible by construction — its whole job
+       was covering the shadow at the two ends, and with no shadow left there is nothing to
+       cover. The top edge does clip whatever entry it lands on, exactly as it did before
+       (fading the ink itself would take mask-image, which is not what was here), and the
+       scroll-spy's `rail.scrollTop = 0` at the document top means the resting state of the
+       rail is its own top, with CONTENTS and entry one whole.
+
+       What the hidden scrollbar costs is dragging, and only dragging: the list is still
+       reachable by wheel, by trackpad, by keyboard (every entry is a link, and tabbing
+       through scrolls the port) and by the scroll-spy's own scrollIntoView. */
+    scrollbar-width: none;
+
+    /* The +1px is exactly what the retired border-inline-end used to occupy. box-sizing is
+       border-box, so handing it back as padding leaves the text box, and the distance from
+       text to rule, where they were. */
+    padding-inline-end: calc(var(--sp-30) + 1px);
+    font-family: var(--font-body);
+
+    /* base.css styles the site navigation by element — `nav { display: flex; gap:
+       --sp-50; flex-wrap: wrap }` — so any <nav> on the site is a wrapping flex row with
+       a 24px gap unless it says otherwise. Measured before this line existed: the title
+       and the list became two flex items and the gap stacked on top of the title's own
+       margin. .adj, the adjacent-post nav at the foot of this page, escapes the same rule
+       only because it declares `display: grid`. Stated rather than inherited, because an
+       element selector that reaches a second nav is the kind of thing that is invisible
+       until something moves 24px. */
+    display: block;
+  }
+
+  /* `scrollbar-width: none` covers Chrome 121+, Firefox and Safari 18.2+; this covers the
+     WebKit and Blink builds below those. base.css already reaches for the same pseudo on
+     .code pre, so it is the file's own idiom rather than a second mechanism. */
+  .toc-rail::-webkit-scrollbar { inline-size: 0; block-size: 0; }
+
+  /* The kicker idiom, at --fs-small rather than --fs-kicker: --fs-kicker is declared on
+     .post and the rail is deliberately not inside .post. No 4px rule above it - that mark
+     opens a section of the page, and this is furniture beside one. */
+  .toc-rail__title {
+    margin: 0 0 var(--sp-30);
+    font-size: var(--fs-small);
+    font-weight: 700;
+    line-height: var(--lh-medium);
+    letter-spacing: 0.105rem;
+    text-transform: uppercase;
+    color: var(--ppc-text);
+  }
+
+  .toc-rail__list { margin: 0; padding: 0; list-style: none; }
+
+  /* The 4px mark, reused as a position marker instead of a section opener. Laid down
+     transparent on every row so that marking one costs no layout shift - the same idiom
+     base.css uses for the current nav item. */
+  .toc-rail__item {
+    border-inline-start: 4px solid transparent;
+    padding-inline-start: var(--sp-30);
+  }
+  .toc-rail__item + .toc-rail__item { margin-block-start: 0.4rem; }
+  .toc-rail__item[data-level="1"] .toc-rail__link { padding-inline-start: var(--sp-30); }
+  .toc-rail__item[data-level="2"] .toc-rail__link { padding-inline-start: calc(2 * var(--sp-30)); }
+  .toc-rail__item:has(> [aria-current]) { border-inline-start-color: var(--ppc-ui); }
+
+  /* Two lines, then an ellipsis. Clamping keeps the list scannable; the link text is
+     untouched, so the accessible name is always the full heading however few lines are
+     painted, and hover or keyboard focus lifts the clamp for the reader too.
+
+     240px is the rail's floor, and this block is the reason for the number. Measured in
+     the built page over the 123 entries the nine posts then had, at 1440x900 (13.88px type), rail
+     inline-size against entries whose text does not fit two lines, against entries that fit
+     on one, and against the number of DOCUMENTS hiding any heading text at all:
+
+         rail       168  192  200  216  240  256  280  312  392
+         clamped     25   10    8    5    3    2    2    1    0
+         one-line    46   61   64   75   83   92  100  110  118
+         documents    6    6    4    2    1    1    1    1    0
+
+     The deciding row is the last one, not the first. At 168px — what a centred gutter left
+     over at the switch, and the width every figure in this block used to be quoted at —
+     six of the nine documents hide heading text. 200 clears bind9 and 안전하고, 216 clears
+     sysmon and tso, and 240 clears how-to-win: eight of the nine then print every heading
+     in full. Everything past 240 buys exactly one more document, 인지부조화이론, whose two
+     longest headings are 57 and 73 characters against a corpus median of 19 and which does
+     not print them whole at any width measured below 392px — a rail wider than the date
+     rail, its gutter and a third of the measure together. Those two are a writing problem,
+     not a width.
+
+     So 240 is a floor and not a cap, and the geometry above is written to guarantee it by
+     moving the page box rather than by hoping for a wide viewport. Raising the old cap
+     alone would have been exactly backwards: a 240px maximum over a 168px minimum changes
+     nothing in this table for anyone below 1584px, and below 1584 is precisely where the
+     rail was starved. Where the gutter is wide enough to pay for more out of empty space
+     it grows to the 320px cap, and that is worth having but is not what the floor is for —
+     rendered at 1920, where it reaches the cap: 110 of the 123 entries on one line and one
+     entry clamped.
+
+     What else the floor buys, at 1440x900 where the box has 754px of room: the lists that
+     scroll inside themselves go 3 at 168 to 2 at 192 to 1 from 216 up. The one left is
+     how-to-win, 1,014px of list at 240 and still scrolling at every width measured up to
+     392, so the scroll-spy's scrollIntoView is still doing work — do not delete it on the
+     strength of the widening.
+
+     keep-all is kept, and it now holds by a wider margin rather than a narrower one. The
+     narrowest text box the rail offers is still the level-2 entry at the deepest indent,
+     192.6px at the 1440px switch against 120.1px before, and the widest run that keep-all
+     refuses to break is unchanged — it is a property of the type, not of the box: 106.9px
+     for sysmon_view.py, a Latin filename rather than one of the Korean 어절 the rule exists
+     for, and 83.4px for the widest Hangul run, 84.1px at 1920 where the type reaches its
+     14px cap. That is 85px of slack where there were 13. Note what the segments are:
+     keep-all still takes the break opportunity in front of an opening parenthesis, so a
+     nine-syllable run that brackets a gloss halfway through is two segments and not one,
+     and the whitespace-delimited word is the wrong unit to measure. These figures come
+     from wrapping each heading in a 1px box with break-word off and reading the ink extent
+     of every line it produced. Measured over all 123 entries at every column of the table
+     above: 0 entries overflow their box and 0 break inside a Korean word at any of them,
+     so break-word never fires. It stays as the escape valve for anything past that.
+
+     The clamp stays at two lines. At 240 exactly three entries exceed it and all three are
+     in 인지부조화이론; a third line would spend the uniform one-to-two-line rhythm — the thing
+     that makes the list scannable — on one document. */
+  .toc-rail__link {
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 2;
+    overflow: hidden;
+    font-size: var(--fs-small);
+    font-weight: 400;
+    line-height: var(--lh-base);
+    text-decoration: none;
+    color: var(--page-meta);
+    word-break: keep-all;
+    overflow-wrap: break-word;
+    /* The other half of the `nav a` inheritance, and both halves are visible in a
+       measurement rather than theoretical. base.css sets the site nav in synthesised
+       small-caps — right for five one-word section names across the top of the page,
+       wrong for 123 sentence-shaped headings — and lays a 4px transparent
+       border-block-end plus 2px of padding under every nav link so that marking the
+       current one costs no layout shift. Measured on this rail before the reset: 6px of
+       dead space under each of 28 entries, 168px of it on how-to-win. The rail draws its
+       own current-item mark on the inline edge, so it wants neither. */
+    font-variant-caps: normal;
+    border-block-end: 0;
+    padding-block-end: 0;
+  }
+  .toc-rail__link:hover { color: var(--page-fg); text-decoration: underline; text-underline-offset: 0.18em; }
+  .toc-rail__link[aria-current] { color: var(--ppc-text); font-weight: 700; }
+  /* Reading the clipped half of a heading should not need a pointer. */
+  .toc-rail__link:hover,
+  .toc-rail__link:focus-visible { -webkit-line-clamp: none; }
+
+  /* ── which presentation ──────────────────────────────────────────────────
+     1440px, unchanged, but the arithmetic under it is not. It used to be the width at
+     which a centred page box left room for the narrowest rail the design allowed:
+
+         vw >= --w-page + 2 x (168 + --toc-gap + --toc-edge)  =  1440px
+
+     The rail no longer takes what a centred gutter has left over, so that is no longer
+     the question. What the switch has to guarantee now is that pushing the page box out
+     to --page-left still leaves the measure a gutter on its other side:
+
+         vw >= --toc-edge + 240 + --toc-gap + --w-page + right gutter
+            =     16      + 240 +    24     +   1024   +      96      =  1400px
+
+     1440 clears that with 40px to spare and leaves 136px on the right, so the literal
+     stays exactly where it was and every width at which either presentation was checked
+     is still the width it was checked at. 96px is the article's own date rail plus its
+     gutter: the measure keeps the same clearance to its right as it has to its left
+     inside the page box. The width below which the document would actually scroll
+     sideways is lower still - --toc-edge + 240 + --toc-gap + --w-page + --root-pad, about
+     1327px.
+
+     Measured rail width: 240.0px at 1440, held to 1584, 256.0 at 1616, 320.0 from 1744 up
+     where it holds at the cap. Below the switch the rail parks off-screen and the inline
+     list is the contents. This is the one place either presentation is turned on or off,
+     so they cannot both be showing and cannot both be hidden — checked in a browser at 21
+     widths from 320 to 2560, both sides of the switch included, with and without a classic
+     scrollbar, and on a post with no contents at all, where neither appears.
+
+     If --w-content moves again, this literal moves with it - a media query cannot read a
+     custom property, which is why the rail's own geometry above is written so that a
+     stale literal here can only park the rail early, never push it onto the text or the
+     page box off the screen. */
+  @media (min-width: 1440px) {
+    .toc-rail {
+      --toc-x: calc(100% - var(--page-left) + var(--toc-gap));
+      visibility: visible;
+    }
+
+    /* The page box moves out to --page-left. The header does NOT go with it.
+
+       This rule is a no-op from 1584 up: --page-left IS the centred position there, so
+       `--page-left - --root-pad` is the margin `margin-inline: auto` was already
+       computing. It bites only in the 1440-1584 band.
+
+       The header used to travel with it. A second rule here,
+       `body:has(.post-shell) > header`, restated base.css's header padding against
+       --page-left so that the wordmark stayed on the page box's left edge through the
+       shift. It is deleted, and the reason is what the shift looks like from the reading
+       chair rather than from the arithmetic. base.css's padding is symmetric by
+       construction; --page-left is not, because it is a max() that grows on the LEFT
+       only — so inside the band the override made the bar 280px/181px and slid the whole
+       thing ~50px right, on article pages and nowhere else. Every other route kept a
+       still bar, so the one place a reader spends real time was the one place the
+       furniture moved under them. A bar that is in the same place on every page is worth
+       more than a left edge shared with body text across a 144px band, and that is the
+       trade this file now takes. base.css states the fixed-bar contract; nothing on an
+       article page may restate the header's padding again.
+
+       What it costs, stated rather than discovered: inside the band an article's page box
+       starts up to 79.5px right of the wordmark above it — measured 79.5 at a 1440px
+       window, 49.5 at 1500, 7.5 at 1584, 0.00 at 1700 and up and 0.00 at 1262 and below.
+       Bounded by the same arithmetic as everything else here: --page-left is a max()
+       against the centred position, so it is zero the moment a centred gutter can seat
+       the rail, and never negative.
+
+       Keyed on the ARTICLE, not on the rail, and that is a reversal worth stating. It
+       used to read `main:has(.toc-rail)`, so the gutter was reserved only where a rail
+       was actually painted, and the price was a step: Older/Newer between a post with
+       contents and one without moved the left edge by up to 72px inside the band (79.5
+       with a classic scrollbar). Under the premise this page is now built on — every post
+       gets headings — the two forms converge, because every article ends up with a rail;
+       keying on .post-shell is arriving at that steady state early instead of shipping a
+       step that exists only for as long as 16 documents are missing their headings.
+
+       What it costs meanwhile, stated rather than discovered: those 16 get an empty 280px
+       left gutter between 1440 and 1584, and their text sits up to 72px right of the
+       Writing index entry it was clicked from. That divergence is not new — it is exactly
+       what the nine rail posts already had, and the same arithmetic bounds it (--page-left
+       is a max() against the centred position, so it is 0 from 1584 up and 0 below 1440).
+       What IS new is that it is uniform across all 30 articles instead of stepping between
+       them. The rule below is still keyed on the rail: a hairline with nothing beside it
+       is a stray line, so the gutter is reserved on every article and marked on none that
+       has no list to mark. */
+    main:has(.post-shell) {
+      /* Containing block for the rule below, and nothing else reads it. `position:
+         relative` with `z-index: auto` establishes no stacking context, so the rail's own
+         `z-index: 1` still resolves against the root; and `position: fixed` ignores a
+         relative ancestor entirely, so the rail does not become a child coordinate
+         system. Verified at 1440 / 1700 / 1920: the rail's computed inline-end edge is
+         unchanged to the pixel. */
+      position: relative;
+      margin-inline-start: calc(var(--page-left) - var(--root-pad));
+      margin-inline-end: auto;
+    }
+
+    /* ── the rule ──────────────────────────────────────────────────────────
+       The hairline down the rail's inline-end edge. It is now a box in the DOCUMENT
+       rather than a box in the viewport, and that is the fix for the cut at its top.
+
+       What was wrong. It was `position: fixed` with `inset-block: var(--toc-y) 0`, so it
+       began --sp-120 down the VIEWPORT — 103px at 1440, 112 at 1700, 120 from 1920 up —
+       while the header is 59.4px tall. Measured on how-to-win at 1700x860 before this
+       change: header rule at y = 59, line top at y = 112, 52px of empty gutter between
+       them. Scrolling did not close it either: the line is viewport-fixed, so the band
+       came back at every scroll position, by then with nothing above it at all because
+       the header is `position: static` and has scrolled away.
+
+       Why no fixed box can be right here. It has one top, and the top this line needs is
+       in two places: at the header's bottom edge while the header is on screen, and at
+       the viewport's own top once it is not. An absolutely positioned box whose
+       containing block begins at the header is both of those for free — scrolling carries
+       its top off the top of the screen and the line simply runs to the edge.
+
+       So: `inset-block: 0` inside <main>. Nothing here names a height, and that is the
+       point. <main> is in normal flow directly under a full-bleed, `position: static`
+       header and opens with padding rather than margin, so no margin collapses through
+       it and main's padding box begins EXACTLY at the header's bottom border. The number
+       that edge takes is 59.43px at 1440 and 59.60 at 1700 — 16 + a 26.43/26.60 shared
+       line box + 16 + a 1px border — but it is never written down here, so --sp-40,
+       --fs-body or --lh-medium can all move and the line follows the header with no edit
+       in this file. `inset-block-end: 0` is the same trade at the other end: main's
+       padding box ends with its --sp-120 of bottom padding, which is the document's last
+       pixel, so at the foot of the document the line ends on the viewport floor exactly.
+
+       What it costs, stated because the retired note argued the opposite: the line is now
+       3,270px long on journalctl instead of one screen tall, so it travels while the
+       fixed rail stands still. A 1px vertical line that spans the whole viewport at every
+       scroll position is indistinguishable from one pinned to it — there is no feature on
+       it that can be seen to move — and in exchange the top is right at scrollY 0 and at
+       every scrollY after it. Measured on journalctl at scrollY 0 / 400 / 1200 / 3503 and
+       on how-to-win at 0 / 800: painted from the header's edge, or from y = 0 once past
+       it, to the last painted row, at all six.
+
+       It can no longer be a pseudo-element of .toc-rail, and that is what the move buys.
+       .toc-rail is `position: fixed`, which makes it the containing block for any
+       absolutely positioned descendant, so an abs child of it is measured from the list
+       box and clipped back to the list's own height — the original cut, the one the fixed
+       line was introduced to escape. Hung on <main> the two are unrelated boxes, and the
+       rail is free to stay fixed.
+
+       Where it sits across is derived, not restated. The rail's inline-end edge is
+       --toc-gap inside the page box's left edge by construction, and main's padding box
+       begins --root-pad OUTSIDE that same edge (the margin above, plus base.css's
+       `padding-inline: --root-pad`), so the line stands at --root-pad - --toc-gap from
+       main's inline start, less its own 1px. Measured: -1.53px at 1440 and -1.00px from
+       1488 up, where --root-pad reaches its 24px cap and cancels --toc-gap exactly — so
+       across almost the whole band the line is simply the pixel before <main>. Both terms
+       are :root tokens, --page-left is still stated once, and the line tracks the page box
+       because <main> does. The -1px is what keeps this the SAME pixel
+       the retired version painted — the rail's padding-inline-end still hands back the
+       1px a border-inline-end would have occupied, so the line has to land on the rail's
+       own inline-end edge, not in the gap past it. Measured at 1700x860: painted column
+       x = 306 before the move and x = 306 after, against a rail edge at 306.5.
+
+       Declared inside the switch and nowhere else. It has no parked state now — a line
+       whose x is measured from <main> would stand in the left margin at every width below
+       1440 — so it is simply not generated there, and this media query is again the one
+       place where any part of either presentation is turned on or off. The one thing that
+       costs is the slide: crossing 1440 the rail still slides in from the left and the
+       line now appears at once beside it. That is a resize-only artefact, against an
+       off-screen box on every one of these pages at every width. */
+    main:has(.toc-rail)::before {
+      content: "";
+      position: absolute;
+      inset-block: 0;
+      inset-inline-start: calc(var(--root-pad) - var(--toc-gap) - 1px);
+      inline-size: 1px;
+      /* The header's rule, not the page hairline. Both lines frame the same reading
+         column, so they are one mark in two places; --page-hairline also measured 1.93:1
+         against --page-bg, which is under the 4.5:1 a 1px line needs to read as drawn. */
+      background: var(--crimson);
+      /* The empty gutter beside the list belongs to the page, not to this nav. */
+      pointer-events: none;
+    }
+
+    .toc { display: none; }
+  }
+
+  /* The one thing the move from a border to a painted box cost, handed back.
+
+     Forced colours replace `background-color` with Canvas and `border-color` with a system
+     colour, so the retired border-inline-end survived Windows High Contrast Mode and a
+     background does not. Measured in this page with forced-colors emulated, two 1px strips
+     of the same var(--page-hairline) laid side by side over the gutter: as a border it
+     paints rgb(0, 0, 0), as a background rgb(255, 255, 255) — the Canvas it sits on, which
+     is to say nothing at all.
+
+     Drawn as a border again here and only here, with no colour of its own so the UA forces
+     one. box-sizing is border-box, so the 1px border is the same 1px box in the same place;
+     nothing else about the geometry moves. Outside the switch above rather than inside it,
+     because a nested @media is not worth relying on through the build; the selector only
+     matches an element the switch has already generated. */
+  @media (forced-colors: active) {
+    main:has(.toc-rail)::before {
+      background: none;
+      border-inline-end: 1px solid;
+    }
+
+    /* The rail hides its scrollbar and paints no overflow cue of any kind, so the only
+       thing saying the list continues is the entry the top edge clips. That trade is
+       legible at normal contrast; a High Contrast reader should not have to take it. So
+       the scrollbar comes back here, in the one mode where a second vertical bar is the
+       lesser problem — it is drawn in system colours against a system Canvas, so it does
+       not read as a tan twin of the rule the way --sand-dark did. */
+    .toc-rail { scrollbar-width: thin; }
+    .toc-rail::-webkit-scrollbar { inline-size: auto; block-size: auto; }
+
+  }
+
+  @media (prefers-reduced-motion: reduce) {
+    .toc-rail { transition: none; }
+  }
+
+  /* Fixed furniture has no place on paper. A paper page is normally narrower than the
+     switch, so the inline list is already the one showing; this block is what keeps the
+     invariant true when it is not, since `print` and `min-width: 1440px` can both match
+     and only this one may win. */
+  @media print {
+    .toc-rail { display: none; }
+    /* `display: none` on the rail no longer takes the rule with it: the rule is a
+       pseudo-element of <main> now, and <main> prints. `content: none` generates no box
+       at all, which is what the pseudo-element on the rail used to get for free. */
+    main:has(.toc-rail)::before { content: none; }
+    .toc { display: block; }
+
+    /* `display: block` shows the <details>, not the list inside it. A closed <details>
+       hides its own content, and the print pipeline does not open it: measured with
+       Page.printToPDF on journalctl, 405,956 bytes with the summary as the reader left it
+       against 424,429 for the same page with the list opened by hand. So what paper was
+       getting from the line above was the word CONTENTS between two rules and nothing
+       under it — the one presentation left on paper, printing none of itself.
+
+       ::details-content is the UA's own box for that hidden content, so this prints the
+       list without touching the open state on screen: no `open` attribute to set and
+       restore, no beforeprint handler to depend on, and nothing at all outside @media
+       print. An engine without the pseudo-element ignores the line and prints exactly
+       what it prints today. The marker goes with it — a disclosure triangle in front of a
+       list that is already there is an instruction to click, on the one medium where
+       nothing can be. */
+    .toc::details-content { content-visibility: visible; }
+    .toc__summary { list-style: none; }
+  }
+
+  /* ══ Body ════════════════════════════════════════════════════════════════ */
+  .prose {
+    font-size: var(--fs-body);
+    line-height: var(--lh-large);
+    /* Korean breaks between 어절, never inside one. keep-all behaves as `normal` for
+       Latin, so it is safe for the whole column; break-word is the escape valve for a
+       bare URL longer than the measure. */
+    word-break: keep-all;
+    overflow-wrap: break-word;
+  }
+  .post[lang='ko'] .prose { line-height: var(--lh-ko); }
+
+  /* Gazette block rhythm, as measured:
+       :where(.is-layout-constrained) > *  → spacing-48
+       :where(p + p)                       → spacing-24
+       :where(.wp-block-heading + p)       → spacing-24 */
+  .prose > * { margin-block-start: var(--sp-48); }
+  .prose > :first-child { margin-block-start: 0; }
+  /* Paragraph to paragraph is the reference's .7em (--sp-para, 11.2px at this 16px body)
+     and no longer the Gazette's 24px. The two selectors used to share one declaration and
+     are split here because they are two different joins: a paragraph continuing a
+     paragraph is the tightest join in the document, and a heading handing off to its own
+     first paragraph is a section opening, which keeps --sp-24. Nothing else moves — the
+     48px block rhythm, the 64px above an h2/h3 and the list and figure gaps are all
+     written off their own tokens and none of them reads --sp-para. */
+  .prose > p + p { margin-block-start: var(--sp-para); }
+  .prose > :is(h2, h3, h4, h5, h6) + p { margin-block-start: var(--sp-24); }
+  /* A heading opens a section, so it gets more air than an ordinary block. */
+  .prose > :is(h2, h3) { margin-block-start: var(--sp-64); }
+  .prose > :is(h2, h3, h4, h5, h6) + :is(h3, h4, h5, h6) { margin-block-start: var(--sp-24); }
+  .prose > :is(h2, h3, h4, h5, h6) { scroll-margin-block-start: var(--sp-40); }
+  .prose p { text-wrap: pretty; }
+
+  /* ── staccato ────────────────────────────────────────────────────────────
+     Measured on the real corpus at the 728px/20px this rule was cut against: 305 of 715
+     paragraphs (42%) set as a single line, and 11 documents are over half —
+     dont-assume and journalctl are at 100%, i-have-to has 50 one-liners out of 69.
+     A 928px column at 19px only pushes that up — an independent recount over the same
+     corpus puts the share near 60% — so the widening strengthens this rule rather than
+     weakening it, and proseShape()'s 60-character threshold is unaffected either way:
+     it reads the source, not the rendered line.
+     A one-line paragraph is 32px of ink over a 24px gap; fifty in a row is not prose,
+     it is verse. Those documents take the book setting instead: the indent marks the
+     paragraph and the gap goes to zero. Indent OR gap, never both — two signals for
+     one boundary. proseShape() picks the 11; nothing is hand-tagged. 1em is one Hangul
+     square (0.889em advance), which is the conventional Korean indent. */
+  .post[data-rhythm='staccato'] .prose > p + p {
+    margin-block-start: 0;
+    text-indent: 1em;
+  }
+  /* A paragraph opening after any other block already has 48px of air above it;
+     indenting it as well would mark one boundary twice. */
+  .post[data-rhythm='staccato'] .prose > :not(p) + p { text-indent: 0; }
+
+  /* ── links ───────────────────────────────────────────────────────────────
+     Weight and underline only, no colour (base.css). Korean strong runs to 177 uses
+     across the corpus and is also 700, so the underline is what carries the link. */
+  .prose a { text-underline-offset: 0.18em; }
+
+  /* ── emphasis ────────────────────────────────────────────────────────────
+     Pretendard ships no italic, so a browser would synthesise an oblique by shearing
+     Hangul — which skews the vertical stems the syllable block is built on. The ten
+     Korean <em> runs left after the figure captions are lifted out are all already
+     inside quotation marks, so the slant carried no information anyway. 500 is a real
+     instance on the variable axis (45–930) and does not collide with strong at 700. */
+  .prose em { font-style: italic; }
+  .post[lang='ko'] .prose em { font-style: normal; font-weight: 500; }
+
+  /* ── inline semantics ────────────────────────────────────────────────────────
+     Six elements Markdown has no syntax for, which an author therefore writes as raw HTML.
+     They pass through intact (rawHtml is off, so they ship as opaque raw nodes), and until
+     now every one of them rendered at its UA default -- which on this ground meant a
+     browser-yellow <mark> and a <kbd> in the engine's own monospace at full size. Zero in
+     the corpus today; all six are one keystroke away in any technical post, and the whole
+     cost of covering them is this block.
+
+     <del> is deliberately absent: strikethrough is the one of these that Markdown DOES have
+     syntax for (~~x~~), the UA line-through is correct, and a colour change would say
+     something the author did not. */
+  .prose kbd {
+    font-family: var(--font-mono);
+    font-size: 0.875em;
+    /* Same plate, border and padding as the inline code span below -- a key and a code span
+       are the same kind of object on the page, and a second treatment for them would be a
+       second vocabulary. What carries the difference is the element itself, which is what a
+       screen reader announces. */
+    background: var(--sand);
+    border: 1px solid var(--sand-dark);
+    padding: 0.1em 0.3em;
+  }
+  /* The palette has no yellow, and --ppc-wash -- the token the ladder nominally reserves
+     for emphasis grounds -- is capped at 1.25:1 against the page by design. Rendered, that
+     is not a highlight: #f7ebeb on #fffbf4 measured 1.06:1, a band the eye does not find.
+     --sand is the one ground on this page that is provably legible at a glance, because
+     every code plate and every inline code span already sits on it, and black over it is
+     17.6:1. The padding is what turns a fill that hugs the glyphs into a marked run; the
+     1px border and the mono face are what keep it from reading as code. */
+  .prose mark { background: var(--sand); color: inherit; padding: 0.1em 0.2em; }
+  /* A dotted underline is the one abbreviation convention that survives without a tooltip,
+     and `help` is the cursor that says a title is there to be read. */
+  .prose abbr[title] {
+    text-decoration: underline dotted;
+    text-underline-offset: 0.18em;
+    cursor: help;
+  }
+  /* line-height: 0 is what keeps a footnote marker or an exponent from opening up the line
+     it sits in; the offsets then place the glyph without the UA's `vertical-align`, which
+     is what does the opening. */
+  .prose :is(sub, sup) {
+    font-size: 0.75em;
+    line-height: 0;
+    position: relative;
+    vertical-align: baseline;
+  }
+  .prose sup { inset-block-end: 0.45em; }
+  .prose sub { inset-block-start: 0.25em; }
+  /* <ins> is underlined by the UA at the same place a link is. The offset matches the link
+     rule so the two at least sit on one line, and the weight stays at the body's. */
+  .prose ins { text-decoration-thickness: from-font; text-underline-offset: 0.18em; }
+  .prose small { font-size: var(--fs-medium); color: var(--page-meta); }
+
+  /* ── quotes ──────────────────────────────────────────────────────────────
+     .wp-block-quote::before is an 8px bar with a 1rem gap, as measured. Its
+     spacing-120 block margins are for pull quotes; the two quotes in this corpus are
+     citations, so they keep the ordinary 48px rhythm and the body size. */
+  .prose blockquote {
+    display: grid;
+    grid-template-columns: calc(8px + 1rem) minmax(0, 1fr);
+    margin-inline: 0;
+  }
+  .prose blockquote::before {
+    content: "";
+    grid-column: 1;
+    grid-row: 1 / span 20;
+    border-inline-start: 8px solid var(--ppc-ui);
+    margin-inline-end: 1rem;
+  }
+  .prose blockquote > * { grid-column: 2; }
+  .prose blockquote > :first-child { margin-block-start: 0; }
+  /* A quoted paragraph is still a paragraph, so it takes --sp-para with the rest. This
+     rule exists only to reach inside the blockquote, which `.prose > p + p` cannot. */
+  .prose blockquote p + p { margin-block-start: var(--sp-para); }
+
+  /* ── lists ───────────────────────────────────────────────────────────────
+     113 bullets and 59 numbers across the corpus, nearly all inside technical
+     documents where a list is a procedure. The marker hangs outside the measure so
+     the text edge stays on the column's left edge. */
+  .prose :is(ul, ol) { padding-inline-start: 1.4em; margin-inline: 0; }
+  .prose li + li { margin-block-start: var(--sp-30); }
+  .prose li > :is(ul, ol) { margin-block-start: var(--sp-30); }
+  .prose li::marker { color: var(--page-meta); }
+  .prose ol { font-variant-numeric: tabular-nums; }
+  /* A list inside a staccato document keeps its gaps: the indent is for paragraphs. */
+  .post[data-rhythm='staccato'] .prose li { text-indent: 0; }
+
+  /* CommonMark's loose list, restored. A blank line between items wraps each item's content
+     in <p>, which is the author's own gesture for "these are paragraphs, not entries"; with
+     `p { margin: 0 }` in base.css a loose list and a tight one were rendering pixel for
+     pixel the same. `:has(> p)` reads the distinction straight off the markup, and it is
+     uniform per list -- CommonMark makes a list loose or tight as a whole, so either every
+     item carries a <p> or none does and a mixed list cannot occur. */
+  .prose li:has(> p) + li { margin-block-start: var(--sp-40); }
+
+  /* ── task lists ──────────────────────────────────────────────────────────────
+     GFM emits <ul class="contains-task-list"><li class="task-list-item">
+     <input type="checkbox" disabled>. Unstyled that is a bullet AND a grey system checkbox
+     on every row: two markers for one list. Zero in the corpus; a checklist is the most
+     likely thing a procedural post (journalctl, bind9 and sysmon are all procedures) grows
+     next, which is what earns it a block.
+
+     The box hangs in the marker gutter, so a task item's text starts on exactly the column
+     a plain item's text does, and it does that with inline margins rather than a grid. The
+     grid version worked and then did not: GFM emits the checkbox as a direct child of the
+     <li> only in a TIGHT list, and moves it inside the item's first <p> the moment the list
+     is loose. Rendered, that left the loose rows with an unstyled grey system checkbox (the
+     child selector missed it), the <p> pinned to the text column with an empty marker
+     column beside it, and the item's second paragraph and nested list at the wrong indent.
+
+     The arithmetic below has no opinion about which shape it is in, because it is all on
+     the input itself: -1.4em pulls the box out by the list's own indent, and the end margin
+     gives back exactly what is left over, so the next inline content resumes on the li's
+     content edge either way. font-size: inherit is load-bearing and not tidiness -- a UA
+     gives an <input> 13.33px, and every em here has to be the list's em, not that one.
+     Only .task-list-item is touched, so a plain <li> sharing the list keeps its bullet. */
+  .prose .task-list-item { list-style: none; }
+  /* Descendant, not child: in a loose list this input is li > p > input. */
+  .prose .task-list-item input[type='checkbox'] {
+    --box: 0.8em;
+    font-size: inherit;
+    margin-inline: -1.4em calc(1.4em - var(--box));
+    /* appearance: none rather than accent-color: the control is `disabled`, and a disabled
+       native checkbox is greyed out by the engine whatever accent it is given -- so the one
+       state that has to read, "this is done", is the one accent-color cannot paint. */
+    appearance: none;
+    inline-size: var(--box);
+    block-size: var(--box);
+    /* The empty box IS this row's marker, so it is drawn in the colour the discs beside it
+       are drawn in (`li::marker { color: var(--page-meta) }` above). --sand-dark, the
+       border the code plate and the inline code span use, measured 1.25:1 against the page
+       and left the unchecked rows looking unmarked next to the filled ones -- which reads
+       as "these items have no bullet", not as "these items are not done". */
+    border: 1px solid var(--page-meta);
+    background: var(--page-bg);
+    /* The box is a picture of state, not a control -- it is disabled, and a pointer that
+       says otherwise invites a click that does nothing. */
+    cursor: default;
+    /* An inline box with no text in it sits its bottom edge on the baseline, which is a
+       touch low against the row it marks. */
+    transform: translateY(0.05em);
+  }
+  /* Filled, not ticked. A drawn checkmark needs either a glyph outside the Latin subset or
+     a pseudo-element on a form control, and the second is not reliable across engines; a
+     filled crimson square against an empty sand one is unambiguous, costs one declaration
+     and cannot fail to paint. The state is in the DOM as well (`checked`), which is what
+     assistive tech reads either way. */
+  .prose .task-list-item input[type='checkbox']:checked {
+    background: var(--ppc-ui);
+    border-color: var(--ppc-ui);
+  }
+  /* Its own block rather than the rail's, 370 lines up: these two selectors have the same
+     specificity as the ones above, so an override has to come after them in source order.
+     Measured before it was moved -- appearance stayed `none` in forced colours and the
+     checked square went on painting a background the mode had already discarded.
+     A background fill is exactly what forced colours drop, so "done" would have become
+     indistinguishable from "not done". Handing the box back to the engine is better than
+     any fallback of ours: a High Contrast reader gets a real system checkbox, in system
+     colours, carrying the state that is in the DOM either way. The grid still holds it in
+     the marker gutter, so nothing about the row moves. */
+  @media (forced-colors: active) {
+    .prose .task-list-item input[type='checkbox'] {
+      appearance: auto;
+      transform: none;
+      /* The engine picks the box's size back up, so the gutter arithmetic is handed back
+         to it as well rather than being computed against a width we no longer set. */
+      margin-inline: 0 0.25em;
+    }
+  }
+
+  /* ── figures ─────────────────────────────────────────────────────────────
+     Built by src/lib/satteri-figure.mjs: 26 images, 24 figures, 11 captions.
+
+     Width is the body measure, not an extension. 18 of the 26 images are narrower than
+     928px (356–896px) and five are terminal strips as short as 12–38px tall; stretching
+     any of them to the column would upscale a screenshot into mush. So no `width` is
+     declared at all — satteri-prose.mjs stamps the intrinsic width/height, the browser
+     takes that as the used width, and only the 8 oversize ones (1115–1965px) are pulled
+     back by max-width. Widening the measure moved five images out of that group and did
+     not upscale anything: an image without a `width` never grows past its own pixels.
+
+     No plate, and no frame of any kind. .fig__plate used to paint --sand behind the
+     images, ring them in a 1px --sand-dark border and inset them by 16px of padding —
+     a mat and a frame, argued for as something for a 779x12 terminal strip to sit on.
+     It is removed at the author's instruction: a photograph on this site is the thing
+     itself, not a picture of a picture hung on the page, and the border was the only
+     rule in the prose that enclosed content rather than separating it. The class stays
+     because it is satteri-figure's DOM contract and because the flex row below is what
+     puts a three-image set on one line; it is now layout and nothing else. A narrow
+     strip is left to sit on the page ground, which is what every other block here does. */
+  /* Every image, not only the ones satteri-figure lifted into a <figure>. It converts a
+     paragraph holding nothing BUT images, so an image with text beside it, a linked image
+     [![](a)](b), an image in a list or a table cell, and a raw-HTML <img> all stay bare --
+     while satteri-prose stamps the intrinsic width on them just the same. Measured on a
+     fixture carrying one bare 1965px screenshot: documentElement.scrollWidth 2379 against
+     clientWidth 1659, i.e. the whole page scrolling sideways by 720px. None of the corpus's
+     26 images escape today, which is what makes this the cheapest kind of fix: one rule,
+     before the first post that does. `height: auto` is the other half -- without it the
+     stamped height would hold while the width shrank, and the picture would squash. */
+  .prose img { max-width: 100%; height: auto; }
+
+  .prose .fig { margin-inline: 0; }
+  .prose .fig__plate {
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--sp-40);
+    align-items: flex-start;
+  }
+  .prose .fig img { display: block; }
+  /* 인지부조화이론.md puts three 1600x2134 scans on three adjacent lines, which
+     CommonMark folds into a single paragraph. They are a set, so they share a row. */
+  .prose .fig[data-count='3'] img { flex: 1 1 0; min-width: 0; }
+  .prose .fig figcaption {
+    margin-block-start: var(--sp-30);
+    font-size: var(--fs-medium);
+    line-height: var(--lh-base);
+    color: var(--page-meta);
+    text-wrap: pretty;
+  }
+
+  /* ── tables ──────────────────────────────────────────────────────────────
+     Wrapped in .scroller by satteri-prose.mjs, so a wide table scrolls inside itself
+     and the page never scrolls sideways. tabindex makes the overflow keyboard-
+     reachable, which is why the wrapper is a focusable region. */
+  .prose .scroller { overflow-x: auto; }
+  .prose .scroller:focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: 2px; }
+  .prose table {
+    border-collapse: collapse;
+    width: 100%;
+    font-size: var(--fs-medium);
+    line-height: var(--lh-base);
+  }
+  .prose :is(th, td) {
+    text-align: start;
+    vertical-align: top;
+    padding: var(--sp-30) var(--sp-40) var(--sp-30) 0;
+    border-block-end: 1px solid var(--page-hairline);
+  }
+  .prose th {
+    font-family: var(--font-body);
+    font-size: var(--fs-small);
+    font-weight: 700;
+    letter-spacing: 0.105rem;
+    text-transform: uppercase;
+    color: var(--ppc-text);
+    border-block-end: 4px solid var(--ppc-ui);
+    white-space: nowrap;
+  }
+
+  /* ── code ────────────────────────────────────────────────────────────────
+     The <figure class="code"> wrapper and the Shiki palette are owned by the
+     `codeBlock` transformer in astro.config.mjs (theme: css-variables). Only the block
+     rhythm and the inline span belong to this page. */
+  .prose figure.code { margin-inline: 0; }
+  /* The one fence that never reaches the plate. Astro ORs its own
+     defaultExcludeLanguages = ["math"] into the highlight check, so a ```math fence skips
+     the highlighter and arrives as a bare <pre> -- no plate, no scroll region, no copy
+     control, and no overflow-x, which is the part that matters: a long line in a bare <pre>
+     scrolls the PAGE. No value of excludeLangs un-excludes it and the corpus has 0 of them,
+     so this is a guard, not a fix. Scoped away from .code pre, whose own overflow-x is an
+     inline style this could not outrank in any case. */
+  .prose pre:not(.code *) { max-width: 100%; overflow-x: auto; }
+  .prose :not(pre) > code {
+    font-family: var(--font-mono);
+    font-size: 0.875em;
+    background: var(--sand);
+    border: 1px solid var(--sand-dark);
+    padding: 0.1em 0.3em;
+    word-break: break-all;
+  }
+
+  /* ── thematic break ──────────────────────────────────────────────────────
+     Three in the corpus. It reuses the header's mark rather than introducing a second
+     kind of line onto a page whose only graphic element is that mark. */
+  .prose hr {
+    width: 3rem;
+    height: 0;
+    border: 0;
+    border-block-start: 4px solid var(--ppc-ui);
+    margin-block: var(--sp-80);
+  }
+
+  /* ── footnotes ──────────────────────────────────────────────────────────────
+     DOM contract, emitted by Sätteri's GFM footnotes:
+       <sup><a data-footnote-ref aria-describedby="footnote-label">1</a></sup>   in the body
+       <section data-footnotes class="footnotes">
+         <h2 class="sr-only" id="footnote-label">Footnotes</h2>
+         <ol><li id="user-content-fn-1"><p>… <a class="data-footnote-backref">↩</a></p></li>
+
+     Zero in the corpus, and the most plausible missing construct in it: 8 of the 30 posts
+     are translations or commentaries that cite a source, and every one of them does it with
+     a parenthesis today. Unstyled, the apparatus renders at body size with nothing between
+     it and the last paragraph, which reads as one more section rather than as notes.
+
+     The separator is the 4px x 3rem mark, not a full-width hairline -- the same mark <hr>
+     and the code plate's language label already use, and the only graphic element this page
+     has. A hairline would be a second kind of line for the same job.
+
+     The `↩` is U+21A9 and the Latin subset stops at U+2248, so that one glyph comes from a
+     system fallback face. Left as it is on purpose: it is a single mark at the end of a
+     note, the alternatives are an ASCII digraph in the middle of typeset prose or a rebuild
+     of both Latin faces to add one codepoint, and the Sätteri option that would change it
+     (`gfm: { footnotes: { backContent } }`) is a worse trade than the fallback. Whoever
+     owns scripts/subset-fonts.py can close it by adding U+21A9 to LATIN. */
+  .prose .footnotes {
+    margin-block-start: var(--sp-80);
+    font-size: var(--fs-medium);
+    line-height: var(--lh-base);
+    color: var(--page-meta);
+  }
+  .prose .footnotes::before {
+    content: '';
+    display: block;
+    inline-size: 3rem;
+    block-size: 4px;
+    background: var(--ppc-ui);
+    margin-block-end: var(--sp-40);
+  }
+  /* The list is the notes themselves, so it sits on the text column rather than hanging its
+     numbers off it -- the numbers ARE the content here, being what the body refers to. */
+  .prose .footnotes ol { margin-block: 0; }
+  .prose .footnotes li { scroll-margin-block-start: var(--sp-40); }
+  /* Following a marker lands the reader in the middle of a list of near-identical
+     paragraphs; this is the one thing that says which one was asked for. --ppc-wash is the
+     same emphasis ground <mark> takes, and the inline padding puts a margin around the text
+     without moving it. */
+  .prose .footnotes li:target {
+    background: var(--ppc-wash);
+    padding-inline: var(--sp-30);
+    margin-inline: calc(-1 * var(--sp-30));
+  }
+  /* Same story as the checkbox: the mark is a background, and this mode has none. */
+  @media (forced-colors: active) {
+    .prose .footnotes li:target { background: none; border-inline-start: 2px solid; }
+  }
+  /* Both ends of the round trip. The global <a> rule is 700 and underlined, which on a bare
+     numeral and a bare arrow is a heavy mark for a small target, so both take the accent
+     colour instead and keep the body's weight. They stay real links with a real focus ring
+     -- the site's :focus-visible outline is untouched here. */
+  .prose [data-footnote-ref],
+  .prose .data-footnote-backref {
+    text-decoration: none;
+    font-weight: 400;
+    color: var(--ppc-text);
+  }
+  .prose [data-footnote-ref]:hover,
+  .prose .data-footnote-backref:hover { text-decoration: underline; }
+  /* The backref is the last thing on a note's last line and reads as punctuation, not as a
+     word, so it gets the space a word would have had and nothing else. */
+  .prose .data-footnote-backref { margin-inline-start: 0.25em; }
+
+  /* ══ Footer ══════════════════════════════════════════════════════════════
+     The Gazette closes an article with a share block and a related-articles carousel,
+     neither of which a 30-post archive can fill honestly. Two neighbours in
+     byHugoOrder and a way back into the year you came from is the whole of it. Here
+     the rail earns its keep: it holds the direction label, so both titles stay on the
+     page's single left edge. */
+  .post__foot {
+    grid-column: 1 / -1;
+    display: grid;
+    grid-template-columns: subgrid;
+    margin-block-start: var(--sp-120);
+    border-block-start: 1px solid var(--page-hairline);
+    padding-block-start: var(--sp-48);
+  }
+  .adj { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; }
+  .adj__item {
+    grid-column: 1 / -1;
+    display: grid;
+    grid-template-columns: subgrid;
+    align-items: baseline;
+    padding-block: var(--sp-40);
+    text-decoration: none;
+    font-weight: 400;
+    border-block-end: 1px solid var(--page-hairline);
+  }
+  .adj__item:last-child { border-block-end: 0; }
+  .adj__label {
+    grid-column: 1;
+    justify-self: end;
+    font-family: var(--font-body);
+    font-size: var(--fs-small);
+    font-weight: 700;
+    letter-spacing: 0.105rem;
+    text-transform: uppercase;
+    color: var(--ppc-text);
+  }
+  .adj__title {
+    grid-column: 2;
+    font-family: var(--font-display);
+    font-size: var(--fs-h5);
+    line-height: var(--lh-medium);
+    color: var(--page-fg);
+    word-break: keep-all;
+    text-wrap: balance;
+  }
+  .adj__item:hover .adj__title { text-decoration: underline; text-underline-offset: 0.14em; }
+  .post__back {
+    grid-column: 2;
+    margin-block-start: var(--sp-48);
+    font-size: var(--fs-small);
+    letter-spacing: 0.02em;
+  }
+
+  /* ══ Rail collapse ═══════════════════════════════════════════════════════
+     The same 36rem breakpoint the index uses, so both pages fold at one width. */
+  @container post (max-width: 36rem) {
+    .post,
+    .post__foot,
+    .adj,
+    .adj__item { grid-template-columns: minmax(0, 1fr); }
+    .post > *,
+    .post__back { grid-column: 1; }
+    .post__head { display: flex; flex-direction: column; align-items: flex-start; }
+    .post__kicker { order: -2; }
+    .adj__label { justify-self: start; margin-block-end: 0.25rem; }
+  }
+</style>
diff --git a/src/pages/writing/index.astro b/src/pages/writing/index.astro
@@ -2,6 +2,7 @@
 import Base from '../../layouts/Base.astro';
 import { getCollection } from 'astro:content';
 import { byHugoOrder, postUrl } from '../../lib/order';
+import { hasHangul, langOf } from '../../lib/hangul';
 
 const posts = (await getCollection('writing'))
   .filter((p) => !p.data.draft)
@@ -13,19 +14,26 @@ const years = [...new Set(posts.map((p) => p.data.date.slice(0, 4)))].sort().rev
 /** Rail date. The year is stated by the section kicker, so only MM.DD is repeated. */
 const md = (iso: string) => `${iso.slice(5, 7)}.${iso.slice(8, 10)}`;
 
-/** lang="ko" only on titles that contain Hangul — used for speech and font fallback. */
-const HANGUL = /[ᄀ-ᇿ㄰-㆏가-힣]/;
-
 const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
+
+/* This page is an English document - heading, year kickers, "30 posts" - that sets 21
+   Korean titles, six of them above the fold. Deciding the font preload from the document
+   language would skip the Korean face on the site's densest page and leave those titles
+   in fallback until a third-wave request lands. Ask the strings that will be painted. */
+const ko = posts.some((p) => hasHangul(p.data.title, p.data.deck));
 ---
-<Base title="Writing :: sqzer" description="Essays and technical notes, 2024–2026.">
+<Base
+  title="Writing :: sqzer"
+  description="Essays and technical notes, 2024–2026."
+  ko={ko}
+>
   <div class="index">
-    <header class="index__head">
-      <h1 class="index__title">Writing</h1>
-      {posts.length > 0 && (
-        <p class="index__count">{posts.length} posts · {span}</p>
-      )}
-    </header>
+      {/* The reference carries no page title on its section pages: the bar's left slot
+          already prints the page name, so an <h1> repeating it is the same word twice.
+          Kept in the document as .sr-only rather than deleted — the page still needs one
+          top-level heading for assistive tech and for the document outline, and the bar's
+          slot is a <span> precisely because it is not one. */}
+      <h1 class="index__title sr-only">Writing</h1>
 
     {posts.length === 0 && <p class="index__empty">Nothing here yet.</p>}
 
@@ -36,11 +44,11 @@ const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
           {posts.filter((p) => p.data.date.slice(0, 4) === y).map((p) => (
             <li class="entry" data-type={p.data.type}>
               <time class="entry__date" datetime={p.data.date}>{md(p.data.date)}</time>
-              <h3 class="entry__title" lang={HANGUL.test(p.data.title) ? 'ko' : undefined}>
+              <h3 class="entry__title" lang={langOf(p.data.title, 'en')}>
                 <a href={postUrl(p)}>{p.data.title}</a>
               </h3>
               {p.data.deck && (
-                <p class="entry__deck" lang={HANGUL.test(p.data.deck) ? 'ko' : undefined}>{p.data.deck}</p>
+                <p class="entry__deck" lang={hasHangul(p.data.deck) ? 'ko' : undefined}>{p.data.deck}</p>
               )}
             </li>
           ))}
@@ -58,7 +66,34 @@ const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
      ──────────────────────────────────────────────────────────────── */
   .index {
     /* Grid */
-    --content-w: 45.5rem;  /* 728px, the Gazette content measure */
+    /* Must equal --w-content in src/styles/tokens.css: this is what puts an entry title
+       on the same left edge as the <h1> of the article it links to. Moving one without
+       the other silently splits that edge - see the note beside --w-content.
+
+       Bound through var() rather than re-stated, and the unit is why. The article caps
+       at an absolute --w-content (928px); a rem literal here only matches it at a 16px
+       root. Chrome's font-size setting is one click from 12px, and .index has
+       margin-inline: auto, so the moment its cap falls below the shell the box centres
+       and the two left edges part. Measured with a 58rem literal at a 12px root: the
+       article <h1> landed 116px left of the entry title at 1488 and 1920, and 103.5px
+       left at 1024; at 14px, 58px and 38.6px. Through var() it is 0.00px at 12 / 14 /
+       16 / 20 / 24px roots, because both pages then cap on the same absolute length.
+       The 58rem fallback is the stand-alone value, used only if Base ships no tokens -
+       the same var(global, measured-literal) shape as the type scale below.
+
+       One exception to the shared edge, and it is not this page's doing nor fixable from
+       here: the nine articles that carry a contents rail push their own page box right
+       between 1440 and 1584px, where a centred gutter cannot seat the rail's 240px floor.
+       This page has no rail and nothing to make room for, so it deliberately does not
+       follow - shifting it in sympathy would buy an empty 280px left gutter at 1440 for a
+       page with nothing to put in it. Measured delta, article title minus entry title, at
+       a 16px root: 72px at 1440, 48 at 1488, 24 at 1536, and 0.00px from 1584 up, at 1439
+       and below, and at every width for the 21 articles with no rail. Those are
+       layout-viewport widths, the same basis the Geometry note uses; a classic 15px
+       scrollbar puts the band at 1440-1598 in window widths and the worst case at 79.5px,
+       measured at a 1440px window. See the ══ Geometry ══ note in
+       src/pages/writing/[year]/[slug].astro for why it was traded. */
+    --content-w: var(--w-content, 58rem);   /* 928px, = --w-content */
     --rail-w: 4.5rem;      /* 72px date rail */
     --rail-gap: 1.5rem;    /* 24px  */
 
@@ -93,17 +128,26 @@ const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
     --index-title-font: var(--font-display);
 
     container: index / inline-size;
-    max-width: min(100%, calc(var(--rail-w) + var(--rail-gap) + var(--content-w))); /* 824px */
+    max-width: min(100%, calc(var(--rail-w) + var(--rail-gap) + var(--content-w))); /* 1024px */
     margin-inline: auto;
     font-family: var(--font-body);
     color: var(--ink);
   }
 
-  /* Masthead */
-  .index__head {
-    padding-left: calc(var(--rail-w) + var(--rail-gap));
-    margin-bottom: var(--gap-section);
-  }
+  /* Masthead.
+     Flush with the page box, NOT indented onto the entry column. It used to carry the
+     same `padding-left: --rail-w + --rail-gap` the year kickers and the entries carry,
+     which put "Writing" 96px in from the left edge — so this was the one section index
+     whose title did not start where /papers/, /talk/ and /code/ start theirs. Reading
+     the four in a row, /writing/ was visibly the odd one, and the h1 is the last thing
+     that should be telling you which page you are on by moving.
+
+     The 96px indent still belongs to everything below it: the year kicker and the entry
+     titles hang off the 72px date rail, and that column is the page's spine. The
+     masthead is not in that column — it is the page's own name, addressed to the shell,
+     not a row in the list — so it takes the shell's edge. Measured after the change at a
+     1700px window: 330.5px for the h1 on all four section indexes, against 426.5 here
+     before it. */
 
   .index__title {
     margin: 0;
@@ -112,19 +156,9 @@ const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
     font-weight: 500;
     line-height: var(--lh-tight);
     letter-spacing: -0.01em;
-    /* Newsreader overlaps Sanomat at opsz 56-72; pinned even at a 320px viewport. */
-    font-variation-settings: 'opsz' 56;
     color: var(--ink);
   }
 
-  .index__count {
-    margin: 0.75rem 0 0;
-    font-size: var(--fs-small);
-    line-height: var(--lh-base);
-    letter-spacing: 0.02em;
-    font-variant-numeric: tabular-nums;
-    color: var(--meta);
-  }
 
   .index__empty {
     padding-left: calc(var(--rail-w) + var(--rail-gap));
@@ -204,7 +238,6 @@ const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
     font-size: var(--fs-h5);
     font-weight: 500;
     line-height: var(--lh-medium);
-    font-optical-sizing: auto;
     color: var(--ink);
     word-break: keep-all;       /* stops Korean titles breaking mid-word */
     text-wrap: balance;
@@ -246,9 +279,9 @@ const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
     text-wrap: pretty;
   }
 
-  /* Folds when the rail no longer fits */
+  /* Folds when the rail no longer fits.
+     The page title is .sr-only and its wrapper is gone, so there is nothing here to un-indent. */
   @container index (max-width: 36rem) {
-    .index__head,
     .index__empty,
     .year__label { padding-left: 0; }
 
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -1,7 +1,30 @@
 /* Baseline typography. Layout specifics live with each template. */
 *, *::before, *::after { box-sizing: border-box; }
 
-html { -webkit-text-size-adjust: 100%; }
+/* `scrollbar-gutter: stable` is load-bearing, not a polish detail: it is half of what
+   makes the header sit in the same place on every route.
+
+   The header pads itself with max(--root-pad, (100% - --w-page) / 2), and that `100%` is
+   the body's content width, which a classic scrollbar narrows by 15px. /papers/, /talk/,
+   /code/ and /404 are short enough not to scroll; / , /writing/ and all 30 articles do.
+   So the bar was centring against two different widths and landed 7.5px apart between a
+   scrolling page and a non-scrolling one — measured 338 against 330.5 of padding at a
+   1700px window, at every width, which is visible the moment you click from /papers/ to
+   /writing/. Reserving the gutter everywhere makes the two cases one case: every page now
+   lays out in the same 1685px, scrollbar painted or not.
+
+   What it costs. The four short pages give up 15px of usable width and their content
+   steps 7.5px left — which is the point, since that is the step that puts them on the
+   same edge as everything else. It reserves nothing where the scrollbar is an overlay
+   (every touch browser, and macOS unless "always show" is set), so no mobile width pays
+   for it. And it cannot create horizontal overflow: it narrows the ICB, and every box on
+   this site is sized from that width rather than from 100vw — verified 0px of
+   documentElement.scrollWidth overhang on all seven routes at 320 through 2560.
+
+   On `html` rather than `body` because the viewport takes its scrollbar-gutter from the
+   root element's box; declared on body it would apply to body's own scroll container,
+   which is not the one painting the bar. */
+html { -webkit-text-size-adjust: 100%; scrollbar-gutter: stable; }
 
 body {
   margin: 0;
@@ -10,7 +33,9 @@ body {
   font-family: var(--font-body);
   font-size: var(--fs-body);
   line-height: var(--lh-large);
-  font-optical-sizing: auto;
+  /* font-optical-sizing was here for Newsreader's opsz axis. Lora has no opsz axis and
+     neither does Pretendard, so the declaration is now provably inert - and `auto` is
+     the initial value in any case. Removed rather than left implying an axis exists. */
   -webkit-font-smoothing: antialiased;
 }
 
@@ -29,12 +54,27 @@ h5 { font-size: var(--fs-h5); line-height: var(--lh-medium); }
 h6 { font-size: var(--fs-h6); line-height: var(--lh-medium); }
 
 p { margin: 0; }
-p + p { margin-block-start: var(--sp-24); }
+/* See --sp-para in tokens.css: 0.7em, the reference's own paragraph gap, replacing the
+   flat 24px this held. Written as a top margin on the second of a pair rather than as a
+   bottom margin on every paragraph, so a paragraph that ends a block contributes nothing
+   to the gap below it and the block rhythm stays the one thing that sets it. */
+p + p { margin-block-start: var(--sp-para); }
 
 /* Prose links carry no colour: weight and underline only. */
 a { color: inherit; font-weight: 700; text-decoration: underline;
     text-underline-offset: 0.18em; text-decoration-thickness: from-font; }
 
+/* A style choice, not a correctness guard - worth saying plainly, because the shape of
+   the rule invites the other reading. It has never been about synthesis: the retired
+   display face shipped its whole 200-800 axis and Lora ships 400-700, so `a
+   { font-weight: 700 }` inside a heading always had a real instance to select.
+   What the line does is keep every title in the Writing index, every adjacent-post link
+   and the wordmark at the heading's own 500 instead of stepping to 700 merely for being
+   a link. The rule above targets <a> and the heading rule targets <h1>-<h6>, different
+   elements, so there is no specificity contest - the a simply applies unless this line
+   overrides it. Delete it and linked headings get heavier; that is all that happens. */
+:is(h1, h2, h3, h4, h5, h6) a { font-weight: inherit; }
+
 :focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: 2px; }
 
 .skip { position: absolute; left: -9999px; }
@@ -42,33 +82,532 @@ a { color: inherit; font-weight: 700; text-decoration: underline;
   background: var(--page-bg); padding: var(--sp-30) var(--sp-40);
   border: 2px solid var(--ppc-ui); }
 
+/* Read by assistive tech, drawn nowhere. Two callers, and only one of them is new:
+   the code plate's copy control announces its result through a .sr-only live region,
+   and the Markdown processor already emits <h3 class="sr-only" id="footnote-label">
+   Footnotes</h3> above the footnote list. With no definition in the sheet that second
+   one has been shipping as a visible 24.5px English headline in the middle of a Korean
+   article. `clip-path` alone would do it in every engine this site targets; the width,
+   height and margin are the belt that keeps the box from being a focus-scroll target.
+   Not `display: none`, which would take it out of the accessibility tree as well. */
+.sr-only {
+  position: absolute;
+  inline-size: 1px;
+  block-size: 1px;
+  margin: -1px;
+  padding: 0;
+  border: 0;
+  overflow: hidden;
+  white-space: nowrap;
+  clip-path: inset(50%);
+}
+
 /* Shell */
-body > header, main { max-width: var(--w-content); margin-inline: auto;
-  padding-inline: var(--root-pad); }
-body > header { padding-block: var(--sp-48) var(--sp-40);
-  border-bottom: 1px solid var(--page-hairline);
-  display: flex; flex-wrap: wrap; gap: var(--sp-40);
-  align-items: baseline; justify-content: space-between; }
+/* box-sizing is border-box, so `max-width: 728px` plus the then 32px maximum of
+   --root-pad left a 664px content box — the measure of the day was never actually reached,
+   and on the index the rail then ate another 96px, leaving a 568px title column. The
+   shell is sized to the page box (rail + gap + measure) with the padding added on top
+   of it, which is why --root-pad (now 16→24px) cannot shrink the measure however it is
+   retuned — and, by the same token, cannot shrink the side gutter either: it lives
+   inside this max-width, so it moves where the hairline below ends, not where the text
+   starts. See the --w-content note in tokens.css for the cancellation. */
+main {
+  max-width: calc(var(--w-page) + 2 * var(--root-pad));
+  margin-inline: auto;
+  padding-inline: var(--root-pad);
+}
+
+/* The header is full-bleed, `main` is not.
+   Both used to share the rule above, which capped the header at the same width and left
+   its bottom rule floating in from each edge — a short line across the middle of the
+   screen rather than a boundary. The box now spans the viewport so the rule and the
+   shadow reach both edges, while the padding reproduces what the shared max-width used
+   to do: above `--w-page` + 2 × `--root-pad` it resolves to (100% − --w-page) / 2 and
+   centres a --w-page content box, below it falls back to --root-pad.
+
+   THE CONTRACT IS THAT THIS BAR NEVER MOVES. Same left edge, same right edge, symmetric
+   padding, on every route and at every width — that is what a reader crossing from
+   /papers/ to an article is entitled to, and it is the only thing this expression owes.
+   Two things used to break it and both are gone:
+
+     - the scrollbar. `100%` is the body's content width, so the bar centred against 1700
+       on a short page and 1685 on a scrolling one. `scrollbar-gutter: stable` at the top
+       of this file reserves the gutter on every page, so there is one width now.
+     - the article template. It restated this padding against its own --page-left, so the
+       whole bar slid ~50px right and went asymmetric (280 left / 181 right at 1500)
+       inside the 1440–1584 band, on article pages only. That override is deleted.
+
+   An earlier version of this note promised something stronger and no longer true: that
+   the wordmark always sits on `main`'s left edge. It cannot, because between 1440 and
+   1584 an article pushes `main` right to seat the contents rail. Only `main` moves now;
+   the bar stays where it is, and the two edges part by 79.5px at a 1440px window, 49.5
+   at 1500, 7.5 at 1584 and 0.00 everywhere else. That was the trade the fixed bar was
+   chosen over the shared edge for. See the "which presentation" block in
+   src/pages/writing/[year]/[slug].astro. */
+/* Height follows the reference: 16px of padding above and below a single ~27px row, for a
+   60px bar. The old 48/16 split made it 88px, which read as a section of the page rather
+   than a rule across the top of it.
+
+   That row is ONE line box shared by two type sizes, and the line-height below is what
+   makes it one. The reference declares `line-height: 170%` on body, which computes to a
+   fixed 27.2px at the declaring element and inherits as a LENGTH, so its 24px title and
+   its 19.2px nav sit in the same 27.2px row and the bar is 16 + 27.2 + 16 + 1 = 60.2px.
+   Our --lh-large is a ratio: it resolves per element, the larger slot then sets the row,
+   and a 1.25x title would have pushed this bar to 70px. calc() against --fs-body
+   reproduces the reference's inheritance instead — 18.88 x 1.4 = 26.4px at 1440, for a
+   59.4px bar.
+
+   align-items is centre rather than baseline, and that is deliberate: both slots now have
+   the same line box, so centre, start and stretch are the same pixel, while baseline
+   would step one of them off the row. The reference does not baseline-align either — its
+   title and nav are floats in a shared line box, and their baselines are measurably 1.5px
+   apart.
+
+   row-gap is smaller than column-gap because the wrapped state is a real state here (the
+   nav drops under the slot below ~24em) and it should not inherit the desktop gap. See
+   the note on the nav gap below.
+
+   The rule itself is the Gazette's, measured off news.harvard.edu/gazette: 1px solid
+   #a51c30 — the same crimson this site already carries as its brand, and the Gazette's
+   masthead ground is rgb(255,251,244), the identical cream to --page-bg here. It is
+   --crimson rather than --ppc-ui on purpose: --ppc-ui is the per-section token, and the
+   Gazette's rule does not change colour by section. An earlier version of this note said
+   --ppc-ui "turns blue on /code/ and ochre on /talk/"; measured on the built output it
+   does not — only the article template passes `section`, so every other route renders
+   is-slate and --ppc-ui there is slate, which is also the resting nav colour. Even if
+   those pages did pass one, a header accent that changes per section is the thing this
+   line is avoiding. The hover rule further down follows the same reasoning. */
+body > header { padding-block: var(--sp-40);
+  padding-inline: max(var(--root-pad), calc((100% - var(--w-page)) / 2));
+  border-bottom: 1px solid var(--crimson);
+  box-shadow: 0 1px 12px rgb(0 0 0 / 0.07);
+  line-height: calc(var(--fs-nav) * var(--lh-medium));
+  display: flex; flex-wrap: wrap;
+  column-gap: var(--sp-40); row-gap: var(--sp-30);
+  align-items: center; justify-content: space-between; }
 main { padding-block: var(--sp-64) var(--sp-120); }
 
-/* Wordmark and navigation.
-   small-caps is deliberately avoided: none of Newsreader, Inter or Pretendard ships an
-   `smcp` feature, so `font-variant: small-caps` would be synthesised by the browser.
-   Synthesis scales capitals down, which thins their stems — on a serif with a 3.95 stroke
-   contrast the hairlines disappear around 14px.
-   The kicker idiom is used instead: uppercase, absolute letter-spacing, 700, body face.
-   .105rem is 1.68px, which reads as 0.153em at 11px and tightens to 0.12em at 14px. */
-body > header > a { font-family: var(--font-body); font-size: var(--fs-medium);
-  text-transform: uppercase; letter-spacing: 0.105rem; font-weight: 700;
-  text-decoration: none; color: var(--page-fg); }
-nav { display: flex; gap: var(--sp-40); flex-wrap: wrap; }
-nav a { font-family: var(--font-body); font-size: var(--fs-small);
-  text-transform: uppercase; letter-spacing: 0.105rem; font-weight: 700;
-  text-decoration: none; color: var(--page-meta);
-  /* The current item is marked by a 4px rule in the section colour. It is laid down
-     transparent on every item so that marking one causes no layout shift. */
-  border-block-end: 4px solid transparent; padding-block-end: 2px; }
-nav a:hover { color: var(--ppc-text); }
-nav a[aria-current='page'] { color: var(--ppc-text); border-block-end-color: var(--ppc-ui); }
+/* Site footer. No rule above it and no pushing to the bottom of the viewport.
+
+   Both were tried and both were wrong. The rule repeated a mark the crimson line under the
+   header already owns, on a copyright notice that does not carry that weight. The bottom
+   pinning needed `body` to be a flex column, and that broke /writing/ outright: `main`
+   carries `margin-inline: auto`, an auto cross-axis margin cancels `align-items: stretch`,
+   so `main` collapsed to fit-content — and `.index` inside it is a `container-type:
+   inline-size` box, which may not size from its contents, so it computed to width 0 and the
+   entry titles wrapped one word per line. On a short page the notice simply follows the
+   content now, which is what the reference does too.
+
+   Padding is --sp-40 rather than --sp-48: one line of --fs-small needs air, not a band. */
+.site-foot {
+  padding-inline: max(var(--root-pad), calc((100% - var(--w-page)) / 2));
+  padding-block: var(--sp-40);
+  font-size: var(--fs-small);
+  line-height: var(--lh-base);
+  letter-spacing: 0.02em;
+  color: var(--page-meta);
+}
+
+/* Page name and navigation.
+   One family, one weight, one small-caps texture — and TWO sizes, which is the half that
+   was wrong here. The note this replaces claimed the reference "sets its title and its nav
+   identically - 19.2px, weight 400, letter-spacing normal". Measured, it does not: the
+   title is 24px with .15em (3.6px) of tracking and the nav is 19.2px with none — a 1.25x
+   step, with the tracking on the title alone. Flattening the two removed the only size
+   contrast in the bar and left the left slot at 42px of ink against the reference's 108;
+   a quarter of that reference width is letter-spacing. 1.25x of --fs-body lands 23.6px at
+   1440 and 20.2px at 375, against the reference's flat 24 at both.
+
+   The tracked size is written as calc(--fs-body * 1.25) rather than picked off the heading
+   ladder: --fs-h4 is close at 1440 but the ladder's steps are not parallel, so it drifts
+   to 1.21x at 375. The relationship is the thing being matched, not a number.
+
+   Lora ships no `smcp`, so the browser synthesises these, and an earlier note refused
+   small-caps for that reason. The stroke argument behind that refusal does not survive
+   measurement: at 400 Lora's 'o' is 2.69 contrast with a .0380em thin stroke, which at
+   19px is a 0.72px hairline, and the reference ships exactly this - synthesised Lora
+   small-caps - at exactly this size. Synthesis alone was never a reason; it is how
+   small-caps reach the screen on most faces. Lora has no opsz axis, so one measurement
+   covers every size here. */
+body > header > .page-name, nav a { font-family: var(--font-body); font-size: var(--fs-nav);
+  font-variant-caps: small-caps; letter-spacing: normal; font-weight: 400;
+  text-transform: none; text-decoration: none; }
+body > header > .page-name { font-size: calc(var(--fs-nav) * 1.25); letter-spacing: 0.15em; }
+nav { display: flex; gap: var(--sp-50); flex-wrap: wrap; }
+/* .8em is the reference's own nav spacing (`div.links a { margin-right: .8em }`), and as
+   an em it tracks the type instead of drifting against it: 15.1px at 1440, 12.9px at 375,
+   where the site gap's flat 24px was 1.27em of this type at 1440 and 1.49em at 375 —
+   loosest exactly where the row is tightest. Scoped to this header rather than edited into
+   the rule above, because `nav` is an element selector and the article page has two more
+   navs: .toc-rail escapes it with display:block, and .adj is a subgrid that reads this gap. */
+/* The reference's own spacing, .8em, but resolved against the size it is .8em OF.
+   Written as `0.8em` here it resolved against <nav>'s inherited font-size, which is body's
+   16px because --fs-nav is set on `nav a` and not on `nav` — so the gap sat at 12.8px while
+   the links beside it rendered 19.07px, and the measured 0.671em read as cramped. The
+   reference puts its .8em on the link itself (`div.links a { margin-right: .8em }`) at
+   19.2px, which is 15.36px; calc against the token reproduces that at every width. */
+body > header nav { column-gap: calc(var(--fs-nav) * 0.8); row-gap: calc(var(--fs-nav) * 0.4); }
+
+/* The bar's height must not depend on which page is open, and between 375 and 430px it did.
+   The page name and the nav are two flex items in one wrapping row, so whether the nav folds
+   to a second line is decided by how wide the name is — and the name changes per route
+   ("Code" against "Hyukjin An" is 6 characters and about 80px). Measured across all seven
+   routes: identical two-row bars at 320 and 360, identical one-row bars from 450 up, and a
+   375-430 band where the short names fit beside the nav and the long ones do not — 55.55px
+   against 88.81px depending on the page.
+
+   Below the width where the LONGEST name still fits, every route stacks. The nav taking a
+   full row is not a fallback here; it is the narrow layout, and applying it to all seven is
+   what makes the bar the same object everywhere. 450px is where the sweep showed the last
+   route stop folding, so the stack ends one pixel under it. */
+@media (max-width: 449px) {
+  body > header nav { flex-basis: 100%; }
+}
+nav a { color: var(--page-meta); }
+
+/* The current page is named in the left slot, so the nav does not mark it a second time.
+   What used to be here — a 4px rule in --ppc-ui plus a colour change, laid down
+   transparent on every item — fails on four counts. It is a duplicate of a signal the
+   slot now carries; the reference marks nothing in its nav for exactly that reason (all
+   five of its links measure identical on the page they point at). It occupied the bottom
+   edge the hover rule needs, so the current item would have grown a 2px rule on top of a
+   4px one. Its 4px + 2px of padding was 6px of header height on every item on every page,
+   which is most of what put this bar over 60px. And --ppc-ui resolves to slate on five of
+   six routes, i.e. it drew the "you are here" mark in precisely the colour of the links
+   that are not current. aria-current stays in the markup: it is the programmatic state,
+   and it is not the same question as whether the state is painted twice. */
+
+/* Hover and focus: a 2px rule that grows out of the centre — the reference's one piece of
+   motion, and the detail that reads first in a side-by-side. transform-origin is left at
+   its 50% default, which is what opens it outward instead of wiping it in from one side.
+   The reference's scaleX(.99) is an anti-aliasing dodge worth 0.29px a side, not a design
+   decision, so this goes to a full 1.
+
+   It costs nothing in layout: absolutely positioned against the padding box, which with
+   the shared line box above puts it 4.3px under the baseline at 1440 — the reference's is
+   4px under, crossing the descenders of "Writing" by about a pixel. It is a rule through
+   the type, not a clearance underline, and both are that on purpose.
+
+   --crimson rather than --ppc-ui, for the reason given above the header rule: this bar's
+   accent is the brand crimson at every route, and --ppc-ui here would be the resting nav
+   grey on five of six of them. The reference's own rule (#c44) is likewise a step off its
+   link red, not the link colour itself.
+
+   Reduced motion needs nothing here. The guard at the foot of this file already selects
+   *, *::before, *::after — the transition degrades to an instant appear, which is the
+   right outcome. Do not add a second query.
+
+   Focus keeps the global 3px outline as well; the rule is additive, not a replacement.
+   On its own it would be 127px² of indicator against the ~383px² WCAG 2.4.13 asks for,
+   and a keyboard user could not tell focus from hover. */
+body > header nav a { position: relative; }
+body > header nav a::before {
+  content: ''; position: absolute; inset-inline: 0; bottom: 0; block-size: 2px;
+  background: var(--crimson); transform: scaleX(0);
+  transition: transform 0.3s ease; }
+body > header nav a:focus-visible::before { transform: scaleX(1); }
+body > header nav a:focus-visible { color: var(--crimson-dark); }
+/* A bare :hover latches after a tap on a touch screen and the item stays marked until the
+   next tap elsewhere, so the pointer states are gated on a pointer that can actually
+   hover. :focus-visible above is not gated — that is the keyboard path.
+   The colour moves toward the accent, which on this ground means DOWN in lightness and up
+   in contrast (6.63:1 -> 10.80:1). The reference brightens instead, 7.71:1 -> 5.67:1,
+   because it starts from a saturated red with headroom to lose; ours starts from a
+   neutral, and lifting it would make the hovered item recede and fail AAA. The gesture
+   being matched is "the item takes the accent", not the direction of the luminance step. */
+@media (hover: hover) {
+  body > header nav a:hover { color: var(--crimson-dark); }
+  body > header nav a:hover::before { transform: scaleX(1); }
+}
+
+/* ── Code blocks ──────────────────────────────────────────────────────────────
+   DOM contract, emitted by the `sqzer:code-block` transformer in astro.config.mjs:
+     figure.code[data-language][data-wrap?]
+       > figcaption.code__lang           (absent when data-language is "plaintext")
+       > pre.astro-code[tabindex=0][role=group][aria-label]
+           > code > span.line > span[style="color:var(--astro-code-token-*)"]
+
+   The `pre` carries an inline style (background-color / color / overflow-x) that no
+   stylesheet can outrank without !important, so it is steered through the
+   --astro-code-* custom properties in tokens.css instead of being overridden.
+
+   Two corrections against the earlier draft:
+   1. The UA sheet gives `figure` `margin-inline: 40px`. Left unreset, all 55 code
+      blocks sit 40px right of the one left edge the whole page is built on.
+   2. No negative bleed. The bleed existed to compensate a shell whose content box
+      was 664px; base.css now sizes the shell to the 1024px page box, so the prose
+      column really is 928px and the block is already the full measure. Bleeding
+      -root-pad on top of that gives a 976px box that lines up with nothing —
+      neither the rail's left edge nor the page box's right edge — and pushes the
+      printed width to 944px. Flush is both correct and simpler. */
+.code {
+  /* Containing block for the copy control, and the control's own box. See the copy
+     block below for why the size is declared up here rather than beside the button. */
+  position: relative;
+  --copy-size: 2rem;
+
+  margin-block: var(--sp-48);
+  margin-inline: 0;                  /* reset the UA figure margin */
+  padding-block: var(--sp-40);
+  background: var(--sand);
+}
+/* No figcaption to hold the control, so the plate reserves its row instead. The selector
+   is the transformer's contract, not a guess: a figcaption is emitted for every language
+   except plaintext, which is what a fence with no language becomes. Measured on the built
+   output: 52 code plates, 7 of them uncaptioned (6 fences carry no language and one is
+   written ```plaintext). The three terms are the same three the captioned path already spends
+   (padding, row, caption margin), so both kinds of block open their first line of code at
+   exactly the same depth. */
+.code[data-language='plaintext'] {
+  padding-block-start: calc(var(--sp-40) + var(--copy-size) + var(--sp-40));
+}
+:is(h2, h3, h4, h5, h6) + .code { margin-block-start: var(--sp-32); }
+
+/* The language label reuses the kicker idiom and the 4px x 3rem rule rather than
+   inventing a second type role for it. */
+.code__lang {
+  display: flex;
+  align-items: center;
+  gap: var(--sp-30);
+  /* The control sits at the right end of this row. align-items is already center, so the
+     label stays optically where it was and the row grows around it. */
+  min-block-size: var(--copy-size);
+  padding-inline: var(--code-pad);
+  margin-block-end: var(--sp-40);
+  font-family: var(--font-body);
+  font-size: var(--fs-small);
+  line-height: var(--lh-medium);
+  text-transform: uppercase;
+  letter-spacing: 0.105rem;
+  font-weight: 700;
+  color: var(--ppc-text);
+}
+.code__lang::before {
+  content: '';
+  flex: none;
+  inline-size: 3rem;
+  block-size: 4px;
+  background: var(--ppc-ui);
+}
+
+.code pre {
+  margin: 0;
+  padding-inline: var(--code-pad);   /* rule's left end = first character's left end */
+  font-family: var(--font-mono);
+  font-size: var(--fs-code);
+  line-height: var(--lh-base);       /* 16 x 1.5 = 24 = --sp-24, on the paragraph grid */
+  font-variant-ligatures: none;
+  tab-size: 2;
+
+  /* Horizontal scroll shadow, no JS. The two `local` layers are plate-coloured masks
+     that scroll with the content; the two `scroll` layers are shadows fixed to the box.
+     At either end the mask covers the shadow, so a shadow shows only where there is
+     more to see. Modern engines interpolate gradients with premultiplied alpha, so
+     `transparent` no longer produces a grey midpoint — the colour does not have to be
+     restated as an rgba literal, and --sand stays the single source of truth. */
+  background-image:
+    linear-gradient(to right, var(--sand), transparent),
+    linear-gradient(to left,  var(--sand), transparent),
+    linear-gradient(to right, rgb(0 0 0 / 0.16), rgb(0 0 0 / 0)),
+    linear-gradient(to left,  rgb(0 0 0 / 0.16), rgb(0 0 0 / 0));
+  background-position: left center, right center, left center, right center;
+  background-repeat: no-repeat;
+  background-size: 2.5rem 100%, 2.5rem 100%, 0.75rem 100%, 0.75rem 100%;
+  background-attachment: local, local, scroll, scroll;
+
+  scrollbar-width: thin;
+  scrollbar-color: var(--sand-dark) transparent;
+}
+.code pre::-webkit-scrollbar { block-size: 6px; }
+.code pre::-webkit-scrollbar-thumb { background: var(--sand-dark); }
+.code pre::-webkit-scrollbar-track { background: transparent; }
+
+/* The `<code>` inside the plate carries the text, and the UA sheet gives it its own
+   `font-family: monospace` — which beats what it would have inherited from the rule
+   above, so --font-mono never reached a single character of code. That is invisible
+   in English: Chrome resolves the `monospace` generic per the element's language, and
+   every article in this corpus is <article lang="ko">, where the generic is the Korean
+   fixed default (GulimChe on Windows) rather than Consolas. Measured on bind9's first
+   line, "sudo yum update -y" at --fs-code 16px: 144.00px as lang=ko, 158.34px with
+   this rule (= Consolas, and = what the same line already measured under lang=en).
+   Inherit rather than restate --font-mono, so the plate keeps one source of truth. */
+.code pre code { font-family: inherit; }
+
+/* The default outline-offset would draw the ring outside the plate. */
+.code pre:focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: -3px; }
+
+/* ```<lang> wrap — for blocks whose lines cannot be preserved. Longest line measured
+   in the corpus is 1,639 characters (one raw sysmon XML event). */
+.code[data-wrap] pre { white-space: pre-wrap; overflow-wrap: anywhere; }
+
+/* ── Per-language retuning ────────────────────────────────────────────────────
+   Custom properties inherit, so declaring one on the figure resolves the inline
+   `color: var(--astro-code-token-*)` on every span inside it. That is the reason
+   for choosing the css-variables theme: a transformer cannot do this.
+
+   Measured on the built DOM, not assumed. The earlier draft applied a punctuation
+   override to bash, sh and shellsession as well; against the real output that is
+   wrong on all three counts:
+     bash         punctuation = 1 span in 30 blocks, and it is ' &&' — a conjunction,
+                  which the override would paint the colour reserved for "command
+                  starts here". bash's prompt is already token-function ($ x21 of 59).
+     sh           punctuation = 0 spans.
+     shellsession never appears: the corpus writes ```console, and data-language keeps
+                  the author's word, so that selector matches no element.
+   Only console earns it: punctuation = 17, of which 16 are prompt glyphs ($ x13, # x3). */
+.code[data-language='console'] { --astro-code-token-punctuation: var(--crimson-dark); }
+
+/* bash keyword = 150 spans, of which 136 are `<` and `>` from XML and log output
+   pasted into a ```bash fence, not redirects. 136 purple angle brackets is not a
+   reading of the code. The 14 real pipes are structural too, so slate is right for both. */
+.code[data-language='bash'],
+.code[data-language='sh'] { --astro-code-token-keyword: var(--slate); }
+
+/* Inline code. 52 spans across 2 documents, median 14 characters — sparse and short.
+   No corner radius: this page's vocabulary is a 4px rule and a 1px hairline, not curves. */
+:not(pre) > code {
+  font-family: var(--font-mono);
+  font-size: 0.9em;
+  background: var(--sand);
+  padding: 0.1em 0.3em;
+}
+
+/* ── Copy control ───────────────────────────────────────────────
+   One icon button per code plate, built by the script in the article template. Nothing
+   is emitted for it server-side, so a reader with no JS is never handed a control that
+   cannot work — and nothing here is conditional on the script having run, so a plate
+   looks the same before, during and after it does.
+
+   That is the whole reason the row is reserved in CSS above rather than made by the
+   script: the button is taken out of flow and dropped into a band that already exists,
+   so no code block reflows when it appears. The band is also why the control cannot end
+   up over code at any width. It is above the scroller, not inside it, on a plate whose
+   width the reader never has to share with it.
+
+   DOM contract, from the script:
+     button.code__copy[data-state=idle|done|fail][aria-label]
+       > svg > g[data-glyph=copy|done|fail]
+
+   Colour: there is no green in this palette, so success is not signalled by hue — the
+   glyph changes shape, and the accent is the same accent every other active control on
+   the page takes. Failure is told honestly instead of being coloured: the script selects
+   the block's text, which is a far louder signal than a red button, and says so through
+   the live region.
+
+   No focus rule. The global :focus-visible ring at the top of this file is what the rest
+   of the site's controls wear, and outline-offset: 2px lands it inside the plate.
+   No reduced-motion rule either: the blanket query at the foot of this file already
+   drops these transitions with !important. */
+.code__copy {
+  position: absolute;
+  inset-block-start: var(--sp-40);
+  /* Right edge in by the same --code-pad the first character is in by on the left. */
+  inset-inline-end: var(--code-pad);
+  display: grid;
+  place-items: center;
+  inline-size: var(--copy-size);
+  block-size: var(--copy-size);     /* 32px square, over the 24px WCAG 2.2 target floor */
+  padding: 0;
+  border: 1px solid var(--sand-dark);
+  background: none;
+  color: var(--page-meta);
+  cursor: pointer;
+  transition: color 120ms, border-color 120ms, background-color 120ms;
+}
+.code__copy > svg { display: block; }
+
+/* All three glyphs ship in one <svg> and data-state picks the one that paints. The button
+   never rewrites its own markup, so it cannot change size between states and there is no
+   second request for an icon. */
+.code__copy [data-glyph] { display: none; }
+.code__copy[data-state='idle'] [data-glyph='copy'],
+.code__copy[data-state='done'] [data-glyph='done'],
+.code__copy[data-state='fail'] [data-glyph='fail'] { display: inline; }
+
+/* Gated, like the nav's hover: on a touch device :hover sticks after the tap, which would
+   leave the control reading as active on the last block touched. */
+@media (hover: hover) {
+  .code__copy:hover { color: var(--ppc-text); border-color: var(--ppc-ui); background: var(--page-bg); }
+}
+.code__copy[data-state='done'] { color: var(--ppc-text); border-color: var(--ppc-ui); }
+.code__copy[data-state='fail'] { color: var(--ppc-text); border-color: var(--ppc-ui); background: var(--ppc-wash); }
+
+@media print {
+  .code { background: none; break-inside: avoid; }
+  /* Paper has no clipboard. The control goes, and so does the row that was reserved for
+     it -- on plaintext blocks that is 48px of empty plate at the head of every fence. */
+  .code__copy { display: none; }
+  .code[data-language='plaintext'] { padding-block-start: var(--sp-40); }
+  .code__lang { min-block-size: 0; }
+  /* overflow-x is inline on the pre, so this is the one place !important is required;
+     without it everything past the scroll edge is cut off the page. */
+  .code pre {
+    overflow-x: visible !important;
+    white-space: pre-wrap;
+    background-image: none;
+  }
+}
+
+/* ── Diagrams ─────────────────────────────────────────────────────────────────
+   DOM contract, emitted by src/lib/satteri-diagram.mjs:
+     figure.diagram[data-diagram][tabindex=0][role=region][aria-label]
+       > svg[width][height][viewBox]     (baked by scripts/render-diagrams.mjs)
+
+   The SVG carries its own <style>, scoped to its own #mmd-<hash> id and holding the palette
+   this file's tokens resolved to at bake time, so nothing here paints the picture. What is
+   left is the frame, and the one thing the picture cannot do for itself: fit.
+
+   A diagram is baked at intrinsic size and scaled down here to whatever width the column
+   has, which is the opposite of what this block did before. The old rule kept every drawing
+   at 100% and scrolled the frame, on the argument that scaling the widest one would render
+   its labels at 9.0px. The argument was sound and the trade was still wrong: a picture you
+   have to drag sideways inside a page you are scrolling down is not a picture you read, and
+   it hid a diagram's whole point - its shape - behind a 928px slot. The reader said so.
+
+   So the fix is two-sided, and only half of it is here. The other half is in
+   src/lib/diagrams.mjs: the two sequence diagrams are now baked with `sequence.wrap`, which
+   breaks long message labels instead of letting one label set the distance between two
+   lifelines. That is what makes fitting affordable - 1,103x660 becomes 660x780 and needs no
+   scaling at all, 1,540x874 becomes 1,194x958 and scales 0.75, so 16px labels land at 12px
+   rather than 9.3px. The flowchart is the one that does not come down: its width is four
+   boxes in a row, not one long line, so it still fits at 0.59 and its 15px labels land at
+   8.8px. It is a four-box pipeline whose shape survives that; its fine print does not, and
+   the honest answer there is the reader's own pinch-zoom, which on vector art costs nothing.
+
+   `max-inline-size: 100%` with `block-size: auto` is the replaced-element idiom: the root's
+   width/height attributes give the intrinsic ratio, so the height follows the width and the
+   picture never grows past what mermaid measured. Nothing scrolls, so the scroll shadows,
+   the thin scrollbar and the print override that used to undo `overflow-x` are all gone with
+   it: the five rules this block compiles to measure 373 B raw in the built sheet, 173 B
+   brotli, where the scrolling version measured 1,036 B.
+
+   Global rather than in the article template, because the article template is not the only
+   page that renders Markdown: src/pages/index.astro puts about.md through this pipeline too.
+   A reader who never opens a page with a diagram pays for this block and nothing else: no JS,
+   no request, no font. */
+.diagram {
+  margin-block: var(--sp-48);
+  margin-inline: 0;                  /* reset the UA figure margin */
+  padding: var(--code-pad);
+  border: 1px solid var(--sand-dark);
+}
+:is(h2, h3, h4, h5, h6) + .diagram { margin-block-start: var(--sp-32); }
+/* Inside the border, like .code pre, so the ring is not drawn over the prose beside it.
+   The figure keeps its tabindex: it is no longer a scroll region, but it is still the one
+   thing on the page a keyboard reader may want to land on before zooming into it. */
+.diagram:focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: -3px; }
+
+/* `display: block` also drops the inline baseline gap under the frame; `margin-inline: auto`
+   centres a drawing narrower than the column, which is now the common case rather than the
+   impossible one. */
+.diagram > svg {
+  display: block;
+  margin-inline: auto;
+  inline-size: auto;
+  max-inline-size: 100%;
+  block-size: auto;
+}
+
+@media print { .diagram { break-inside: avoid; } }
 
 @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
diff --git a/src/styles/fonts.css b/src/styles/fonts.css
@@ -1,59 +1,199 @@
-/* Self-hosted subsets. All three are variable fonts with their axes intact.
-   The character set was taken from the current corpus, so a Korean syllable that has
-   never appeared before will fall back to a system face until the subset is regenerated. */
+/* Two files, two scripts. Every byte and every ratio below was measured on the shipped
+   files; `python scripts/subset-fonts.py` regenerates them and re-prints the hashes.
 
+   Lora is the only Latin face on the site. Body, headings, links, kickers, nav and the
+   wordmark all resolve to it, at 400, 500 and 700 off one weight axis. Pretendard covers
+   Hangul, which Lora does not have at all, so "one face" means one Latin face: the
+   Korean gate below is not a second opinion about type, it is the other half of the
+   character set.
+
+     latin  lora        84,764 ->  22,300   wght 400-700, 105 codepoints, kern + liga
+     ko     pretendard 210,008 -> 118,136   wght 400-700, 963 Hangul codepoints, no
+                                            OpenType features retained (Pretendard's
+                                            GSUB carries no liga to keep)
+
+   That replaces a Newsreader/Inter pair costing 43,268B with one file at 22,300B, and
+   drops the English route from two font requests to one. Coverage does not shrink: the
+   retired masters carried 104 and 107 codepoints, this subset carries the 105 the built
+   corpus actually paints, and it adds U+2022 and U+2248, which no shipped face had
+   before. 27 codepoints in dist/ are covered by neither file - arrows, box drawing,
+   circled digits, three hiragana, one CJK ideograph and thirteen emoji - which is
+   exactly the set that fell back before the swap.
+
+   liga is kept, which the outgoing sans had no use for. Lora is a serif whose f hook
+   runs into a following dotted i, and the built text holds 60 `fi` and 51 `fl` pairs
+   (visible text of dist/, tags stripped). Lora ships exactly f+i and f+l; keeping both
+   costs 396B.
+
+   No italic is shipped. dist/ holds 10 <em>, 0 <i> and 0 <cite>; the 10 are on two
+   articles, both <article class="post" lang="ko">, and [slug].astro sets
+   `.post[lang='ko'] .prose em { font-style: normal; font-weight: 500 }` because
+   Pretendard has no italic to shear. So italic renders zero times in 36 pages, and
+   nothing is synthesised either, because nothing asks for it. Lora-Italic would be
+   ~23KB that is never fetched. To bring it back: drop Lora-Italic-wght.ttf in
+   fonts-src/ and add one JOBS row in scripts/subset-fonts.py.
+
+   The Korean face is not sliced by unicode-range beyond the Hangul gate. Slicing was
+   measured: cutting it into 4/8/12/16 frequency-ordered ranges and resolving each built
+   page against its own syllables leaves the median page pulling 132-140KB at every N,
+   because a Korean post spreads up to 658 distinct syllables across the whole frequency
+   curve. Per-file woff2 overhead then makes the total worse, not better (118,136 as one
+   file becomes ~152,000 across eight). 118KB is the price of the script.
+
+   woff2 encoding is not byte-deterministic, so the ko figure drifts by ~100B per
+   rebuild; the filenames are content-hashed for exactly that reason. */
+
+/* One face, one axis, one @font-face. The display role needs 500 and the body role
+   needs 400, 500 and 700; splitting them into two pinned faces would ship 36,180B in
+   three files against 22,300B in one, and buy nothing, because both roles want the same
+   range. Lora's axis is exactly 400-700, so all three points are real instances and
+   nothing is ever synthesised. There is no optical-size axis on this face: the 38px h1
+   is the 16px letter enlarged, with identical stroke modulation. That is the look this
+   unification buys - and it is cheaper to live with since the retune, because the
+   ladder now tops out at 38px rather than 64px, where an unmodulated enlargement was
+   most visible.
+
+   No size-adjust. The retired display face carried 106% to reach a cap-height reference
+   it was not cut for; with a single Latin face there is no second Latin face to
+   reconcile with, and scaling the whole site up 6% is the opposite of what was asked
+   for. Lora ships at its own scale, and the Korean face is matched to it below. */
 @font-face {
-  font-family: 'Newsreader';
-  src: url('/fonts/newsreader-var.woff2') format('woff2-variations');
-  font-weight: 200 800;
+  font-family: 'Lora';
+  src: url('/fonts/latin-9bba8f25.woff2') format('woff2-variations');
+  font-weight: 400 700;
   font-style: normal;
   font-display: swap;
-  /* Corrects Newsreader (cap .708) against Sanomat (cap .750). */
-  size-adjust: 106%;
 }
 
+/* Hangul only - no Latin outlines survive in this file. The unicode-range gate had
+   already made them unreachable; carrying them cost 12,316B.
+
+   Read that first line before reading the arithmetic: this face draws 963 Hangul
+   codepoints and nothing else, so its own Latin metrics never paint. What size-adjust
+   does here is scale Hangul against Lora's Latin. The x-height ratio is how the number
+   is derived, not what it matches.
+
+     Lora x-height .50000 / Pretendard x-height .53027 = 94.29%
+
+   Why that ratio and not another: Pretendard's Latin is Inter's, uniformly scaled, and
+   the retired 102.9% was exactly Inter .54590 / Pretendard .53027 = 102.95% - the scale
+   at which Pretendard's Latin equals Inter's, and therefore the scale at which its
+   Hangul sits as its designer drew it against that Latin. Substituting Lora for Inter
+   in that chain gives 102.95% x (.50000 / .54590) = .50000 / .53027, with the Inter
+   term cancelling. The new number is the old method, not a new one.
+
+   Checked against what is actually drawn, measured on the shipped file at wght 400:
+   Hangul ink tops at .7881em (median of 961 syllables, max .8027) and bottoms at
+   -.0742em. At 94.29% that top lands at .7431em, against Lora's tallest lowercase at
+   .7600em and its capitals at .7000em - Hangul between cap and ascender, which is where
+   a syllable block belongs. The other candidate ratios overshoot: cap-height parity
+   (.70000 / .70703) is 99.01% and puts Hangul at .7803em, over Lora's ascenders;
+   lowercase-ascender parity (.75500 / .70703) is 106.78% and is further still.
+
+   Note what this costs. Article prose in dist/ is 55,272 Hangul letters against 25,705
+   Latin, so 94.29% shrinks the two thirds of the site that is Korean to match the third
+   that is not: Hangul's ink top goes from .8109em under the old 102.9% to .7431em, 8.4%
+   smaller. That is the honest consequence of unifying on a face with no Hangul. If
+   Korean should give up less, o-height parity (.51600 / .53711) = 96.07% is the
+   defensible middle - and it is a one-line change, because the two KoFallback overrides
+   below do not move with it. */
 @font-face {
-  font-family: 'Inter';
-  src: url('/fonts/inter-var.woff2') format('woff2-variations');
-  font-weight: 100 900;
+  font-family: 'KoBody';
+  src: url('/fonts/ko-02f8cebd.woff2') format('woff2-variations');
+  font-weight: 400 700;
   font-style: normal;
   font-display: swap;
-  /* x-height is within 0.22% of Neue Haas Grotesk Text — no adjustment needed. */
+  size-adjust: 94.29%;
+  unicode-range: U+1100-11FF, U+3130-318F, U+A960-A97F, U+AC00-D7A3, U+D7B0-D7FF;
 }
+/* There is no KoDisplay any more. It existed to track a separate Latin display face at
+   106%; with one Latin face, one Korean declaration serves headings and body alike. */
 
-/* For body <em> and photo credits. The display italic is never used in headlines,
-   so it is not shipped. */
-@font-face {
-  font-family: 'Inter';
-  src: url('/fonts/inter-italic-var.woff2') format('woff2-variations');
-  font-weight: 100 900;
-  font-style: italic;
-  font-display: swap;
-}
+/* ── Fallback metrics ───────────────────────────────────────────────────────────────
+   118KB arrives after HTML, after CSS, after layout. Until it lands, 32 of the 36 built
+   pages paint in a system Korean face, and without these overrides the swap reflows
+   every Korean paragraph.
+
+   The numbers are exact, not fitted. Measured on pretendard-var.woff2: all 963 Hangul
+   codepoints advance 1770/2048 - the histogram has a single entry, no exceptions - with
+   ascent 1950, descent -494, lineGap 0, unitsPerEm 2048.
 
-/* Korean fallback, gated by unicode-range: an English-only page downloads none of it. */
+     advance   1770/2048 = .8642578  x size-adjust .9429 = .8149080
+     ascent    1950/2048 = .9521484  x .9429            = .8977807
+     descent    494/2048 = .2412109  x .9429            = .2274378
+
+   Malgun Gothic, Apple SD Gothic Neo and Noto Sans KR all advance Hangul at exactly
+   1.0em, so the fallback is 12.4% wide uncorrected. Scaling it to .8149080 makes the
+   swap a glyph substitution with no line-break movement anywhere.
+
+     size-adjust      .8149080 / 1.0      =  81.49%
+     ascent-override  .8977807 / .8149080 = 110.17%
+     descent-override .2274378 / .8149080 =  27.91%
+
+   Only the first number moved when KoBody went 102.9% -> 94.29%. The two overrides are
+   ratios in which the KoBody factor appears in both numerator and denominator and
+   cancels exactly - they reduce to 1950/1770 and 494/1770 - so 110.17% and 27.91% hold
+   for any KoBody value. Do not recompute them. */
 @font-face {
-  font-family: 'PretendardBody';
-  src: url('/fonts/pretendard-var.woff2') format('woff2-variations');
-  font-weight: 45 930;
-  font-style: normal;
-  font-display: swap;
-  size-adjust: 102.9%;
+  font-family: 'KoFallback';
+  src: local('Noto Sans KR'), local('NotoSansKR-Regular'), local('Nanum Gothic');
+  size-adjust: 81.49%; ascent-override: 110.17%; descent-override: 27.91%; line-gap-override: 0%;
   unicode-range: U+1100-11FF, U+3130-318F, U+A960-A97F, U+AC00-D7A3, U+D7B0-D7FF;
 }
-@font-face {
-  font-family: 'PretendardDisplay';
-  src: url('/fonts/pretendard-var.woff2') format('woff2-variations');
-  font-weight: 45 930;
-  font-style: normal;
-  font-display: swap;
-  /* Headings are scaled to match the display face, which carries size-adjust: 106%. */
-  size-adjust: 105.4%;
+@font-face { /* macOS, iOS. Later declarations win; an unresolved local() falls upward. */
+  font-family: 'KoFallback';
+  src: local('Apple SD Gothic Neo');
+  size-adjust: 81.49%; ascent-override: 110.17%; descent-override: 27.91%; line-gap-override: 0%;
   unicode-range: U+1100-11FF, U+3130-318F, U+A960-A97F, U+AC00-D7A3, U+D7B0-D7FF;
 }
+@font-face { /* Windows. Its hhea (2229/-495) and typo (1638/-410) disagree; the
+                overrides make it irrelevant which one the browser reads. */
+  font-family: 'KoFallback';
+  src: local('Malgun Gothic');
+  size-adjust: 81.49%; ascent-override: 110.17%; descent-override: 27.91%; line-gap-override: 0%;
+  unicode-range: U+1100-11FF, U+3130-318F, U+A960-A97F, U+AC00-D7A3, U+D7B0-D7FF;
+}
+
+/* Latin fallback. Lora is preloaded on every route, so this is slow-link insurance -
+   but the stack used to name Helvetica, Arial and Segoe UI, which made the unstyled
+   flash sans and the swap sans-to-serif. Against a serif it has to be a serif. Georgia
+   ships on Windows and macOS both and is the closest of the system serifs.
+
+   Matched on mean lowercase advance from hmtx, which is the criterion that keeps the
+   swap from reflowing a line (x-height would give 103.85% and move line breaks). The
+   two overrides are divided by the declared size-adjust, not by the raw ratio, because
+   size-adjust scales the overridden metrics too - the browser computes the final ascent
+   as ascent-override x size-adjust:
+
+     size-adjust       Lora .52404 / Georgia .49914 = 104.99%  ->  105%
+     ascent-override   Lora sTypoAscender  1.006em / 1.05 = 95.81%
+     descent-override  Lora sTypoDescender  .274em / 1.05 = 26.10%
+
+   For comparison: Times New Roman would need 114.09% and Arial 107.05%. Only Georgia is
+   measured here, so platforms without it fall through to the unadjusted generic at the
+   end of the stack rather than to a guessed number. */
+@font-face {
+  font-family: 'LatinFallback';
+  src: local('Georgia');
+  size-adjust: 105%; ascent-override: 95.81%; descent-override: 26.10%; line-gap-override: 0%;
+}
 
 :root {
-  --font-display: 'Newsreader', 'PretendardDisplay', Georgia, 'Times New Roman', serif;
-  --font-body: 'Inter', 'PretendardBody', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+  /* One stack under two names. The templates ask for a display face and a body face;
+     since the unification there is one Latin face, so both resolve to Lora and the
+     distinction survives only in the variable names the page templates already use. */
+  --font-display: 'Lora', 'KoBody', 'KoFallback', 'LatinFallback', Georgia, 'Times New Roman', serif;
+  --font-body:    'Lora', 'KoBody', 'KoFallback', 'LatinFallback', Georgia, 'Times New Roman', serif;
   --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+
+  /* src/pages/writing/index.astro is written as a portable component: it re-declares
+     --font-body and --font-display from `var(--font-sans, ...)` and `var(--font-serif,
+     ...)`, whose fallbacks name Inter and Newsreader as *installed local families*, not
+     as the @font-face families this file defines. Nothing ever defined these two hooks,
+     so the Writing index - 30 titles, the largest type on the site - has been painting
+     in whatever Inter or Newsreader the reader happens to have installed, and otherwise
+     in system-ui and Georgia, while still paying for the preload. Defining them here is
+     what puts the index on the shipped face, without editing the component. */
+  --font-sans:  'Lora', 'KoBody', 'KoFallback', 'LatinFallback', Georgia, 'Times New Roman', serif;
+  --font-serif: 'Lora', 'KoBody', 'KoFallback', 'LatinFallback', Georgia, 'Times New Roman', serif;
 }
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
@@ -22,7 +22,7 @@
   /* Neutrals */
   --black: #000000;
   --grey: #b9b8ad;            /* hairlines only */
-  --sand: #faedda;            /* surface: pre, figure, blockquote */
+  --sand: #faedda;            /* surface: code plate, inline code, kbd, mark */
   --sand-light: #fffbf4;      /* page background */
   --sand-dark: #eed6aa;       /* borders */
 
@@ -35,21 +35,147 @@
   --ppc-ui: var(--crimson);
   --ppc-wash: var(--crimson-wash);
 
-  /* Type scale. Every step is fluid and reaches its maximum at 1488px, the wide measure. */
-  --fs-small:    clamp(0.6875rem, 0.2568vw + 0.6361rem, 0.8750rem);  /* 11→14 */
-  --fs-medium:   clamp(0.7500rem, 0.3425vw + 0.6815rem, 1.0000rem);  /* 12→16 */
-  --fs-body:     clamp(1.0000rem, 0.3425vw + 0.9315rem, 1.2500rem);  /* 16→20 */
-  --fs-xlarge:   clamp(1.2500rem, 1.0274vw + 1.0445rem, 2.0000rem);  /* 20→32 */
-  --fs-2xlarge:  clamp(2.5000rem, 1.3699vw + 2.2260rem, 3.5000rem);  /* 40→56 */
-  --fs-h6:       1.125rem;                                          /* 18, fixed */
-  --fs-h5:       clamp(1.2500rem, 0.3425vw + 1.1815rem, 1.5000rem);  /* 20→24 */
-  --fs-h4:       clamp(1.5000rem, 0.6849vw + 1.3630rem, 2.0000rem);  /* 24→32 */
-  --fs-h3:       clamp(1.6250rem, 1.1986vw + 1.3853rem, 2.5000rem);  /* 26→40 */
-  --fs-h2:       clamp(1.7500rem, 1.7123vw + 1.4075rem, 3.0000rem);  /* 28→48 */
-  --fs-h1:       clamp(2.1250rem, 2.5685vw + 1.6113rem, 4.0000rem);  /* 34→64 */
-  --fs-h1-large: clamp(2.5000rem, 4.1096vw + 1.6781rem, 5.5000rem);  /* 40→88 */
-
-  /* Line heights */
+  /* ── Type scale ────────────────────────────────────────────────────────────
+     Every fluid step is written as clamp(MIN, A vw + B rem, MAX) with
+
+         A = (MAX - MIN) x 1600 / 1168   (rem -> vw)      B = MIN - 0.2 A
+
+     so the middle term equals MIN at exactly 320px and MAX at exactly 1488px,
+     rather than merely approaching them. Rounded to four decimals the residual at
+     either endpoint is under 0.002px on every row. This is worth stating because
+     the file shipped the other kind once: --root-pad below keeps a different shape
+     on purpose, and its factor used to put its documented maximum at vw = 1600, so
+     the token resolved 29.85px at 1488 while the comment beside it said 32. Fixed
+     there; checked here.
+
+     Retuned for Lora, and derived from Lora's outlines rather than scaled off the
+     retired Newsreader/Inter numbers by a constant. Measured on the shipped subset
+     (public/fonts/latin-9bba8f25.woff2, wght 400, upem 1000): x-height .500em,
+     cap .700em, b-ascender .755em, p-descender -.255em. Inter's x-height was
+     .5459em, so an unchanged px size already draws 8.4% less lowercase on Lora;
+     a constant-factor rescale would have buried that instead of spending it.
+
+     The anchor is apparent size, not px. The reference this design was pointed at
+     sets the same face at 14pt = 18.667px, which is 9.333px of x-height. --fs-body
+     reaches 19px = 9.500px of x-height, 1.8% above the reference and 13.0% below
+     the 20px Inter it replaces (10.92px) - a 13% cut in what the eye measures out
+     of a 5% cut in px. Below 1488 the ramp is unchanged at its 16px floor, where
+     Lora is already 8.4% smaller than the Inter that floor was set for.
+
+     The ladder is then a multiple of that body. The reference renders nothing above
+     1.50x body anywhere; ours has to carry a real page title on pages with no image
+     and no other display element, so h1 takes 2.0x, h2 takes the reference's own
+     1.50x, and the rest interpolates:
+
+                    h1     h2     h3     h4     h5     h6    body
+         1488px   38.0   28.5   24.5   22.0   20.0   17.0   19.0
+                 2.00x  1.50x  1.29x  1.16x  1.05x  0.89x
+          320px   27.0   23.0   21.0   19.5   18.0   17.0   16.0
+                 1.69x  1.44x  1.31x  1.22x  1.13x  1.06x
+
+     The retired ladder reached h1 64px (3.2x body) and h1-large 88px (4.4x). h6
+     drops 18 -> 17 so the ladder stays strictly monotone at 320px now that h5's
+     floor is 18; no document in content/ uses h5 or h6 (39 h1, 72 h2, 46 h3,
+     14 h4, 0 below), but .entry__title on the Writing index is --fs-h5, so h5 is
+     really "index entry title" and 20px against a 19px body is deliberate - the
+     reference sets its own index entries at exactly body size.
+
+     --fs-small and --fs-medium are NOT changed. They drive the nav (11->14) and the
+     wordmark (12->16), both of which already land at or below the reference's body
+     size; they were never part of "the type is too big", and cutting them would push
+     the nav under 11px. Their share of body rises (small .70 -> .74, medium .80 -> .84)
+     purely because body fell. --fs-code was left out of that pass for the same reason and
+     cut later, on a complaint of its own - the code plates, not the prose, were the thing
+     reading too large once body stopped moving. 13->16 became 13->14.4; the arithmetic is
+     in the note at the token itself. */
+  --fs-small:    clamp(0.6875rem, 0.2568vw + 0.6361rem, 0.8750rem);  /* 11→14, unchanged */
+  --fs-medium:   clamp(0.7500rem, 0.3425vw + 0.6815rem, 1.0000rem);  /* 12→16, unchanged */
+
+  /* Body is a flat 16px, not a clamp, because that is what the reference is.
+     Measured on nicholas.carlini.com/writing/2025/regex-chess.html at a 1440px viewport:
+     body 16px, line-height 27.2px, paragraph box 928px — the same measure this site now
+     uses. It never declares a font-size at all, so its body is the browser default and
+     does not move with the viewport. Ours reached 18.81px at 1440 against that 16, which
+     is the 17% the reader saw as "전체적으로 큰".
+
+     Growing body with the viewport was the thing being removed, so the replacement is not
+     a shallower clamp. The heading ladder above stays as it is: the complaint was about
+     running text, and the ladder's own clamps are unaffected by this line. What does
+     change is the ladder's ratios to body, since body no longer rises — h1 is 2.375x at
+     1488 where it was 2.00x, and the table above is written in absolute px for that
+     reason. */
+  --fs-body:     1rem;                                               /* 16, fixed */
+
+  /* The header is measured from the reference's own header, not from body.
+     It sets its title at 150% and its nav at 120% of a 16px root: 24px and 19.2px. Those
+     used to hang off --fs-body here, which was right only while body also topped out near
+     19px; with body fixed at 16 the same expression would have shrunk the bar to 20/16 and
+     undone the header work. This token carries the nav size and .page-name takes 1.25x of
+     it, which is the reference's own 24:19.2 ratio.
+
+     It stays a clamp where body does not, because the reference collapses its header into
+     a hamburger on a phone and this one wraps instead: a flat 19.2px nav wraps to a third
+     row at 320px. 16px at 320 is the same size the nav had before this change, so nothing
+     about the phone silhouette moves. */
+  --fs-nav:      clamp(1.0000rem, 0.2740vw + 0.9452rem, 1.2000rem);  /* 16→19.2 */
+  --fs-h6:       1.0625rem;                                          /* 17, fixed */
+  --fs-h5:       clamp(1.1250rem, 0.1712vw + 1.0908rem, 1.2500rem);  /* 18→20 */
+  --fs-h4:       clamp(1.2188rem, 0.2140vw + 1.1759rem, 1.3750rem);  /* 19.5→22 */
+  --fs-h3:       clamp(1.3125rem, 0.2997vw + 1.2526rem, 1.5313rem);  /* 21→24.5 */
+  --fs-h2:       clamp(1.4375rem, 0.4709vw + 1.3433rem, 1.7813rem);  /* 23→28.5 */
+  --fs-h1:       clamp(1.6875rem, 0.9418vw + 1.4991rem, 2.3750rem);  /* 27→38 */
+
+  /* Unreferenced by any selector in src/ - checked, not assumed. Kept because the
+     names are part of the token contract, and re-pegged onto the retuned ladder so
+     that anything which later picks one up lands inside the scale instead of
+     reinstating a 56px or 88px step. --fs-h1-large is named in a comment in
+     src/pages/writing/[year]/[slug].astro explaining why the article title does not
+     use it; that reasoning survives the retune. */
+  --fs-xlarge:   clamp(1.2500rem, 0.5137vw + 1.1473rem, 1.6250rem);  /* 20→26 */
+  --fs-2xlarge:  clamp(1.6250rem, 0.5137vw + 1.5223rem, 2.0000rem);  /* 26→32 */
+  --fs-h1-large: clamp(2.0000rem, 1.1986vw + 1.7603rem, 2.8750rem);  /* 32→46 */
+
+  /* Hooks for src/pages/writing/index.astro, which is written as a portable
+     component: it re-declares --fs-h1 / --fs-h5 / --fs-large from
+     var(--wp-*, <its own literal clamp>). Those literals are the pre-Lora ladder
+     (34→64 and 20→24), so without these three lines the Writing index would keep a
+     64px masthead and 24px entry titles while every article dropped to 38px - and
+     all 30 entry titles on the site are on that one page. This is the same
+     mechanism fonts.css already uses to put the index on the shipped face through
+     --font-sans / --font-serif.
+
+     Written as literal clamps, not var(--fs-h1). Substitution happens at the
+     declaring element, so :root { --wp-h1: var(--fs-h1) } would in fact compute to
+     :root's --fs-h1 and inherit down cleanly - but the index assigns the result
+     back into --fs-h1 on .index, and handing a property to itself through an alias
+     is not a thing to be clever about when the failure mode is 30 titles losing
+     their font-size. Keep these three in step with the rows above. */
+  --wp-h1:       clamp(1.6875rem, 0.9418vw + 1.4991rem, 2.3750rem);  /* = --fs-h1 */
+  --wp-h5:       clamp(1.1250rem, 0.1712vw + 1.0908rem, 1.2500rem);  /* = --fs-h5 */
+  --wp-large:    clamp(1.0000rem, 0.2568vw + 0.9486rem, 1.1875rem);  /* = --fs-body */
+
+  /* Line heights. Unchanged by the move to Lora, and that is a measurement, not an
+     oversight - the tempting number to react to is the wrong one.
+
+     Lora's metrics box is sTypoAscender + sTypoDescender = 1.006 + 0.274 = 1.280em,
+     against exactly 1.000em for the Newsreader this ladder was cut for, which looks
+     like every step below 1.28 has become an overlap. It has not. Lines collide when
+     ink meets ink, and ink is what barely moved: measured on the shipped subset,
+     Lora's lowercase and parentheses run -0.271em to +0.760em, a 1.031em band, against
+     Newsreader's 0.9745em band at its shipped size-adjust of 106% = 1.033em. The
+     heading band shrank by 0.2%.
+
+     What the larger metrics box does is shift the half-leading. At --lh-tight the
+     half-leading is (1.1 - 1.280) / 2 = -0.090em, so the line box spans +0.916em to
+     -0.364em around the baseline and the ink sits inside it with room at both ends:
+     nothing clips, and consecutive lines clear by 1.1 - 1.031 = 0.069em. That is
+     2.6px on the retuned 38px h1, down from 4.4px at 64px - the clearance is a
+     ratio, so shrinking the ladder shrinks it in px but never closes it. Korean
+     headings are looser still - Hangul ink spans at most 0.840em once KoBody's
+     94.29% is applied.
+
+     The one glyph that would break this is $, which tops out at 0.833em; it appears
+     only inside code, which sets --lh-base. */
   --lh-tight: 1.1;   /* h1 */
   --lh-snug: 1.2;    /* h2, h3 */
   --lh-close: 1.25;  /* h4 */
@@ -60,17 +186,172 @@
   /* Spacing */
   --sp-20: 0.44rem;  --sp-30: 0.67rem;  --sp-40: 1rem;     --sp-50: 1.5rem;
   --sp-60: 2.25rem;  --sp-70: 3.38rem;
-  --sp-24: 1.5rem;                                          /* between paragraphs */
+  --sp-24: 1.5rem;                                          /* heading-adjacent rhythm */
+  /* Between paragraphs, and the one spacing token deliberately written in em rather than
+     rem. The reference sets `p { margin-top: 0; margin-bottom: .7em }`, which is 11.2px
+     against its 16px body; --sp-24 was 24px, 2.1x that, and read as too much air. A
+     custom property substitutes as a token stream, so the em resolves against the font
+     size of whichever element consumes it — which is what keeps the gap proportional to
+     the type instead of frozen at one pixel value. Read by three rules and no others:
+     `p + p` in base.css, `.prose > p + p` and `.prose blockquote p + p` in the article
+     template. It is NOT the paragraph-adjacent rhythm: a heading followed by a paragraph
+     keeps --sp-24, because the heading's job is to open a section rather than to continue
+     one. Korean check, where the risk is that the gap disappears into the leading: at
+     --lh-ko 1.75 the line box is 28px and Hangul's ink band fills 0.84em of it, so the
+     white between two lines is 14.6px and the white across a paragraph boundary is
+     25.8px — still 1.77x, which still reads as a boundary. */
+  --sp-para: 0.7em;                                         /* between paragraphs */
   --sp-32: clamp(1.5rem, 0.5vw + 1.4rem, 2rem);
   --sp-48: clamp(2rem, 1vw + 1.8rem, 3rem);                 /* block rhythm */
   --sp-64: clamp(2.5rem, 2.2vw + 2rem, 4rem);
   --sp-80: clamp(2.5rem, 2.5vw + 2rem, 5rem);
   --sp-120: clamp(4rem, 3.5vw + 3.3rem, 7.5rem);
 
-  /* Grid */
-  --w-content: 728px;
+  /* ── Grid ──────────────────────────────────────────────────────────────────
+     --w-content is 928px: Carlini parity, chosen deliberately over the 728px this
+     file used to hold, with the cost measured first and accepted.
+
+     Where the side whitespace comes from. Above the width at which the shell stops
+     growing (1024 + 2 x --root-pad, which --root-pad's own ramp puts at vw = 1063),
+     the distance from the viewport edge to the first prose character is
+
+         left  = (vw - 96 - --w-content) / 2 + 96      right = (vw - 96 - --w-content) / 2
+
+     --root-pad cancels out of both: it is inside the shell's own max-width, so it
+     moves where the header hairline ends, never where the text starts. At 1488px
+     that is 328px left and 232px right whatever the padding does, and --w-content
+     is the only token on the page that can change it. At 728px it was 428 / 332,
+     so this is exactly the 100px a side that was asked for.
+
+     What it cost. Measured over the real corpus (42 English and 664 Korean
+     paragraphs out of content/writing/, greedy-wrapped on spaces only because
+     word-break is keep-all, with Lora's advances and its 2,439 GPOS kern pairs,
+     and Pretendard's uniform 1770/2048 Hangul advance at KoBody's 94.29%). cpl is
+     the mean of the lines that actually wrap; the syllable figure is what one full
+     line holds:
+
+         728px / 19px   English  76.7 cpl   Korean 55.3 cpl   47.0 syllables
+         928px / 19px   English 100.4 cpl   Korean 71.3 cpl   59.9 syllables
+
+     The usual bands are 45-75 characters for English and 41-45 syllables for
+     Korean. 728px was already past the Korean one at 47.0; 928px sits a third past
+     it, and English clears 100 characters a line. The corpus is 79.6% Hangul by
+     letter count, so that is the cost on four fifths of the site, not an edge case.
+     Those were the numbers on the table when the width was picked, and the wider
+     column was picked anyway. This block records the trade. It is not an argument
+     to reverse it, and no narrower fallback is hiding below: 928px is the measure
+     at every viewport wide enough to reach it.
+
+     Both pages hang off this token, and they do not share it. The Writing index
+     keeps its own --content-w, capped at min(100%, rail + gap + --content-w), so
+     the 100% branch follows a narrowing but nothing follows a widening. Moving this
+     line alone was measured, not guessed: with the index pinned at 824px and the
+     article at 1024px, the article title lands 100px to the LEFT of the entry it was
+     clicked from at 1488px and 1920px, and 81px left at 1024px - the wider box is
+     centred, so its left edge moves outward, not inward. --content-w in
+     src/pages/writing/index.astro therefore reads this token through var() rather
+     than re-stating it: a rem literal there matched only at a 16px root, and at a
+     12px root the article <h1> sat 116px left of the entry title. Bound, the two
+     left offsets were compared in a browser at 320 / 768 / 1024 / 1488 / 1920 and
+     at 12 / 14 / 16 / 20 / 24px roots: identical to the pixel at every one.
+
+     One bounded exception to that, added later and named here because this is where
+     a reader will come looking for it. The nine articles that carry a contents rail
+     push their page box right when a centred gutter cannot seat the rail's 240px
+     floor - see the ── the rail ── block in src/pages/writing/[year]/[slug].astro.
+     Inside that band the article title leads the index entry instead of matching it:
+     measured at a 16px root, 72px at 1440, 48 at 1488, 24 at 1536, and 0.00px from
+     1584 up, at 1024 and below, and at every width for the other 21 articles. The
+     band tracks the root size, because the floor is expressed in rem along with the
+     type it exists to fit - measured 0.00px at every width at a 12px root, and at a
+     24px root it runs from 1440 to about 1912. Its worst case there is 195.91px at
+     1520, not the 156.80px it reads at the foot of the band: below 1521 the third,
+     room term of --toc-rail-w is what binds, so --page-left tracks the viewport and
+     the divergence RISES with it until the 15rem floor takes over. Sampling only the
+     usual 1440 / 1488 / 1536 / 1584 columns misses that peak by 8px and the foot of
+     the band by 39px. That is the whole of the divergence: no width, root size or
+     article overflows horizontally, and the page box never loses its full --w-page -
+     measured 928px of measure and 0 horizontal overflow at 12 / 14 / 16 / 20 / 24px
+     roots across 1400-2000px. */
+  --w-content: 928px;
+  /* Page box: the 72px date rail, its 24px gutter, and the measure. The index and the
+     article both hang off this, which is what puts their text on one left edge. */
+  --w-page: calc(var(--w-content) + 4.5rem + 1.5rem);   /* 1024px */
   --w-wide: 1488px;
-  --root-pad: clamp(1rem, 1rem + ((1vw - 0.48rem) * 1.923), 2rem);  /* 16→32 */
+  /* Outer gutter, 16 -> 24px. The reference site runs a flat 1em at every width;
+     this halves the growth toward that without going fixed.
+
+     The shape is deliberate and differs from the type scale above: the middle term
+     is pinned at the 1rem floor for every viewport up to exactly 768px (1vw =
+     0.48rem there, so the bracket is zero and negative below it), and only then
+     ramps. A phone gutter must not ride a desktop ramp - the canonical
+     A vw + B rem form would put 19.1px of padding on a 768px screen and cost the
+     prose 12px it does not have. The factor is what was wrong before, not the
+     shape: at 1.923 the term reached 2rem only at vw = 1600, so the token resolved
+     29.85px at 1488 while the comment claimed 32. At 1.1111 it is exactly 1.5rem
+     at 1488 ((0.93 - 0.48) x 1.1111 = 0.5), and exactly 1rem at 320.
+
+     Cost of the change, stated because it runs against the whitespace complaint by
+     a hair: the shell is 1024 + 2 x --root-pad, so at 1488 the header hairline and
+     the shell box span 1072px instead of 1083.7px, 5.85px less page furniture a
+     side. The prose column does not move at all - see the cancellation above. */
+  --root-pad: clamp(1rem, 1rem + ((1vw - 0.48rem) * 1.1111), 1.5rem);  /* 16→24 */
+
+  /* ── Syntax highlighting ───────────────────────────────────────────────────
+     Astro builds the css-variables theme with `variablePrefix: '--astro-code-'`
+     and NO `variableDefaults` (@astrojs/internal-helpers/dist/shiki.js), and
+     @shikijs/core only emits a fallback when defaults are supplied
+     (dist/index.mjs: `return \`var(${prefix}${name})\``). So every token colour
+     ships as a bare var() with no fallback: leave one of these undefined and the
+     declaration is invalid at computed-value time, `color` falls back to the
+     inherited value, and the block renders as flat body text. They are not
+     optional — they ARE the palette.
+
+     Placement measured on the built DOM (dist, 55 fences, 2,431 token spans):
+       foreground 24.9% · string 21.1% · keyword 16.6% · string-expression 13.6%
+       punctuation 11.3% · function 6.1% · comment 3.5% · constant 2.8% · parameter 0.04%
+     Contrast recomputed against both surfaces, sand #faedda first (the real plate):
+       black 18.19/20.36 · purple-dark 9.84/11.01 · blue-dark 8.94/10.00
+       crimson-dark 9.65/10.80 · ochre-dark 9.11/10.19 · slate 5.93/6.63. */
+  --astro-code-background:              transparent;         /* figure.code paints the plate */
+  --astro-code-foreground:              var(--black);
+  --astro-code-token-string:            var(--black);        /* bash: unquoted argument, not a string */
+  --astro-code-token-keyword:           var(--purple-dark);
+  --astro-code-token-string-expression: var(--blue-dark);
+  --astro-code-token-punctuation:       var(--slate);
+  --astro-code-token-function:          var(--crimson-dark);
+  --astro-code-token-comment:           var(--ochre-dark);
+  --astro-code-token-constant:          var(--blue-dark);
+  --astro-code-token-parameter:         var(--black);
+  --astro-code-token-link:              var(--blue-dark);
+
+  /* Code body. The top of the ramp is 0.9 x the now-fixed --fs-body, not 1.0: a
+     monospace face at the same em reads larger than the text face beside it, because
+     every glyph carries the width of the widest one, so code set at body size looks
+     like the loudest thing in the article. 14.4px is the correction, and it is a
+     correction rather than a redesign - one notch, still above the 13px the reference's
+     own <pre> inherits from the UA monospace default against its 16px body.
+
+     It buys line width as well as quiet. The plate's inner box is 928 - 2*16 = 896px,
+     and a line's width is linear in the size, so dropping 16 -> 14.4 is +10% of column.
+     Measured over the built corpus - 52 plates, longest rendered line each, Consolas:
+       at 16.0px   4 plates overflow 896px
+       at 14.4px   3 plates overflow 896px
+     The one that stops scrolling is the widest line any author here would want kept
+     whole, a 110-column jsonc route table: 955px at 16, 859px at 14.4, 37px of slack.
+     The three that remain are dumps, not code - a 132-column apt log (needs 12.4px), a
+     701-column sysmon XML event (2.3px) and a 1,639-column JSON event (1.0px). No
+     readable size fits those; they keep the scroller, or take ```<lang> wrap.
+
+     How many characters 896px is still depends on the face: 104 at 0.600em (JetBrains
+     Mono, Menlo), 113 at 0.5498em (Consolas, measured off the built page - a 132-column
+     line renders 72.57px per 1px of font-size). Do not quote a character count until
+     public/fonts/jetbrains-mono-latin.woff2 actually ships.
+
+     The ramp's floor stays 13px: at 375 the plate's inner box is 294px, where every
+     fence scrolls whatever the size, and 13px is what ships there today. */
+  --fs-code:  clamp(0.8125rem, 0.1315vw + 0.7817rem, 0.9rem);  /* 13→14.4 */
+  --code-pad: var(--sp-40);                                  /* 16px */
 }
 
 /* Per-section palette, swapped by a class on <body>. */