/* ─────────────────────────────────────────────────────────────────
   blog.luyckx.dev
   Catppuccin Mocha (dark) / Latte (light) · JetBrains Mono + Source Serif 4

   The chrome here is deliberately identical to hub/css/style.css: same
   palette, reset, scanline, tab-bar, terminal-window and focus treatment.
   The palette block is validated against design/tokens.json by
   scripts/verify-design-tokens.py. Change it there, not only here.

   The one intentional divergence from hub and cv is body typography. Those
   surfaces are monospace throughout, which suits a landing page and a CV but
   is punishing over a long essay: uniform advance widths remove the word-shape
   cues that carry sustained reading. Prose is set in a serif; every piece of
   chrome, metadata and code stays JetBrains Mono. Flip --font-prose to
   var(--font-mono) to make the blog fully monospace again.
   ───────────────────────────────────────────────────────────────── */

/* Fonts ─────────────────────────────────────────────────────────── */

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('/fonts/JetBrainsMono-Latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('/fonts/JetBrainsMono-LatinExt.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: italic;
  font-weight: 300 700;
  font-display: swap;
  src: url('/fonts/JetBrainsMono-Italic-Latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: italic;
  font-weight: 300 700;
  font-display: swap;
  src: url('/fonts/JetBrainsMono-Italic-LatinExt.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: 'Source Serif 4';
  font-style: normal;
  font-weight: 200 900;
  font-display: swap;
  src: url('/fonts/SourceSerif4-Latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Source Serif 4';
  font-style: normal;
  font-weight: 200 900;
  font-display: swap;
  src: url('/fonts/SourceSerif4-LatinExt.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* Palette: Catppuccin Mocha ─────────────────────────────────── */

:root {
  --base: #1e1e2e;
  --mantle: #181825;
  --crust: #11111b;
  --surface0: #313244;
  --surface1: #45475a;
  --surface2: #585b70;
  --overlay0: #6c7086;
  --overlay1: #7f849c;
  --text: #cdd6f4;
  --subtext0: #a6adc8;
  --subtext1: #bac2de;
  --pink: #f5c2e7;
  --mauve: #cba6f7;
  --green: #a6e3a1;
  --blue: #89b4fa;
  --yellow: #f9e2af;
  --peach: #fab387;
  --red: #f38ba8;
  --teal: #94e2d5;
  --lavender: #b4befe;
  --maroon: #eba0ac;
  --sky: #89dceb;

  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);

  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
  --font-prose: 'Source Serif 4', Charter, Georgia, 'Times New Roman', serif;
}

:root[data-theme="light"] {
  --base: #eff1f5;
  --mantle: #e6e9ef;
  --crust: #dce0e8;
  --surface0: #ccd0da;
  --surface1: #bcc0cc;
  --surface2: #acb0be;
  --overlay0: #6e7186;
  --overlay1: #626579;
  --text: #4c4f69;
  --subtext0: #5e6175;
  --subtext1: #5c5f77;
  --pink: #ea76cb;
  --mauve: #8839ef;
  --green: #40a02b;
  --blue: #1e66f5;
  --yellow: #df8e1d;
  --peach: #fe640b;
  --red: #d20f39;
  --teal: #179299;
  --lavender: #7287fd;
  --maroon: #e64553;
  --sky: #04a5e5;
}

/* Reset ────────────────────────────────────────────────────────── */

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

html {
  font-size: 16px;
  scroll-behavior: smooth;
  scroll-padding-top: 52px;
}

body {
  font-family: var(--font-mono);
  font-feature-settings: 'liga' 1, 'calt' 1;
  background: var(--base);
  color: var(--text);
  line-height: 1.7;
  min-height: 100svh;
  overflow-x: hidden;
  padding-top: 44px;      /* reserve space for the fixed tab-bar */
  transition: background-color 0.3s ease, color 0.3s ease;
}

::selection {
  background: var(--surface2);
  color: var(--pink);
}

:focus-visible {
  outline: 2px solid var(--pink);
  outline-offset: 2px;
  border-radius: 2px;
}

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0,0,0,0);
  white-space: nowrap; border: 0;
}

.skip-link {
  position: absolute;
  top: -100%;
  left: 16px;
  z-index: 10001;
  padding: 8px 16px;
  background: var(--pink);
  color: var(--crust);
  font-size: 0.85rem;
  font-weight: 600;
  text-decoration: none;
  border-radius: 4px;
}
.skip-link:focus { top: 8px; }

/* Scanline overlay, same trick as hub and cv ─────────────────── */

body::after {
  content: '';
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 9999;
  background: repeating-linear-gradient(
    0deg,
    transparent,
    transparent 2px,
    rgba(0, 0, 0, 0.03) 2px,
    rgba(0, 0, 0, 0.03) 3px
  );
}

:root[data-theme="light"] body::after {
  background: repeating-linear-gradient(
    0deg,
    transparent,
    transparent 2px,
    rgba(0, 0, 0, 0.015) 2px,
    rgba(0, 0, 0, 0.015) 3px
  );
}

/* Tab bar, identical to hub ───────────────────────────────── */

.tab-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 9998;
  background: var(--crust);
  border-bottom: 1px solid var(--surface0);
  display: flex;
  align-items: stretch;
  height: 44px;
  transition: background-color 0.3s ease, border-color 0.3s ease;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
}

.tab-bar::-webkit-scrollbar { display: none; }

.tab-bar a {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 16px;
  color: var(--overlay1);
  font-size: 0.78rem;
  text-decoration: none;
  white-space: nowrap;
  border-right: 1px solid var(--surface0);
  border-bottom: 2px solid transparent;
  transition: background 0.2s, color 0.2s, border-bottom-color 0.2s;
  flex-shrink: 0;
}

.tab-bar a:hover {
  background: var(--mantle);
  color: var(--text);
  text-decoration: none;
}

.tab-bar a.active {
  background: var(--base);
  color: var(--text);
  border-bottom: 2px solid var(--pink);
}

.tab-bar a .icon {
  width: 13px;
  height: 13px;
  flex-shrink: 0;
}

/* The way back up to the hub, the bar's only link.
   Brighter than the CV's section links (--overlay1) for the same reason it is
   brighter there: it leaves the surface rather than moving around inside it.

   Deliberately NOT a copy of the CV's rule. That one is pinned sticky with a
   declared --tab-home-w because the CV's bar carries eleven section anchors,
   overflows, and scrolls itself horizontally under the reader. This bar holds
   one link and can never overflow, so there is nothing to pin it against and
   no scroll-padding for a width contract to serve. Copying the machinery here
   would look like shared code while protecting against nothing.

   Selected as `.tab-bar .tab-home`, not `.tab-home`: `.tab-bar a` above is a
   class AND an element, so a lone class loses to it and this colour would
   silently do nothing. */
.tab-bar .tab-home {
  color: var(--text);
}

.tab-actions {
  display: flex;
  align-items: center;
  margin-left: auto;
  flex-shrink: 0;
  border-left: 1px solid var(--surface0);
}

.tab-actions button {
  background: none;
  border: none;
  border-right: 1px solid var(--surface0);
  color: var(--overlay1);
  font-family: inherit;
  font-size: 0.75rem;
  font-weight: 500;
  padding: 0 12px;
  height: 44px;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 5px;
  transition: color 0.2s, background 0.2s;
  white-space: nowrap;
}

.tab-actions button:last-child { border-right: none; }
.tab-actions button:hover {
  color: var(--text);
  background: var(--mantle);
}
.tab-actions button .icon {
  width: 14px;
  height: 14px;
}

/* Icons ──────────────────────────────────────────────────────────
   An inline <svg> with no width/height falls back to the CSS default of
   300x150, which is enormous. hub gets away with sizing each icon per
   component because it has a fixed set of them; the blog renders icons inside
   generated lists, so a base size is safer. Component rules still override. */

.icon {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  vertical-align: -0.15em;
}

/* Terminal window ─────────────────────────────────────────────── */

/* The frame. 1100 on index and category pages, where the two-column stream has
   something to do with the width; 900 on a post, where the only thing on the
   page is one column of prose and a wider frame would just strand it against
   the left edge. baseof.html sets .is-reading on the post pages. */
:root {
  --frame: 1100px;
  /* Fixed height of one post preview. Holds the kicker, two lines of headline
     and three lines of summary, plus padding. Change this and the two
     -webkit-line-clamp values below together, or entries will either clip
     early or leave a gap under the last line.

     212, not 200: .cat-ref gained 12px of vertical padding to make the category
     link inside a card a real touch target rather than a 19px sliver sitting on
     top of the title's click overlay. The card height has to absorb that, or the
     kicker grows into the summary's budget and the third line is sliced through
     the middle of the glyphs -- with -webkit-line-clamp: 3 already satisfied, so
     no ellipsis ever appears to signal it. */
  --preview-h: 212px;
}
/* The reading frame tracks the measure, it does not float free of it. Measured
   in the browser at 1536px: the 68ch measure rendered 621px inside a 780px
   frame, so 159px of frame sat permanently empty on the right. Widening only
   the frame recreates exactly that gap, which is what the previous note here
   was warning about.

   Widened twice on request. 68ch -> 80ch -> 90ch, with the frame tracking it
   each time: 780px -> 828px -> 876px, which is the measure plus the 24px
   gutters the terminal window already had. The frame still meets the text, so
   the empty-right-hand-column failure the original note warned about has not
   come back. The index frame stays 1100px, so a post is still visibly a
   narrower object than the stream. */
body.is-reading { --frame: 876px; }

.terminal-window {
  max-width: var(--frame);
  margin: 0 auto;
  padding: 20px 24px 48px;
}

/* Figlet banner. The "MARCO LUYCKX" block is byte-identical to the one on hub
   and cv; only the suffix differs (- BLOG here, - CV there, none on the hub).

   The SIZE has to match too, and it did not: this used to be
   clamp(3px, 1.25vw, 13px), so at any width past ~1000px the name rendered one
   pixel smaller here than on the other two surfaces. The same wordmark, very
   slightly shrunk, which is exactly the kind of inconsistency that reads as
   sloppy without being obviously wrong. Now identical to cv/css/terminal.css.

   The 106-column art still fits: at the 14px cap it is 890px against the
   1052px of frame left after padding, and below the cap the 1.4vw track keeps
   it inside the frame at every width down to the 560px breakpoint where the
   banner is swapped for the text heading. */
.ascii-art {
  color: var(--pink);
  font-size: clamp(3px, 1.4vw, 14px);
  line-height: 1.2;
  white-space: pre;
  /* `overflow: hidden`, never `overflow-x: auto`. Setting one axis to a
     non-visible value forces the other to compute to `auto`, which turns this
     purely decorative banner into a scroll container the pointer can catch. */
  overflow: hidden;
  margin-bottom: 32px;
}

/* Section titles ─────────────────────────────────────────────── */

.section-title {
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--text);
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}

.section-title .icon {
  width: 24px;
  height: 24px;
  color: var(--cat, var(--pink));
  flex-shrink: 0;
}

/* The entry count, carried by the heading since the `ls posts/` prompt line
   was removed. Lighter and quieter than the title: it is a fact about the
   list, not part of its name. */
.section-count {
  font-weight: 400;
  color: var(--overlay0);
  font-size: 0.92rem;
  font-variant-numeric: tabular-nums;
}

.section { margin-bottom: 48px; }

.section .stagger {
  opacity: 0;
  transform: translateY(8px);
  animation: reveal 0.6s var(--ease-out) forwards;
}

@keyframes reveal {
  to { opacity: 1; transform: translateY(0); }
}

.stagger:nth-child(1) { animation-delay: 0.10s; }
.stagger:nth-child(2) { animation-delay: 0.20s; }
.stagger:nth-child(3) { animation-delay: 0.30s; }
.stagger:nth-child(4) { animation-delay: 0.40s; }
.stagger:nth-child(5) { animation-delay: 0.50s; }

/* Prompt / terminal syntax ──────────────────────────────────── */

.prompt {
  color: var(--subtext0);
  overflow-wrap: break-word;
  word-break: break-word;
  margin-bottom: 4px;
  font-size: clamp(0.82rem, 1.1vw, 0.92rem);
}

.prompt .user   { color: var(--pink); }
.prompt .at     { color: var(--subtext0); }
.prompt .host   { color: var(--blue); }
.prompt .colon  { color: var(--subtext0); }
.prompt .path   { color: var(--mauve); }
.prompt .dollar { color: var(--text); padding: 0 4px 0 2px; }
.prompt .cmd    { color: var(--yellow); }
.prompt .arg    { color: var(--peach); }
.prompt-hint    { color: var(--overlay0); font-style: italic; }

/* Post list ──────────────────────────────────────────────────── */

/* Magazine hierarchy: the newest post runs the full measure at display size,
   everything after it falls into a quieter two-column stream. The point is
   that one thing is the most important thing on the page.

   Grid, not multi-column. Multicol was the earlier choice because it fills a
   column top to bottom before starting the next, but it cannot give every
   entry the same height -- items take whatever height their text wants. A grid
   can, and the cost is only that reading order becomes left-to-right along a
   row instead of down a column, which for a date-ordered list is still
   chronological and is the order most readers assume anyway. */
.post-list {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: 46px;
  /* The lead is set at display size and needs air above it. */
  margin-top: 26px;
}

.post-item {
  /* Anchor for the stretched link that makes the whole entry clickable. */
  position: relative;
}

/* The lead uses the WHOLE width: headline in the left column, summary and the
   read-it line in the right. Previously both were capped and stacked, so the
   feature occupied the left half and left the right half empty. Two columns
   fill the frame without letting either the headline or the summary grow to an
   unreadable line length -- the column does the capping the max-width used to. */
.post-item.is-lead {
  grid-column: 1 / -1;
  /* Same bleed technique as the stream tiles: the negative margin lets the
     hover surface run past the text while the matching padding keeps the text
     itself exactly where it was. */
  padding: 12px 14px 40px;
  margin: 0 -14px;
  border: 1px solid transparent;
  border-radius: 4px;
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
  column-gap: clamp(32px, 5vw, 64px);
  align-items: start;
  transition: background-color 0.18s var(--ease-out),
              border-color 0.18s var(--ease-out),
              box-shadow 0.22s var(--ease-out),
              transform 0.22s var(--ease-out);
}

/* The tab is clipped to the top edge, so there is no room above it. */
.post-item.is-lead.has-tab { padding-top: 0; }

/* "Latest post" tab, clipped to the top edge of the lead. The rule runs the
   full width so the tab reads as attached to the block rather than floating
   above it. The wording is deliberate: "latest" stays true forever, so unlike
   a "new" badge this never needs an expiry date to stop lying. */
/* `.post-list` prefix is load-bearing: it outranks the hover rule below, which
   sets all four border colours. Without it the hover would repaint this rule
   at half strength and the tab's edge would go PALER on hover. */
.post-list .post-item.is-lead.has-tab { border-top: 2px solid var(--cat, var(--mauve)); }

.post-tab {
  grid-column: 1 / -1;
  margin-bottom: 18px;
  line-height: 1;
}

.post-tab-label {
  display: inline-block;
  background: var(--cat, var(--mauve));
  color: var(--base);
  font-size: 0.58rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  padding: 4px 11px 3px;
  border-radius: 0 0 3px 3px;
}

/* Rows are placed explicitly because the headline has to span the two rows the
   summary and the tail occupy beside it. That means the tab, when present,
   shifts every row down by one: leaving these at their no-tab values pushed
   the kicker past the pinned rows and it reappeared UNDER the headline. */
.is-lead .post-kicker { grid-column: 1 / -1; grid-row: 1; }
.is-lead .post-item-title { grid-column: 1; grid-row: 2 / span 2; }
.is-lead .post-item-summary { grid-column: 2; grid-row: 2; }
.is-lead .post-tail { grid-column: 2; grid-row: 3; }

.is-lead.has-tab .post-tab { grid-row: 1; }
.is-lead.has-tab .post-kicker { grid-row: 2; }
.is-lead.has-tab .post-item-title { grid-row: 3 / span 2; }
.is-lead.has-tab .post-item-summary { grid-row: 3; }
.is-lead.has-tab .post-tail { grid-row: 4; }

/* The lead with a hero gains a picture column on the left and everything else
   moves one column right. The columns are re-declared rather than the old
   placements nudged, because these grid-column values are absolute: leaving
   them at 1 and 2 would drop the headline underneath the picture. */
.post-item.is-lead.has-thumb {
  grid-template-columns: minmax(0, 0.62fr) minmax(0, 1.1fr) minmax(0, 1fr);
}
/* `span 3`, never `1 / -1`. With no grid-template-rows declared the explicit
   row grid is empty, so -1 resolves to line 1 and `2 / -1` normalises to
   `1 / 2`: the picture lands in row 1 and prints under the "Latest post" tab.
   An explicit span is the only form that spans implicit rows. */
.is-lead.has-thumb .post-thumb { grid-column: 1; grid-row: 1 / span 3; align-self: center; }
/* The tab occupies row 1 when present, so everything below shifts down one. */
.is-lead.has-tab.has-thumb .post-thumb { grid-row: 2 / span 3; }
.is-lead.has-thumb .post-kicker { grid-column: 2 / -1; }
.is-lead.has-thumb .post-item-title { grid-column: 2; }
.is-lead.has-thumb .post-item-summary { grid-column: 3; }
.is-lead.has-thumb .post-tail { grid-column: 3; }

/* Stream entries: the same idea at a quarter of the width. The picture spans
   every row so the text block sits beside it as one unit rather than wrapping
   under it. */
.post-item:not(.is-lead).has-thumb {
  display: grid;
  grid-template-columns: minmax(0, 0.28fr) minmax(0, 1fr);
  column-gap: 14px;
  align-content: start;
  /* The picture takes a quarter of the width, so the same headline needs more
     lines than it did full-width and --preview-h stops fitting: titles were
     being sliced mid-word by the overflow:hidden backstop. Height becomes a
     floor rather than a cap, and the grid row equalises the pair anyway, so
     cards still line up without truncating anyone. */
  height: auto;
  min-height: var(--preview-h);
}

/* One more line each, for the same reason: a narrower column turns a two-line
   headline into three. */
.post-item:not(.is-lead).has-thumb .post-item-title { -webkit-line-clamp: 3; }
.post-item:not(.is-lead).has-thumb .post-item-summary { -webkit-line-clamp: 4; }
.post-item:not(.is-lead).has-thumb .post-thumb { grid-column: 1; grid-row: 1 / span 3; align-self: center; }
.post-item:not(.is-lead).has-thumb > :not(.post-thumb) { grid-column: 2; }

/* object-fit: contain, never cover. These are transparent figures of single
   objects, so cropping to fill a box cuts the subject in half; letterboxing
   inside the column costs nothing because the background shows through anyway. */
.post-thumb {
  display: block;
  width: 100%;
  height: auto;
  max-height: 100%;
  object-fit: contain;
  border-radius: 3px;
  /* A literal #1e1e2e, not var(--base): these are transparent figures drawn
     for the dark page, and at thumbnail size their cream line work vanishes
     against a light one. Giving the plate the colour they were designed for
     keeps them legible in both themes. On dark it is invisible against the
     page; on light it reads as a deliberate dark chip. */
  background: #1e1e2e;
  padding: 6px;
}

/* Each stream entry is a discrete object: a rule above it, its own padding,
   and a surface that appears under the pointer. Without the rule the entries
   ran together into one grey slab.

   The rule sits on EVERY entry rather than only between them, so both columns
   open on the same line and the column-rule closes the corner. That is also
   why the lead carries no bottom border: it would double up against these. */
.post-item:not(.is-lead) {
  /* All four borders exist but only the top one is visible: the rest are
     transparent placeholders so the entry can outline itself on hover without
     the box growing by 2px and nudging the text at the moment of interaction. */
  border: 1px solid transparent;
  border-top-color: var(--surface0);
  border-radius: 4px;
  /* The negative margin bleeds the surface and the rule into the gutter while
     the matching padding holds the TEXT on the column edge, so nothing shifts
     out of alignment with the lead above. */
  padding: 20px 14px 22px;
  margin: 0 -14px;
  transition: background-color 0.18s var(--ease-out),
              border-color 0.18s var(--ease-out),
              box-shadow 0.22s var(--ease-out),
              transform 0.22s var(--ease-out);

  /* Every preview is the same box. --preview-h is sized to hold the kicker, a
     two-line headline and a three-line summary; anything longer is clamped
     below rather than allowed to push this entry taller than its neighbours.
     overflow:hidden is the backstop, not the mechanism -- the line clamps do
     the work, so text ends in an ellipsis instead of a sliced glyph. */
  height: var(--preview-h);
  overflow: hidden;
}

/* Hover feedback, four signals at once: the surface tints, the entry outlines
   itself in its category colour, it lifts, and everything inside it grows.

   The growth is a `scale` on the whole entry, NOT a font-size change. Scaling
   is a transform, so it costs no layout: the text, the category, the date and
   the summary all grow together, the fixed 200px height is unaffected, and the
   line clamps do not recompute and re-clip mid-hover. Animating font-size
   would do all three of those things. */
/* @media (hover: hover) as the CV does: on a touch screen :hover latches after
   a tap, and a tile left permanently lifted and outlined reads as selected. */
@media (hover: hover) {
  .post-item:hover {
    background: color-mix(in srgb, var(--cat, var(--mauve)) 8%, transparent);
    border-color: color-mix(in srgb, var(--cat, var(--mauve)) 50%, transparent);
    box-shadow: 0 8px 22px rgb(0 0 0 / 0.16);
    /* Lifted, so it must draw over its neighbours rather than under them. */
    z-index: 2;
  }

  .post-item:not(.is-lead):hover { transform: translateY(-3px) scale(1.025); }

  /* The lead is twice as wide, so the same 2.5% would throw its edges 13px
     outward against the tiles' 6px and read as a different, heavier effect.
     Scaled to move the same NUMBER OF PIXELS instead of the same percentage,
     which is what makes the two feel like one behaviour. */
  .post-item.is-lead:hover { transform: translateY(-3px) scale(1.012); }
}

/* The scale would otherwise animate on a reader who asked for no motion. The
   colour and outline still change, so the feedback survives. */
@media (prefers-reduced-motion: reduce) {
  .post-item:hover { transform: none; }
}

/* The whole entry is the click target, not the ~30 characters of headline.
   The real <a> stays on the title so the link keeps its semantics, its
   keyboard focus and its href in the status bar; this only stretches its hit
   area to the box that the hover surface already promised was clickable. */
.post-item-title a::after {
  content: '';
  position: absolute;
  inset: 0;
}

/* Links nested inside an entry must sit above the stretched overlay or they
   stop being clickable. The category reference and the lead's "Read it" are
   exactly that case. */
.post-kicker a,
.post-tail a {
  position: relative;
  z-index: 1;
}

/* Kicker: category, then date. Uppercase and letterspaced so it reads as
   metadata at a glance and never competes with the headline it sits above.
   --cat is set by the .cat-* class from the post's first category, so the
   accent still tells you what a post is about before you read the title. */
.post-kicker {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
  font-size: 0.69rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
}

.is-lead .post-kicker {
  font-size: 0.72rem;
  letter-spacing: 0.13em;
}

.post-kicker a {
  color: var(--cat, var(--teal));
  text-decoration: none;
}

.post-kicker a:hover { text-decoration: underline; }

.post-date {
  color: var(--overlay0);
  letter-spacing: 0.03em;
  text-transform: none;
}


.post-item-title {
  font-family: var(--font-prose);
  font-size: 1.18rem;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.01em;
  margin: 7px 0 6px;
  display: flex;
  align-items: baseline;
}

/* Two lines of headline, three of summary, ellipsis past that. The clamp is
   presentation only: the full title and summary stay in the markup, so search
   engines and screen readers still get the whole string. */
.post-item:not(.is-lead) .post-item-title,
.post-item:not(.is-lead) .post-item-summary {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.post-item:not(.is-lead) .post-item-title { -webkit-line-clamp: 2; }
.post-item:not(.is-lead) .post-item-summary { -webkit-line-clamp: 3; }

.is-lead .post-item-title {
  font-size: clamp(1.75rem, 3.6vw, 2.6rem);
  line-height: 1.12;
  letter-spacing: -0.022em;
  /* No max-width: the grid column is the measure now. */
  margin: 14px 0 0;
  text-wrap: balance;
}

.post-item-title a {
  color: var(--text);
  text-decoration: none;
  transition: color 0.18s;
}

/* Hovering anywhere in the entry colours the headline, so the feedback tells
   you the whole box is the link rather than just the words under the pointer. */
.post-item:hover .post-item-title a { color: var(--cat, var(--pink)); }

.post-tail {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-top: 22px;
  font-size: 0.8rem;
}

.post-more {
  color: var(--cat, var(--pink));
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.post-arrow { transition: transform 0.2s var(--ease-out); }
.post-more:hover .post-arrow { transform: translateX(4px); }

.post-read { color: var(--overlay0); }
.post-read::before {
  content: '/';
  color: var(--surface2);
  margin-right: 16px;
}

/* One column below this: two columns of ~20ch is not a stream, it is a
   squeeze. The rule between columns becomes a rule between rows. */
@media (max-width: 760px) {
  .post-list {
    grid-template-columns: minmax(0, 1fr);
  }
  /* One column is not two: the lead stacks and the grid placement goes with it,
     otherwise the headline keeps trying to span rows that no longer exist. */
  .post-item.is-lead {
    display: block;
    padding: 12px 10px 30px;
    margin: 0 -10px;
  }
  /* display:block above drops the lead out of grid, so its picture column goes
     with it: the hero becomes a banner across the stacked card instead. */
  .post-item.is-lead.has-thumb .post-thumb {
    width: 100%;
    margin-bottom: 14px;
  }
  /* The stream keeps its picture column on a phone, just a narrower one. At
     this width a stacked thumbnail would push the headline below the fold of
     the card and the list would stop being scannable. */
  .post-item:not(.is-lead).has-thumb {
    grid-template-columns: minmax(0, 0.3fr) minmax(0, 1fr);
    column-gap: 12px;
  }
  .post-item.is-lead.has-tab { padding-top: 0; }
  /* The bleed has to shrink with the frame padding, which drops to 16px here:
     a 14px bleed would sit 2px from the viewport edge. */
  .post-item:not(.is-lead) {
    padding-left: 10px;
    padding-right: 10px;
    margin: 0 -10px;
  }
}

.post-meta {
  color: var(--overlay1);
  font-size: 0.78rem;
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.post-meta .icon { width: 13px; height: 13px; }

.post-meta a {
  color: var(--cat, var(--teal));
  text-decoration: none;
}
.post-meta a:hover { text-decoration: underline; }

/* Inline category reference: small icon plus the display title.
   On a post card this anchor sits ON TOP of the title's stretched click
   overlay, so it is a genuinely different destination inside the tile. The
   vertical padding makes it a deliberate target instead of a 19px sliver that
   a thumb hits by accident on the way to the post. */
.cat-ref {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 6px 0;
}
.cat-ref .icon { width: 12px; height: 12px; }

.post-item-summary {
  font-family: var(--font-prose);
  font-size: 0.97rem;
  color: var(--subtext0);
  line-height: 1.5;
}

.is-lead .post-item-summary {
  font-size: 1.12rem;
  line-height: 1.6;
  /* 14px, matching the headline's top margin, so the two columns start on the
     same line rather than one sitting 2px lower than the other. */
  margin-top: 14px;
}

/* Filtering ──────────────────────────────────────────────────
   .filtered-out hides rather than removes, so the DOM order and the reveal
   animation are untouched when a filter is cleared. */
.post-item.filtered-out { display: none; }

.cat-chip[aria-pressed="true"] {
  color: var(--cat, var(--pink));
  border-color: var(--cat, var(--pink));
  background: color-mix(in srgb, var(--cat, var(--pink)) 14%, transparent);
}

/* The [hidden] attribute only sets display:none in the UA stylesheet, so ANY
   author display rule silently beats it. Without this the status line shows
   with no filter active. */
[hidden] { display: none !important; }

.filter-tally {
  color: var(--overlay0);
  font-variant-numeric: tabular-nums;
}

.filter-status {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  margin-top: 10px;
  font-size: 0.78rem;
}

.filter-open {
  display: inline-block;
  padding: 11px 4px;
  color: var(--blue);
  text-decoration: none;
}
.filter-open:hover { text-decoration: underline; }

.filter-clear {
  background: none;
  border: none;
  border-bottom: 1px dashed var(--overlay1);
  color: var(--overlay1);
  font-family: inherit;
  font-size: inherit;
  padding: 11px 4px;
  cursor: pointer;
}
.filter-clear:hover { color: var(--pink); border-bottom-color: var(--pink); }

/* Category chips ─────────────────────────────────────────────── */

/* Heading on the left, category filter on the right, one row.
   `align-items: baseline` rather than center: the chips are small and the
   heading is large, and centring them makes the chips look like they are
   floating rather than sitting on the same line as the words. */
.posts-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px 20px;
  flex-wrap: wrap;
  margin-bottom: 6px;
}

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

/* Index: no heading, so the chips are the row. Left-aligned and uncapped,
   because with the heading gone there is nothing to sit opposite and the row
   should line up with the posts underneath rather than float against the right
   edge. All seven fit on one line at full width. */
.posts-head.is-bare {
  justify-content: flex-start;
  margin-bottom: 10px;
}

.posts-head.is-bare .cat-list {
  justify-content: flex-start;
  max-width: none;
}

.cat-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  list-style: none;
  justify-content: flex-end;
  /* Two deliberate constraints.

     `flex: 0 1 auto` with a max-width, NOT `flex: 1 1 auto` alone: flexbox
     wraps an over-wide item onto its own row BEFORE it considers shrinking it,
     which is why the whole bar dropped below the heading. Capping the width
     makes the list fit on the heading's line and wrap INTERNALLY instead.

     58% is what balances the two rows. Left free, the chips filled the line and
     left a single one stranded on the second row; at this width they break
     four and three. It is a proportion, not a pixel value, so the balance
     holds as the frame narrows. */
  flex: 0 1 auto;
  max-width: 58%;
  min-width: 0;
}

@media (max-width: 899px) {
  .posts-head { display: block; }
  .posts-head .section-title { margin-bottom: 10px; }
  .cat-list {
    justify-content: flex-start;
    max-width: none;
  }
}

/* The primary filter control on the index. 10px vertical padding takes it
   from 31px to 44px, which also opens the gap between adjacent chips. */
.cat-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 10px 12px;
  border: 1px solid var(--surface1);
  border-radius: 4px;
  color: var(--subtext0);
  font-size: 0.78rem;
  text-decoration: none;
  transition: color 0.2s, border-color 0.2s, background 0.2s;
}

.cat-chip .icon {
  width: 13px;
  height: 13px;
  color: var(--cat, var(--overlay1));
}

.cat-chip:hover {
  color: var(--cat, var(--pink));
  border-color: var(--cat, var(--pink));
  background: var(--surface0);
}

.cat-count { color: var(--overlay0); }

/* The one chip that LEAVES. Every other chip filters the list in place, so a
   chip that navigates to another page needs to say so before it is clicked:
   identical affordance, different outcome, is the kind of thing a reader
   notices only as the page unexpectedly changing under them. The arrow is
   decoration for a fact the href already carries, so it is aria-hidden by
   being generated content, which assistive tech does not announce here. */
.cat-chip.is-shelf::after {
  content: "\2192";
  color: var(--cat, var(--overlay1));
  /* Reserve the space at rest so hover does not resize the chip and shove the
     row along. Interactions must not move unrelated elements. */
  opacity: 0.55;
  transition: opacity 0.2s, transform 0.2s;
}

.cat-chip.is-shelf:hover::after {
  opacity: 1;
  transform: translateX(2px);
}

@media (prefers-reduced-motion: reduce) {
  .cat-chip.is-shelf:hover::after { transform: none; }
}

/* Post ───────────────────────────────────────────────────────── */

.post-header { margin-bottom: 28px; }

.post-title {
  font-size: clamp(1.5rem, 4vw, 2rem);
  font-weight: 700;
  line-height: 1.3;
  color: var(--text);
  margin-bottom: 8px;
  /* Same hang as the prose headings: the icon sits in the left margin so the
     TITLE TEXT keeps the edge shared by the prompt line above it and the meta
     line below it. Indenting the title instead would step it out of that
     column, which is the one thing the header cannot afford. */
  display: grid;
  grid-template-columns: 1.15em 1fr;
  align-items: baseline;
  column-gap: 0.4em;
  margin-left: -1.55em;
  /* A headline is author text and may contain a package name or a domain with
     no break opportunity in it; at 320px one such token would be clipped. */
  overflow-wrap: anywhere;
}

/* The title icon is the CATEGORY icon, in the category colour. lucide-icon.html
   emits class="icon", so this targets that inside .post-title rather than
   inventing a second class for the same shape. */
.post-title > .icon {
  width: 1em;
  height: 1em;
  transform: translateY(0.06em);
  justify-self: end;
  color: var(--cat, var(--overlay1));
}

/* No margin to hang into on a narrow screen. */
@media (max-width: 760px) {
  .post-title { margin-left: 0; grid-template-columns: 1em 1fr; }
}

/* Prose: the one deliberate divergence from hub and cv ───────── */

.prose {
  font-family: var(--font-prose);
  font-size: 1.12rem;
  /* 1.8, up from 1.75, and the two numbers below are why. Leading is not
     decoration at this measure: the longer a line, the further the eye travels
     back to find the start of the next one, and the easier it is to land on the
     line it just read. Every step wider costs a little leading to stay tracked.
     Set with the measure, not independently of it. */
  line-height: 1.8;
  color: var(--subtext1);
  /* 90ch, measuring about 88 real characters. Widened twice on request, from
     68ch (66 characters) through 80ch (78).

     Stated plainly because it is a real trade and was made deliberately: the
     classic comfortable range is 45-75 characters, so this sits past it. The
     cost is landed on by long paragraphs, which is why the leading above went
     up with it, and why Cathedral should stay at the lower end of its
     paragraph range on this measure. */
  max-width: 90ch;
}

.prose p { margin: 0 0 1.15em; }

.prose h2,
.prose h3,
.prose h4 {
  font-family: var(--font-mono);
  color: var(--text);
  line-height: 1.35;
  margin: 2em 0 0.6em;
}

.prose h2 { font-size: 1.2rem; font-weight: 700; }
.prose h3 { font-size: 1.02rem; font-weight: 600; }
.prose h4 { font-size: 0.94rem; font-weight: 600; }

/* Heading icons ─────────────────────────────────────────────────
   _markup/render-heading.html puts a Lucide icon on every heading. The icon
   carries the accent; the text stays --text.

   h3 and h4 used to be hardcoded --mauve and --teal. That predated the accent
   and fought it: a Cyber Security post is red everywhere else and had mauve
   subheadings. Depth is now carried by size and weight, which the type scale
   above already does, and colour means one thing on the page instead of two.

   The icon hangs in the left margin rather than indenting the text, so every
   heading and every paragraph share one left edge. Losing that edge is what
   makes iconed headings read as a template. */
.prose h2,
.prose h3,
.prose h4 {
  display: grid;
  grid-template-columns: 1.5em 1fr;
  align-items: baseline;
  column-gap: 0.45em;
  margin-left: -1.95em;
}

.prose-h-icon {
  width: 1em;
  height: 1em;
  /* Optical, not geometric. A 1em box sits high against a mono cap-height, and
     the eye reads the mismatch as a mistake before it reads the icon. */
  transform: translateY(0.11em);
  justify-self: end;
  color: var(--cat, var(--overlay1));
  flex: none;
}

/* Below the frame there is no left margin to hang into. Collapse to a normal
   inline row rather than pushing the icon off-screen. */
@media (max-width: 760px) {
  .prose h2,
  .prose h3,
  .prose h4 { margin-left: 0; grid-template-columns: 1.2em 1fr; }
}

/* Section numbers ───────────────────────────────────────────────
   1. on h2, 1.1. on h3, so a reader always knows where they are in the
   post. CSS counters rather than markup: the browser numbers whatever is
   visible, which keeps the contents list (same counters, below) in step
   with the prose for free, and gives each edition of a versioned post its
   own numbering -- hidden edition blocks generate no boxes, so they never
   inflate the count. The counter resets per .prose, one per edition. */
.prose { counter-reset: sec; }
.prose h2 { counter-increment: sec; counter-reset: subsec; }
.prose h3 { counter-increment: subsec; counter-reset: subsubsec; }
.prose h4 { counter-increment: subsubsec; }

.prose-h2 > .prose-h-text::before {
  content: counter(sec) '. ';
  color: var(--overlay1);
}

.prose-h3 > .prose-h-text::before {
  content: counter(sec) '.' counter(subsec) '. ';
  color: var(--overlay1);
}

.prose-h4 > .prose-h-text::before {
  content: counter(sec) '.' counter(subsec) '.' counter(subsubsec) '. ';
  color: var(--overlay1);
}

.prose a {
  color: var(--blue);
  text-decoration: none;
  border-bottom: 1px solid var(--surface2);
  transition: border-color 0.2s, color 0.2s;
}
.prose a:hover { color: var(--lavender); border-bottom-color: var(--lavender); }

.prose strong { color: var(--text); font-weight: 600; }
.prose em { font-style: italic; }

.prose ul, .prose ol { margin: 0 0 1.15em 1.4em; }
.prose li { margin-bottom: 0.4em; }
.prose li::marker { color: var(--overlay1); }

.prose blockquote {
  border-left: 2px solid var(--surface2);
  padding-left: 16px;
  margin: 0 0 1.15em;
  color: var(--subtext0);
  font-style: italic;
}

.prose hr {
  border: none;
  border-top: 1px solid var(--surface0);
  margin: 2em 0;
}

/* Verse, emitted by the `poem` shortcode. One .poem-line per source line,
   one .poem-stanza per blank-line-separated block. */
.poem {
  margin: 0 0 1.15em;
  /* Verse sets its own measure. The prose column is 68ch because that is a
     comfortable run of continuous text; a poem's line length is CHOSEN by the
     poem, and a column wide enough for prose invites the long line to sprawl
     until the shape stops being visible. Narrower, and the shape is the first
     thing the reader sees. */
  max-width: 46ch;
}

.poem-stanza {
  /* A stanza break is a change of pressure, so it gets more room than a
     paragraph break does, and the gap has to read as deliberate rather than as
     slack leading. */
  margin: 0 0 1.6em;
}

.poem-stanza:last-child { margin-bottom: 0; }

.poem-line {
  display: block;
  /* Hanging indent. A line too long for the column has to read as the
     continuation of the line above rather than as a new line, or the poem's
     line count silently changes with the viewport. This is how verse has been
     set in print for centuries and its absence is the tell of a site that does
     not take poems seriously. */
  padding-left: 2ch;
  text-indent: -2ch;
  /* Left, never centred, never justified. Centred verse is the amateur tell,
     and justification stretches the word spacing of lines whose spacing is the
     rhythm. Stated so nobody "improves" it later. */
  text-align: left;
  /* Verse is set slightly tighter than prose: at 1.75 the lines of a stanza
     stop reading as a group. */
  line-height: 1.55;
}

/* Literal leading whitespace, preserved by the shortcode rather than passed
   through markdownify, where four spaces would have become a code block. */
.poem-indent { white-space: pre; }

.prose img {
  max-width: 100%;
  height: auto;
  border-radius: 4px;
  /* Same literal #1e1e2e as the index thumbnails, and for the same reason: the
     figures are transparent PNGs drawn for the dark page, so on the light one
     their cream line work would sit on near-white and disappear. The plate
     gives them the ground they were authored against. Invisible on dark. */
  background: #1e1e2e;
  padding: 10px;
}

/* SVG figures are theme-aware by construction (see figures.md), so they must
   NOT get the plate: it would pin them to dark and defeat their media query. */
.prose img[src$=".svg"] {
  background: none;
  padding: 0;
}

/* Scroll container emitted by layouts/_default/_markup/render-table.html.
   A table wider than the reading column scrolls inside this box instead of
   running off the page and being clipped by body{overflow-x:hidden}. */
.table-wrap {
  overflow-x: auto;
  margin: 0 0 1.15em;
  /* Keeps the focus ring off the table's own border when tabbed to. */
  border-radius: 4px;
}

.table-wrap:focus-visible {
  outline: 2px solid var(--teal);
  outline-offset: 2px;
}

.table-wrap table { margin-bottom: 0; }

/* Alignment classes, because the CSP forbids the inline style attribute
   Goldmark would otherwise be asked to emit. */
.prose .ta-left { text-align: left; }
.prose .ta-center { text-align: center; }
.prose .ta-right { text-align: right; }

.prose table {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--font-mono);
  font-size: 0.86rem;
  margin: 0 0 1.15em;
}
.prose th, .prose td {
  border: 1px solid var(--surface0);
  padding: 6px 10px;
  text-align: left;
}
.prose th { color: var(--text); background: var(--mantle); font-weight: 600; }

/* Code ───────────────────────────────────────────────────────── */

.prose code {
  font-family: var(--font-mono);
  font-size: 0.86em;
  background: var(--surface0);
  color: var(--peach);
  padding: 1px 5px;
  border-radius: 3px;
}

/* Chroma wraps highlighted blocks in <div class="highlight">, so the outer
   margin belongs on the wrapper or highlighted and unhighlighted blocks end up
   spaced differently. */
.prose .highlight { margin: 0 0 1.15em; }

.prose pre {
  background: var(--mantle);
  border: 1px solid var(--surface0);
  border-radius: 4px;
  padding: 14px 16px;
  margin: 0 0 1.15em;
  overflow-x: auto;
  font-size: 0.84rem;
  line-height: 1.6;
}

.prose .highlight pre { margin: 0; }

.prose pre code {
  background: none;
  color: var(--text);
  padding: 0;
  font-size: inherit;
}

/* Chroma syntax classes. Colours come from the shared tokens so both
   themes work without a second stylesheet. `noClasses = false` in
   hugo.toml is what makes Chroma emit these instead of inline styles,
   which is what lets the CSP stay `style-src 'self'`. */
.prose .chroma .c, .prose .chroma .cm, .prose .chroma .c1, .prose .chroma .cs { color: var(--overlay0); font-style: italic; }
.prose .chroma .k, .prose .chroma .kd, .prose .chroma .kn, .prose .chroma .kr, .prose .chroma .kt { color: var(--mauve); }
.prose .chroma .kc { color: var(--peach); }
.prose .chroma .s, .prose .chroma .s1, .prose .chroma .s2, .prose .chroma .sb, .prose .chroma .sd { color: var(--green); }
.prose .chroma .m, .prose .chroma .mi, .prose .chroma .mf { color: var(--peach); }
.prose .chroma .nf, .prose .chroma .fm { color: var(--blue); }
.prose .chroma .nb { color: var(--teal); }
.prose .chroma .nc, .prose .chroma .nn { color: var(--yellow); }
.prose .chroma .nv, .prose .chroma .vi { color: var(--text); }
.prose .chroma .o, .prose .chroma .ow { color: var(--teal); }
.prose .chroma .p { color: var(--overlay1); }
.prose .chroma .err { color: var(--red); }
.prose .chroma .ln, .prose .chroma .lnt { color: var(--overlay0); }

/* Category accents. Each sets a single --cat custom property which the rules
   above consume, so adding a colour to a category is one line in its
   _index.md and nothing else. Classes, never inline styles, so style-src
   stays 'self'. */
.cat-pink { --cat: var(--pink); }
.cat-mauve { --cat: var(--mauve); }
.cat-green { --cat: var(--green); }
.cat-blue { --cat: var(--blue); }
.cat-yellow { --cat: var(--yellow); }
.cat-peach { --cat: var(--peach); }
.cat-red { --cat: var(--red); }
.cat-teal { --cat: var(--teal); }
.cat-lavender { --cat: var(--lavender); }
.cat-sky { --cat: var(--sky); }

/* Inline colour, driven by the `c` shortcode. Classes, never inline styles,
   so style-src stays 'self'. */
.c-pink { color: var(--pink); }
.c-mauve { color: var(--mauve); }
.c-green { color: var(--green); }
.c-blue { color: var(--blue); }
.c-yellow { color: var(--yellow); }
.c-peach { color: var(--peach); }
.c-red { color: var(--red); }
.c-teal { color: var(--teal); }
.c-lavender { color: var(--lavender); }
.c-subtext0 { color: var(--subtext0); }
.c-overlay1 { color: var(--overlay1); }

.prose .icon-inline {
  width: 1em;
  height: 1em;
  vertical-align: -0.13em;
  margin-right: 0.1em;
}

/* Figures: markdown images get a caption from their alt text. */
.prose figure { margin: 0 0 1.4em; }
.prose figure img { display: block; width: 100%; }
.prose figcaption {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--overlay1);
  text-align: center;
  margin-top: 8px;
}

/* Footnotes ──────────────────────────────────────────────────── */

/* Hugo emits its own <hr> immediately before the footnote list, so a
   border-top here renders a second rule right under the first. */
.prose .footnotes hr { display: none; }

.footnotes {
  margin-top: 2.5em;
  padding-top: 1em;
  border-top: 1px solid var(--surface0);
  font-size: 0.94rem;
  color: var(--subtext0);
}
.footnotes ol { margin-left: 1.2em; }
.footnote-backref { border-bottom: none; }

/* Contents ───────────────────────────────────────────────────
   Rendered as the output of the `grep '^#\+'` in its own summary line, so it
   reads as one more terminal transcript rather than a widget bolted onto a
   reading page. Chrome, not prose: JetBrains Mono and a size below the body
   text, so the eye passes over it on the way into the essay. */

.post-toc {
  margin: 0 0 28px;
  border: 1px solid var(--surface0);
  border-radius: 4px;
  background: var(--mantle);
  font-size: 0.82rem;
}

.post-toc > summary {
  /* 12px vertical, not 9px: brings the row to 44px so it is a legitimate touch
     target rather than the 40px it was. */
  padding: 12px 14px;
  cursor: pointer;
  list-style: none;          /* Firefox */
  display: flex;
  align-items: center;
  /* The command line is `$ grep '^#\+' <filename>.md`. `grep` and the pattern
     are at their min-content width and cannot shrink, so on a narrow screen
     the entire deficit lands on the filename, which broke mid-token after a
     hyphen. Wrapping lets the count drop to a second line intact instead. */
  flex-wrap: wrap;
  gap: 4px 6px;
  color: var(--subtext0);
  user-select: none;
}

/* The default disclosure triangle is replaced by a caret that matches the one
   the CV uses on its project groups. Both vendor rules are needed: Safari
   still ships ::-webkit-details-marker. */
.post-toc > summary::-webkit-details-marker { display: none; }
.post-toc > summary::marker { content: ''; }

.post-toc > summary::before {
  content: '\25B8';          /* ▸ */
  color: var(--overlay0);
  transition: transform 0.2s ease;
  display: inline-block;
}
.post-toc[open] > summary::before { transform: rotate(90deg); }

.post-toc > summary:hover { color: var(--text); }
.post-toc > summary:hover .cmd { color: var(--yellow); }

/* Reuses the prompt token colours without nesting inside .prompt, which
   carries its own margins meant for a standalone line. */
.post-toc .dollar { color: var(--text); padding-right: 2px; }
.post-toc .cmd    { color: var(--yellow); }
.post-toc .arg    { color: var(--peach); }

.post-toc-count {
  margin-left: auto;
  color: var(--overlay0);
  font-size: 0.76rem;
  white-space: nowrap;
}

/* The heading count is the useful part; on a narrow screen the noun is the
   first thing that can go. */
@media (max-width: 480px) {
  .post-toc-noun { display: none; }
}

#TableOfContents {
  padding: 2px 14px 12px;
  border-top: 1px solid var(--surface0);
  margin-top: 2px;
}

#TableOfContents ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

#TableOfContents li { margin: 0; }

/* 8px vertical padding, not 3px. These are stacked links a reader taps with a
   thumb; at 3px they were 26px tall with no gap between adjacent targets. */
#TableOfContents a {
  display: block;
  /* 12px, giving a 44px row: the base line box is only ~20px, so 8px was still
     8px short of a comfortable target. */
  padding: 12px 0;
  color: var(--subtext0);
  text-decoration: none;
  line-height: 1.5;
}

#TableOfContents a:hover { color: var(--blue); }

/* The ## / ### markers come from the nesting depth rather than from the
   markup, so the heading level is never encoded twice. */
#TableOfContents > ul > li > a::before {
  content: '## ';
  color: var(--overlay0);
}

/* Each entry repeats its heading's Lucide icon (single.html renders it from
   the same resolution render-heading.html made), in the same category accent
   as the icons in the prose. Sized against the mono line rather than reusing
   .prose-h-icon, whose grid placement only makes sense inside a heading. */
.post-toc-icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  margin-right: 0.4em;
  color: var(--cat, var(--overlay1));
}

/* The same section numbers the prose headings carry, from the same kind of
   counter, so the list and the article cannot drift apart. On the title span
   rather than the link, so the number sits between the icon and the text the
   way it does in a heading. */
#TableOfContents > ul { counter-reset: toc-sec; }
#TableOfContents > ul > li { counter-increment: toc-sec; }
#TableOfContents ul ul { counter-reset: toc-subsec; }
#TableOfContents ul ul > li { counter-increment: toc-subsec; }
#TableOfContents ul ul ul { counter-reset: toc-subsubsec; }
#TableOfContents ul ul ul > li { counter-increment: toc-subsubsec; }

#TableOfContents > ul > li > a > .post-toc-text::before {
  content: counter(toc-sec) '. ';
  color: var(--overlay1);
}

#TableOfContents ul ul .post-toc-text::before {
  content: counter(toc-sec) '.' counter(toc-subsec) '. ';
  color: var(--overlay1);
}

#TableOfContents ul ul ul .post-toc-text::before {
  content: counter(toc-sec) '.' counter(toc-subsec) '.' counter(toc-subsubsec) '. ';
  color: var(--overlay1);
}

#TableOfContents ul ul {
  /* Aligns the ### under the text of the ## above it, not under its marker. */
  padding-left: 18px;
}

#TableOfContents ul ul > li > a::before {
  content: '### ';
  color: var(--overlay0);
}

#TableOfContents ul ul ul {
  /* One character wider than the level above: the #### marker is four
     characters where ### is three, so the extra 6px keeps each level's
     text aligned under its parent's text. */
  padding-left: 24px;
}

#TableOfContents ul ul ul > li > a::before {
  content: '#### ';
  color: var(--overlay0);
}

#TableOfContents ul ul a { font-size: 0.95em; color: var(--overlay1); }

/* Post footer / navigation ───────────────────────────────────── */

/* Older / newer. Two equal columns that collapse to one on a phone. Each side
   carries the accent of its own post's first category, which is what makes
   the pair scannable: you can see you are about to leave the category you are
   reading before you read the headline. */
.post-nav {
  display: grid;
  /* minmax(0, 1fr), not 1fr. A bare `1fr` is `minmax(auto, 1fr)`, whose floor
     is the track's min-content -- so a post title containing one long
     unbreakable token would push the two-column grid wider than the frame.
     .post-list and .is-lead in this file already get this right. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 12px;
  margin-top: 44px;
}

.post-nav-item {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 12px 14px;
  border: 1px solid var(--surface0);
  border-radius: 4px;
  text-decoration: none;
  transition:
    border-color 0.2s ease,
    background-color 0.2s ease,
    box-shadow 0.2s ease,
    /* Exponential ease-out on the lift: the card should decelerate into place
       the way a real object does, not glide linearly. */
    transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}

/* Behind (hover: hover) so a tap on a phone does not leave the card stuck in
   its hovered state until the next tap elsewhere. */
@media (hover: hover) {
  .post-nav-item:hover {
    border-color: var(--cat, var(--surface2));
    background: var(--mantle);
    /* Lift and grow. 1.02 is deliberately small: these two cards sit side by
       side in a grid, and anything larger makes the hovered one crowd its
       neighbour instead of reading as raised above it. */
    transform: translateY(-2px) scale(1.02);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);
  }

  .post-nav-item:hover .post-nav-title { color: var(--cat, var(--text)); }
}

/* Keyboard parity: tabbing to the card gives the same feedback the pointer
   gets, which is the whole point of the effect. */
.post-nav-item:focus-visible {
  outline: none;
  border-color: var(--cat, var(--surface2));
  background: var(--mantle);
  transform: translateY(-2px) scale(1.02);
  box-shadow: 0 0 0 2px var(--cat, var(--teal));
}

/* The arrow leans in the direction it points. Paired with the lift, it is what
   makes the card feel like it is responding rather than just recolouring. */
@media (hover: hover) {
  .post-nav-item.is-older:hover .icon { transform: translateX(-3px); }
  .post-nav-item.is-newer:hover .icon { transform: translateX(3px); }
}

.post-nav-item .icon {
  transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}

@media (prefers-reduced-motion: reduce) {
  /* Keep the colour and shadow feedback, drop the movement. */
  .post-nav-item:hover,
  .post-nav-item:focus-visible { transform: none; }
  .post-nav-item.is-older:hover .icon,
  .post-nav-item.is-newer:hover .icon { transform: none; }
}

/* Only one neighbour exists at the ends of the archive. Without this the sole
   "Newer" card sits in the left column, pointing right from the wrong side. */
.post-nav-item.is-only { grid-column: 2; }

.post-nav-item.is-newer { text-align: right; }

.post-nav-label {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cat, var(--overlay1));
}

.post-nav-item.is-newer .post-nav-label { justify-content: flex-end; }

.post-nav-label .icon { width: 13px; height: 13px; }

.post-nav-title {
  font-family: var(--font-prose);
  font-size: 0.95rem;
  line-height: 1.4;
  color: var(--text);
  /* Titles are author-supplied and can contain a package name, a domain or a
     CVE id with no break opportunity in it. */
  overflow-wrap: anywhere;
}

@media (max-width: 620px) {
  .post-nav { grid-template-columns: 1fr; }
  .post-nav-item.is-only { grid-column: 1; }
  /* Right-aligning a full-width card on a phone just makes it look broken. */
  .post-nav-item.is-newer { text-align: left; }
  .post-nav-item.is-newer .post-nav-label { justify-content: flex-start; }
}

/* Related. A plain dated list, not a third row of cards: the two above are
   already cards, and stacking a third variant of the same idea is what makes
   the bottom of a post feel like a junk drawer. */
.post-related { margin-top: 36px; }

.post-related .prompt { margin-bottom: 10px; }

.post-related-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.post-related-item {
  display: flex;
  align-items: baseline;
  gap: 12px;
  border-bottom: 1px solid var(--surface0);
  font-size: 0.88rem;
}

.post-related-item:last-child { border-bottom: none; }

.post-related-date {
  color: var(--overlay0);
  font-size: 0.78rem;
  flex-shrink: 0;
  font-variant-numeric: tabular-nums;
}

/* The padding lives on the anchor, not the <li>, so the touch target is the
   whole row rather than the 24px of text inside it. */
.post-related-item a {
  display: block;
  padding: 10px 0;
  color: var(--subtext0);
  text-decoration: none;
}

.post-related-item a:hover { color: var(--cat, var(--blue)); }

@media (max-width: 480px) {
  .post-related-item {
    flex-direction: column;
    gap: 2px;
  }
}

.post-footer {
  margin-top: 40px;
  padding-top: 20px;
  border-top: 1px solid var(--surface0);
}

.back-link {
  display: inline-block;
  /* 12px of vertical padding brings a 23px text link to a 44px target. */
  padding: 12px 0;
  color: var(--pink);
  text-decoration: none;
  font-size: 0.86rem;
  border-bottom: 1px dashed var(--pink);
}

/* Recurring end sections ─────────────────────────────────────────
   Curiosity of the week and Quote of the week, rendered by
   partials/post-extras.html from the `curiosity` and `quote` front matter
   fields. Fixed colours rather than the category accent: they are the
   FORMAT's sections, the same pair on every post, and colouring them per
   post would claim they belong to the essay rather than to the week. */

.post-extras {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 14px;
  margin-top: 34px;
}

.post-extra {
  border: 1px solid var(--surface0);
  border-radius: 6px;
  padding: 15px 18px 17px;
  background: var(--mantle);
}

.post-extra-label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  margin-bottom: 9px;
}

.post-extra-label .icon { width: 1.2em; height: 1.2em; }
.post-extra.is-curio .post-extra-label { color: var(--sky); }
.post-extra.is-quote .post-extra-label { color: var(--yellow); }

.post-extra-body {
  font-family: var(--font-prose);
  font-size: 0.96rem;
  line-height: 1.65;
  color: var(--subtext1);
}

.post-extra-body p { margin: 0 0 0.6em; }
.post-extra-body p:last-child { margin-bottom: 0; }

.post-quote-text { font-style: italic; font-size: 1.08rem; }

.post-quote-attr {
  font-family: var(--font-mono);
  font-size: 0.74rem;
  color: var(--overlay1);
  margin-top: 9px;
  text-align: right;
}

@media (max-width: 760px) {
  .post-extras { grid-template-columns: minmax(0, 1fr); }
}

/* The cards render inside .prose (so they share its measure and swap with
   the edition they belong to), where `.prose p` would re-space every line.
   Same specificity, later in the file, so these win; the two-class rules
   below then restore the margins the cards actually want. */
.post-extras p { margin: 0; }
.post-extras .post-extra-label { margin-bottom: 9px; }
.post-extras .post-quote-attr { margin-top: 9px; }

/* Editions ("time machine") ──────────────────────────────────────
   partials/post-editions.html + js/editions.js. A <details> folded shut by
   default, so the version machinery costs a post exactly one quiet line
   until a reader asks for it; same disclosure pattern as the contents
   block above. The accent follows the post's category via --cat. */

.post-editions {
  margin: 0 0 28px;
  border: 1px solid var(--surface0);
  border-radius: 4px;
  background: var(--mantle);
  font-size: 0.82rem;
}

.post-editions > summary {
  padding: 12px 14px;
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px 6px;
  color: var(--subtext0);
  user-select: none;
}

.post-editions > summary::-webkit-details-marker { display: none; }
.post-editions > summary::marker { content: ''; }

.post-editions > summary::before {
  content: '\25B8';          /* ▸ */
  color: var(--overlay0);
  transition: transform 0.2s ease;
  display: inline-block;
}
.post-editions[open] > summary::before { transform: rotate(90deg); }

.post-editions > summary:hover { color: var(--text); }
.post-editions > summary:hover .cmd { color: var(--yellow); }

.post-editions .dollar { color: var(--text); padding-right: 2px; }
.post-editions .cmd    { color: var(--yellow); }
.post-editions .arg    { color: var(--peach); }

.ed-count {
  margin-left: auto;
  /* overlay1, not overlay0: at this size overlay0 misses AA contrast on the
     mantle in both themes. Same reasoning on .ed-date below. */
  color: var(--overlay1);
  font-size: 0.76rem;
  white-space: nowrap;
}

.ed-body {
  border-top: 1px solid var(--surface0);
  padding: 16px 14px;
}

.ed-rail {
  position: relative;
  display: flex;
  justify-content: space-between;
  max-width: 440px;
}

/* A history of one: a static stop, no connecting line, nothing dimmed. */
.ed-rail.is-single::before { display: none; }
.ed-rail.is-single .ed-node { cursor: default; }

/* The line the stops sit on. Inset by half a node's width on each side so it
   starts and ends under a dot rather than running past the last one. */
.ed-rail::before {
  content: '';
  position: absolute;
  top: 7px;
  left: 26px;
  right: 26px;
  height: 2px;
  background: var(--surface1);
}

.ed-node {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  padding: 0 8px;
  background: none;
  border: none;
  font-family: inherit;
  color: var(--overlay1);
  cursor: pointer;
}

/* Shipped disabled; editions.js enables them. Without JS the rail must read
   as inert rather than as live controls that swallow clicks. */
.ed-node:disabled { cursor: default; opacity: 0.6; }
.ed-node:disabled:hover { color: var(--overlay1); }
.ed-node:disabled:hover .ed-dot { border-color: var(--surface2); }

.ed-dot {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2px solid var(--surface2);
  background: var(--mantle);
  transition: border-color 0.18s, background 0.18s;
}

.ed-node:hover { color: var(--text); }
.ed-node:hover .ed-dot { border-color: var(--cat, var(--mauve)); }
.ed-node.is-sel { color: var(--text); }
.ed-node.is-sel .ed-dot {
  border-color: var(--cat, var(--mauve));
  background: var(--cat, var(--mauve));
}

.ed-v { font-size: 0.76rem; font-weight: 600; margin-top: 4px; }
.ed-date { font-size: 0.68rem; color: var(--overlay1); }

.ed-foot {
  display: flex;
  align-items: center;
  gap: 12px 18px;
  flex-wrap: wrap;
  margin-top: 12px;
}

.ed-note {
  font-family: var(--font-prose);
  font-style: italic;
  font-size: 0.9rem;
  color: var(--subtext0);
  flex: 1 1 30ch;
}

.ed-toggle {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 0.76rem;
  color: var(--subtext0);
  cursor: pointer;
  /* No white-space: nowrap: at 320px the label must wrap inside the card
     rather than run past its padding. */
  /* 44px touch target without growing the row visually. */
  padding: 10px 0;
}

/* The word counts the generator computes, surfaced beside the note when an
   edition has a previous one to be compared against. */
.ed-stat {
  font-size: 0.74rem;
  color: var(--overlay1);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.ed-stat .ed-plus { color: var(--green); }
.ed-stat .ed-minus { color: var(--red); }

.ed-toggle input {
  accent-color: var(--cat, var(--mauve));
  width: 14px;
  height: 14px;
  cursor: pointer;
}

.ed-toggle.is-off { opacity: 0.45; cursor: not-allowed; }
.ed-toggle.is-off input { cursor: not-allowed; }

/* Shown while reading a superseded edition. Peach, not the category accent:
   it is a caution about WHEN you are, not a fact about the post.
   Block flow, not flex: on a phone a flex row stranded the icon alone on
   its first line. Inline, the icon travels with the sentence. */
.ed-banner {
  margin: 0 0 22px;
  border: 1px dashed color-mix(in srgb, var(--peach) 55%, transparent);
  background: color-mix(in srgb, var(--peach) 7%, transparent);
  border-radius: 4px;
  padding: 9px 14px;
  font-size: 0.78rem;
  color: var(--subtext0);
  line-height: 1.7;
}

.ed-banner .icon { color: var(--peach); vertical-align: -0.18em; margin-right: 4px; }

.ed-latest {
  background: none;
  border: none;
  font-family: inherit;
  /* 14px semibold: large-text contrast rules apply, which the blue link
     colour passes in the light theme where 12.5px regular did not. */
  font-size: 0.875rem;
  font-weight: 600;
  padding: 0;
  color: var(--blue);
  cursor: pointer;
  border-bottom: 1px dashed var(--blue);
}
.ed-latest:hover { color: var(--lavender); border-bottom-color: var(--lavender); }

/* Word-level diff marks inside a diff block. Additions get a green wash and
   underline, removals a red wash and strike, so the distinction survives
   colour-blindness by shape as well as hue. */
.prose ins.dm,
.prose del.dm,
.post-title ins.dm,
.post-title del.dm {
  border-radius: 2px;
  padding: 0 2px;
}

.prose del.dm,
.post-title del.dm {
  background: color-mix(in srgb, var(--red) 13%, transparent);
  /* subtext1, not subtext0: struck text should recede, but not below AA
     contrast on the red wash in the light theme. */
  color: var(--subtext1);
  text-decoration: line-through;
  text-decoration-color: color-mix(in srgb, var(--red) 65%, transparent);
  text-decoration-thickness: 1px;
}

.prose ins.dm,
.post-title ins.dm {
  background: color-mix(in srgb, var(--green) 15%, transparent);
  color: var(--text);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--green) 55%, transparent);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}

/* Empty state ────────────────────────────────────────────────── */

.empty {
  color: var(--overlay1);
  font-style: italic;
}

/* Error page ─────────────────────────────────────────────────── */

.err-code {
  color: var(--peach);
  font-size: clamp(3rem, 12vw, 6rem);
  font-weight: 700;
  line-height: 1;
}

.err-msg {
  color: var(--subtext0);
  margin: 12px 0 24px;
}

/* Footer ─────────────────────────────────────────────────────
   Deliberately quiet: it is the last thing on the page and should not compete
   with the essay above it. Same --frame measure as .terminal-window so it
   lines up with the content rather than floating loose. */

.site-footer {
  max-width: var(--frame);
  margin: 0 auto;
  padding: 20px 24px 40px;
  border-top: 1px solid var(--surface0);
  text-align: center;
}

/* Set like an inscription rather than an aside: roman instead of italic,
   letterspaced, and a shade brighter. A motto that gets whispered in italics
   reads as an apology for being there. */
/* The unsigned line above the motif. Quieter than the motif on purpose: the
   motif is the inscription, this is the thought that leads to it. Held to a
   readable measure and balanced so it never breaks to a single orphan word. */
.footer-quote {
  max-width: 46ch;
  margin: 0 auto 10px;
  font-size: 0.86rem;
  line-height: 1.7;
  color: var(--overlay1);
  text-wrap: balance;
}

.footer-motif {
  font-family: var(--font-prose);
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--subtext0);
  letter-spacing: 0.13em;
  margin-bottom: 8px;
}

.footer-legal {
  font-size: 0.72rem;
  color: var(--overlay0);
}

@media (max-width: 760px) {
  .site-footer { padding: 20px 16px 32px; }
}

/* Responsive ────────────────────────────────────────────────── */

@media (max-width: 760px) {
  .terminal-window {
    padding: 36px 16px 40px;
  }

  .prose {
    font-size: 1.05rem;
  }
}

/* Reduced motion ────────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001s !important;
    animation-delay: 0s !important;
    transition-duration: 0.001s !important;
  }
  .section .stagger {
    opacity: 1;
    transform: none;
  }
}

/* Icon sprite ───────────────────────────────────────────────── */

/* Must not occupy layout space. Set here rather than as a style
   attribute so the CSP can stay `style-src 'self'`. */
.icon-sprite {
  position: absolute;
  overflow: hidden;
}

/* Small screens: a readable name instead of an unreadable banner ────
   The figlet banner is 103 columns wide. Fitting that into ~288px of
   content forces a 4.0px glyph cell, which is not small text -- it is an
   illegible smear that still costs its full height. Below 560px it is hidden
   and the page's real <h1>, which until now was screen-reader-only, is shown
   in its place. Nothing is lost: the banner is aria-hidden decoration and the
   h1 carries the same words.

   The un-hiding has to undo every part of the .sr-only clip, not just the
   position, or the heading stays a 1px box. */
@media (max-width: 560px) {
  .ascii-art { display: none; }

  .banner-title {
    position: static;
    width: auto;
    height: auto;
    padding: 0;
    margin: 0 0 24px;
    overflow: visible;
    clip: auto;
    clip-path: none;
    white-space: normal;
    font-size: clamp(1.25rem, 6vw, 1.6rem);
    font-weight: 700;
    line-height: 1.25;
    color: var(--pink);
  }
}
