/* Theme: follows the system light/dark setting, no toggle.
   Muted greys are kept at or above 4.5:1 against their own background so
   body text, meta lines, and the breadcrumb all clear WCAG AA. */
:root {
  color-scheme: light;

  --bg: #bcbcbe;
  --text: #111;
  --muted: #484848;          /* 4.8:1 on --bg */
  --rule: #949496;
  --rule-strong: #606060;    /* 3.3:1 on --bg (UI/border, not text) */
  --quote-text: #3e3e3e;     /* 5.6:1 on --bg */
  --code-bg: #d4d4d4;
  --pre-bg: #d8d8d8;
  --img-bg: #d8d8d8;

  --tok-comment: #505050;    /* 5.7:1 on --pre-bg */
  --tok-string: #0d5715;
  --tok-number: #754000;
  --tok-keyword: #084f87;

  /* Spacing scale, in rem so it scales with the user's root font-size
     rather than staying fixed - names the ladder already in use across the
     homepage/post-list CSS instead of leaving it as unrelated hardcoded px
     values in each rule. */
  --space-1: 0.25rem;  /* 4px */
  --space-2: 0.375rem; /* 6px */
  --space-3: 0.75rem;  /* 12px */
  --space-4: 1rem;     /* 16px */
  --space-5: 1.5rem;   /* 24px */
  --space-6: 2.25rem;  /* 36px */
  --space-7: 3rem;     /* 48px */

  /* Homepage type scale. Three levels, clearly separated:
     site title > category heading > post row (= body). Article headings on
     post pages are NOT on this ladder - see .md-content h1/h2/h3. A post
     row is a single line at body size, its date muted but the same size -
     there is no fourth, smaller level on this page. */
  --type-site-title: 2rem;   /* 32px */
  --type-section: 1.375rem;  /* 22px */
  --type-body: 1rem;         /* 16px */
}

@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;

    --bg: #000;
    --text: #cccccc;
    --muted: #a3a3a3;         /* 8.3:1 on --bg */
    --rule: #2a2a2a;
    --rule-strong: #3d3d3d;
    --quote-text: #bbb;
    --code-bg: #181818;
    --pre-bg: #141414;
    --img-bg: #181818;

    --tok-comment: #8b887f;  /* 5.2:1 on --pre-bg */
    --tok-string: #a3c07a;
    --tok-number: #d9a066;
    --tok-keyword: #7fb3d9;
  }
}

/* font-display: optional (not swap). The font is self-hosted and precached
   by the service worker, so it's normally already on disk - optional lets
   the browser use it immediately when available with only a tiny block
   period, and permanently skip the swap otherwise, rather than always
   painting the fallback font first and reflowing into Roboto once it's
   ready. Every navigation here is a full page load (no client-side
   routing), so swap's fallback-then-reflow was happening on every nav, not
   just first visit - it read as page text jumping/re-laying-out right
   after the loading state cleared. */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  font-display: optional;
  src: url('/fonts/roboto-400.woff2') format('woff2');
}

@font-face {
  font-family: 'Roboto';
  font-style: italic;
  font-weight: 400;
  font-display: optional;
  src: url('/fonts/roboto-italic-400.woff2') format('woff2');
}

@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 600;
  font-display: optional;
  src: url('/fonts/roboto-600.woff2') format('woff2');
}

@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 700;
  font-display: optional;
  src: url('/fonts/roboto-700.woff2') format('woff2');
}

* { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: 'Roboto', sans-serif;
  background: var(--bg);
  color: var(--text);
  max-width: 640px;
  margin: 0 auto;
  padding: 48px 24px;
}

/* Shared 3-slot top nav: left (back arrow or reserved spacer), center
   (wordmark or folder title), right (profile circle or TOC button).
   Post pages leave the center slot empty (the post's own H1 is the title)
   and fade this header on scroll (see .nav-hidden below). */
header {
  margin: -48px -24px 24px;
  padding: 24px 24px 0;
  position: sticky;
  top: 0;
  z-index: 20;
  background: var(--bg);
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.top-nav {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding: 12px 0;
}

/* The homepage's header holds nothing at all now - its three slots are all
   reserved spacers, since the site name moved into the page body as
   .site-title and a sticky copy of the same string above it read as a
   second title (see renderHomeHeader in app.js). Collapse the empty band
   rather than leaving 88px of blank sticky chrome above the title. Scoped
   to the homepage via #post-list: post pages keep the full-height header,
   which really does hold a back arrow, a TOC button and the post's own
   title on scroll. */
body:has(#post-list) header {
  margin-bottom: 0;
  padding: 0;
}

body:has(#post-list) .top-nav {
  padding: 0;
}

.nav-slot {
  display: flex;
  align-items: center;
  /* Matches .circle-btn's height. Every page's #crumbs starts as this
     empty 3-slot skeleton in the static HTML (see index.html/section.html)
     before app.js fills it in, so the header must already be full height
     on first paint - otherwise it grows once a slot's circle button
     renders, shifting all page content down (the post-page nav flash). */
  min-height: 40px;
}

.nav-left { justify-content: flex-start; }
.nav-center { justify-content: center; }
.nav-right { justify-content: flex-end; }

.nav-wordmark {
  font-size: 1.1rem;
  letter-spacing: 0.05em;
  color: var(--text);
  white-space: nowrap;
  max-width: 60vw;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Post title in the top nav's center slot: a real <button> for a11y, styled
   to look like plain nav text (see .nav-wordmark), visible only once the
   article's own H1 has scrolled out of view (see setupPostTitleReveal). */
.nav-title-btn {
  font-family: inherit;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
}

.nav-title-btn:hover,
.nav-title-btn:focus-visible {
  text-decoration: underline;
}

.nav-title-btn[hidden] {
  display: none;
}

.circle-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--rule-strong);
  background: var(--bg);
  color: var(--text);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  /* The one link style on the site that is NOT underlined, deliberately:
     these are icon-only circular buttons (back arrow, TOC, prev/next) whose
     content is an SVG, so there is no text to underline - the circle border
     is already the affordance. Every link with visible TEXT is underlined
     by default. */
  text-decoration: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.circle-btn:hover,
.circle-btn:focus-visible {
  border-color: var(--text);
}

.circle-btn .icon {
  width: 20px;
  height: 20px;
}

/* Prev/next pills in the bottom nav: same border/background/color and
   hover-darken as .circle-btn (same rounded visual language), but a pill
   shape sized to fit an icon plus the sibling post's title rather than a
   fixed 40px circle. The chevron is kept even though there's now visible
   text - unlike .circle-btn's icon-only buttons, this one is exempt from
   the site's "icon only when icon-only" framing because it's a functional
   directional indicator (which way this link goes), the same category as
   a back-arrow, not decoration on top of text that already says it all.
   No underline, deliberately: styled purely as a button (border +
   hover-darken is the affordance), same as .circle-btn and the topic
   dropdown's menu items - NOT the site's general "visible-text links
   underline" rule, which doesn't apply to button-shaped controls.
   Titles stay on one line by design (nowrap, no truncation) - the pill
   grows to fit rather than wrapping, per the short-title convention
   (roughly 3-6 words) this site already writes to. */
.post-nav-pill {
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: 100%;
  padding: 8px 16px;
  border-radius: 20px;
  border: 1px solid var(--rule-strong);
  background: var(--bg);
  color: var(--text);
  font-size: 0.9rem;
  line-height: 1.3;
  text-decoration: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.post-nav-pill:hover,
.post-nav-pill:focus-visible {
  border-color: var(--text);
}

.post-nav-pill .icon {
  flex: none;
  width: 18px;
  height: 18px;
}

/* The chevron glyph's stroke doesn't fill its own icon box symmetrically,
   so the gap to the pill's edge on the arrow side reads as bigger than the
   gap to the edge on the text side even with identical padding/gap values.
   Pulls the icon a few px closer to that edge to compensate, without
   touching its size. */
.post-nav-prev .icon {
  margin-left: -4px;
}

.post-nav-next .icon {
  margin-right: -4px;
}

.post-nav-pill span {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Icon-then-text (prev) vs text-then-icon (next) - the two pills use
   different DOM child order (see renderPostBottomNavHtml in render.js),
   not flex-direction, so no CSS reversal is needed here: default row
   order already puts the chevron on each pill's outer edge. */

/* Header/bottom-nav fade, post pages only (see setupPostScrollFade in
   app.js). Transform, not display:none, so the fade is smooth and the
   sticky positioning never jumps. */
header.nav-hidden {
  opacity: 0;
  transform: translateY(-100%);
  pointer-events: none;
}

.bottom-nav.nav-hidden {
  opacity: 0;
  transform: translateY(100%);
  pointer-events: none;
}

/* Bottom nav: prev/next post within a section, same shape as the top nav. */
.bottom-nav {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  max-width: 640px;
  margin: 0 auto;
  padding: 12px 24px calc(12px + env(safe-area-inset-bottom));
  background: var(--bg);
  border-top: 1px solid var(--rule);
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.bottom-nav[hidden] {
  display: none;
}

/* Room for the fixed bottom nav used to be reserved here, on the article
   itself - the shared footer now sits below the article, so the clearance
   moved down to it (see main:has(.post-single) ~ .site-footer) or the nav
   would cover the footer's own contact lines instead. */

/* The table-of-contents modal: styled as a full page, not a floating card,
   so opening it reads as "the content changed" rather than a dialog popping
   up over it. Same page shell (max-width, padding, top-nav grid) as every
   real page; the close button sits in the same nav-right slot the button
   that opened it was in, so it lands under the cursor. */

.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 30;
  overflow-y: auto;
  background: var(--bg);
}

.modal-overlay[hidden] {
  display: none;
}

.modal-card {
  max-width: 640px;
  margin: 0 auto;
  padding: 24px 24px 48px;
}

.modal-card .top-nav {
  margin-bottom: 24px;
}

.modal-close {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--rule-strong);
  background: var(--bg);
  color: var(--text);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.modal-close:hover,
.modal-close:focus-visible {
  border-color: var(--text);
}

.modal-close .icon {
  width: 20px;
  height: 20px;
}

.modal-card h2 {
  font-size: 1.2rem;
  font-weight: normal;
  margin-bottom: 12px;
}

/* The post's own title at the top of the TOC modal - a real anchor
   (href="#top"), so underlined by default like every other text link. */
.toc-top-link {
  color: var(--text);
  text-decoration: underline;
}

/* Site title: the largest text on the page and the anchor of the
   left-aligned block rhythm. Homepage only - it is NOT the sticky nav
   wordmark (.nav-wordmark), which is unchanged and shared with post
   pages. */
.site-title {
  font-size: var(--type-site-title);
  font-weight: 400;
  line-height: 1.2;
  letter-spacing: -0.01em;
  text-align: left;
  margin: 0 0 var(--space-5);
}

/* Shared site footer, same on every blog page (see render.js's
   renderFooterHtml). Deliberately plain left-aligned text - no card, box,
   border, or background of its own - matching the rest of the page. Only
   the thin rule above it separates it from the content, the same 1px
   var(--rule) every post-list row uses. */
.site-footer {
  margin-top: var(--space-7);
  padding-top: var(--space-5);
  border-top: 1px solid var(--rule);
}

/* Post pages have a fixed bottom nav overlaying the end of the page, and
   .post-single's own padding-bottom was sized for the article alone - the
   footer now sits below it, so the clearance has to move down here. */
main:has(.post-single) ~ .site-footer {
  padding-bottom: 72px;
}

/* NOTE: the homepage's footer keeps its own border-top, unlike before.
   This used to be suppressed because the post list closed itself with a
   hairline under its last row, and the two stacked into parallel 1px lines
   48px apart. The list has no borders at all now, so the footer's rule is
   the only line in that area and is doing its actual job - separating the
   footer from the content above it, exactly as on a post page. */

.site-footer-name {
  font-size: 0.95rem;
  margin-bottom: var(--space-3);
}

.site-footer-links {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Each footer line is plain text now - no leading icon or emoji to align
   against, so no flex gap either (see renderFooterHtml's own note on why
   the glyphs went). */
.site-footer-link {
  display: inline-flex;
  align-items: center;
  color: var(--muted);
  text-decoration: none;
  font-size: 0.95rem;
}

/* Only real links (email, GitHub, CV) are underlined - the class is shared
   with the location row, which is a plain <span> stating a fact and must
   not look clickable. Hence the a. prefix: underlining .site-footer-link
   outright would draw a line under the live clock too. Underlined by
   default now, not only on hover, matching every other text link. */
a.site-footer-link {
  text-decoration: underline;
}

a.site-footer-link:hover,
a.site-footer-link:focus-visible {
  color: var(--text);
}

.toc-links {
  list-style: none;
}

.toc-links li {
  border-top: 1px solid var(--rule);
}

.toc-links li:last-child {
  border-bottom: 1px solid var(--rule);
}

/* TOC modal entries: real text links, so underlined by default like the
   rest of the site. */
.toc-links a {
  display: block;
  padding: 12px 0;
  color: var(--text);
  text-decoration: underline;
}

/* Hidden by default and revealed by app.js only once a fetch has been
   pending past a short threshold - on a fast/cached navigation the content
   swaps in before that timer fires, so the text never paints at all
   (avoids the flash/jitter of showing it unconditionally on every nav). */
#loading {
  display: none;
  color: var(--muted);
  font-size: 0.95rem;
}

/* Post page title. Off-screen by default (post.html's #section-title is
   empty until app.js fills it), only ever shown on the single-post layout
   below - nothing else on post.html renders this text, it exists purely so
   setupPostTitleReveal has something to observe scrolling past. */
#section-title {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}

main:has(.post-single) #section-title {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip: auto;
  white-space: normal;
  font-size: 1.5rem;
  font-weight: normal;
  line-height: 1.3;
  margin-bottom: 8px;
}

.section-head {
  margin-bottom: 0;
}

main:has(.post-single) .section-head {
  margin-bottom: 24px;
}

.post-entry {
  margin-bottom: 64px;
  padding-bottom: 64px;
  border-bottom: 1px solid var(--rule);
  scroll-margin-top: 24px;
}

.post-entry:last-child {
  border-bottom: none;
  margin-bottom: 0;
  padding-bottom: 0;
}

.post-meta {
  margin-bottom: 32px;
}

.post-meta .meta-line {
  font-size: 0.85rem;
  color: var(--muted);
}

/* Rendered markdown, shared by posts and the home page */

.md-content {
  line-height: 1.8;
  font-size: 1rem;
}

/* Headings carry anchor ids, so keep linked headings off the viewport edge. */
.md-content h1,
.md-content h2,
.md-content h3 {
  scroll-margin-top: 24px;
}

.md-content h1 {
  font-size: 1.3rem;
  font-weight: normal;
  margin: 0 0 var(--space-4);
}

/* Article h2s on post pages. Values unchanged - this rule used to be shared
   with the homepage's headings, which now have their own scale
   (.section-heading). Kept separate deliberately: in-article headings and
   the homepage's category headings are different levels of different
   documents. */
.md-content h2 {
  font-size: 1.3rem;
  font-weight: normal;
  line-height: 1.8;
  margin: var(--space-6) 0 var(--space-3);
  border-bottom: 1px solid var(--rule);
  padding-bottom: var(--space-2);
}

.md-content h2:first-child {
  margin-top: 0;
}

/* Category heading on the homepage (homepage.json's `section`). Plain text,
   never a link - a category has no page of its own. h1-scale but clearly
   subordinate to .site-title; the post rows beneath sit at body scale, so
   this is unambiguously the heading level of the list.

   No border-bottom: the heading is separated from its own list by spacing
   alone. Size, weight and the 48px gap above it already mark the group
   boundary, and a rule here competed with the hairlines between the rows
   just below it. (.md-content h2 on post pages keeps ITS underline - that
   is a different rule for in-article headings, deliberately split from this
   one, and post pages never use .section-heading.) */
.section-heading {
  font-size: var(--type-section);
  font-weight: 400;
  line-height: 1.3;
  margin: var(--space-7) 0 var(--space-4);
}

.post-section:first-child .section-heading {
  margin-top: 0;
}

/* The homepage's topic dropdown toggle: a real <button>, styled to read
   exactly as .section-heading did (same type scale, weight, line-height,
   margins) so swapping category headings for one flat list plus a filter
   doesn't change the page's visual rhythm. Unlike .section-heading it's
   always the first (and only) heading in #post-list, so it always gets the
   .post-section:first-child zeroed-margin-top behaviour above for free -
   no zeroing rule needed here.

   Underlined like any other link/interactive text on the site (the
   site-wide "everything underlines" rule from session 036), rather than
   looking like a plain heading with no affordance that it does something. */
.topic-toggle {
  display: inline-block;
  font-family: inherit;
  font-size: var(--type-section);
  font-weight: 400;
  line-height: 1.3;
  margin: var(--space-7) 0 var(--space-4);
  background: none;
  border: none;
  padding: 0;
  color: var(--text);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

.topic-toggle:hover,
.topic-toggle:focus-visible {
  color: var(--muted);
}

/* Positions the dropdown menu directly under the toggle. */
.topic-filter {
  position: relative;
}

.topic-menu {
  position: absolute;
  z-index: 10;
  top: 100%;
  left: 0;
  min-width: 220px;
  margin: var(--space-2) 0 0;
  padding: var(--space-2) 0;
  list-style: none;
  background: var(--bg);
  border: 1px solid var(--rule-strong);
}

.topic-menu[hidden] {
  display: none;
}

.topic-menu button {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  font-size: var(--type-body);
  background: none;
  border: none;
  padding: var(--space-3) var(--space-4);
  color: var(--text);
  cursor: pointer;
  text-decoration: none;
}

.topic-menu button:hover,
.topic-menu button:focus-visible {
  background: var(--code-bg);
}

.md-content h3 {
  font-size: 1rem;
  font-weight: bold;
  margin: 24px 0 8px;
}

.md-content p {
  margin-bottom: 16px;
}

/* The homepage intro block (content/home.md). Deliberately carries NO type
   overrides: it sits at plain body scale and weight, so it reads as
   ordinary text under the site title rather than as a distinct component.

   The border-bottom is the one divider inside the page body, separating the
   intro from the index below it. Deliberately identical to the footer's own
   divider - same 1px, same var(--rule), and the same rhythm around it
   (--space-7 above the line, --space-5 below) - so the page's two
   horizontal lines read as one device used twice, not two different ones.
   The gap BELOW has to be padding here rather than margin on the heading:
   .post-section:first-child .section-heading zeroes its own margin-top, so
   a margin would collapse to nothing.

   The post list itself has no rules at all (see .post-toc .toc-item) - rows
   are separated by spacing alone. These two lines are the exceptions. */
#home-content {
  margin-bottom: 0;
  padding-top: 0;
  padding-bottom: var(--space-7);
  border-bottom: 1px solid var(--rule);
}

/* The trailing paragraph's own 16px would otherwise stack on top of the
   padding above, pushing the divider too far from the text it follows. */
#home-content p:last-child {
  margin-bottom: 0;
}

/* Clears the divider before the first category heading, matching the
   footer's own padding-top above its line. */
.post-toc {
  padding-top: var(--space-5);
}

.md-content a {
  color: var(--text);
  text-decoration: underline;
}

/* Link cards: a whole title + description clickable as one block, so a
   linked page can "speak for itself" in the middle of a post instead of the
   reader hunting for a separate underlined link. Source syntax is a fenced
   ```link:/url block, handled in app.js's renderMarkdown. Plain text, no
   card chrome - underlined by default like any other link, just spanning
   the title and description together. */
.link-card {
  display: block;
  margin-bottom: 16px;
  color: var(--text);
  text-decoration: underline;
}

.link-card-title {
  display: block;
  font-weight: normal;
  margin-bottom: 4px;
}

.link-card-desc {
  display: block;
  color: var(--muted);
}

.md-content ul,
.md-content ol {
  margin: 0 0 16px 24px;
}

.md-content li {
  margin-bottom: 8px;
}

/* Images: never wider than the column, never upscaled past their natural
   size, and never so tall they push the rest of the post off screen.
   `height: auto` alone (no `width: auto`) is deliberate: it lets the
   width/height attributes the renderer emits establish an aspect-ratio
   box, so the space is reserved before the file loads. Adding
   `width: auto` would discard that and reintroduce layout shift. */
/* Images: never wider than the column, and sized from the width/height
   attributes the renderer emits so the space is reserved before the file
   loads (this is what keeps layout shift near zero).
   No `max-height` here: clamping the height while the width follows the
   attribute ratio stretches tall images. Portrait images are simply as
   tall as their aspect ratio makes them. */
.md-content img {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 0 0 16px;
  border-radius: 4px;
  background: var(--img-bg);
}

/* An image with no known intrinsic size (remote, or missing at build time)
   still must not overflow the column. */
.md-content img:not([width]) {
  width: auto;
}

.md-content li img {
  display: inline-block;
  vertical-align: middle;
  margin: 0;
}

.md-content a img {
  margin-bottom: 0;
}

/* Tables: scroll horizontally on narrow screens rather than overflowing. */

.md-content table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 16px;
  font-size: 0.95rem;
  display: block;
  overflow-x: auto;
}

.md-content th,
.md-content td {
  text-align: left;
  padding: 8px 12px;
  border-bottom: 1px solid var(--rule);
  vertical-align: top;
}

.md-content th {
  font-weight: bold;
  border-bottom: 2px solid var(--rule-strong);
  white-space: nowrap;
}

.md-content tbody tr:last-child td {
  border-bottom: none;
}

.md-content blockquote {
  margin: 0 0 16px;
  padding: 4px 0 4px 20px;
  border-left: 3px solid var(--rule);
  color: var(--quote-text);
}

.md-content blockquote p:last-child {
  margin-bottom: 0;
}

/* Code */

.md-content code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.85em;
}

.md-content p code,
.md-content li code,
.md-content td code,
.md-content th code {
  background: var(--code-bg);
  padding: 1px 5px;
  border-radius: 3px;
}

.md-content pre {
  background: var(--pre-bg);
  border: 1px solid var(--rule);
  border-radius: 4px;
  padding: 16px;
  margin-bottom: 16px;
  overflow-x: auto;
  line-height: 1.6;
}

.md-content pre code {
  font-size: 0.85rem;
  background: none;
  padding: 0;
}

.tok-c { color: var(--tok-comment); font-style: italic; }
.tok-s { color: var(--tok-string); }
.tok-n { color: var(--tok-number); }
.tok-k { color: var(--tok-keyword); }

/* The homepage's post list (see renderHomeList in app.js). Real <ul>/<li>
   markup with real markers: these rows ARE a list of posts, so the browser's
   own disc marker is the honest rendering of that, and it survives with no
   CSS (a non-CSS crawler or a reader-mode view still sees a list). The
   padding-left is the marker's gutter - `list-style-position: outside`
   (the default) hangs the disc in it, so the TEXT still lines up on the
   same left edge as the section heading above it, which is what keeps the
   left-aligned block rhythm intact. */
/* :not(.topic-menu) excludes the topic dropdown's own <ul> - it's a menu of
   plain-text buttons, not a list of posts, and shouldn't get a marker or
   the post rows' left-gutter padding. Without this exclusion, this rule's
   higher specificity (0,2,0 vs. .topic-menu's 0,1,0) would win the cascade
   over .topic-menu's own list-style: none, even though .topic-menu is the
   later, more specific-to-its-purpose rule in reading order. */
.post-toc ul:not(.topic-menu) {
  list-style: disc;
  margin: 0;
  padding: 0 0 0 var(--space-4);
}

/* The marker belongs to the <li>, but the row's only child is a block-level
   .toc-title div, which would drop the text to the line below the disc.
   Making the title itself the list item keeps marker and text on one line
   without adding a wrapper or faking the bullet with ::before. */
.post-toc .toc-item > .toc-title {
  display: list-item;
}

/* list-style-type INHERITS, so the `list-style: none` on .toc-item above
   cascades into this child and would cancel the marker entirely - it has to
   be re-declared here, not just relied on from the <ul>. (That inheritance
   is exactly what silently produced an indented row with no dot.) */
.post-toc .toc-item > .toc-title {
  list-style-type: disc;
}

/* The disc is explicitly painted in --text, the same primary foreground the
   row's title uses, so it reads as part of the item rather than as faint
   chrome. Setting it explicitly (rather than leaving the marker to inherit
   or to a browser default) is deliberate: --text flips with the theme
   (#111 on light, #cccccc on dark), so the dot is dark-on-light and
   light-on-dark automatically, and never a hardcoded black or white that
   would disappear into one of the two backgrounds. */
.post-toc .toc-item > .toc-title::marker {
  color: var(--text);
}

/* 16px each side (32px between adjacent rows) against the 48px between
   categories - the sibling gap has to stay visibly smaller than the group
   gap or nothing reads as grouped.

   list-style: none on the <li> itself is deliberate and NOT a contradiction
   of the disc above: the marker is rendered by the inner .toc-title (see
   its display: list-item), so leaving the <li>'s own marker on would draw a
   second disc on the row. The row is still a real <li> either way - this
   only decides which box paints the one marker. */
/* No borders anywhere in this list - not between rows, not after the last
   one. Rows are separated by their own padding alone (16px each side, so
   32px between adjacent rows) against the 48px above a category heading:
   the sibling gap stays visibly smaller than the group gap, which is what
   makes the grouping read without drawing a single line. Same decision as
   the heading, which also lost its rule.

   This deliberately replaces three interlocking border rules that used to
   live here (a border-top on every row, a closing border-bottom on the
   final row, and a :first-child override cancelling the first row's top
   border so it didn't double against the heading's own rule). With no
   borders at all, none of that bookkeeping is needed. */
.post-toc .toc-item {
  list-style: none;
  padding: var(--space-4) 0;
}

/* The date is part of the title line - "Title (1 August 2026)" - not a
   separate meta row. Inline, same size and leading as the title it sits on,
   differentiated only by the muted colour every other secondary string on
   the site uses. Lives inside the <a>, so it inherits the hover underline
   and the whole line is one click target. */
.post-toc .toc-date {
  color: var(--muted);
  font-size: inherit;
  font-weight: inherit;
  white-space: nowrap;
}

/* Post rows are a plain list, not a stack of headings: one line each,
   "Title (1 August 2026)" at body scale and weight, with only the date
   muted. No description/blurb under it - the row is the whole entry, and
   the post itself is one click away. No margin-bottom either: nothing sits
   beneath the title inside a row any more, so the row's own padding is the
   only gap that should exist. */
.post-toc .toc-title {
  font-size: var(--type-body);
  font-weight: 400;
  line-height: 1.4;
}

/* Underlined by default, like every other text link on the site - a link
   should look like a link before you point at it. The hover rule is gone
   rather than inverted: there is nothing left for it to change. */
.post-toc .toc-title a {
  color: var(--text);
  text-decoration: underline;
}

.empty-state {
  color: var(--muted);
  font-size: 0.95rem;
  padding: 24px 0;
}
