blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit cb20946600eb02e86b7607c64bb8aae1326b0888
parent d19bd8b59ac60796a90ed3432d63b30edb54a50f
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 04:18:42 +0900

feat: end each post with the post itself

The footer under every article, the Older and Newer links and the
"All <year> posts" link, is gone at the author's request, along with
the neighbour lookup in getStaticPaths and the styles that only served
it.

The contents rail used that footer to know the reader had reached the
end: a final section too short to bring its heading up to the reading
line was marked while the footer was on screen. It now watches a
one-pixel marker appended after everything in <body>, pulled up over
the pixel it adds, which is on screen only at full scroll. The footer
came into view hundreds of pixels early, so this also stops the rail
jumping to the last entry while the heading before it can still be
reached. On the 14 posts with a rail, sweeping top to bottom in Chrome:
every post still marks its first entry at the top and its last at the
bottom, and entries never marked fall from 8 to 6.

Diffstat:
Msrc/pages/writing/[year]/[slug].astro | 175+++++++++++++++++--------------------------------------------------------------
Msrc/styles/base.css | 24+++++++++++-------------
2 files changed, 47 insertion(+), 152 deletion(-)

diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -2,22 +2,21 @@
 import Base from '../../../layouts/Base.astro';
 import { getCollection, render } from 'astro:content';
 import type { GetStaticPaths } from 'astro';
-import { byHugoOrder, postUrl } from '../../../lib/order';
+import { postUrl } from '../../../lib/order';
 import { proseShape, excerpt, longDate } from '../../../lib/prose';
 import { hasHangul, langOf } from '../../../lib/hangul';
 import { assertRendered } from '../../../lib/rendered';
 
 export const getStaticPaths: GetStaticPaths = async () => {
-  const posts = (await getCollection('writing')).filter((p) => !p.data.draft).sort(byHugoOrder);
-  return posts.map((p, i) => ({
+  const posts = (await getCollection('writing')).filter((p) => !p.data.draft);
+  return posts.map((p) => ({
     // params must be the decoded form — Korean slugs are passed through verbatim.
     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 },
+    props: { post: p },
   }));
 };
 
-const { post, newer, older } = Astro.props;
+const { post } = Astro.props;
 assertRendered(post);
 const { Content, headings } = await render(post);
 
@@ -256,29 +255,6 @@ const ld = {
     )}
 
     <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>
-              {/* 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>
-          )}
-          {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>
   {/* JSON.stringify does not escape `<`, so a deck or a title containing `</script>` closes
@@ -337,11 +313,12 @@ const ld = {
    * 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.
+   * pointing at the section above it. A second observer watches an empty marker appended
+   * after everything else in <body>, against the plain viewport. It is on screen only when
+   * the page is scrolled as far as it goes, which is the one position a short final
+   * section can never be marked from, so there the last entry is the answer. Watching the
+   * end of the document rather than anything above it keeps this from firing while the
+   * last heading can still be scrolled up to the line.
    */
   const rail = document.querySelector('.toc-rail');
   if (rail) {
@@ -364,9 +341,11 @@ const ld = {
         // 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;
+        // Only once the reader has moved: an article short enough to fit on screen whole
+        // shows its end marker on load, before anything has been read. A short one that
+        // scrolls a little can reach its end with no heading past the line, and the last
+        // entry is still the answer there, so scrollY counts as well as `passed`.
+        if (atEnd && (passed.size || scrollY > 0)) 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
@@ -406,13 +385,19 @@ const ld = {
       );
       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);
-      }
+      // One pixel tall, pulled up over the pixel it adds, so it moves nothing and occupies
+      // the document's last pixel. Zero height does not work: measured, the fractional
+      // document height left a zero-area marker just past the viewport at full scroll,
+      // and the last entry was never marked on 8 of the 14 posts with a rail.
+      const end = document.createElement('div');
+      end.setAttribute('aria-hidden', 'true');
+      end.style.height = '1px';
+      end.style.marginBlockStart = '-1px';
+      document.body.append(end);
+      new IntersectionObserver((entries) => {
+        atEnd = entries[entries.length - 1].isIntersecting;
+        apply();
+      }).observe(end);
     }
   }
 </script>
@@ -574,8 +559,8 @@ const ld = {
      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
+     not a rail is painted in it, so moving from a post with contents to one without no
+     longer steps the left edge. 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.
 
@@ -1060,10 +1045,8 @@ const ld = {
        --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. */
+       margin. 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;
   }
 
@@ -1286,7 +1269,7 @@ const ld = {
 
        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
+       was actually painted, and the price was a step: moving 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;
@@ -1889,98 +1872,12 @@ const ld = {
      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;
-  }
-  /* 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, measured on every post from 320 to 1700: the card is 1024 x 61 at 1700 and
-     328 wide at 375, where the title sits under the label and the card is 79-105px tall
-     depending on how the title wraps. All four corners and the centre 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);
-    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 { grid-template-columns: minmax(0, 1fr); }
+    .post > * { 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; }
-    /* Back onto the one track, under the label. Left at column 2, the title lands in an
-       implicit max-content column, the 1fr track shrinks to nothing and the label is
-       painted over the start of the title. */
-    .adj__title { grid-column: 1; }
   }
 </style>
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -96,8 +96,7 @@ a { color: var(--crimson); font-weight: inherit; text-decoration: none; }
    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); }
+  :is(.prose, .about) a:hover { color: var(--crimson-lift); }
 }
 
 /* The rule that used to sit here held linked headings at their own 500 against the
@@ -251,9 +250,8 @@ main { padding-block: var(--sp-64) var(--sp-120); }
 /* 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
+   here rather than by scoping `nav a` to the header, because .toc-rail reads its
+   `text-decoration: none`, and narrowing it would underline every rail 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;
@@ -311,8 +309,8 @@ nav { display: flex; gap: var(--sp-50); flex-wrap: wrap; }
    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 rule above, because `nav` is an element selector and the article page has one more
+   nav, .toc-rail, which escapes it with display:block. */
 /* 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
@@ -401,17 +399,17 @@ body > header nav a:focus-visible { color: var(--crimson-dark); }
 
    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
+   rectangle spanning the element's whole padding box. Four of the six wrap in the corpus
+   as it stands -- entry titles on the Writing index (2 lines 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.
+   takes it to two lines. One is worse than wrapping: .toc-rail__link is a block box that
+   fills its column, so the bar would be 265px wide under 87px 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