blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit cef552971b8c087ee5a990f88ccd9896093dd0eb
parent 87002dd449870fa572ac26b81d668b115589dfb9
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Wed, 23 Sep 2026 14:23:49 +0900

feat: links take the accent, and the footer names where else to find me

Brings link styling in line with the reference and finishes the About and Code pages'
identity details.

Links
- Body links are crimson at the weight of the text around them, with no rule at rest.
What stood there was weight 700 plus an underline, which made a link a different piece
of type from the sentence holding it.
- Hover is a colour step and nothing else, everywhere in the body of a page: the index
entries, the project list, the contents rail, the kicker, the adjacent-post titles.
The animated rule stays where the reference keeps it, on the two bars that bracket the
page.
- The hover colour is derived, not picked. The reference moves its body links
rgb(160,32,32) to rgb(192,48,48), a luminance increase of 1.572x; the same factor on
this site's crimson lands on #cb233b, which holds 5.30:1 against the page.

Two corrections worth recording, because both were measured wrong the first time and the
wrong readings had already been written into comments:
- The reference does change colour on hover. The first probe said otherwise because the
page sets scroll-behavior: smooth, so the element's position was read before the scroll
settled and the pointer landed on nothing — a null result that looked like a null state.
- Project names on /code/ do wrap, at 360px. Two reports had said they never do.

Footer
- GitHub, LinkedIn and Instagram, at the copyright's size, carrying rel="me" because
these are identity claims rather than references.

Content
- The About page is running prose with a portrait, replacing the stub, and its LinkedIn
handle is the author's own correction.
- VTZero's post pointed at github.com/devastator-x, an account that 404s. Both the link
and the clone command now name the repository that exists.

Verified: 36 pages, 0 errors; the content gate passes; hover driven over CDP at every
link on the site; no layout shift at any of them; the forced-colors fallbacks were
re-pointed when the class they named went away.

Diffstat:
Mcontent/about.md | 2+-
Mcontent/writing/VTZero.md | 4++--
Msrc/layouts/Base.astro | 20++++++++++++++++++++
Msrc/pages/code/index.astro | 44+++++++++++++++++++++++++++++++++++++++++---
Msrc/pages/writing/[year]/[slug].astro | 153++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/pages/writing/index.astro | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/styles/base.css | 230++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/styles/tokens.css | 13+++++++++++++
8 files changed, 491 insertion(+), 33 deletion(-)

diff --git a/content/about.md b/content/about.md
@@ -33,5 +33,5 @@ What I write is under [writing](/writing/), and the tools I have published are u
 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
+[LinkedIn](https://www.linkedin.com/in/sqzer). Mail reaches me at
 contact.extinct932@passmail.net.
diff --git a/content/writing/VTZero.md b/content/writing/VTZero.md
@@ -7,7 +7,7 @@ tags: []
 # 개요
 Virustotal의 vt-py를 활용하여 유의미한 데이터(국가, 소유자, 유해정도) 추철 및 대량 검색기능 제공
 
-https://github.com/devastator-x/VTZero
+https://github.com/sqzer-x/VTZero
 
 # 필요조건
 VirusTotal API 키 필요
@@ -16,7 +16,7 @@ API키는 회원가입 하면 자동으로 발급이 되며 키는 `https://www.
 # 설정
 1. Repo clone
 ```bash
-git clone https://github.com/devastator-x/VTZero.git
+git clone https://github.com/sqzer-x/VTZero.git
 cd your-repo
 ```
 
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
@@ -70,6 +70,18 @@ const pageName = currentNav?.label ?? SITE_OWNER;
    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. */
+/* Profiles, not a link list: rel="me" below only means anything for places that are
+   this person. GitHub and Instagram are the pair sqzer.com's own footer carries. The
+   LinkedIn handle is the author's own correction — what stood here came from the previous
+   about.md and was /in/devastator-x, the same handle as a GitHub account that now 404s.
+   No automated check would have caught it: LinkedIn answers robots with a 999 whatever
+   the URL, so a dead profile and a live one look identical from here. */
+const ELSEWHERE = [
+  { label: 'GitHub',    href: 'https://github.com/sqzer-x' },
+  { label: 'LinkedIn',  href: 'https://www.linkedin.com/in/sqzer' },
+  { label: 'Instagram', href: 'https://www.instagram.com/sqzer_x' },
+];
+
 const SITE_YEAR_FROM = 2009;
 const buildYear = new Date().getUTCFullYear();
 ---
@@ -107,6 +119,14 @@ const buildYear = new Date().getUTCFullYear();
     <main id="main"><slot /></main>
     <footer class="site-foot">
       <p>Copyright &copy; {SITE_YEAR_FROM}&ndash;{buildYear} {SITE_OWNER}</p>
+      {/* rel="me" because these are identity claims, not references — it is what lets a
+          profile link back and have the pair mean something. No target="_blank": the site
+          opens nothing in a new tab anywhere else, and the footer is not the place to
+          start. Text rather than the icons sqzer.com uses, because this design has no
+          icon vocabulary in its chrome and one pair of glyphs would be the whole of it. */}
+      <nav class="site-foot__social" aria-label="Elsewhere">
+        {ELSEWHERE.map((n) => <a href={n.href} rel="me">{n.label}</a>)}
+      </nav>
     </footer>
   </body>
 </html>
diff --git a/src/pages/code/index.astro b/src/pages/code/index.astro
@@ -131,9 +131,47 @@ const ko = projects.some((p) => hasHangul(p.data.name, p.body));
      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; }
+  /* The rule that comes back on hover is the header nav's wipe, opening from the centre
+     over 0.3s -- see .hover-wipe in base.css. The name already wraps, which is why it
+     takes the per-fragment technique rather than an absolute bar: an absolute bar would
+     draw one rectangle under the last line at the width of the widest. Measured: at 375
+     the longest name fits by 6.45px (321.55px in a 328px column) and nothing wraps, but
+     the column is 313px at 360 and 273px at 320, and USFX takes two lines from 360 down,
+     UDFX from 320, OMMP from 280. Hovered at 320, USFX draws two rules of 144 and 171px,
+     one per line, each opening from its own centre.
+     The rule needs to sit one pixel lower than the padding-box bottom, not higher:
+     measured at 1700, the auto underline's top row was 148.88 and the unpadded background's
+     147.88. Pushing down is `padding-block-end`, never --wipe-y -- the background is
+     clipped to the border box and a position past 100% is simply not painted. This link is
+     inline, so the padding costs no layout: A/B'd with it removed, .project__name,
+     .project__about and .projects and the document height are identical to three decimals
+     at 1700 and at 375, and the only number that moves is the link's own border box, 26 ->
+     25. 0.05em is that pixel at 20px and 0.9px at the 18px floor.
+     The colour keeps stepping to --crimson-dark, and both halves move inside the pointer
+     gate: a bare :hover latches after a tap on a touch screen and would leave the project
+     marked until the next tap elsewhere. :focus-visible stays outside the gate -- it is
+     the keyboard path, and it had no underline at all before the gate went in. */
+  .project__name a { color: var(--crimson); text-decoration: none; padding-block-end: 0.05em; }
+  /* Hover is a colour step, not a rule. The reference draws its animated underline in one
+     place only - the header nav - and every link in the body of a page, its writing index,
+     its code list and even its year rail, simply lifts from rgb(160,32,32) to
+     rgb(192,48,48) with no decoration at all. Measured over CDP on all three. */
+  .project__name a:focus-visible { color: var(--crimson-lift); }
+  @media (hover: hover) {
+    .project__name a:hover { color: var(--crimson-lift); }
+  }
+
+  /* Forced colours drop background-image, so the wipe is not painted there at all and the
+     colour step is overridden too -- see the note beside .hover-wipe in base.css. The
+     underline this rule replaced is what that mode can still draw, so it comes back here
+     and only here. Written in this file rather than in base.css because Astro scopes a
+     page rule on every compound, which outranks the same selector written globally. */
+  @media (forced-colors: active) {
+    .project__name a:focus-visible { text-decoration: underline; }
+  }
+  @media (forced-colors: active) and (hover: hover) {
+    .project__name a:hover { 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
diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -204,6 +204,13 @@ const ld = {
                 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. */}
+            {/* The <span> exists only so the hover rule has an inline box to draw on. The
+                link itself is a -webkit-box with a 2-line clamp, which Chrome blockifies,
+                so it fills the 265px rail column while its text measures 87px -- a rule on
+                it would be a 265px bar under 87px of heading. The span's fragments are the
+                text, per line (measured 257.98 / 132.86 on a wrapped entry), and the clamp,
+                the ellipsis, the hover clamp-lift and the scroll spy's
+                querySelectorAll on .toc-rail__link all read the <a> and are unaffected. */}
             <a class="toc-rail__link" href={`#${h.slug}`} data-target={h.slug} lang={langOf(h.text, 'en')}>{h.text}</a>
           </li>
         ))}
@@ -248,6 +255,9 @@ const ld = {
           {older && (
             <a class="adj__item" href={postUrl(older)} rel="prev">
               <span class="adj__label">Older</span>
+              {/* Inline wrapper for the same reason as the rail entry above: .adj__title is
+                  a grid item and so a block box filling its 832-928px column, while the
+                  title measures 341px. The span is what the rule is drawn on. */}
               <span class="adj__title">{older.data.title}</span>
             </a>
           )}
@@ -673,8 +683,41 @@ const ld = {
     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; }
+  /* The header nav's wipe, via .hover-wipe in base.css. Neither of these two ever wraps
+     ("Writing" and a four-digit year), but they take the same shared rule as everything
+     else rather than a private copy of the absolute-bar technique.
+     This is the one site whose rule sits lower than the padding-box bottom, and the way to
+     do that is padding, not --wipe-y: the background is clipped to the border box, so a
+     position past 100% paints nothing. Measured, when this was written as
+     `calc(100% + 0.14em)`: the rule disappeared completely. `padding-block-end` moves the
+     box the background is clipped to, and on an inline box it changes no line box and no
+     layout: A/B'd with it removed, .post__kicker, .post__title, .post__byline and .prose
+     and the document height are identical to three decimals at 1700 and at 375, and the
+     only number that moves is this link's own border box, 19.97 -> 18.00.
+     0.14em is the 2px that puts the rule back where `text-underline-offset: 0.3em` had it,
+     4px under the baseline -- the deepest offset on the site, because this line is
+     uppercase with 1.68px of tracking and the rule has to clear the caps, not an
+     x-height. */
+  .post__kicker a { color: inherit; text-decoration: none; padding-block-end: 0.14em; }
+  /* Hover is a colour step, not a rule. The reference draws its animated underline in one
+     place only - the header nav - and every link in the body of a page, its writing index,
+     its code list and even its year rail, simply lifts from rgb(160,32,32) to
+     rgb(192,48,48) with no decoration at all. Measured over CDP on all three. */
+  .post__kicker a:focus-visible { color: var(--crimson); }
+  @media (hover: hover) {
+    .post__kicker a:hover { color: var(--crimson); }
+  }
+
+  /* Forced colours drop background-image, so the wipe is not painted there and the colour
+     step is overridden too -- see the note beside .hover-wipe in base.css. The underline
+     this rule replaced is what that mode can still draw, so it comes back here, with the
+     offset it carried before. */
+  @media (forced-colors: active) {
+    .post__kicker a:focus-visible { text-decoration: underline; text-underline-offset: 0.3em; }
+  }
+  @media (forced-colors: active) and (hover: hover) {
+    .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.
@@ -800,8 +843,35 @@ const ld = {
      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; }
+  /* Headings here wrap from 768 down -- counted over all 136 contents entries in the
+     corpus: 1 wraps at 768, 2 at 430, 5 at 375, 10 at 320, and at 320 three of them take a
+     third line. That is why the rule is .hover-wipe's per-fragment background and not an
+     absolute bar: each line gets its own rule opening from its own centre, measured at 3
+     rules of 221 / 231 / 156px on the worst of them. The 1px of padding puts the rule on
+     the row the auto underline
+     had at 1024 (top 401.53) and one row below it at 768, which is the right way to miss
+     by a pixel: these headings are the most Latin text on the page -- DoH(DNS over HTTPS),
+     journalctl, sysmon -- and the rule can no longer skip their descenders, so the pixel
+     is spent clearing them rather than crowding them. Same A/B as the kicker: .toc__item,
+     .toc__list, .prose and the document height unmoved, only the link's own box 19 -> 18.
+     Colour stays --page-fg at rest; the rule itself is crimson, like the header's. */
+  .toc__item a { font-weight: 400; text-decoration: none; color: var(--page-fg);
+    padding-block-end: 0.07em; }
+  .toc__item a:focus-visible { color: var(--crimson); }
+  @media (hover: hover) {
+    .toc__item a:hover { color: var(--crimson); }
+  }
+
+  /* Forced colours drop background-image, so the wipe is not painted there and the colour
+     step is overridden too -- see the note beside .hover-wipe in base.css. The underline
+     this rule replaced is what that mode can still draw, so it comes back here, with the
+     offset it carried before. */
+  @media (forced-colors: active) {
+    .toc__item a:focus-visible { text-decoration: underline; }
+  }
+  @media (forced-colors: active) and (hover: hover) {
+    .toc__item a:hover { text-decoration: underline; }
+  }
 
   /* ── the rail ────────────────────────────────────────────────────────────
      The reference site floats a fixed #leftnav at `left: calc(50% - 31em)` and slides it
@@ -1097,7 +1167,40 @@ const ld = {
     border-block-end: 0;
     padding-block-end: 0;
   }
-  .toc-rail__link:hover { color: var(--page-fg); text-decoration: underline; text-underline-offset: 0.18em; }
+  /* Hover lives on the <a> and the rule is drawn on the <span> inside it -- see the
+     markup note beside the wrapper. The 1px of padding-block-end is what puts the rule
+     where `text-underline-offset: 0.18em` had it. It has to be padding: written as
+     `--wipe-y: calc(100% + 0.07em)` the rule came out 1px thick instead of 2px, clipped
+     by the border box, which is the kind of thing only a pixel scan catches. The span is
+     inline, so the padding changes no layout at all here -- A/B'd with it removed, even
+     .toc-rail__link's own box is unchanged, because the padding sits inside the clamped
+     box rather than around it -- and it is not eaten by the link's `overflow: hidden`
+     either: measured, the rule draws its full 2px inside the clamp, on every visible line.
+     Checked on the cognitive-dissonance post at 1440, the one article with entries past
+     two lines: rest clamps to 2 lines with the ellipsis intact, hover lifts to 4
+     (41.63 -> 83.25px) and all four lines carry their own rule.
+     The colour step to --page-fg stays and moves inside the pointer gate with the rule:
+     on a touch screen a bare :hover would leave one rail entry darkened until the next
+     tap elsewhere, which on a rail of up to 28 entries reads as a wrong current-item
+     mark. The
+     clamp-lift two rules down is deliberately left ungated -- it is a way to read a
+     clipped heading, not a way to mark one, and gating it would take that away from a
+     touch reader entirely. */
+  .toc-rail__link:focus-visible { color: var(--crimson); }
+  @media (hover: hover) {
+    .toc-rail__link:hover { color: var(--crimson); }
+  }
+
+  /* Forced colours drop background-image, so the wipe is not painted there and the colour
+     step is overridden too -- see the note beside .hover-wipe in base.css. The underline
+     this rule replaced is what that mode can still draw, so it comes back here, with the
+     offset it carried before. */
+  @media (forced-colors: active) {
+    .toc-rail__link:focus-visible { text-decoration: underline; text-underline-offset: 0.18em; }
+  }
+  @media (forced-colors: active) and (hover: hover) {
+    .toc-rail__link:hover { 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,
@@ -1749,9 +1852,9 @@ const ld = {
   @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
+  /* Both ends of the round trip. These keep an explicit accent colour of their own rather
+     than inheriting the global link colour, because a bare numeral and a bare arrow are
+     small targets that want the darker end of the family. 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 {
@@ -1759,6 +1862,14 @@ const ld = {
     font-weight: 400;
     color: var(--ppc-text);
   }
+  /* The one hover underline on the site that does NOT take the header's wipe, and the
+     reason is that these two are not navigation labels. They are a bracketed numeral and
+     a return arrow, roughly 8px of ink each, set inside running prose where every other
+     link is underlined at rest. A 0.3s centre-out wipe across 8px is a flicker rather
+     than a gesture, and drawing it in --crimson would put an accent bar mid-paragraph
+     that reads as emphasis on the word beside it. They are also a deliberate pair -- same
+     colour, same weight, same rule -- so animating the backref alone would split them.
+     Nothing renders here today either way: there are no footnotes in content/writing/. */
   .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
@@ -1810,7 +1921,31 @@ const ld = {
     word-break: keep-all;
     text-wrap: balance;
   }
-  .adj__item:hover .adj__title { text-decoration: underline; text-underline-offset: 0.14em; }
+  /* The card is the trigger, as before -- the whole .adj__item is the link, and the hit
+     area is unchanged. The rule is drawn on the inline span inside .adj__title so it stops
+     at the text instead of running the width of the column.
+     No --wipe-y: the padding-box bottom of a 20px line is 3px under the baseline, which is
+     exactly where `text-underline-offset: 0.14em` had it. The rule does not move; it gains
+     the motion and the crimson. :focus-visible is on the <a>, which is the card, so the
+     keyboard sees the same mark the pointer does.
+     Hit area re-measured after the wrapper went in: the card is still 1024 x 61 at 1700
+     and 328 x 82.66 on a wrapped title at 375, and all four corners and the centre still
+     hit-test to the link. */
+  .adj__item:focus-visible .adj__title { color: var(--crimson); }
+  @media (hover: hover) {
+    .adj__item:hover .adj__title { color: var(--crimson); }
+  }
+
+  /* Forced colours drop background-image, so the wipe is not painted there and the colour
+     step is overridden too -- see the note beside .hover-wipe in base.css. The underline
+     this rule replaced is what that mode can still draw, so it comes back here, with the
+     offset it carried before. */
+  @media (forced-colors: active) {
+    .adj__item:focus-visible .adj__title { text-decoration: underline; text-underline-offset: 0.14em; }
+  }
+  @media (forced-colors: active) and (hover: hover) {
+    .adj__item:hover .adj__title { text-decoration: underline; text-underline-offset: 0.14em; }
+  }
   .post__back {
     grid-column: 2;
     margin-block-start: var(--sp-48);
diff --git a/src/pages/writing/index.astro b/src/pages/writing/index.astro
@@ -215,7 +215,6 @@ const ko = posts.some((p) => hasHangul(p.data.title, p.data.deck));
   }
 
   .entry:last-child { border-bottom: none; }
-  .entry:hover { background-color: var(--ppc-wash); }
 
   .entry__date {
     grid-column: 1;
@@ -255,10 +254,59 @@ const ko = posts.some((p) => hasHangul(p.data.title, p.data.deck));
     inset: 0;
   }
 
-  .entry:hover .entry__title a {
-    text-decoration: underline;
-    text-underline-offset: 0.14em;
-    text-decoration-thickness: from-font;
+  /* The row is still the trigger, and the hit area above is untouched: the rule is drawn
+     by .hover-wipe (base.css) on the link's own background, not by a second pseudo-element,
+     because ::before is already spent on the full-row hit box.
+
+     This is the site that decided the technique. Korean titles wrap here and nowhere on
+     this page is the column wide enough to stop them: 5 entries wrap at 320, 2 at 375, 1
+     at 430, none from 768 up. An absolutely positioned bar -- the header's own method --
+     would draw one rectangle under the last line at the width of the widest, i.e. a bottom
+     border on the title block. `box-decoration-break: clone` gives each line fragment its
+     own rule, each opening from its own centre: the worst case at 375 is 235.44 x 48.33
+     over 2 fragments of 186.45 and 235.44, and it gets two rules of those widths.
+
+     `clone` is documented to disable `text-wrap: balance`, which these titles use, so it
+     was A/B'd rather than assumed: every entry's box, fragment count, fragment widths and
+     the document height are identical with `clone` and with `slice` at 320, 375, 430 and
+     768. The balancer only diverges in columns narrower than this page ever gets.
+
+     No --wipe-y. The background's 100% is the padding-box bottom, which lands 3px under a
+     20px baseline -- the same row `text-underline-offset: 0.14em` was putting it on, so
+     the rule does not move at all here, it only acquires the motion.
+
+     What does change is colour: from-font black becomes --crimson, which is what "like the
+     header" means and is the first accent on this index. A 2px rule is a non-text element
+     needing 3:1; crimson measures 7.25:1 on --page-bg. */
+  @media (hover: hover) {
+    /* The row wash used to sit outside this gate while the rule under the title sat inside
+       it. On a touch screen :hover latches after a tap, so a tapped row kept its wash until
+       the next tap elsewhere while the title's rule did not — one half of the row's hover
+       state stuck to it and the other half did not. Both halves are the same gesture and
+       now share the same gate. */
+    .entry:hover { background-color: var(--ppc-wash); }
+    .entry:hover .entry__title a { color: var(--crimson); }
+  }
+  /* Keyboard parity, which this row never had: the outline below marks the row, and this
+     marks the title the same way the pointer does. Outside the gate on purpose.
+     The row hit area is re-measured after the change and unmoved: 1024 x 61, with all
+     four corners and the centre hit-testing to the link through the ::before above. */
+  /* Hover is a colour step, not a rule. The reference draws its animated underline in one
+     place only - the header nav - and every link in the body of a page, its writing index,
+     its code list and even its year rail, simply lifts from rgb(160,32,32) to
+     rgb(192,48,48) with no decoration at all. Measured over CDP on all three. */
+  .entry__title a:focus-visible { color: var(--crimson); }
+
+  /* Forced colours drop background-image, so the wipe is not painted there at all and the
+     colour step is overridden too -- see the note beside .hover-wipe in base.css. The
+     underline this rule replaced is what that mode can still draw, so it comes back here
+     and only here. Written in this file rather than in base.css because Astro scopes a
+     page rule on every compound, which outranks the same selector written globally. */
+  @media (forced-colors: active) {
+    .entry__title a:focus-visible { text-decoration: underline; text-underline-offset: 0.14em; }
+  }
+  @media (forced-colors: active) and (hover: hover) {
+    .entry:hover .entry__title a { text-decoration: underline; text-underline-offset: 0.14em; }
   }
 
   .entry__title a:focus-visible { outline: 0; }
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -61,19 +61,48 @@ p { margin: 0; }
 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; }
+/* Links are crimson at the weight of the text around them, and carry no rule at rest —
+   the reference's own treatment, measured on its article pages: rgb(160,32,32), weight
+   400 against a 400 paragraph, text-decoration none. What stood here was weight 700 plus
+   an underline, which made a link a different piece of type from the sentence holding it.
+
+   One thing to know about this, stated rather than discovered later. Colour alone is a
+   weaker signal than an underline, and WCAG's technique for colour-only links wants 3:1
+   between the link and the text beside it. --crimson on this site's black body measures
+   2.81:1 — under that line, though the same crimson is 7.25:1 against the page and so
+   never hard to read. The reference is further under it at 2.06:1. What closes the gap
+   There is deliberately no hover or focus rule here to close that gap, because the
+   reference has none either: driven over CDP on its own article page, its body links
+   compute text-decoration none at rest AND on hover, with no background, no border, and
+   zero `a:hover` rules anywhere in its stylesheets. An earlier version of this file added
+   an animated rule on hover on the assumption that it did; that was measured from the
+   rest state alone and was wrong.
+
+   Keyboard users are still served: the global :focus-visible ring near the top of this
+   file rings these links like every other control on the site. The 2.81:1 is the honest
+   cost of the change for a reader who only looks. */
+a { color: var(--crimson); font-weight: inherit; text-decoration: none; }
+
+/* The reference does change on hover, and the earlier note here said it did not. That was
+   wrong twice over: the first probe never hovered at all — the page sets
+   `scroll-behavior: smooth`, so the rect was read before the scroll finished and the
+   pointer landed on nothing, which is also why `document.querySelectorAll(':hover')` came
+   back empty and the reading looked like a null result. Driven properly, the chain ends in
+   the <a> and the colour moves rgb(160,32,32) -> rgb(192,48,48).
+
+   So: colour only, still no rule and no background, and no transition — every other colour
+   change on this site is instant. Gated on a real pointer, because a bare :hover latches
+   after a tap and would leave the last link touched sitting in the hover colour. Keyboard
+   users get the global :focus-visible ring rather than this, which is the stronger signal
+   of the two. */
+@media (hover: hover) {
+  :is(.prose, .about) a:hover,
+  .post__back a:hover { color: var(--crimson-lift); }
+}
+
+/* The rule that used to sit here held linked headings at their own 500 against the
+   global `a { font-weight: 700 }`. That global is `inherit` now, so a link in a heading
+   already takes the heading's weight and the override has nothing left to override. */
 
 :focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: 2px; }
 
@@ -200,6 +229,12 @@ main { padding-block: var(--sp-64) var(--sp-120); }
 
    Padding is --sp-40 rather than --sp-48: one line of --fs-small needs air, not a band. */
 .site-foot {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  justify-content: space-between;
+  column-gap: var(--sp-48);
+  row-gap: var(--sp-30);
   padding-inline: max(var(--root-pad), calc((100% - var(--w-page)) / 2));
   padding-block: var(--sp-40);
   font-size: var(--fs-small);
@@ -208,6 +243,44 @@ main { padding-block: var(--sp-64) var(--sp-120); }
   color: var(--page-meta);
 }
 
+/* Copyright left, profiles right — the header's own arrangement, so the two bars that
+   bracket the page read as a pair rather than as two unrelated strips. The gap is the
+   header nav's, derived from the same token so the two rows of links stay in step. */
+.site-foot p { margin: 0; }
+.site-foot__social { display: flex; flex-wrap: wrap; column-gap: calc(var(--fs-nav) * 0.8); }
+/* Size and case come back from .site-foot, not from the global `nav a` two rules up.
+   That selector is unscoped — it reaches every <nav> on the site — so these links were
+   arriving at --fs-nav (19.2px) in small-caps beside a 14px copyright line. Overridden
+   here rather than by scoping `nav a` to the header, because .adj and .toc-rail both
+   read its `text-decoration: none`, and narrowing it would underline every adjacent-post
+   link at rest. small-caps is dropped with the size: at one shared font-size its
+   lowercase would still draw shorter than the line it is meant to match. */
+.site-foot__social a {
+  color: inherit;
+  font-size: inherit;
+  font-variant-caps: normal;
+  letter-spacing: inherit;
+  font-weight: 400;
+  text-decoration: none;
+}
+/* The rule under these is the header's wipe now, drawn by .hover-wipe further down. This
+   link is the one of the seven that cannot carry that class — it is written in
+   Base.astro — so the shared rule names the selector instead of the markup carrying it.
+   --wipe-y pulls the rule up to where the auto underline used to sit. Measured on the live
+   footer at 1700: the old 1px underline's top row was 801.20 and the unlifted background's
+   was 802.20, so the lift is one pixel, written as 0.07em so it tracks --fs-small's clamp
+   instead of drifting against it down to 11px. (0.21em was the first guess, from the
+   padding box being 4px under the baseline; it put the rule two pixels too high.)
+   The colour step stays, and moves inside the pointer gate together with the rule: this
+   is the header's "the item takes the accent" gesture, and a colour that latches after a
+   tap on a touch screen is exactly the defect the gate exists for. */
+.site-foot__social a { --wipe-y: calc(100% - 0.07em); }
+.site-foot__social a:focus-visible { color: var(--crimson-dark); background-size: 100% 2px; }
+
+@media (hover: hover) {
+  .site-foot__social a:hover { color: var(--crimson-dark); background-size: 100% 2px; }
+}
+
 /* 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
@@ -320,6 +393,137 @@ body > header nav a:focus-visible { color: var(--crimson-dark); }
   body > header nav a:hover::before { transform: scaleX(1); }
 }
 
+/* --- The same wipe, everywhere else a rule appears on hover --------------------
+   Seven more links grow an underline on hover and used to do it with
+   `text-decoration: underline`, which has no in-between state to animate: the rule is
+   either absent or whole. This gives them the header's gesture -- a 2px crimson rule
+   opening from the centre over 0.3s -- without giving them the header's technique.
+
+   The header draws its rule with an absolutely positioned ::before at `bottom: 0`. That
+   is right for a nav item and wrong for most of these, because an absolute bar is one
+   rectangle spanning the element's whole padding box. Five of the seven wrap in the corpus
+   as it stands -- entry titles on the Writing index (2 lines at 375), adjacent-post titles
+   (2 at 375), rail entries (2 at 1440, 4 once hover lifts the clamp), inline contents
+   headings (3 at 320), project names on /code/ (2 lines from 360 down: one name at 360,
+   two at 320, three at 280) -- and on a wrapped link that rectangle lands under the last
+   line only at the width of the widest, reading as a bottom border rather than an
+   underline. The project names are the narrow escape: at 375 the longest still fits, by
+   6.45px -- 321.55px in a 328px column -- and it is the 360px column, 313px wide, that
+   takes it to two lines. Two are worse than wrapping: .toc-rail__link and .adj__title
+   are block boxes that fill their column, so the bar would be 265px and 832px wide under
+   87px and 341px of text even on a single line.
+
+   A background gradient sized 0% -> 100% fails the same way by default, because the
+   background positioning area of an inline box is the union of its fragments. What fixes
+   it is `box-decoration-break: clone`, which hands every line fragment its own copy of
+   the background, each opening from its own centre. Measured on this site's own worst
+   cases: 2 rules of 186 and 235px on a two-line entry title, 3 of 221/231/156 on a
+   three-line contents heading, 4 on a rail entry lifted out of its clamp -- one per line,
+   each the width of its own line. The two blockified sites get one inline wrapper in the
+   markup so they have fragments to clone, and the wrappers cost nothing measurable --
+   clamp, ellipsis, the rail's clamp-lift, the scroll spy's querySelectorAll and every
+   layout figure come out unchanged.
+
+   On a single-line inline link the two techniques are pixel-identical, which is worth
+   knowing before anyone tries to unify them: the absolute bar's containing block and the
+   background positioning area are both the fragment's padding box. Both drawn on the same
+   project name and scanned out of the captured frames, they occupy rows 148.88 and 149.88,
+   x 330.50, width 104 -- the same pixels. So the header keeps its ::before rather than
+   being folded in here: converting it would trade a compositor-driven transform for a
+   repainting background animation to save one declaration, and the header is the reference
+   everything else is being matched to.
+
+   The cost, stated plainly: a rule you draw yourself cannot skip ink. Where
+   `text-decoration` used to break around a descender this runs straight through it --
+   the same look as `text-decoration-skip-ink: none`. On the 21 Korean titles nothing
+   changes; on the Latin ones (journalctl, AI Odyssey, ARP Spoofing) the rule crosses the
+   descender. That is the price of the gesture, and there is no third option: skip-ink
+   belongs to text-decoration, and text-decoration has nothing to animate.
+
+   Each site then puts the rule back where its own `text-underline-offset` had it, because
+   100% -- the bottom of the padding box -- sits a different distance under the baseline at
+   every type size and family here. Those distances are not flattened to one number on
+   purpose: the header's own rule sits 0.254em under its baseline, deeper than six of these
+   sit today, because a flex item's bar hangs at the line-box bottom rather than the
+   glyph-box bottom. What is being copied is the motion, not the distance.
+
+   There are two knobs and they are not interchangeable, which is a fact about
+   `background-clip` rather than a matter of taste. --wipe-y moves the rule UP: the
+   background is clipped to the border box, so a position past 100% is not drawn at all.
+   Measured, before this was understood: `calc(100% + 0.14em)` on the kicker rendered
+   nothing, and `calc(100% + 0.07em)` on the rail rendered the rule 1px thick instead of
+   2px, silently clipped. To sit LOWER, a site adds `padding-block-end`, which moves the
+   bottom of the box the background is clipped to.
+
+   On an inline box that is free, and "free" here is an A/B measurement rather than a
+   claim about the spec: with the padding removed from each of the four sites that use it,
+   every containing block, every sibling and document.scrollHeight come back byte-identical
+   to three decimals, and the single number that changes is the link's own border box
+   (project 26 -> 25, kicker 19.97 -> 18.00, contents 19 -> 18). Vertical padding on an
+   inline box does not enter its line box, so nothing around it moves; it grows only the
+   box the background is clipped to, and incidentally the link's own hit target by a pixel
+   or two downward. On a BLOCK box the same padding would move everything below it, which
+   is why the footer link -- the only block-level site of the seven -- is tuned with
+   --wipe-y instead.
+
+   Where the old and the new position disagree by a pixel and there is no exact answer, the
+   rule goes a pixel lower rather than a pixel higher: it no longer skips ink, so the extra
+   pixel is the one that keeps it off a descender.
+
+   No local reduced-motion query. The guard at the foot of this file already selects
+   *, *::before and *::after, the transition degrades to an instant appear, and a second
+   query here would be dead code. */
+/* The footer bar is the only place left that takes this; the body links that used to
+   carry .hover-wipe now step colour instead, the way the reference's body links do. */
+.site-foot__social a {
+  background-image: linear-gradient(var(--crimson), var(--crimson));
+  background-repeat: no-repeat;
+  background-position: 50% var(--wipe-y, 100%);
+  background-size: 0% 2px;
+  -webkit-box-decoration-break: clone;
+          box-decoration-break: clone;
+  transition: background-size 0.3s ease;
+}
+
+/* The one mode the background rule cannot reach, handed back.
+
+   Forced colours drop `background-image` outright -- measured in every one of the seven
+   sites with forced-colors emulated, `background-image` computes to `none`, so the rule
+   that marks a hovered link is simply not painted. The colour step goes with it: `color`
+   is forced to LinkText at rest and on hover alike. What these seven had before this
+   change was `text-decoration: underline`, which the UA paints in the forced text colour
+   and survives the mode intact -- the prose links on this site still draw theirs there
+   today. Without a fallback a High Contrast reader gets no hover or focus affordance at
+   all on any of the seven, where they had one before.
+
+   Same trade [slug].astro already makes twice, for the task-list checkbox and the
+   :target footnote: what is a background elsewhere is a real border or a real decoration
+   here.
+
+   Only the footer pair lives in this file. The other six are written beside their own
+   trigger rules in the page that owns them, because an Astro page scopes a rule by adding
+   its attribute to EVERY compound -- `.project__name[cid] a[cid]` -- which ties with a
+   selector of the same shape written here and then wins on stylesheet order. Measured
+   before the move: .project__name and .entry__title computed `text-decoration-line: none`
+   on hover in this mode while the other five computed `underline`. Inside the page the
+   same scope lands on both rules and the pseudo-class settles it. .site-foot__social a
+   cannot move -- its markup is Base.astro's -- and does not need to: its rest rule is in
+   this file, above this block.
+
+   Two queries rather than one nested pair: the pointer half keeps the (hover: hover)
+   gate, because a latched underline after a tap is the same defect in this mode as in
+   any other.
+
+   Not applied to the header nav, which shares the blind spot and predates this work: its
+   bar is a pseudo-element background and disappears here too. Changing the reference is a
+   separate decision from matching it. */
+@media (forced-colors: active) {
+  .site-foot__social a:focus-visible { text-decoration: underline; }
+}
+@media (forced-colors: active) and (hover: hover) {
+  .site-foot__social a:hover { text-decoration: underline; }
+}
+
 /* ── Code blocks ──────────────────────────────────────────────────────────────
    DOM contract, emitted by the `sqzer:code-block` transformer in astro.config.mjs:
      figure.code[data-language][data-wrap?]
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
@@ -14,6 +14,19 @@
      All 24 pairs were contrast-checked: dark >= 4.5, base >= 3.0, bright >= 4.5 on black,
      wash <= 1.25. */
   --crimson-dark:   #771120;  --crimson:   #a51c30;  --crimson-bright: #faa2a2;  --crimson-wash: #f7ebeb;
+
+  /* Hover step for a link in running text, and the one colour here that is not a rung of
+     the ladder above — it sits between --crimson and --crimson-bright, which is why it is
+     declared apart rather than pretending to be a fifth step.
+
+     Derived from the reference rather than picked: its body links go rgb(160,32,32) at
+     rest to rgb(192,48,48) on hover, measured over CDP on its own article page, which is
+     a luminance increase of x1.572. The same factor applied to --crimson lands here.
+     Contrast: 5.30:1 on --page-bg, so it stays over the 4.5:1 normal text needs while
+     spending the headroom the way the reference spends it. Against the black body text
+     beside it this reads 3.84:1, where --crimson at rest is 2.81:1 — so the hovered link
+     is the one state that clears the 3:1 a colour-only link is asked for. */
+  --crimson-lift:   #cb233b;
   --blue-dark:      #0e455f;  --blue:      #186285;  --blue-bright:    #70cafa;  --blue-wash:    #e6f0f7;
   --ochre-dark:     #553a0c;  --ochre:     #775315;  --ochre-bright:   #f6ae30;  --ochre-wash:   #f7ede0;
   --purple-dark:    #5e127b;  --purple:    #831daa;  --purple-bright:  #dda3fa;  --purple-wash:  #f3ebf7;