/* ==========================================================================
   gkarchemsky.com
   Dark-first, content-forward theme for long-form vulnerability research.
   ========================================================================== */

:root {
  --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
    Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
    "Liberation Mono", monospace;

  /* The shell is fluid: it stretches with the viewport instead of sitting in
     a fixed centred column, so the page fills whatever device it is on.
     Only running prose is capped — paragraphs have a hard readability ceiling
     around 90 characters no matter how big the screen is. Code, tables,
     figures and the TOC rail all take the remaining width. */
  --measure: 54rem;        /* running prose only — the one hard cap */
  --wide: 150rem;          /* safety stop on ultra-wide displays    */
  /* Scales with the viewport, so the page keeps a comfortable margin on a
     large display instead of running into the edges. */
  --gutter: clamp(1.5rem, 7vw, 12rem);
  --radius: 6px;

  color-scheme: dark;

  --bg: #0b0d10;
  --bg-elev: #12151b;
  --bg-code: #0e1116;
  /* Hairlines were #222831 / #1a1f27, which measured 1.31:1 and 1.10:1 against
     the surfaces they sit on — under the 1.5:1 where a 1px line stops reading
     as a line at all. The separators in the mobile menu were the worst case at
     1.10:1 and were effectively invisible. Raised to 2.20:1 and 1.66:1, moving
     only lightness so the palette's blue-grey hue is unchanged. */
  --border: #404b5c;
  --border-soft: #333d4c;
  --text: #d7dee7;
  --text-strong: #f2f5f8;
  --text-muted: #8a94a2;
  --text-faint: #7d8895;   /* 4.8:1 on --bg; #626c7a was 3.7:1 */
  /* Accent + status colours are Apple's system palette, read out of UIKit on
     iOS 26.5 and cross-checked against AppKit on macOS 26.6.1.
     Dark appearance uses the standard variants; every one clears WCAG AA as
     text on --bg-elev (cyan 10.4:1, green 9.0:1, orange 8.2:1, red 5.3:1,
     purple 5.0:1). */
  /* Post rows carry a surface at rest, not only on hover — without one they
     read as loose text on the page background and the eye has nothing to
     separate one entry from the next.

     Same blue-grey as everything else, only lifted. The resting step is
     deliberately small: enough to bound the entry, not enough to compete with
     the writing. Hover then has somewhere to go — a deeper fill, the full
     border weight, and a shadow. */
  --card-rest-bg: #101319;
  --card-rest-border: #272f3b;
  --card-hover-bg: #171c24;
  /* Border and shadow are deliberately NOT repeated in the light blocks
     below. color-mix substitutes at computed-value time, so var(--accent) and
     var(--border) inside resolve against whichever theme is in force — one
     definition covers both, and the treatment cannot drift apart between
     them. Only the fill, which is a literal colour, is per-theme. */
  --card-hover-border: color-mix(in srgb, var(--accent) 60%, var(--border));
  --card-hover-shadow: 0 8px 26px -18px color-mix(in srgb, var(--accent) 70%, transparent);

  --accent: #3cd3fe;                     /* systemCyan, dark            */
  --accent-dim: #00c0e8;                 /* systemCyan, light — pressed */
  --accent-bg: rgba(60, 211, 254, 0.1);
  --warn: #ff9230;                       /* systemOrange, dark          */
  --c-green: #30d158;                    /* systemGreen, dark           */
  --c-red: #ff4245;                      /* systemRed, dark             */
  --c-purple: #db34f2;                   /* systemPurple, dark          */
  --callout-bg: #12151b;                 /* the surface those ratios assume */
  --sel: rgba(60, 211, 254, 0.22);
}

:root[data-theme="light"] {
  color-scheme: light;
  --bg: #ffffff;
  --bg-elev: #f7f8fa;
  --bg-code: #f6f8fa;
  --border: #a4b0be;
  --border-soft: #bac4d3;
  --text: #24292f;
  --text-strong: #0d1117;
  --text-muted: #5b6672;
  --text-faint: #6b7280;   /* 4.8:1 on white; #8994a1 was 3.1:1 */
  /* Light appearance uses Apple's *high-contrast* variants. The standard ones
     are tuned for fills and fail as text on white — systemCyan is 2.16:1 and
     systemGreen 2.22:1. The high-contrast set clears AA (4.5:1+). */
  --card-rest-bg: #fafbfc;
  --card-rest-border: #e3e8ee;
  --card-hover-bg: #f1f4f8;
  --accent: #007eae;                     /* systemCyan HC    4.57:1 */
  --accent-dim: #00658b;                 /* derived: accent darkened for press */
  --accent-bg: rgba(0, 126, 174, 0.08);
  --warn: #c55300;                       /* systemOrange HC  4.55:1 */
  --c-green: #008932;                    /* systemGreen HC   4.54:1 */
  --c-red: #e9152d;                      /* systemRed HC     4.56:1 */
  --c-purple: #b02fc2;                   /* systemPurple HC  5.21:1 */
  --callout-bg: #ffffff;                 /* ratios above are vs white, so
                                            callouts sit on white, not the
                                            tinted elevated surface */
                                            label to 4.11:1; no fill keeps it
                                            on white at 4.57:1 */
  --sel: rgba(0, 126, 174, 0.18);
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme]) {
    color-scheme: light;
    --bg: #ffffff;
    --bg-elev: #f7f8fa;
    --bg-code: #f6f8fa;
    --border: #a4b0be;
    --border-soft: #bac4d3;
    --text: #24292f;
    --text-strong: #0d1117;
    --text-muted: #5b6672;
    --text-faint: #6b7280;   /* 4.8:1 on white; #8994a1 was 3.1:1 */
    --card-rest-bg: #fafbfc;
    --card-rest-border: #e3e8ee;
    --card-hover-bg: #f1f4f8;
    --accent: #007eae;
    --accent-dim: #00658b;
    --accent-bg: rgba(0, 126, 174, 0.08);
    --warn: #c55300;
    --c-green: #008932;
    --c-red: #e9152d;
    --c-purple: #b02fc2;
    --callout-bg: #ffffff;
    --sel: rgba(0, 126, 174, 0.18);
  }
}

/* --------------------------------------------------------------------------
   Base
   -------------------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  /* No `scroll-behavior: smooth` here, deliberately.
     
     It governs exactly two things, because every programmatic scroll in
     site.js names its own behavior: plain `<a href="#…">` links, and scrolls
     the *browser* performs — of which restoring your position after a refresh
     is one. Animating that is the bug: refresh a page you had scrolled down
     and it starts at the top and slides to where you were, dragging the layout
     past you on the way. On a phone, where restoration lands later, it was the
     whole of the reported glitch.
     
     Suppressing it during load was tried first and is worse: the guard has to
     end at some arbitrary moment, and flipping the property part-way through a
     page's life means a scroll begun under one rule finishes under another.
     Having no global smooth at all needs no timing and cannot be mistimed. A
     hash link jumps instead of gliding, which is the browser's own default and
     lands in the right place because of the scroll-padding below. */
  scroll-padding-top: 5rem;
  /* Reserve the scrollbar's width on every page, whether that page scrolls or
     not. Where scrollbars take up space — Safari set to always show them, and
     the default on Windows and most Linux desktops — the layout width
     otherwise differs between a short page and a long one, and a window sized
     within a scrollbar's width of a breakpoint gets a different layout on
     each. On macOS's overlay scrollbars this reserves nothing. */
  scrollbar-gutter: stable;
}

/* Nothing transitions until the page has settled. Set by the inline script in
   the head and removed by site.js after load, so it covers the whole window in
   which a custom property can resolve late and turn a value correction into a
   visible animation.

   Transitions only — animations are left alone, so the wordmark's cursor keeps
   blinking through the load rather than starting with a jump. */
:root.is-preload *,
:root.is-preload *::before,
:root.is-preload *::after {
  transition: none !important;
}

/* The same freeze, for the one synchronous measurement the header takes with
   .nav-collapsed briefly removed (see measureNav in site.js). Reading the
   links' natural width means laying them out inline for an instant; nothing is
   painted in that instant, but .nav-collapsed also moves the controls' `order`
   and margins, and those would otherwise animate on the way out and back.
   Separate from .is-preload because that class carries its own meaning — the
   load-time freeze — and the tracer reads it. */
:root.is-measuring *,
:root.is-measuring *::before,
:root.is-measuring *::after {
  transition: none !important;
}

/* No scroll guard here any more: with no global smooth to suppress, there is
   nothing for a load-time guard to do. See the note on `html` above. */

@media (prefers-reduced-motion: reduce) {
  /* `scroll-behavior` is not reset here: it is already auto everywhere. The
     scrolls that do animate name their behavior in site.js, and scrollBehavior()
     there returns 'instant' for exactly this media query — a stylesheet reset
     cannot override an explicit value, which is why the decision lives in the
     script rather than here. */
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 17px;
  line-height: 1.7;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

::selection { background: var(--sel); }

a {
  color: var(--accent);
  text-decoration: none;
}
@media (hover: hover) {
  a:hover { text-decoration: underline; text-underline-offset: 3px; }
}

img, svg, video { max-width: 100%; height: auto; }

hr {
  border: 0;
  border-top: 1px solid var(--border);
  margin: 2.5rem 0;
}

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

.wrap {
  width: 100%;
  max-width: var(--wide);
  margin: 0 auto;
  padding-inline: var(--gutter);
}

.skip-link {
  position: absolute;
  left: -9999px;
}
.skip-link:focus {
  left: 1rem;
  top: 1rem;
  z-index: 100;
  background: var(--bg-elev);
  border: 1px solid var(--accent);
  padding: 0.5rem 1rem;
  border-radius: var(--radius);
}

/* --------------------------------------------------------------------------
   Header
   -------------------------------------------------------------------------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  /* The bar as glass, in the way iOS and macOS do it. Three parts, and all
     three are needed — any one alone reads as "translucent", not as a
     material:

       a thin tint, so what passes underneath is coloured rather than shown
       a wide blur with saturation pushed up, which is what makes the colour
         beneath bloom instead of smearing
       a bright hairline along the top inside edge, which is the whole trick:
         it is the lit edge of a pane catching the light, and without it the
         bar looks like a faded rectangle

     The tint is 76% rather than the 88% it was, so the blur has something to
     work with. It is a balance, not a maximum — below about 70% the wordmark
     starts competing with whatever scrolls under it. */
  background: color-mix(in srgb, var(--bg) 76%, transparent);
  backdrop-filter: saturate(200%) blur(22px);
  -webkit-backdrop-filter: saturate(200%) blur(22px);
  /* No `contain: paint` here, and no transform or will-change either. All
     three would let the compositor cache the blur, and all three clip or
     reparent what hangs below the bar: the menu panel and the search card are
     both descendants that paint outside it. `contain: paint` was tried and
     erased the whole dropdown — and it did so invisibly to a geometry check,
     because the panel's box stays correct while its pixels are clipped. */
  border-bottom: 1px solid color-mix(in srgb, var(--border-soft) 65%, transparent);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--text-strong) 10%, transparent),
    0 1px 12px -8px color-mix(in srgb, var(--text-strong) 22%, transparent);
}

/* A 22px backdrop blur under a sticky bar is re-rasterised as the page scrolls
   beneath it, and on iOS that is the one thing in this header expensive enough
   to show: the bar appears to re-render while you scroll. Nothing about it is
   measurable — the geometry, the classes and --header-h are identical in every
   frame of a scroll in both engines — which is what points at the compositor
   rather than the layout.
   
   Half the radius on a touch device. The glass still reads as glass at 11px
   against a 76% tint; what goes is the cost per frame. Kept at full strength
   for a pointer, where it has never misbehaved. */
@media (pointer: coarse) {
  .site-header {
    backdrop-filter: saturate(180%) blur(11px);
    -webkit-backdrop-filter: saturate(180%) blur(11px);
  }
}

/* Without backdrop-filter there is no glass to be had, and a 76% tint is just
   a see-through bar. Those browsers get the solid surface instead. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .site-header { background: var(--bg); }
}

.site-header .wrap {
  display: flex;
  align-items: center;
  /* The two sizes the search panel's caret has to be placed against, named so
     it can be derived rather than measured by hand. An icon button is centred
     in the bar, so the drop from its bottom edge to the bar's is exactly half
     the difference — arithmetic that stays true if either number moves. */
  --bar-h: 3.75rem;
  --icon-btn: 1.9rem;
  /* How far the panel hangs below the bar. Enough to see the page between
     them, which is what makes it read as a box rather than more of the bar. */
  --panel-drop: 0.55rem;
  --wrap-gap: 2rem;
  /* Declared here, not on .nav-controls: the stacked row sets its own
     column-gap from this value, and a variable on a child is not visible to
     its parent. */
  --controls-gap: 0.7rem;

  /* The two numbers that decide the whole bar.

     --search-w is one width, not a range. It was `clamp(8rem, 20vw, 12rem)`
     with a `min-width` floor and `flex: 0 1 auto`, so the open field rendered
     anywhere from 96px to 192px depending on the viewport and on how tight the
     row already was. Every decision about the bar was being taken against that
     moving number, which is why none of them held still.

     --nav-clearance is the smallest gap allowed between the right edge of the
     wordmark's blinking cursor and the left edge of HOME. site.js collapses
     the menu when the row cannot honour it, and drops the search field into
     its card rather than let the field eat into it. Measured tightest case
     before this existed: 40px. */
  --search-w: 12rem;
  --nav-clearance: 3rem;
  gap: var(--wrap-gap);
  height: var(--bar-h);
}

.brand {
  display: inline-flex;
  align-items: center;
  /* Shrinkable, not fixed. Pinning it with `flex: 0 0 auto` did guarantee
     clearance, but the wordmark is `white-space: nowrap` — so on a 320px phone
     the row could no longer fit and the controls were pushed 56px off the right
     edge. It gives way now, and the handle truncates with an ellipsis.
     
     That ellipsis lives on `.brand-label`, not here. This comment claimed it
     for a long time while no `text-overflow` existed anywhere in the file, and
     adding one here would not have worked either: `.brand` is a flex container,
     so the handle was an anonymous flex item and `text-overflow` cannot apply to
     one. The result was a wordmark cut mid-glyph — 55px gone at 320px, reading
     "gkarcher". */
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  padding-right: 0.5rem;   /* clearance that survives at the tightest width */
  gap: 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.95rem;
  color: var(--text-strong);
  white-space: nowrap;
  letter-spacing: -0.01em;
}

/* Where the truncation actually happens. `min-width: 0` opts out of the
   automatic minimum size so this can shrink below its content, and the three
   overflow properties then do the ellipsis between them. */
.brand-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.brand-avatar {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  border-radius: 8px;
  object-fit: cover;
  flex: none;
  /* A photo carries its own colour; a hairline keeps it from floating against
     either background. */
  box-shadow: 0 0 0 1px var(--border);
}
@media (hover: hover) {
  .brand:hover { text-decoration: none; color: var(--accent); }
}
.brand .sigil { color: var(--accent); }
.brand .cursor {
  display: inline-block;
  /* Never shrinks. As a flex item with the default `flex-shrink: 1` it was
     crushed to zero width whenever the bar was tight — measured gone at 320px,
     375px *and* 414px, so the blinking cursor was missing on every phone rather
     than only the narrowest. The label's ellipsis is what should absorb a tight
     row; this is 9px of the site's identity. */
  flex: 0 0 auto;
  width: 0.55em;
  height: 1.05em;
  vertical-align: -0.18em;
  margin-left: 0.15em;
  background: var(--accent);
  animation: blink 1.2s steps(2, start) infinite;
}
@keyframes blink { to { visibility: hidden; } }

.site-nav {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 1.4rem;
}

/* The current-page link carries a filled box, which extends past the text it
   sits on — so the visual gap to the brand is smaller than the flex gap
   suggests, and at the tightest full-nav width the box appeared to touch the
   wordmark. This keeps the box's own edge clear of it.

   Padding, not margin: .site-nav above uses `margin-left: auto` to push itself
   and the controls against the right edge of the header, and overriding that
   with a fixed margin left the whole group hugging the wordmark with the right
   half of the bar empty. */
.site-nav { padding-left: 0.75rem; }

.site-nav a.nav-link {
  font-family: var(--font-mono);
  /* 0.8rem — 12.8px — sat too small beside what shares the bar with it: the
     wordmark is 0.95rem and the icons are 17px, so the links read as a
     footnote to the row rather than as its main content. 0.88rem closes most
     of that gap while staying a step under the wordmark, which should still
     lead. */
  font-size: 0.88rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.site-nav a.nav-link[aria-current="page"] {
    color: var(--text-strong);
    text-decoration: none;
  }
@media (hover: hover) {
  .site-nav a.nav-link:hover {
    color: var(--text-strong);
    text-decoration: none;
  }
}
.site-nav a.nav-link[aria-current="page"] { color: var(--accent); }

/* A hairline between the links, centred in the 1.4rem gap the row already
   has. Quiet on purpose — it is there to group the links as a set, not to be
   read as a control.

   Absolutely positioned, so it contributes nothing to the row's width. That
   matters more than it looks: site.js measures the natural width of this row
   to decide when the links have to give way to the menu button, and a
   separator that took up space would move that threshold on every link.

   Skipped in the dropdown, where the links stack and already carry a
   border-bottom each — a vertical rule there would cut across them. */
.site-nav a.nav-link { position: relative; }
.site-nav a.nav-link + a.nav-link::before {
  content: "";
  position: absolute;
  left: -0.7rem;                 /* half of .site-nav's gap */
  top: 50%;
  transform: translateY(-50%);
  width: 1px;
  height: 0.85em;
  background: color-mix(in srgb, var(--border-soft) 65%, transparent);
  pointer-events: none;
}
.site-header.nav-collapsed .site-nav a.nav-link + a.nav-link::before { content: none; }
@media (max-width: 1040px) {
  .site-nav a.nav-link + a.nav-link::before { content: none; }
}

.nav-sep {
  width: 1px;
  height: 1.25rem;
  background: var(--border);
}

/* Same fixed box as .nav-search-toggle, deliberately. Both sit in the same
   flex row, and a flex item shrinks by default — with only padding and no
   dimensions, this button changed size as the header ran out of room, so the
   two icons stopped matching. `flex: 0 0 auto` opts it out of shrinking;
   the width/height give it the box to keep. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: var(--icon-btn);
  height: var(--icon-btn);
  padding: 0;
  background: none;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
  color: var(--text-muted);
  line-height: 0;
  transition: color 0.15s ease, background 0.15s ease;
}
@media (hover: hover) {
  .theme-toggle:hover { color: var(--accent); background: var(--bg-elev); }
}
.theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The icons themselves must not scale with the button either. */
.theme-toggle svg { width: 17px; height: 17px; flex: 0 0 auto; }
.theme-toggle .moon { display: none; }
:root[data-theme="light"] .theme-toggle .moon { display: block; }
:root[data-theme="light"] .theme-toggle .sun { display: none; }
@media (prefers-color-scheme: light) {
  :root:not([data-theme]) .theme-toggle .moon { display: block; }
  :root:not([data-theme]) .theme-toggle .sun { display: none; }
}

.nav-toggle { display: none; }

/* Search + theme toggle. Outside .site-nav so they never collapse into the
   mobile menu. */
.nav-controls {
  display: flex;
  align-items: center;
  /* One clearance for every divider in the bar.

     The two dividers are built differently — .nav-sep is a real element and
     takes the row's gap on each side, while the one before the theme toggle is
     drawn as a pseudo-element and centres itself in a single gap — so equal
     spacing has to be arranged rather than inherited. Three declarations do it:
     the group cancels the wrap's gap down to this value, the theme toggle adds
     one more of it so its divider has a double gap to centre in, and the
     divider is offset by exactly one.

     0.7rem is the clearance the nav links' own hairlines already sit in, half
     of .site-nav's 1.4rem gap, so the whole row measures the same. */
  gap: var(--controls-gap);
}
.nav-search + .theme-toggle { margin-left: var(--controls-gap); }

/* Inline, the links and the controls are two separate groups and the row's own
   gap is what tells them apart — no rule between them. .nav-sep exists for the
   compact bar, where the icons sit together as one cluster. */
.nav-sep { display: none; }

/* The compact bar reads as one cluster of controls, so it is bracketed: a rule
   before the menu button, between each pair, and after the theme toggle.

   The outer two are pseudo-elements for the same reason as the one before the
   theme toggle — a real element takes the row's gap on both sides and spreads
   the icons out. The group also cancels the wrap's gap down to --controls-gap,
   so every rule in the cluster sits in the same clearance.

   Inline, none of this applies: the links and the controls are two groups and
   the row's own gap is what separates them. */
@media (max-width: 1040px) {
  .nav-sep { display: block; }
  .nav-controls {
    margin-left: calc(var(--controls-gap) - var(--wrap-gap));
    /* Room for the closing rule, which would otherwise be drawn past the
       gutter. The cluster steps in by one clearance so that rule lands on the
       content edge instead — where the eye already reads a boundary. */
    margin-right: var(--controls-gap);
  }
}
.site-header.nav-collapsed .nav-sep { display: block; }
.site-header.nav-collapsed .nav-controls {
  margin-left: calc(var(--controls-gap) - var(--wrap-gap));
  margin-right: var(--controls-gap);
}

.nav-toggle { position: relative; }
@media (max-width: 1040px) {
  .nav-toggle::before,
  .theme-toggle::after { content: ""; }
}
.site-header.nav-collapsed .nav-toggle::before,
.site-header.nav-collapsed .theme-toggle::after { content: ""; }

.nav-toggle::before,
.theme-toggle::after {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 1px;
  height: 1.25rem;
  background: var(--border);
  pointer-events: none;
}
.nav-toggle::before  { left: calc(var(--controls-gap, 0.5rem) * -1); }
.theme-toggle::after { right: calc(var(--controls-gap, 0.5rem) * -1); }


/* The divider between the field and the theme toggle, matching .nav-sep — the
   one beside the menu button — in height and colour.

   Drawn as a pseudo-element rather than a second .nav-sep span, because a real
   element would take the row's gap on both sides and push the two icons apart
   again. This costs no width, which also keeps it out of the measurement
   site.js runs on this group to decide when the bar has to collapse. */
.nav-controls .theme-toggle { position: relative; }
.nav-search + .theme-toggle::before {
  content: "";
  position: absolute;
  left: calc(var(--controls-gap) * -1);
  top: 50%;
  transform: translateY(-50%);
  width: 1px;
  height: 1.25rem;
  background: var(--border);
  pointer-events: none;
}

@media (max-width: 1040px) {
  /* The 2rem row gap is desktop's, where it separates the wordmark from the
     nav links. Here there are no links — the hamburger carries `margin-left:
     auto` and is what actually holds the right-hand group against the edge, so
     all 2rem of it buys is 32px of nothing between the hamburger and the search
     icon, which already have .nav-sep between them.
     
     Reclaiming it is what lets the wordmark fit whole on a narrow phone
     rather than truncating. */
  .site-header .wrap { --wrap-gap: 0.75rem; --controls-gap: 0.5rem; }

  /* The brand's own spacing tightens with it. 0.6rem between the avatar, sigil,
     handle and cursor is desktop's rhythm; three gaps of it plus 0.5rem of
     trailing padding is 37px of the ~173px the wordmark needs, and at 375px the
     row was still 12px short after the row gap came down. At 0.4rem and 0.25rem
     the handle fits whole on a 375px phone, which is the width worth winning —
     below that the ellipsis takes over, which is what it is for. */
  .brand { gap: 0.4rem; padding-right: 0.25rem; }

  /* Same order as desktop reading left to right: menu, separator, search,
     theme. The hamburger stands in for the nav links it replaces, so it takes
     their position rather than being pushed to the end. */
  .nav-toggle { order: 2; margin-left: auto; }
  .nav-controls { order: 3; }
  .brand { min-width: 0; }

  .nav-search { width: auto; margin: 0; min-width: 0; }
  /* No `min-width: 0` on the control group any more, here or anywhere. It
     let the group be squeezed below its own contents so the open field could
     take the room, back when the field shrank to absorb a tight row. It does
     not shrink now — it is one constant width and falls back to the card — so
     all this can do is push the theme toggle past the gutter, which it did by
     7px at 320px the moment the collapsed class started applying here. What
     gives in a tight row is the wordmark, which has the ellipsis for it. */


  /* There is a width below which the bar cannot hold the wordmark and a
     usable field at the same time. The field opens as a panel under the bar
     there, and site.js decides when by measuring, so the answer follows the
     wordmark and the icons rather than a fixed breakpoint. */
}
/* With the field open the wordmark stops giving way — the field yields
   instead, down to its own floor, and if that is still not enough the links
   collapse (see .nav-collapsed). Only while open: with the field closed the
   brand stays shrinkable, which is what keeps a 320px phone from pushing the
   controls off the right edge (see .brand). So the wordmark is never half a
   word — it is shown whole, or hidden by the 520px rule above.

   Deliberately NOT inside the 1100px media query. It lived there first, which
   put it in the one range where the problem does not occur: the wordmark is
   clipped between 1101px and roughly 1255px, where the links are still inline
   and an open field leaves them nothing to take but the brand.

   Not while the field is a panel. There it takes no room in the row, so there
   is nothing for the wordmark to give way to — and pinning it hands a
   truncated wordmark its full width back, which moves everything beside it.

   Whenever the field is in the bar, though — not only while it is open. Keying
   this to the open state made closing the field jerk: .search-open comes off
   the instant the icon is tapped and the field then spends 0.18s getting to
   zero, so for that 0.18s the wordmark was unpinned while the field still held
   its full width. Between 1100 and 1255 it collapsed from 188px into an
   ellipsis, cutting the cursor mid-blink, and grew back as the field emptied.

   Pinning it in both states costs nothing: panel mode is off exactly when the
   row has been measured to hold the wordmark, the menu button and the controls
   with the field at its floor, so there is by construction room for it. What
   gives instead is the field, which is on its way out anyway. */
.site-header:not(.search-panel) .brand { flex-shrink: 0; }

/* No room for the wordmark beside an open field: the field opens as a panel
   under the bar instead of squeezing into it.

   The bar itself does not change — same height, same three icons in the same
   places — so tapping search never moves the thing you just tapped. The panel
   drops from beneath, which is what makes the caret below honest: it points at
   the control the panel belongs to.

   .search-panel goes on and comes off in site.js, and it follows the width of
   the bar rather than the state of the field — so everything below is written
   to be correct with the field closed as well as open. */

/* The one frame in which the field changes shape. site.js puts this on, swaps
   .search-panel, forces the layout and takes it off again — see the note by
   setPanelMode(). Everything the swap touches has to be listed: the field, the
   form that holds it, the caret, and the icon whose colour is a state of the
   panel. */
.site-header.shape-switch .nav-search,
.site-header.shape-switch .nav-search-input,
.site-header.shape-switch .nav-search-toggle,
.site-header.shape-switch .nav-search-toggle::after,
.site-header.shape-switch .nav-controls {
  transition: none !important;
}

/* The form keeps the geometry it has closed: the width of its icon, no gap
   after it, and no give. Opening otherwise restores the form's own gap and
   lets `flex: 0 1 auto; min-width: 0` shrink it below its icon, and whatever
   that frees goes to the wordmark, which slides the icons along with it. A
   panel asks the row for nothing, so neither should the form holding it. */
.site-header.search-panel .nav-search,
.site-header.search-panel .nav-search.open {
  flex: 0 0 auto;
  width: auto;
  min-width: 0;
  gap: 0;
}


/* The field. The selector names .nav-search as well, which looks like one
   class too many. It is not: the field's own open-state rules in the component
   section are three classes deep and come later in the file, so at equal
   specificity they win on order and put back the width, padding and border
   this block is replacing. A fourth class settles it wherever this block
   sits. */
.site-header.search-panel .nav-search .nav-search-input {
  position: absolute;
  /* Against .site-header, which is the nearest positioned ancestor — the form
     and the control cluster are both static, on purpose. So the box is placed
     against the bar rather than against the form it lives in. */
  top: calc(100% + var(--panel-drop));
  left: var(--gutter);
  right: var(--gutter);
  width: auto;
  max-width: none;
  min-width: 0;
  /* A card, not a band. Held off the bar by --panel-drop so the page shows
     between the two, inset to the gutter so it lines up with the column below,
     and closed on all four sides — a band sharing the bar's fill and running
     edge to edge read as more bar rather than as something that had dropped
     out of it. */
  border-radius: 10px;
  border-width: 1px;
  border-color: var(--border);
  padding: 0.7rem 0.85rem;
  background: var(--bg-elev);
  /* Two shadows: a tight one that seats the card against the page and a wide
     soft one that lifts it off. One alone reads as either a sticker or a
     smudge. */
  box-shadow: 0 2px 5px -3px color-mix(in srgb, var(--text-strong) 42%, transparent),
              0 16px 34px -20px color-mix(in srgb, var(--text-strong) 62%, transparent);
}

/* Restoring what this block's own border-color took away: the field turns its
   hairline accent on focus, the same accent the icon above it is wearing, and
   at four classes deep the plain `:focus` rule no longer reached it. */
.site-header.search-panel .nav-search .nav-search-input:focus {
  border-color: var(--accent);
}
/* And the caret with it. The caret is drawn on the button and the focus is on
   the field, which are siblings, so the state has to be read across — a grey
   arrow sitting on an accent card is two shapes, not one. Where `:has` is
   missing the caret simply stays in the resting hairline, which is the same
   thing it does with the field open and focus elsewhere in the bar. */
.site-header.search-panel .nav-search:has(.nav-search-input:focus) .nav-search-toggle::after {
  border-color: var(--accent);
}

/* The caret: a square rotated 45 degrees with two of its edges bordered, so it
   reads as the card's own top corner drawn up towards the icon rather than as
   a separate mark stuck on. Centred under the icon, drawn in the card's fill
   and hairline, and sitting astride the card's top border so the two are one
   shape.

   The offset is derived, not measured. From the icon's bottom edge it is half
   of whatever the bar has spare around an icon button, then the bar's own
   hairline, then the drop — which lands on the card's top border — and finally
   back up by half the square so the square straddles that border rather than
   hanging under it. Every term is a token, so moving the bar height or the
   icon size moves the caret with them. */
.nav-search-toggle { position: relative; }
.site-header.search-panel.search-open .nav-search-toggle { color: var(--accent); }
.site-header.search-panel .nav-search-toggle::after {
  content: "";
  position: absolute;
  left: 50%;
  top: calc(100%
            + (var(--bar-h) - var(--icon-btn)) / 2
            + 1px
            + var(--panel-drop)
            - 0.275rem);
  width: 0.55rem;
  height: 0.55rem;
  transform: translateX(-50%) rotate(45deg);
  background: var(--bg-elev);
  border-left: 1px solid var(--border);
  border-top: 1px solid var(--border);
  opacity: 0;
  pointer-events: none;
  z-index: 1;
}
.site-header.search-panel.search-open .nav-search-toggle::after { opacity: 1; }

/* Something behind the card. On its own it reads as a bubble parked over the
   page; with a wash under it, it reads as a layer the bar has opened and the
   page as the thing behind that layer.

   A wash toward --bg rather than a dim toward black: the same declaration then
   works in both themes, fading the page out on light and down on dark, where a
   fixed dark tint would only be right on one of them.

   On body, not on .site-header, and that is forced rather than chosen — the
   bar carries a backdrop-filter, which makes it the containing block for fixed
   descendants, so a fixed overlay declared inside it would be measured against
   the bar instead of the viewport. Sitting above .to-top (40) and below the
   bar (50), so the bar and its card stay clear of it. */
body::after {
  content: "";
  position: fixed;
  inset: var(--header-h, 3.75rem) 0 0 0;
  z-index: 45;
  background: color-mix(in srgb, var(--bg) 64%, transparent);
  opacity: 0;
  visibility: hidden;
  /* Taps go to the page underneath, which is what already closes an empty
     field — the wash is something to see, not something to hit. */
  pointer-events: none;
  transition: opacity 0.18s ease, visibility 0s linear 0.18s;
}
html:has(.site-header.search-panel.search-open) body::after {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.2s ease, visibility 0s linear 0s;
}
@supports (backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px)) {
  html:has(.site-header.search-panel.search-open) body::after {
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
  }
}

@media (prefers-reduced-motion: no-preference) {
  /* It drops. A card should arrive by moving, not by being uncovered — and the
     clip that uncovered the full-bleed band cannot be used on a card anyway:
     `clip-path: inset(0)` clips to the border box, and the two shadows that
     lift this card off the page are painted outside it, so the finished card
     would have had no shadow at all.

     A short translate carries no such cost, distorts nothing, and is what the
     menus this is imitating do. visibility rides along with a delay equal to
     the travel, so the closed card is out of the tab order without the close
     losing its animation. The caret travels with the card rather than fading
     in place, since it is meant to be part of it. */
  .site-header.search-panel .nav-search .nav-search-input {
    transform: translateY(-0.45rem);
    transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.3, 1),
                opacity 0.15s ease,
                visibility 0s linear 0.2s;
  }
  .site-header.search-panel .nav-search-toggle::after {
    transform: translateX(-50%) translateY(-0.45rem) rotate(45deg);
    transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.3, 1),
                opacity 0.15s ease,
                border-color 0.15s ease;
  }
  .site-header.search-panel .nav-search.open .nav-search-input,
  .site-header.search-panel .nav-search .nav-search-input.is-open {
    transform: none;
    transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.3, 1),
                opacity 0.16s ease,
                visibility 0s linear 0s;
  }
  .site-header.search-panel .nav-search.open .nav-search-toggle::after {
    transform: translateX(-50%) rotate(45deg);
    transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.3, 1),
                opacity 0.18s ease 0.04s,
                border-color 0.15s ease;
  }
}

@media (max-width: 1040px) {
  .site-nav {
    position: fixed;
    inset: var(--header-h, 3.75rem) 0 auto 0;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    background: var(--bg-elev);
    border-bottom: 1px solid var(--border);
    padding: 0.5rem 1.5rem 1rem;
    /* Revealed by clipping, not by sliding in from off-screen.
       `transform: translateY(-120%)` parked the panel ~360px above the
       viewport, so on the way in it swept down *through* the header band and
       covered the brand and the hamburger for the length of the animation.
       It could not simply be given a lower z-index either: the panel is a
       descendant of .site-header, and a descendant always paints above its
       ancestor's own background, so no stacking order puts it behind the bar.

       Clipping sidesteps the whole problem. The panel stays where it belongs
       — pinned at 3.75rem, directly under the header — and the clip edge
       travels down to uncover it, so it never paints in the header's band at
       any point in the animation. Content is not distorted the way scaleY()
       would squash it, and clipped-away area takes no pointer events.

       `visibility` still rides alongside. Clipping hides a thing visually but
       takes it out of neither the tab order nor the accessibility tree, so
       with the menu closed a keyboard user on a narrow window tabbed from the
       brand through six invisible links before reaching anything they could
       see — while aria-expanded on the toggle said "false". visibility:
       hidden removes it from both, and the 0.22s delay holds it visible for
       exactly as long as the close takes, so closing is still animated.
       The same pattern is used for .nav-search-input further down. */
    clip-path: inset(0 0 100% 0);
    visibility: hidden;
    transition: clip-path 0.22s ease, visibility 0s linear 0.22s;
    /* The open panel is its own scroll container, and `contain` stops a scroll
       that reaches its end from chaining through to the page underneath. On a
       short menu there is nothing to scroll at all, which the wheel handler in
       site.js covers. */
    max-height: calc(100vh - var(--header-h, 3.75rem));
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  .site-nav.open {
    clip-path: inset(0 0 0 0);
    visibility: visible;
    transition: clip-path 0.22s ease, visibility 0s linear 0s;
  }
  .site-nav a.nav-link {
    padding: 0.7rem 0;
    border-bottom: 1px solid var(--border-soft);
    font-size: 0.85rem;
  }
  /* Closes the list at the top as well as the bottom, so the panel reads as a
     bounded set rather than one that starts mid-air. */
  .site-nav a.nav-link:first-of-type { border-top: 1px solid var(--border-soft); }
  .nav-toggle {
    display: inline-flex;
    background: none;
    border: 0;
    color: var(--text-muted);
    cursor: pointer;
    padding: 0.4rem;
    line-height: 0;
  }
}

/* The same rules again, driven by measurement instead of by width.

   The breakpoint above cannot see the thing that actually decides whether the
   wordmark survives: how much room the row really needs. That changes when the
   search field opens, and with the rendered width of the brand itself. site.js
   measures the brand through the tip of its blinking cursor — that dot is the
   visual end of the wordmark, so it is the edge that has to stay clear — and
   adds .nav-collapsed as soon as the links would not fit beside it.

   A mirror of the block above rather than merged with it: a media query and a
   class cannot be combined in one selector. The width rule stays as the
   baseline because without JS the hamburger cannot open at all. */
.site-header.nav-collapsed .nav-toggle { order: 2; margin-left: auto; }
.site-header.nav-collapsed .wrap { --controls-gap: 0.5rem; }
.site-header.nav-collapsed .nav-controls { order: 3; }
.site-header.nav-collapsed .brand { min-width: 0; }
.site-header.nav-collapsed .nav-search { width: auto; margin: 0; min-width: 0; }

.site-header.nav-collapsed .site-nav {
  position: fixed;
  inset: var(--header-h, 3.75rem) 0 auto 0;
  flex-direction: column;
  align-items: stretch;
  gap: 0;
  background: var(--bg-elev);
  border-bottom: 1px solid var(--border);
  padding: 0.5rem 1.5rem 1rem;
  /* Revealed by clipping, not by sliding in from off-screen.
     `transform: translateY(-120%)` parked the panel ~360px above the
     viewport, so on the way in it swept down *through* the header band and
     covered the brand and the hamburger for the length of the animation.
     It could not simply be given a lower z-index either: the panel is a
     descendant of .site-header, and a descendant always paints above its
     ancestor's own background, so no stacking order puts it behind the bar.

     Clipping sidesteps the whole problem. The panel stays where it belongs
     — pinned at 3.75rem, directly under the header — and the clip edge
     travels down to uncover it, so it never paints in the header's band at
     any point in the animation. Content is not distorted the way scaleY()
     would squash it, and clipped-away area takes no pointer events.

     `visibility` still rides alongside. Clipping hides a thing visually but
     takes it out of neither the tab order nor the accessibility tree, so
     with the menu closed a keyboard user on a narrow window tabbed from the
     brand through six invisible links before reaching anything they could
     see — while aria-expanded on the toggle said "false". visibility:
     hidden removes it from both, and the 0.22s delay holds it visible for
     exactly as long as the close takes, so closing is still animated.
     The same pattern is used for .nav-search-input further down. */
  clip-path: inset(0 0 100% 0);
  visibility: hidden;
  transition: clip-path 0.22s ease, visibility 0s linear 0.22s;
  /* The open panel is its own scroll container, and `contain` stops a scroll
     that reaches its end from chaining through to the page underneath. On a
     short menu there is nothing to scroll at all, which the wheel handler in
     site.js covers. */
  max-height: calc(100vh - var(--header-h, 3.75rem));
  overflow-y: auto;
  overscroll-behavior: contain;
}
.site-header.nav-collapsed .site-nav.open {
  clip-path: inset(0 0 0 0);
  visibility: visible;
  transition: clip-path 0.22s ease, visibility 0s linear 0s;
}
.site-header.nav-collapsed .site-nav a.nav-link {
  padding: 0.7rem 0;
  border-bottom: 1px solid var(--border-soft);
  font-size: 0.85rem;
}
.site-header.nav-collapsed .site-nav a.nav-link:first-of-type {
  border-top: 1px solid var(--border-soft);
}
.site-header.nav-collapsed .nav-toggle {
  display: inline-flex;
  background: none;
  border: 0;
  color: var(--text-muted);
  cursor: pointer;
  padding: 0.4rem;
  line-height: 0;
}


/* The footer had no rule above it, so on a short page the icon row floated
   with nothing separating it from the content. The line goes on the inner
   .wrap rather than the <footer> so it spans the text column and lines up with
   every other split line on the site — .page-head, .intro, .related. */
.site-footer .wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.15rem;
  text-align: center;
  /* No margin-top: main.page-content already carries the bottom padding that
     separates content from the footer, and a margin here would stack on it.

     padding-bottom is a different matter — nothing below this carries any, so
     without it the copyright line sits hard against the bottom edge of the
     document. That went unnoticed while a licence paragraph sat underneath
     and lent its default <p> margin; removing that paragraph took the gap
     with it, on exactly the pages long enough to fill the viewport.
     Kept equal to the flex `gap` above, so the space under the copyright line
     matches the space between it and the icon row — the footer reads as evenly
     spaced rather than as a block with a tail. The two move together: narrowing
     the gap under the icons without following it here would put the tail back. */
  /* Still larger than the rhythm below, because this space does a different
     job: the gap and padding-bottom set the footer's *internal* spacing, while
     this one separates the whole block from the page above it across the rule.
     It only has to be clearly more than they are, not much more — at 1.25rem
     the icon row looked pinned to the rule, at 2rem it floated away from it. */
  padding-top: 1.6rem;
  padding-bottom: 1.15rem;
  border-top: 1px solid var(--border-soft);
}

/* Printing already drops the icons; a rule with nothing under it is worse
   than no rule. */
@media print { .site-footer .wrap { border-top: 0; margin-top: 0; padding-top: 0; } }
.site-footer a { color: var(--text-muted); }

/* The full logo row lives here, so give it the widest of the three gaps. It
   is a single centred line at every width; see the .socials rule for how. */
/* align-self, because .site-footer .wrap is a centred column and its
   children are therefore sized to their content. The gap percentage below
   needs a definite width to divide against. */
.footer-socials { align-self: stretch; }
.footer-socials .socials {
  justify-content: center;
  --gap: 1.6rem;
}
.footer-socials .socials a { color: var(--text-muted); }
@media (hover: hover) {
  .footer-socials .socials a:hover { color: var(--accent); }
}

.footer-meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem;
}

/* --------------------------------------------------------------------------
   Social icon row
   -------------------------------------------------------------------------- */

.socials {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  list-style: none;
  margin: 0;
  padding: 0;

  --icon: 21px;
  --gap: 1.1rem;

  /* The row never breaks onto a second line. It gives every icon its full
     size first and spends whatever is left on the gaps, so a narrow phone
     closes the icons up instead of dropping the last one underneath.

     --n (how many icons) and --n1 (n - 1, the number of gaps) come from
     social.html, which counts the icons it emitted. The fallbacks make the
     subtraction land far below --gap if the row ever renders without them,
     so min() picks the design gap and nothing looks broken.

     The outer max() is the floor. Past the point where even the whole row of
     gaps has been spent the icons would start touching, so below that the
     0.5rem holds and the icons give way instead — which is also what happens
     if several more networks are switched on in _config.yml. */
  column-gap: var(--gap);
  column-gap: max(0.5rem, min(var(--gap), calc((100% - var(--n, 1) * var(--icon)) / var(--n1, 1))));
}
/* Narrower than a zero gap can absorb, flex shrink takes over. Nothing
   shrinks without min-width: 0 on both the item and the link, and the icon
   needs height: auto to scale square — pinned at 21px tall while its width
   is squeezed, every logo comes out horizontally compressed. */
.socials li { min-width: 0; }
.socials a {
  color: var(--text-muted);
  display: inline-flex;
  min-width: 0;
  line-height: 0;
  transition: color 0.15s ease, transform 0.15s ease;
}
@media (hover: hover) {
  .socials a:hover {
    color: var(--accent);
    transform: translateY(-2px);
    text-decoration: none;
  }
}
.socials svg {
  width: var(--icon);
  max-width: 100%;
  height: auto;
  fill: currentColor;
  display: block;
}

.socials--lg { --icon: 25px; --gap: 1.35rem; }

/* --------------------------------------------------------------------------
   Home — identity block
   -------------------------------------------------------------------------- */

/* The 4rem bottom padding was the only thing separating content from the
   footer before the footer had a rule of its own. With the rule there it is
   doing the job twice, so the bottom is tightened while the top is left alone. */
/* Top and bottom kept equal so the content sits evenly between the header
   rule above and the footer rule below. */
.page-content { padding: 1rem 0 1.5rem; }

/* Same rule and rhythm as .page-head on the other pages, so the home page's
   split line sits where a reader has learned to expect it. */
.intro {
  max-width: 100%;
  margin-bottom: 1.25rem;
  padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--border-soft);
}

.intro-tagline {
  /* No top margin: the h1's own 0.6rem sets this distance, which is the same
     0.6rem that separates a title from its lede on every other page. */
  margin: 0 0 0.9rem;
  /* Monospace, but the same rank as .page-head .lede — this line sits in the
     same slot every other page fills with a lede, inside the same block, above
     the same rule. Size and colour match it so the two read as one level of the
     hierarchy in two voices; the face is what makes the home page its own. */
  font-family: var(--font-mono);
  font-size: 0.96rem;
  line-height: 1.5;
  color: var(--text);
  /* No max-width: the line is meant to read as one line. It still wraps on a
     narrow phone, which is correct — forcing nowrap there would push the page
     sideways, and a horizontal scrollbar costs more than a second line does. */
}

/* Deliberately identical to .page-head h1 — see that rule, which sets both.
   One page title, one size. */

/* This, not the tagline's bottom margin, is what sets the distance to the icon
   row — 44px, set back when the name sat directly above the icons with nothing
   between them. With a tagline line in between it is a gap inside one block. */
.intro .socials { margin-top: 1.5rem; }

/* --------------------------------------------------------------------------
   Section headings
   -------------------------------------------------------------------------- */

.section { margin-top: 2.25rem; }

/* A section that follows one ending in a "more" link needs much more air.
   That link sits under its own hairline and is right-aligned, and the next
   section opens with a heading under *its* hairline — so at the old 2.25rem
   the junction was two rules 36px apart with a small orphaned link floating
   between them. It read as one continuous run of dividers rather than the end
   of one stream and the start of another. This only arises on the home page,
   where two lists sit one above the other. */
.section:has(.list-more) + .section { margin-top: 4.25rem; }

/* ---- home: the two streams, side by side --------------------------------
   Research on the left, CTF write-ups on the right, each with its own newest
   post, its own short list and its own way onward. The site splits the two
   everywhere else; this makes the split the shape of the landing page rather
   than something the reader infers after scrolling.

   Sides are fixed. Ordering them by which stream published last would move
   the columns under a returning reader.

   `align-items: start` so a short column does not stretch to match a long one
   — the two lists are rarely the same length, and stretching would strand the
   button far below the last entry.
   -------------------------------------------------------------------------- */
.home-streams {
  display: grid;
  grid-template-columns: 1fr 1fr;
  /* Wide enough that the two columns' hover surfaces cannot meet — see the
     bleed override below. */
  gap: 0 3.5rem;
  align-items: start;
  /* Three bands down the page: the featured card, the list under it, and the
     button. Named here so each column can opt into them with subgrid. */
  grid-template-rows: auto auto auto;
}

/* The whole-card hover surface bleeds 1.25rem past each edge of a row, so it
   reads as a card rather than as a tinted line of text. That works on a
   full-width list, where it spills harmlessly into the page margin. Between
   two columns there is no margin to spill into: at the default bleed the left
   column's surface ended at x=638 and the right column's began at x=642, four
   pixels apart, so hovering either one looked like a rectangle running into
   its neighbour. Narrowed here so ~32px of gutter always separates them. */
/* Home columns are not pulled out — the surface is exactly the column, which
   is exactly the width of the featured card above it. */
.home-streams .post-list { margin-inline: 0; }
.home-streams .post-list .post-item { padding-inline: 1.1rem; }
/* The home page leads with one featured entry and lists a few others beneath
   it. Those others are context, not the headline, so their resting surface is
   quieter than the list pages use: no fill at all, and a hairline at roughly
   half strength. The featured card keeps the full treatment, which is what
   makes it read as the thing to look at first.

   Done by overriding the surface's inputs rather than by writing a second
   ::after rule, so the card keeps one definition everywhere. Hover is left
   alone deliberately — going from no fill to the full hover fill makes the
   pointer's effect on these rows more pronounced, not less. */
.home-streams .stream-list {
  /* A rung below the featured card on both axes, at rest and on hover:

       secondary at rest   faint fill, light hairline
       secondary on hover  more fill, full soft hairline
       featured at rest    --bg-elev, full --border          <- still louder
       featured on hover   --card-hover-bg, accent border, shadow

     So hovering one of these is clearly something, and still never competes
     with the entry the page is leading on. Expressed against --bg-elev and
     --border-soft rather than the card tokens, because a custom property
     cannot be defined in terms of itself. */
  --card-rest-bg: color-mix(in srgb, var(--bg-elev) 45%, transparent);
  --card-rest-border: color-mix(in srgb, var(--border-soft) 60%, transparent);
  /* Hover is not overridden: these rows light up exactly as the featured card
     does, so the pointer means one thing everywhere on the page. Only the
     resting state says which entry is the headline. */
}

.stream {
  min-width: 0;                    /* let long titles wrap, not overflow */
  /* Subgrid, so both columns share the parent's rows instead of each sizing
     its own. Without it a featured card was only as tall as its own text — a
     two-line description beside a four-line one left the shorter card visibly
     stunted and the two lists starting at different heights. With it, both
     cards take the height of the taller one and the lists and buttons line up
     across the page.

     Degrades cleanly: a browser without subgrid ignores the two lines below
     and falls back to columns sized independently — untidy, not broken. */
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
}

/* The card fills its band rather than sitting at the top of it, and the
   excerpt takes the slack so "Read more" stays at the foot of both cards
   instead of floating mid-box in the shorter one.

   No `height: 100%` here. A percentage height on a grid item resolves against
   the row but is not clamped by it, so the card overhung its band by 10px and
   sat on top of the first row of the list below. Grid items stretch to their
   row by default; saying so explicitly is enough and cannot overflow. */
.stream .featured-wrap { align-self: stretch; }


.stream .featured-wrap,
.stream .featured {
  display: flex;
  flex-direction: column;
}
.stream .featured { flex: 1 1 auto; align-items: flex-start; }
.stream .featured .excerpt { flex: 1 0 auto; }

/* The list under each featured card is a compact index — date and title only.
   Excerpts belong to the card above it; repeating them three more times in a
   half-width column is what made the single-column version so tall. */
.stream-list { margin-top: 1.5rem; }
.stream-list .post-item { padding: 0.85rem 0; }
.stream-list .post-item h3 { font-size: 0.98rem; margin: 0.15rem 0 0; }

/* Sits under the start of the list it belongs to. */
.stream-more {
  /* Pinned to the stream's last subgrid row. The children fill rows in order,
     so a column with no post list has only two of them and its button would
     ride up into row 2 while the other column's stayed in row 3 — the two
     buttons out of line whenever one stream is empty and the other is not. */
  grid-row: 3;
  margin: 1.4rem 0 0;
  /* Left, but inset to the list's text column rather than the column's outer
     edge, so the button starts where the post titles above it start.

     The value matches `.home-streams .post-list .post-item` above, which is
     what actually sets the inset here — 1.1rem, not the 1.25rem the generic
     .post-list rule carries. That rule wins on specificity ((0,3,0) against
     (0,2,0)), so quoting the generic value put this button 3px right of the
     titles it is supposed to line up with. Keep the two in step.

     Centring it, which this replaced, put the button on the column midline,
     which lines up with nothing else in the stream. */
  text-align: left;
  padding-left: 1.1rem;
}

@media (max-width: 900px) {
  /* One column below this. Two lists at ~380px wide cramp the titles more
     than seeing both streams at once is worth. */
  .home-streams { grid-template-columns: 1fr; gap: 0; }
  .home-streams .stream + .stream { margin-top: 3.5rem; }
}
.section-head {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  margin-bottom: 0;
  padding-bottom: 0.7rem;
  border-bottom: 1px solid var(--border-soft);
}

.section-head h2 {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0;
  font-weight: 500;
}

.section-head .more {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-faint);
}
@media (hover: hover) {
  .section-head .more:hover { color: var(--accent); }
}

/* --------------------------------------------------------------------------
   Featured (latest) post card
   -------------------------------------------------------------------------- */

/* The label straddles the card's top border and punches a gap in it, so it
   reads as naming this card rather than heading a section above it. */
.featured-wrap {
  position: relative;
  margin-top: 0.6rem;
}

.featured-label {
  position: absolute;
  top: 0;
  left: 1.5rem;
  transform: translateY(-50%);
  z-index: 1;
  margin: 0;
  padding: 0 0.55rem;
  background: var(--bg);
  font-family: var(--font-mono);
  font-size: 1.1rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent);
}

.after-featured {
  margin: 0.9rem 0 0;
  text-align: right;
}
.after-featured .more {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-muted);
}
@media (hover: hover) {
  .after-featured .more:hover { color: var(--accent); }
}

/* Foot of the home page's post list. Ruled off and full width, because its job
   is to say the list stopped early rather than to be a quiet "more" link — the
   last card otherwise looks like the oldest post that exists. */
.list-more {
  margin: 1.75rem 0 0;
  padding-top: 1.1rem;
  border-top: 1px solid var(--border-soft);
  text-align: right;
}
.list-more .more {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-muted);
}
@media (hover: hover) {
  .list-more .more:hover { color: var(--accent); }
}

.featured {
  position: relative;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-elev);
  padding: 1.6rem 2.1rem 1.75rem;
  max-width: 100%;
  overflow: hidden;
}
.featured::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 3px;
  background: linear-gradient(180deg, var(--accent), transparent 85%);
}

.stream .featured.is-empty {
  border-style: dashed;
  justify-content: center;
  align-items: center;
  text-align: center;
}
/* The accent bar reads as "there is something here". */
.stream .featured.is-empty::before { display: none; }
.stream .featured.is-empty p {
  margin: 0;
  color: var(--text-faint);
  font-family: var(--font-mono);
  font-size: 0.9rem;
}

.featured .meta,
.post-item .meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--text-muted);
  margin-bottom: 1rem;
}
.featured .meta time,
.post-item .meta time { color: var(--text); }

.featured h3 {
  font-size: clamp(1.4rem, 3.4vw, 1.85rem);
  line-height: 1.25;
  letter-spacing: -0.02em;
  margin: 0 0 0.8rem;
  font-weight: 640;
}
.featured h3 a { color: var(--text-strong); }
@media (hover: hover) {
  .featured h3 a:hover { color: var(--accent); text-decoration: none; }
}

.featured .excerpt {
  color: var(--text);
  margin: 0 0 1.6rem;
}
.featured .excerpt p { margin: 0; }

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  letter-spacing: 0.02em;
  color: var(--bg);
  background: var(--accent);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  padding: 0.55rem 1.1rem;
  transition: background 0.15s ease, transform 0.15s ease;
}
@media (hover: hover) {
  .btn:hover {
    background: var(--accent-dim);
    border-color: var(--accent-dim);
    text-decoration: none;
    transform: translateY(-1px);
  }
}
.btn .arrow { transition: transform 0.15s ease; }
@media (hover: hover) {
  .btn:hover .arrow { transform: translateX(3px); }
}

.stream-more .btn {
  color: var(--accent);
  background: transparent;
  transition: background 0.15s ease, color 0.15s ease,
              border-color 0.15s ease, transform 0.15s ease;
}

.stream-more .btn:focus-visible {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--bg);
  }
@media (hover: hover) {
  .stream-more .btn:hover {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--bg);
  }
}

/* --------------------------------------------------------------------------
   Post list
   -------------------------------------------------------------------------- */

.post-list {
  list-style: none;
  margin: 0;
  padding: 0;
  /* No max-width here. The list is *meant* to be wider than its column — the
     hover surface bleeds 1.25rem past each edge (see the margin-inline rule
     further down) and each row pads the same amount back, so the text lands on
     the column edge exactly. Capping the width while the negative margin still
     pulls it left makes the bleed one-sided and narrows the text column. */
}

/* Roomier than it looks like it needs to be: now that excerpts render at full
   body brightness, entries need real space and a visible rule to stop reading
   as one continuous block. */
.post-item {
  padding: 2.4rem 0;
  border-bottom: 1px solid var(--border);
}
/* No first-child exception. Zeroing the top padding aligned the first entry
   with the section rule, but once every entry is a hover card it made the
   first one 38px shorter than the rest — its highlight hugged the date line
   while every other card had air above it. The list keeps uniform cards and
   the section heading gives back the space instead (see .section-head). */
.post-item:last-child { border-bottom: 0; }

.post-item :is(h2, h3) {
  font-size: 1.32rem;
  line-height: 1.3;
  letter-spacing: -0.016em;
  margin: 0 0 0.6rem;
  font-weight: 640;
}
.post-item :is(h2, h3) a { color: var(--text-strong); }
@media (hover: hover) {
  .post-item :is(h2, h3) a:hover { color: var(--accent); text-decoration: none; }
}

.post-item .excerpt {
  color: var(--text);
  font-size: 0.95rem;
  margin: 0;
}
.post-item .excerpt p { margin: 0; }

/* --------------------------------------------------------------------------
   Whole-card links
   The title anchor is still the only real link; site.js makes the rest of the
   card follow it. These rules are the affordance — added by the same script,
   so nothing here promises a click that JS is not there to handle.
   -------------------------------------------------------------------------- */

.card-link { cursor: pointer; }

/* List entries have no box of their own, so the hover surface is drawn by a
   pseudo-element that bleeds past the text column and sits behind the
   content — no layout shift, and the rule between entries stays put. */
/* The hover surface and the hover *target* must be the same rectangle.

   They were not. The surface bled 1.25rem past each edge of a row so it would
   read as a card rather than a tinted line of text — but the element that
   receives :hover is the row, which stops at the text column. That left a 20px
   strip down each side of every visible box where the pointer was inside the
   card you can see and outside the thing that lights it: move onto the edge of
   the box and it switched off again. The same mismatch was fixed vertically
   once (see the note below); this is the horizontal half of it.

   Now the row itself spans the full surface — pulled outward by a negative
   margin on the list and given matching padding — so the text lands exactly
   where it did, and the surface is `inset: 0`, identical to the row. */
.post-list,
.tag-posts { margin-inline: -1.25rem; }
.post-list .post-item,
.tag-posts .tag-post { padding-inline: 1.25rem; }

/* No separator between entries any more. It existed to stop rows running
   together when they had no surface of their own; now that each one is drawn
   as a card at rest, the line lands a pixel inside the card's own bottom
   border and reads as a doubled edge. The card does the separating. */
.post-list .post-item,
.tag-posts .tag-post { border-bottom: 0; }
.post-list .post-item::before,
.tag-posts .tag-post::before { content: none; }

.post-item,
.tag-post,
.post-item.card-link,
.tag-post.card-link { position: relative; }
.post-item.card-link > *,
.tag-post.card-link > * { position: relative; z-index: 1; }
.post-item.card-link::after,
.tag-post.card-link::after {
  content: "";
  position: absolute;
  /* Vertically flush with the <li>, because the <li> is what receives the
     hover. Insetting it looked tidier but left a 1.2rem band at the top and
     bottom of every entry where the pointer was inside the hover target and
     outside the box it had just lit up. The surface has to be the target. */
  /* 1.5px in from the top and bottom, so adjacent cards leave a 3px seam
     instead of meeting edge to edge. Deliberately tiny: this inset is exactly
     the dead strip that was fixed earlier — a pointer inside the row but
     outside the surface — and at 1.5px it is imperceptible, where the 1.2rem
     inset that caused the original bug was not. Horizontal stays 0, because
     the row was widened specifically to match the surface. */
  inset: 1.5px 0;
  z-index: 0;
  border-radius: 12px;
  /* Drawn at rest as well as on hover, so every entry has an edge and the
     list does not read as bare text on the page background. Hovering deepens
     the surface rather than conjuring it; colour and border carry the whole
     change, so nothing moves under the text. */
  background: var(--card-rest-bg);
  border: 1px solid var(--card-rest-border);
  box-shadow: none;
  transition: background-color 0.16s ease, border-color 0.16s ease,
              box-shadow 0.16s ease;
  pointer-events: none;
}


.post-item.card-link:focus-within::after,
.tag-post.card-link:focus-within::after {
    background: var(--card-hover-bg);
    border-color: var(--card-hover-border);
    box-shadow: var(--card-hover-shadow);
  }

.post-item.card-link:focus-within :is(h2, h3) a,
.tag-post.card-link:focus-within :is(h2, h3) a { color: var(--accent); }
@media (hover: hover) {
  .post-item.card-link:hover::after,
  .tag-post.card-link:hover::after {
    background: var(--card-hover-bg);
    border-color: var(--card-hover-border);
    box-shadow: var(--card-hover-shadow);
  }
  .post-item.card-link:hover :is(h2, h3) a,
  .tag-post.card-link:hover :is(h2, h3) a { color: var(--accent); }
}

/* The featured card already is a box, so it lifts instead. The transform goes
   on the wrapper so the label tab travels with the border it straddles. */
.featured-wrap.card-link { transition: transform 0.16s ease; }
.featured-wrap.card-link .featured {
  transition: background-color 0.16s ease, border-color 0.16s ease,
              box-shadow 0.16s ease;
}
.featured-wrap.card-link .featured::before { transition: width 0.16s ease; }

.featured-wrap.card-link:focus-within { transform: translateY(-2px); }

.featured-wrap.card-link:focus-within .featured {
    /* The same three tokens every other card hovers with. The fill moves too:
       border and shadow alone made the pointer's effect read as an outline
       change on a card that stayed the same colour. */
    background: var(--card-hover-bg);
    border-color: var(--card-hover-border);
    box-shadow: var(--card-hover-shadow);
  }

.featured-wrap.card-link:focus-within .featured::before { width: 4px; }

.featured-wrap.card-link:focus-within h3 a { color: var(--accent); }
@media (hover: hover) {
  .featured-wrap.card-link:hover { transform: translateY(-2px); }
  .featured-wrap.card-link:hover .featured {
    /* The same three tokens every other card hovers with. The fill moves too:
       border and shadow alone made the pointer's effect read as an outline
       change on a card that stayed the same colour. */
    background: var(--card-hover-bg);
    border-color: var(--card-hover-border);
    box-shadow: var(--card-hover-shadow);
  }
  .featured-wrap.card-link:hover .featured::before { width: 4px; }
  .featured-wrap.card-link:hover h3 a { color: var(--accent); }
}

@media (prefers-reduced-motion: reduce) {
  .post-item.card-link::after,
  .featured-wrap.card-link,
  .featured-wrap.card-link .featured { transition: none; }
  
.featured-wrap.card-link:focus-within { transform: none; }
@media (hover: hover) {
  .featured-wrap.card-link:hover { transform: none; }
}
}

.readmore {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  color: var(--accent);
  white-space: nowrap;
}

/* Year grouping on the blog index */
.year-rule {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin: 2.75rem 0 1.25rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-faint);
  letter-spacing: 0.1em;
}
.year-rule::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border-soft);
}
/* :first-child alone was wrong from page two onward — the top year heading
   there is not the first in the DOM, so it kept a margin the first entry is
   not meant to have and opened a gap above the list. .is-first-shown is put
   on the first non-hidden child by markEdges() in site.js. */
.post-list .year-rule:first-child,
.post-list .year-rule.is-first-shown { margin-top: 0; }

/* `hidden` is honoured by a UA-stylesheet rule, [hidden] { display: none },
   which any class-level display declaration outranks — and .year-rule sets
   display: flex. Pagination hides entries with the attribute, so without this
   a year heading stayed on screen after its posts had been paged away, leaving
   bare years stacked at the end of the list. */
.post-list .year-rule[hidden],
.post-list .month-rule[hidden],
.post-list .post-item[hidden] { display: none; }

/* Month heading, nested inside its year.

   The year stays flush with the page's left edge and the months indent under
   it, so a year visually brackets everything below it until the next one. The
   posts indent with the months rather than the year — otherwise the month
   label reads as a caption floating beside the list instead of a heading over
   it. Scoped to the paginated list: the home page shows too few posts for
   month headings to be anything but noise. */
.month-rule {
  margin: 1.6rem 0 0.8rem var(--list-indent, 1.5rem);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.post-list .year-rule + .month-rule { margin-top: 0.9rem; }

/* The month label takes the same shape as the year above it: text, then a
   hairline running to the right edge. Boxing the label instead — rules above,
   below and to both sides — would have put four lines around a single word on
   a page that already carries a year bracket down the left and a separator
   under every post. This says the same thing with one line, and says it in an
   idiom the page has already taught the reader one level up.

   The hierarchy ends up reading by weight rather than by enclosure:
     2026 ───────────────────   full-width, brighter rule
       │ MARCH ─────────────    indented, softer rule
       │ post                   inside the year's bracket */
.post-list.is-grouped .month-rule {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}
.post-list.is-grouped .month-rule::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border-soft);
}

.post-list.is-grouped .post-item { margin-left: var(--list-indent, 1.5rem); }

/* A hairline running down the year's block, so the bracket is visible and not
   just implied by the indent. Sits in the gutter the indent opens up. */
.post-list.is-grouped .month-rule,
.post-list.is-grouped .post-item {
  border-left: 1px solid var(--border-soft);
  padding-left: 1rem;
  margin-left: calc(var(--list-indent, 1.5rem) - 1rem);
}

@media (max-width: 620px) {
  .post-list.is-grouped { --list-indent: 0.9rem; }
}

/* Re-assert `hidden` after the grouped-list rules above.
   `.post-list .month-rule[hidden]` up at the top of this section is (0,3,0),
   and so is `.post-list.is-grouped .month-rule { display: flex }` — equal
   specificity, so the later of the two wins, and the later one was the
   flex. The result: a month heading could not be hidden at all, and page
   two of a paginated list would carry stray month labels with no posts
   under them. This is (0,4,0) *and* last, so it wins on both counts.

   This is the third time this pattern has bitten in this file (the year
   rules, the search status, and now this), so the rule of thumb: any
   component that sets `display` needs a matching `[hidden]` reset placed
   after it, and the built pages are checked for
   elements that carry `hidden` while still computing as visible. */
.post-list.is-grouped .year-rule[hidden],
.post-list.is-grouped .month-rule[hidden],
.post-list.is-grouped .post-item[hidden] { display: none; }

/* --------------------------------------------------------------------------
   Tags
   -------------------------------------------------------------------------- */

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  list-style: none;
  margin: 0;
  padding: 0;
}
.tag {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-muted);
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.1rem 0.5rem;
}
@media (hover: hover) {
  /* `.tag`, not `a.tag`. The filter chips on /tags/ became <button> when they
     stopped being jump links, and this rule kept requiring an anchor — so all
     twenty of them had no hover feedback at all, while `button.tag` further
     down still declared a transition for a hover that could never fire.
     :focus-visible is here for the same reason: these are real controls and
     were relying on the UA default ring. */
  .tag:hover {
    color: var(--accent);
    border-color: var(--accent);
    text-decoration: none;
  }
}
.tag:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
@media (hover: hover) {
  button.tag:disabled:hover {
    /* A chip that cannot narrow the selection should not invite the click. */
    color: var(--text-muted);
    border-color: var(--border);
  }
}

/* Tag index page */
.tag-cloud {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  list-style: none;
  /* 3rem was set when this was a jump-link cloud sitting above unrelated
     content. It is a control for the list directly beneath it now, and the two
     read as one component. */
  margin: 0 0 1.25rem;
  padding: 0;
  max-width: 100%;
}
.tag-cloud .tag { font-size: 0.8rem; padding: 0.25rem 0.7rem; }
.tag-cloud .count { color: var(--text-faint); margin-left: 0.35rem; }

/* --------------------------------------------------------------------------
   Page / article
   -------------------------------------------------------------------------- */

.page-head {
  max-width: 100%;
  margin-bottom: 1.25rem;
  padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--border-soft);
}
/* Keeps its own margin because it has no rule or padding to space against —
   but the value has to track .page-head's, or the one page without a rule ends
   up with a different gap from every page that has one. */
.page-head.no-rule { border-bottom: 0; padding-bottom: 0; margin-bottom: 1.25rem; }

/* A heading with no lede under it needs less room beneath. The 1.25rem is
   measured for a heading *and* its lede — it separates the pair from what
   follows — so with no lede it becomes the gap under the h1 itself, wider than
   the 0.6rem every other page sits at. */
.page-head:not(:has(.lede)) { margin-bottom: 0.6rem; }

/* Every page title, the home page's name included. One selector rather than
   two matching ones, so they cannot drift apart again. */
.page-head h1,
.intro h1 {
  font-size: clamp(1.8rem, 4.5vw, 2.5rem);
  line-height: 1.15;
  letter-spacing: -0.025em;
  color: var(--text-strong);
  margin: 0 0 0.6rem;
  font-weight: 650;
}
.page-head .lede {
  max-width: var(--measure);
  color: var(--text);
  margin: 0;
  font-size: 1.02rem;
}

/* A page that supplies its own <h1> in the body instead of through the layout
   — see `page_head: false` in _layouts/page.html. Matched to .page-head h1 so
   the two routes are indistinguishable on screen. */
.prose > h1:first-child {
  font-size: clamp(1.8rem, 4.5vw, 2.5rem);
  line-height: 1.15;
  letter-spacing: -0.025em;
  color: var(--text-strong);
  margin: 0 0 1.25rem;
  padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--border-soft);
  font-weight: 650;
}

/* Article shell: body in column 1, sticky TOC rail in column 2.
   The TOC is second in source order but flips above the body on narrow
   screens, where the shell becomes a single flex column. */
.article-shell {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(13rem, 20rem);
  gap: 3.5rem;
  align-items: start;
}
@media (max-width: 1000px) {
  .article-shell {
    display: flex;
    flex-direction: column;
    gap: 0;
    /* the grid rule's `start` would shrink-wrap each item on the cross axis */
    align-items: stretch;
  }
  .article-shell > .toc { order: -1; }
}


/* Top right of the post header, level with the date line, as a pill rather
   than a bare link — it is a control that leaves the page, not part of the
   post's metadata, and it should not read as another item in the meta row.

   Absolutely positioned so it shares the meta line's vertical space instead of
   costing a row of its own above the title. */
.post-header { position: relative; }

.post-back {
  position: absolute;
  top: 0;
  right: 0;
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  line-height: 1;
}
.post-back a {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.4rem 0.8rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-elev);
  color: var(--text-muted);
  white-space: nowrap;
  transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
@media (hover: hover) {
  .post-back a:hover {
    color: var(--accent);
    border-color: var(--accent);
    text-decoration: none;
  }
}
.post-back a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* On a phone the meta line wraps to two rows and an absolute pill would sit on
   top of it, so it returns to the flow — still right-aligned. */
@media (max-width: 640px) {
  .post-back {
    position: static;
    text-align: right;
    margin: 0 0 0.75rem;
  }
}

@media print { .post-back { display: none; } }

.post-header { margin-bottom: 2.25rem; max-width: 100%; }

.post-header .meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.7rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-muted);
  margin-bottom: 1rem;
}

.post-header h1 {
  font-size: clamp(1.9rem, 4.8vw, 2.6rem);
  line-height: 1.14;
  letter-spacing: -0.028em;
  color: var(--text-strong);
  margin: 0 0 0.8rem;
  font-weight: 650;
}

/* No measure cap: the header sits outside the article grid and spans the full
   width, so a lede that stopped at 54rem wrapped early with obvious room left
   beside it — it read as broken rather than as a deliberate column. */
.post-header .lede {
  font-size: 1.1rem;
  color: var(--text);
  margin: 0 0 1.1rem;
}

/* Multi-part series banner (Project Zero style) */
.series {
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  background: var(--bg-elev);
  padding: 1rem 1.2rem;
  margin: 1.75rem 0;
  font-size: 0.92rem;
}
.series .series-label {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 0.6rem;
}
.series ol {
  margin: 0;
  padding-left: 1.2rem;
  color: var(--text);
}
.series li { margin: 0.2rem 0; }
.series li[aria-current="true"] {
  color: var(--text-strong);
  font-weight: 600;
}
.series li[aria-current="true"]::marker { color: var(--accent); }

/* The revision date in the meta line. Deliberately quieter than the
   publication date: it is context, not the headline. */
.meta .updated { color: var(--text-muted); }
.meta .updated time { color: inherit; }

/* Revision log, rendered above the post footer when `revisions:` is set. */
.revisions {
  margin: 3rem 0 0;
  padding-top: 1.25rem;
  border-top: 1px solid var(--border-soft);
  font-size: 0.88rem;
}
.revisions-title {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 0.7rem;
}
.revisions ul { list-style: none; margin: 0; padding: 0; }
.revisions li {
  display: flex;
  gap: 0.9rem;
  align-items: baseline;
  margin: 0.35rem 0;
  color: var(--text);
}
.revisions time {
  flex: none;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-muted);
}
@media (max-width: 600px) {
  .revisions li { flex-direction: column; gap: 0.15rem; }
}

/* Artifacts / PoC callout */
.artifacts {
  display: flex;
  align-items: flex-start;
  gap: 0.9rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-elev);
  padding: 1rem 1.2rem;
  margin: 1.75rem 0;
  font-size: 0.92rem;
}
.artifacts svg { flex: none; width: 18px; height: 18px; fill: var(--accent); margin-top: 0.25rem; }
.artifacts .t {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
  display: block;
  margin-bottom: 0.2rem;
}
.artifacts p { margin: 0; color: var(--text); }

/* --------------------------------------------------------------------------
   Long-form prose
   -------------------------------------------------------------------------- */

.prose {
  max-width: 100%;
  font-size: 1.125rem;
  line-height: 1.75;
}

/* Prose fills the shell like every other block, so page widths match. If long
   posts start to feel tiring to read, set this to var(--measure) — that alone
   pulls paragraphs back to ~90 characters without touching the layout. */
.prose > p,
.prose > ul,
.prose > ol,
.prose > dl,
.prose > blockquote,
.prose > h2,
.prose > h3,
.prose > h4,
.prose > h5,
.prose > h6,
.prose > .callout,
.prose > .footnotes {
  max-width: 100%;
}

.prose > * + * { margin-top: 1.25rem; }

.prose h2,
.prose h3,
.prose h4 {
  color: var(--text-strong);
  line-height: 1.3;
  letter-spacing: -0.018em;
  scroll-margin-top: 5rem;
  position: relative;
}
.prose h2 {
  font-size: 1.5rem;
  font-weight: 640;
  margin-top: 1.4rem;
  /* Stated rather than inherited: everything else here is set — size, weight,
     the space above, the rule — and the space below was the one value left to
     the browser. On its own this changes nothing, because adjacent margins
     collapse and the paragraph below asks for more; the rule beneath is what
     actually closes the gap. Both are set so neither can quietly become the
     one that decides. */
  margin-bottom: 0.7rem;
  padding-top: 0.7rem;
  border-top: 1px solid var(--border-soft);
}
/* A heading belongs to what follows it, so it sits closer to that than to
   what came before. Adjacent vertical margins collapse to the larger of the
   two, so this is the rule that sets the distance — the heading's own
   margin-bottom is never the deciding value. */
.prose h2 + * { margin-top: 0.7rem; }

.prose h3 { font-size: 1.2rem; font-weight: 620; margin-top: 2.25rem; }
.prose h4 {
  font-size: 1rem;
  font-weight: 620;
  margin-top: 1.75rem;
  color: var(--text);
}

/* Anchor links on hover, like most research blogs */
.prose .anchor {
  position: absolute;
  left: -1.2rem;
  width: 1.2rem;
  color: var(--text-faint);
  opacity: 0;
  font-weight: 400;
  text-align: left;
}

.prose .anchor:focus { opacity: 1; text-decoration: none; }
@media (hover: hover) {
  .prose h2:hover .anchor,
  .prose h3:hover .anchor { opacity: 1; text-decoration: none; }
}
@media (max-width: 700px) { .prose .anchor { display: none; } }

.prose ul, .prose ol { padding-left: 1.4rem; }
.prose li { margin: 0.4rem 0; }
.prose li > ul, .prose li > ol { margin-top: 0.4rem; }

.prose strong { color: var(--text-strong); font-weight: 620; }

.prose blockquote {
  margin: 1.75rem 0;
  padding: 0.2rem 0 0.2rem 1.2rem;
  border-left: 3px solid var(--border);
  color: var(--text);
  font-style: italic;
}
.prose blockquote p { margin: 0.5rem 0; }

.prose figure { margin: 2rem 0; }
.prose figure img {
  display: block;
  margin: 0 auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-elev);
}
.prose figcaption {
  margin-top: 0.7rem;
  font-size: 0.85rem;
  color: var(--text-faint);
  text-align: center;
  font-family: var(--font-sans);
}

/* Tables */
.prose table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.92rem;
  margin: 1.75rem 0;
}
.prose th, .prose td {
  text-align: left;
  padding: 0.55rem 0.8rem;
  border-bottom: 1px solid var(--border-soft);
  vertical-align: top;
}
.prose th {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
  border-bottom-color: var(--border);
  font-weight: 500;
  white-space: nowrap;
}
@media (hover: hover) {
  .prose tbody tr:hover { background: var(--bg-elev); }
}

/* Wide content must scroll inside itself, never the page */
.table-scroll { overflow-x: auto; max-width: 100%; }
.prose .table-scroll { margin: 1.75rem 0; }
.prose .table-scroll table { margin: 0; }
/* Fallback for no-JS: a lone wide table still won't push the page sideways. */
.prose > table { display: block; overflow-x: auto; max-width: 100%; }
.prose .table-scroll > table { display: table; }

/* Footnotes */
.prose .footnotes {
  margin-top: 2.75rem;
  padding-top: 1.25rem;
  border-top: 1px solid var(--border);
  font-size: 0.9rem;
  color: var(--text-muted);
}
.prose .footnotes ol { padding-left: 1.2rem; }
.prose sup[role="doc-noteref"] a,
.prose a.footnote { text-decoration: none; }

/* --------------------------------------------------------------------------
   Code
   -------------------------------------------------------------------------- */

.prose code,
.prose kbd {
  font-family: var(--font-mono);
  font-size: 0.875em;
}

/* inline */
.prose :not(pre) > code {
  background: var(--bg-elev);
  border: 1px solid var(--border-soft);
  border-radius: 4px;
  padding: 0.1em 0.36em;
  color: var(--text-strong);
  word-break: break-word;
}

.prose pre {
  margin: 0;
  padding: 1rem 1.1rem;
  overflow-x: auto;
  font-family: var(--font-mono);
  font-size: 0.855rem;
  line-height: 1.6;
  tab-size: 4;
}
.prose pre code {
  background: none;
  border: 0;
  padding: 0;
  color: inherit;
  font-size: inherit;
}

/* Rouge emits div.highlighter-rouge > div.highlight > pre.highlight.
   Only the outermost element gets the box; the inner wrapper is neutralised. */
.prose div.highlighter-rouge,
.prose figure.highlight {
  position: relative;
  margin: 1.75rem 0;
  background: var(--bg-code);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.prose div.highlighter-rouge > .highlight,
.prose figure.highlight > .highlight {
  background: none;
  border: 0;
  margin: 0;
  padding: 0;
}
.prose pre.highlight { background: none; }

/* --------------------------------------------------------------------------
   Code block filename bar and line highlighting
   Both are driven by an HTML comment above the fence (see site.js), so the
   Markdown still renders as a plain code block on github.com.
   -------------------------------------------------------------------------- */

.code-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.5rem 0.85rem;
  border-bottom: 1px solid var(--border);
  background: var(--bg-elev);
  border-radius: var(--radius) var(--radius) 0 0;
}
.code-file {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.02em;
  color: var(--text-muted);
}
/* With a bar present the copy button belongs in it, not floating over code. */
.prose .has-head .copy-btn { top: 0.42rem; right: 0.5rem; }

/* One element per line, so a highlight can span the full scroll width rather
   than stopping at the end of the text. */
.highlight .ln {
  display: block;
  min-width: max-content;
  /* A blank source line has no content and would collapse to zero height,
     stacking its line number on top of the next one. */
  min-height: 1.6em;
  /* Cancels the pre's horizontal padding so a highlighted line reaches the
     block's edges, then puts it back inside the line. */
  padding: 0 1.1rem;
  margin: 0 -1.1rem;
}
.highlight .ln.hl {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  box-shadow: inset 3px 0 0 var(--accent);
}

/* Copy button injected by post.js */
.copy-btn {
  position: absolute;
  top: 0.45rem;
  right: 0.45rem;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.22rem 0.5rem;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s ease, color 0.15s ease;
}

.copy-btn:focus { opacity: 1; }
@media (hover: hover) {
  .prose div.highlighter-rouge:hover .copy-btn,
  .prose figure.highlight:hover .copy-btn { opacity: 1; }
  .copy-btn:hover { color: var(--accent); border-color: var(--accent); }
}
.copy-btn.copied { color: var(--accent); border-color: var(--accent); }

/* Rouge tokens — dark. Comment grey matches --text-faint so it clears AA
   against --bg-code rather than fading out. */
.highlight .c, .highlight .c1, .highlight .cm, .highlight .cs,
.highlight .cd, .highlight .cp { color: #7d8895; font-style: italic; }
.highlight .k, .highlight .kd, .highlight .kn, .highlight .kp,
.highlight .kr, .highlight .kt, .highlight .kc { color: #ff7b9c; }
.highlight .o, .highlight .ow { color: #9fb0c4; }
.highlight .s, .highlight .s1, .highlight .s2, .highlight .sb,
.highlight .sc, .highlight .sd, .highlight .se, .highlight .sh,
.highlight .si, .highlight .sx, .highlight .sr, .highlight .ss { color: #a5e075; }
.highlight .m, .highlight .mf, .highlight .mh, .highlight .mi,
.highlight .mo, .highlight .il { color: #d9a0f5; }
.highlight .n, .highlight .nx { color: var(--text); }
.highlight .nf, .highlight .fm { color: #7cc4ff; }
.highlight .nc, .highlight .nn { color: #ffd479; }
.highlight .na, .highlight .nv, .highlight .vi,
.highlight .vg, .highlight .vc { color: #7ee0d0; }
.highlight .nb, .highlight .bp { color: #3cd3fe; }
.highlight .nt { color: #ff7b9c; }
.highlight .p, .highlight .pi { color: #8a94a2; }
.highlight .err { color: #ff4245; }
.highlight .gd { color: #ff4245; background: rgba(255, 66, 69, 0.12); }
.highlight .gi { color: #30d158; background: rgba(48, 209, 88, 0.1); }
.highlight .gh, .highlight .gu { color: var(--text-faint); }
.highlight .ge { font-style: italic; }
.highlight .gs { font-weight: 600; }

/* Rouge tokens — light */
:root[data-theme="light"] .highlight .c,
:root[data-theme="light"] .highlight .c1,
:root[data-theme="light"] .highlight .cm { color: #6a737d; }
:root[data-theme="light"] .highlight .k,
:root[data-theme="light"] .highlight .kd,
:root[data-theme="light"] .highlight .kn,
:root[data-theme="light"] .highlight .kt,
:root[data-theme="light"] .highlight .nt { color: #cf222e; }
:root[data-theme="light"] .highlight .s,
:root[data-theme="light"] .highlight .s1,
:root[data-theme="light"] .highlight .s2 { color: #032f62; }
:root[data-theme="light"] .highlight .m,
:root[data-theme="light"] .highlight .mi,
:root[data-theme="light"] .highlight .mh { color: #005cc5; }
:root[data-theme="light"] .highlight .nf,
:root[data-theme="light"] .highlight .fm { color: #6f42c1; }
:root[data-theme="light"] .highlight .nc,
:root[data-theme="light"] .highlight .nn { color: #6f42c1; }
:root[data-theme="light"] .highlight .na,
:root[data-theme="light"] .highlight .nv { color: #e36209; }
:root[data-theme="light"] .highlight .nb,
:root[data-theme="light"] .highlight .bp { color: #005cc5; }
:root[data-theme="light"] .highlight .p { color: #24292f; }

@media (prefers-color-scheme: light) {
  :root:not([data-theme]) .highlight .c,
  :root:not([data-theme]) .highlight .c1,
  :root:not([data-theme]) .highlight .cm { color: #6a737d; }
  :root:not([data-theme]) .highlight .k,
  :root:not([data-theme]) .highlight .kd,
  :root:not([data-theme]) .highlight .kn,
  :root:not([data-theme]) .highlight .kt,
  :root:not([data-theme]) .highlight .nt { color: #cf222e; }
  :root:not([data-theme]) .highlight .s,
  :root:not([data-theme]) .highlight .s1,
  :root:not([data-theme]) .highlight .s2 { color: #032f62; }
  :root:not([data-theme]) .highlight .m,
  :root:not([data-theme]) .highlight .mi,
  :root:not([data-theme]) .highlight .mh { color: #005cc5; }
  :root:not([data-theme]) .highlight .nf,
  :root:not([data-theme]) .highlight .fm { color: #6f42c1; }
  :root:not([data-theme]) .highlight .nc,
  :root:not([data-theme]) .highlight .nn { color: #6f42c1; }
  :root:not([data-theme]) .highlight .na,
  :root:not([data-theme]) .highlight .nv { color: #e36209; }
  :root:not([data-theme]) .highlight .nb,
  :root:not([data-theme]) .highlight .bp { color: #005cc5; }
  :root:not([data-theme]) .highlight .p { color: #24292f; }
}

/* --------------------------------------------------------------------------
   Callouts

   Authored with GitHub's alert syntax so the same Markdown renders natively on
   github.com and as a styled box here:

       > [!WARNING]
       > Only run this against a device you own.

   site.js upgrades the blockquote; without JS it degrades to a plain quote.
   -------------------------------------------------------------------------- */

.callout {
  --cal: var(--accent);
  display: flex;
  gap: 0.85rem;
  border: 1px solid var(--border);
  border-left: 3px solid var(--cal);
  border-radius: var(--radius);
  background: var(--callout-bg);
  padding: 1rem 1.15rem;
  margin: 1.75rem 0;
  font-size: 0.97rem;
}
.callout-note      { --cal: var(--accent); }
.callout-tip       { --cal: var(--c-green); }
.callout-important { --cal: var(--c-purple); }
.callout-warning   { --cal: var(--warn); }
.callout-caution   { --cal: var(--c-red); }

.callout > svg {
  flex: none;
  width: 18px;
  height: 18px;
  fill: var(--cal);
  margin-top: 0.28rem;
}
.callout-body { min-width: 0; }
.callout-title {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--cal);
  margin: 0 0 0.3rem;
  font-weight: 500;
}
.callout p { margin: 0.5rem 0; }
.callout p:first-of-type { margin-top: 0; }
.callout p:last-child { margin-bottom: 0; }
.callout > * + * { margin-top: 0; }

/* --------------------------------------------------------------------------
   Remaining prose elements
   -------------------------------------------------------------------------- */

.prose h5, .prose h6 {
  color: var(--text);
  font-size: 0.92rem;
  font-weight: 620;
  margin-top: 1.5rem;
  letter-spacing: 0.01em;
}
.prose h6 { color: var(--text-muted); text-transform: uppercase; font-size: .8rem; letter-spacing: .08em; }

/* Definition lists — handy for "field: meaning" reference sections */
.prose dl { margin: 1.75rem 0; }
.prose dt {
  font-family: var(--font-mono);
  font-size: 0.9rem;
  color: var(--text-strong);
  font-weight: 600;
  margin-top: 1rem;
}
.prose dt:first-child { margin-top: 0; }
.prose dd {
  margin: 0.3rem 0 0;
  padding-left: 1.1rem;
  border-left: 1px solid var(--border);
  color: var(--text);
}

/* Task lists (GFM `- [ ]`). kramdown emits these disabled, which the platform
   renders too dim to read, so draw the box ourselves. */
.prose .task-list-item { list-style: none; margin-left: -1.4rem; }
.prose .task-list-item input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  width: 1em;
  height: 1em;
  margin: 0 0.55rem 0 0;
  vertical-align: -0.14em;
  border: 1px solid var(--border);
  border-radius: 3px;
  background: var(--bg-elev);
  opacity: 1;
}
.prose .task-list-item input[type="checkbox"]:checked {
  background: var(--accent);
  border-color: var(--accent);
}
.prose .task-list-item input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  left: 0.3em;
  top: 0.12em;
  width: 0.22em;
  height: 0.48em;
  border: solid var(--bg);
  border-width: 0 2px 2px 0;
  transform: rotate(42deg);
}

/* Keyboard keys */
.prose kbd {
  display: inline-block;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: 4px;
  padding: 0.05em 0.42em;
  font-size: 0.8em;
  color: var(--text-strong);
  line-height: 1.5;
  white-space: nowrap;
}

/* Highlighted text */
.prose mark {
  background: var(--accent-bg);
  color: var(--text-strong);
  border-radius: 3px;
  padding: 0.05em 0.25em;
}

/* Collapsible sections — good for long register dumps and full exploit source */
.prose details {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-elev);
  padding: 0 1.15rem;
  margin: 1.75rem 0;
}
.prose details[open] { padding-bottom: 0.9rem; }
.prose summary {
  cursor: pointer;
  padding: 0.85rem 0;
  font-family: var(--font-mono);
  font-size: 0.85rem;
  color: var(--text-strong);
  list-style: none;
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.prose summary::-webkit-details-marker { display: none; }
.prose summary::before {
  content: "\25B8";
  color: var(--accent);
  transition: transform 0.15s ease;
  display: inline-block;
}
.prose details[open] > summary::before { transform: rotate(90deg); }
@media (hover: hover) {
  .prose summary:hover { color: var(--accent); }
}
.prose details > *:not(summary) { margin-top: 0; }
.prose details > .highlighter-rouge:first-of-type { margin-top: 0; }

/* Abbreviations */
.prose abbr[title] {
  text-decoration: underline dotted;
  text-underline-offset: 3px;
  cursor: help;
}

/* Sub/superscript that doesn't disturb line height */
.prose sub, .prose sup { line-height: 0; font-size: 0.75em; }

/* Standalone images (no <figure> wrapper) */
.prose p > img {
  display: block;
  margin: 0 auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

/* Two-up figure comparisons (before/after, patched/unpatched) */
.prose .img-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.25rem;
  margin: 2rem 0;
}
.prose .img-row figure { margin: 0; }
.prose .img-row figcaption { font-size: 0.8rem; }
@media (max-width: 620px) {
  .prose .img-row { grid-template-columns: 1fr; }
}

.prose .wide { margin-inline: 0; }
.prose .wide img { width: 100%; }

/* Whatever the figure's width ends up being, the image fills it and no more.
   No max-width on the figure itself — that would cap it at the text column and
   cancel the bleed above. */
.prose figure img { max-width: 100%; height: auto; }

/* Embedded media stays inside the column and keeps its aspect ratio */
.prose iframe,
.prose video {
  display: block;
  width: 100%;
  max-width: 100%;
  aspect-ratio: 16 / 9;
  height: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: #000;
  margin: 1.75rem 0;
}
.prose video { aspect-ratio: auto; }

/* --------------------------------------------------------------------------
   Table of contents rail
   -------------------------------------------------------------------------- */

.toc {
  position: sticky;
  top: 5.5rem;
  max-height: calc(100vh - 8rem);
  overflow-y: auto;
  font-size: 0.85rem;
  border-left: 1px solid var(--border-soft);
  padding-left: 1.25rem;
}
.toc .toc-title {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 0.85rem;
}
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc li { margin: 0; }
.toc a {
  display: block;
  padding: 0.28rem 0;
  color: var(--text-muted);
  line-height: 1.4;
  border-left: 2px solid transparent;
  margin-left: -1.35rem;
  padding-left: 1.35rem;
}
@media (hover: hover) {
  .toc a:hover { color: var(--text-strong); text-decoration: none; }
}
.toc a.active {
  color: var(--accent);
  border-left-color: var(--accent);
}
.toc .lvl-3 a { padding-left: 2.1rem; font-size: 0.95em; color: var(--text-muted); }
@media (hover: hover) {
  .toc .lvl-3 a:hover { color: var(--text-muted); }
}
.toc .lvl-3 a.active { color: var(--accent); }

@media (max-width: 1000px) {
  .toc {
    position: static;
    max-height: none;
    border-left: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--bg-elev);
    padding: 1rem 1.2rem;
    margin: 0 0 2.5rem;
    max-width: var(--measure);
  }
  .toc a { margin-left: 0; padding-left: 0.5rem; }
  .toc .lvl-3 a { padding-left: 1.4rem; }
}

/* Reading progress bar */
.progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 2px;
  width: 0;
  background: var(--accent);
  z-index: 60;
  transition: width 0.1s linear;
}

.post-signoff {
  margin: 0;
  font-size: 0.9rem;
  color: var(--text-muted);
}

/* Whichever of these comes first is the end of the post, so it carries the
   full-weight rule and the wider gap. .related renders nothing at all when a
   post shares no tags with anything, and in that case the pager is first and
   has to take that treatment over — otherwise a post with no related items got
   a lighter separator than every other post, for no reason a reader could see.
   The `.related + .pager` rule below demotes the pager back to a sibling rule
   whenever there is in fact a related block above it. */
.pager {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  margin-top: 3rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--border);
}
.related + .pager {
  margin-top: 2.25rem;
  padding-top: 1.25rem;
  border-top-color: var(--border-soft);
}
@media (max-width: 620px) { .pager { grid-template-columns: 1fr; } }

.pager-title {
  grid-column: 1 / -1;          /* the title spans; the links keep the columns */
  text-align: center;           /* always centred, like .related-title */
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 0.2rem;
}

.pager a {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.9rem 1.1rem;
  display: block;
  background: var(--bg-elev);
}
@media (hover: hover) {
  .pager a:hover { border-color: var(--accent); text-decoration: none; }
}
.pager .dir {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
  display: block;
  margin-bottom: 0.3rem;
}
.pager .t { color: var(--text-strong); font-size: 0.95rem; line-height: 1.4; }
.pager .next { text-align: right; }

/* --------------------------------------------------------------------------
   Long unbreakable strings
   A vulnerability write-up is full of tokens with no spaces in them: hashes,
   base64 blobs, registry paths, `CVE-2026-20675-something-long`, advisory
   URLs, symbol names. By default a word longer than its column does not wrap —
   it overflows, and because the page is one column that turns into a
   horizontal scrollbar on the whole document. Measured: a single long URL in
   prose pushed a 1280px page to 1801px, and one long tag pushed the blog
   index, home and tags pages sideways on a phone.

   `break-word` breaks only when the word cannot fit, so ordinary prose is
   untouched. Inline code and URLs get `anywhere`, which also lets the browser
   count the break opportunity when sizing flex and grid items — without it a
   long token still forces its container wider than the viewport.
   -------------------------------------------------------------------------- */

/* Site-wide baseline rather than a list of components. Enumerating selectors
   missed two on the first pass — the tags page renders post titles as a bare
   <a> with no class, and an over-long word inside an excerpt overflows as
   *inline text* without ever widening its parent's box, so it does not show up
   when you go looking for wide elements. Anything that holds text inherits
   this; nothing has to remember to opt in. */
body { overflow-wrap: break-word; }

.prose,
.prose p,
.lede,
.excerpt,
.excerpt p,
h1, h2, h3, h4,
.post-item :is(h2, h3),
.featured h2,
.related-name,
.pager .t,
/* Tokens with no natural break point at all. */
.prose :not(pre) > code,
.prose a[href],
.tag,
.pager .t,
.related-name {
  overflow-wrap: anywhere;
}

/* Flex and grid children refuse to shrink below their longest word unless told
   they may; without this the wrapping above never gets a chance to apply. */
.tags li,
.pager a,
.related li,
.related li a,
.post-item,
.featured { min-width: 0; }

.tag { max-width: 100%; }

/* --------------------------------------------------------------------------
   Archive rail
   The whole post list, sticky, beside the home / blog / tags pages. Mirrors
   .article-shell's geometry so a reader moving between a post and the blog
   index sees the rail stay in the same place rather than jump.
   -------------------------------------------------------------------------- */

.page-shell {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(13rem, 18rem);
  gap: 3.5rem;
  align-items: start;
}

/* With no posts the archive include renders nothing, but the grid went on
   reserving its 288px column — so the "no posts yet" note sat in a 757px
   column with a phantom gutter beside it, under a rule spanning the full
   1101px. The placeholder on /publications/, which has no shell, filled the
   width and looked right; these two did not, for no reason a reader could see.
   Collapse to one column when there is no rail to put in the second. */
.page-shell:not(:has(> .archive-rail)) { display: block; }
@media (max-width: 1000px) {
  .page-shell { display: flex; flex-direction: column; gap: 0; align-items: stretch; }
  /* Below the content on a phone: the archive is a lookup tool, not the
     first thing you want between the header and the newest post. */
  .page-shell > .archive-rail { order: 1; margin-top: 3rem; }

  /* ...and the pager after the archive, not before it.
     
     The pagination is appended to .page-shell by site.js, so it arrives last
     in the DOM — but the rail's `order: 1` lifted the rail past it, leaving
     content, pager, archive. That put the controls for the list in the middle
     of the page with a whole index below them. Ordering it explicitly keeps
     the pager as the last thing on the page in both layouts. */
  .page-shell > .pagination { order: 2; }
}

/* Sticky beside the list, exactly like the TOC beside a post.

   `overflow-y: auto` rather than `scroll`: the inner scrollbar appears only
   when the archive is actually taller than the space it has. A short archive
   never shows one. This is the unavoidable trade for sticky — an element
   pinned to the viewport cannot show more than a viewport of content without
   scrolling something.

   If the archive outgrows the space often enough to be annoying, the fix is
   to give the rail fewer entries (scope it to the current page), not to
   remove the scroll container — that only hides the overflow. */
.archive-rail {
  position: sticky;
  top: 5.5rem;
  max-height: calc(100vh - 8rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  font-size: 0.85rem;
  border-left: 1px solid var(--border-soft);
  padding-left: 1.1rem;
  /* Room for the scrollbar to live in. Each year's summary puts its post
     count hard against the right edge, and an overlay scrollbar — macOS's
     default — reserves no space and paints over whatever is beneath, which is
     why `scrollbar-gutter: stable` does not answer this case. Padding holds
     the content clear of both kinds. */
  padding-right: 1rem;
}
@media (max-width: 1000px) {
  .archive-rail {
    position: static;
    max-height: none;
    border-left: 0;
    border-top: 1px solid var(--border);
    padding: 1.5rem 0 0;
  }
}

.archive-title {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 0.8rem;
}

.archive-year { border-bottom: 1px solid var(--border-soft); }
.archive-year:last-of-type { border-bottom: 0; }

.archive-year > summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  cursor: pointer;
  padding: 0.45rem 0;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-strong);
  list-style: none;          /* the default triangle sits badly against a rule */
}
.archive-year > summary::-webkit-details-marker { display: none; }
@media (hover: hover) {
  .archive-year > summary:hover { color: var(--accent); }
}
.archive-year > summary .n {
  color: var(--text-faint);
  font-size: 0.72rem;
}
/* Own disclosure arrow, so it points the right way in both states. */
.archive-year > summary .y::before {
  content: "▸";
  display: inline-block;
  width: 1em;
  color: var(--text-faint);
  transition: transform 0.15s ease;
}
.archive-year[open] > summary .y::before { transform: rotate(90deg); }

.archive-year ul {
  list-style: none;
  margin: 0 0 0.35rem;
  padding: 0 0 0 1em;
}
/* The tinted rectangle is exactly the rail's content column, and the link
   fills it.

   Two things were wrong. The tint sat 6px inside the column on the left and
   overhung it by 9px on the right, so it lined up with nothing. And the
   padding that gives it breathing room was on the <li> while the hover
   listener is on the <a> inside — which meant the <a> stopped 9px short of
   the tint on each side, and moving onto the edge of the rectangle you can
   see switched it off. Same surface-versus-target mismatch as the post cards.

   So: the <li> cancels the list's indent to span the full column, and the
   padding moves to the <a>, which is both the hover target and the thing
   being padded. Surface and target are the same rectangle by construction. */
.archive-year li {
  /* 1px above and below so consecutive tints leave a 2px seam rather than
     running together into one block. Margin rather than an inset on the tint:
     the tint is this element's own background and the hover target is the <a>
     filling it, so shrinking the box would put back the dead strip the
     padding move removed. Adjacent vertical margins collapse, so the seam
     between two rows is 2px and not 4px — the value is the seam, not half
     of it. */
  margin: 2px 0 2px -1em;
  padding: 0;
  border-radius: 5px;
}
.archive-year li a {
  display: grid;
  grid-template-columns: 3.2rem minmax(0, 1fr);
  gap: 0.5rem;
  padding: 0.18rem 1em;
  color: var(--text-muted);
  line-height: 1.35;
}
@media (hover: hover) {
  .archive-year li a:hover { color: var(--accent); text-decoration: none; }
}
.archive-year li a .d {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-faint);
  white-space: nowrap;
  padding-top: 0.1em;
}
.archive-year li a .t { min-width: 0; overflow-wrap: anywhere; }

/* The post you are on, when the rail is shown beside one.
   Matches on the <a>, which is where archive-rail.html now sets aria-current
   — on the <li> it was valid but poorly supported, and inconsistent with the
   site nav. */
.archive-year li a[aria-current="page"] { color: var(--text-strong); }
.archive-year li a[aria-current="page"] .t { font-weight: 600; }

/* The post currently under the reading line on a list page. Marked with a bar
   in the rail's gutter rather than a background, so it reads as a position
   indicator rather than as a selected item. */
.archive-year li.is-reading { position: relative; }
.archive-year li.is-reading::before {
  content: "";
  position: absolute;
  /* -1.1rem before the li gained 0.55rem of outward margin; pulled back by
     the same amount so the bar still lands against the rail's own border. */
  left: -0.55rem;
  top: 0.35rem;
  bottom: 0.35rem;
  width: 2px;
  border-radius: 1px;
  background: var(--accent);
}
.archive-year li.is-reading a { color: var(--text-strong); }
.archive-year li.is-reading a .d { color: var(--accent); }

/* The rail mirrors the current page, so entries off it are hidden outright.
   Explicit reset because `hidden` works through a UA rule that any
   class-level `display` outranks — a trap this file has hit three times. */
.archive-year li[hidden],
.archive-year[hidden] { display: none; }

/* ---- hover pairing ------------------------------------------------------
   Hovering a post card lights its archive entry, and hovering an archive
   entry lights the card. Deliberately a *tint*, not the gutter bar above:
   the bar answers "where am I in the page" and persists as you scroll, this
   answers "which one is this" and vanishes when you move away. If both used
   the same mark there would be two identical indicators on screen at once
   meaning different things.

   Nothing here moves anything — see the note in site.js. The pointer stays
   over whatever it was over.
   -------------------------------------------------------------------------- */
.archive-year li.is-hover { background: var(--bg-elev); }
.archive-year li.is-hover a { color: var(--text-strong); }

/* On the card side the whole-card hover surface already exists, so the
   pairing reuses it — hovering the rail entry makes the card look exactly as
   it would if you were hovering the card itself. One vocabulary, not two. */
.post-item.is-hover::after,
.tag-post.is-hover::after {
  background: var(--card-hover-bg);
  border-color: var(--card-hover-border);
  box-shadow: var(--card-hover-shadow);
}
.post-item.is-hover :is(h2, h3) a,
.tag-post.is-hover :is(h2, h3) a { color: var(--accent); }

@media (prefers-reduced-motion: reduce) {
  .archive-year li.is-hover { transition: none; }
}


.archive-total {
  margin: 1rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-faint);
}

@media print { .archive-rail { display: none; } }

/* ---- publications -------------------------------------------------------- */
.pub-list { list-style: none; margin: 0; padding: 0; }
.pub-item { padding: 1.6rem 0; border-bottom: 1px solid var(--border-soft); }
.pub-item:last-child { border-bottom: 0; }
.pub-item .meta {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
  font-family: var(--font-mono); font-size: 0.74rem;
  color: var(--text-muted); margin-bottom: 0.5rem;
}
.pub-kind {
  text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--accent);
}
.pub-item :is(h2, h3) { font-size: 1.1rem; margin: 0 0 0.35rem; line-height: 1.35; }
.pub-authors { margin: 0 0 0.35rem; font-size: 0.88rem; color: var(--text-muted); }
.pub-note { margin: 0.35rem 0 0; font-size: 0.92rem; color: var(--text); }
.pub-links { margin: 0.6rem 0 0; display: flex; gap: 1rem;
             font-family: var(--font-mono); font-size: 0.78rem; }

/* --------------------------------------------------------------------------
   Command palette (⌘K)
   -------------------------------------------------------------------------- */

.cp-overlay {
  position: fixed;
  inset: 0;
  z-index: 200;
  background: color-mix(in srgb, #000 45%, transparent);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  display: flex;
  justify-content: center;
  /* Near the top, not centred: the list grows downward, and a box that
     re-centres itself as results change is disorienting to type into. */
  padding: 12vh 1rem 1rem;
}
.cp-overlay[hidden] { display: none; }

/* The page must not scroll behind the palette. */
:root.cp-open, :root.cp-open body { overflow: hidden; }

.cp-panel {
  width: min(38rem, 100%);
  max-height: 70vh;
  display: flex;
  flex-direction: column;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.45);
  overflow: hidden;
}

.cp-input {
  appearance: none;
  border: 0;
  border-bottom: 1px solid var(--border-soft);
  background: transparent;
  color: var(--text-strong);
  font-family: var(--font-sans);
  font-size: 1rem;
  padding: 1rem 1.1rem;
  width: 100%;
}
.cp-input:focus { outline: none; }
.cp-input::placeholder { color: var(--text-faint); }

.cp-list {
  list-style: none;
  margin: 0;
  padding: 0.4rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  flex: 1 1 auto;
}

.cp-item {
  display: grid;
  grid-template-columns: 4.4rem minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.75rem;
  padding: 0.55rem 0.7rem;
  border-radius: 7px;
  cursor: pointer;
  color: var(--text);
  font-size: 0.92rem;
}
.cp-item.is-active { background: var(--accent-bg); color: var(--text-strong); }
.cp-item mark { background: transparent; color: var(--accent); font-weight: 600; }
.cp-loading { color: var(--text-faint); grid-template-columns: 1fr; cursor: default; }

.cp-kind {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  text-align: center;
  padding: 0.15rem 0;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--text-faint);
}
.cp-post   { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); }
.cp-tag    { color: var(--c-purple); border-color: color-mix(in srgb, var(--c-purple) 35%, transparent); }
/* …except the ctf tag, which is green everywhere it appears. The badge still
   says "tag"; only the colour changes, so the row is recognisable at a glance
   without claiming to be a different kind of result. */
.cp-item[data-tag="ctf"] .cp-tag {
  color: var(--c-green);
  border-color: color-mix(in srgb, var(--c-green) 35%, transparent);
}
.cp-item[data-tag="ctf"] .cp-title { color: var(--c-green); }
.cp-action { color: var(--c-green);  border-color: color-mix(in srgb, var(--c-green) 35%, transparent); }

.cp-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cp-meta { font-family: var(--font-mono); font-size: 0.7rem; color: var(--text-faint); white-space: nowrap; }

.cp-empty { margin: 0; padding: 1.4rem 1.1rem; color: var(--text-faint); font-size: 0.9rem; }

.cp-hint {
  margin: 0;
  padding: 0.55rem 1.1rem;
  border-top: 1px solid var(--border-soft);
  font-size: 0.72rem;
  color: var(--text-faint);
  display: flex;
  gap: 0.4rem;
  align-items: center;
}
.cp-hint kbd {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: 4px;
  padding: 0.05rem 0.3rem;
  color: var(--text-muted);
}

@media (max-width: 620px) {
  .cp-overlay { padding: 6vh 0.6rem 0.6rem; }
  .cp-item { grid-template-columns: 3.6rem minmax(0, 1fr); }
  .cp-meta { display: none; }
}

@media print { .cp-overlay { display: none !important; } }

/* Two lines of one statement, broken with <br>. The prose line-height of 1.75
   is set for reading paragraphs and is too airy when the "paragraph" is a
   two-line introduction — the break reads as a gap rather than a wrap. Applied
   with a kramdown IAL ({: .tight-lines}) so posts keep the looser measure. */
.prose p.tight-lines { line-height: 1.45; }

/* ---- tag filter ---------------------------------------------------------- */

.tag-filter { margin-bottom: 0.9rem; }

/* The chips are buttons now, not links — they change this page rather than
   navigate. Inherits .tag's look so nothing shifts visually. */
button.tag {
  cursor: pointer;
  font: inherit;
  font-family: var(--font-mono);
  transition: opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
/* `var(--bg)`, not #fff. The accent is a light cyan on the dark theme, so
   white text on it measures 1.76:1 — the selected chip was the least legible
   thing on the page in the default theme. Every other accent-filled control
   in this file (.btn, .pagination-item.is-current, .video-facade-play) uses
   the page background and gets 11:1. */
button.tag.is-on {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--bg);
}
button.tag.is-on .count { color: var(--bg); opacity: 0.75; }

/* ---- the ctf tag, in green ----------------------------------------------
   CTF write-ups are the site's one genuinely different kind of post, and they
   already read as green wherever a list marks them. This carries that colour
   onto the tag itself, so `ctf` is the same green in the chips under a post
   title, in the filter row on /tags/, on each row in that list, and in the
   command palette — one identity in every place the tag appears, rather than
   a colour that only exists in list metadata.

   Keyed on data-tag rather than on the visible text, so it survives the tag
   being renamed in a post's front matter and cannot be triggered by a tag
   that merely contains the letters.
   -------------------------------------------------------------------------- */
.tag[data-tag="ctf"] {
  color: var(--c-green);
  border-color: color-mix(in srgb, var(--c-green) 40%, transparent);
}
@media (hover: hover) {
  .tag[data-tag="ctf"]:hover {
    color: var(--c-green);
    border-color: var(--c-green);
  }
}
/* Full opacity, not a 70% mix: mixing toward transparent over --bg-elev gave
   #4aaa6e in light mode, 2.72:1 at 0.72rem. --text-faint, which this
   overrides, manages 4.8:1 in both themes. */
.tag[data-tag="ctf"] .count { color: var(--c-green); }

/* Selected states keep the green rather than reverting to the accent — the
   point is that this tag is always green. `var(--bg)` rather than #fff for
   the same reason every other filled control in this file uses it: on the
   dark theme white-on-green is 2.02:1, while the page background gives
   11:1. */
button.tag.is-on[data-tag="ctf"] {
  background: var(--c-green);
  border-color: var(--c-green);
  color: var(--bg);
}
button.tag.is-on[data-tag="ctf"] .count { color: var(--bg); opacity: 0.75; }
.tag.is-mini[data-tag="ctf"][aria-pressed="true"] {
  color: var(--c-green);
  border-color: var(--c-green);
}
/* Placed after `.tag.is-mini:focus-visible` below would be simpler, but that
   rule uses the `outline` shorthand, which resets outline-color. Naming both
   classes here outranks it (0,4,0 vs 0,3,0) wherever they coincide. */
.tag[data-tag="ctf"]:focus-visible,
.tag.is-mini[data-tag="ctf"]:focus-visible { outline-color: var(--c-green); }

/* A tag that cannot narrow the current selection any further. Kept visible
   rather than removed: the set of chips staying put is what makes the counts
   legible as you click. */
button.tag.is-out {
  opacity: 0.35;
  cursor: not-allowed;
  text-decoration: line-through;
}

/* `hidden` is honoured by a UA rule, [hidden] { display: none }, which any
   class-level display declaration outranks — the same trap the year rules hit.
   Anything here that sets `display` needs its own [hidden] rule. */
.tag-status[hidden] { display: none; }

.tag-status {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0.9rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-muted);
}
.tag-clear {
  font: inherit;
  background: none;
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.1rem 0.6rem;
  color: var(--text-muted);
  cursor: pointer;
}
@media (hover: hover) {
  .tag-clear:hover { color: var(--accent); border-color: var(--accent); }
}

/* Pulled up so the first row keeping its padding does not push the list away
   from the chips — but only part of the way. Cancelling the full 1.1rem left
   the first card's top edge 2px under the chip cloud, far too tight for a
   surface that lights up on hover.

   Tuned so the space below the chip cloud equals the space above it: the page
   heading sits 20px clear of the chips, and now so does the first card. The
   chips read as a band with equal air on both sides rather than as something
   pushed up against the list. */
.tag-posts { list-style: none; margin: 0; padding: 0; }
.tag-post {
  padding: 1.1rem 0;
  border-bottom: 1px solid var(--border-soft);
}
.tag-post:last-child,
.tag-post.is-last-shown { border-bottom: 0; }
.tag-post[hidden] { display: none; }
.tag-post :is(h2, h3) { font-size: 1.05rem; margin: 0.15rem 0 0.5rem; line-height: 1.35; }
.tag-post .meta {
  display: flex; align-items: center; gap: 0.5rem;
  font-family: var(--font-mono); font-size: 0.74rem; color: var(--text-muted);
}

.tag-post-tags {
  display: flex; flex-wrap: wrap; gap: 0.35rem;
  list-style: none; margin: 0; padding: 0;
}
/* A real <button> now, so it needs the UA button styling reset back to what
   the <span> looked like — font, line-height and the focus ring, which the
   span never had and which a keyboard user needs. */
button.tag.is-mini {
  font-family: var(--font-mono);
  line-height: inherit;
  -webkit-appearance: none;
  appearance: none;
}
.tag.is-mini {
  font-size: 0.68rem;
  padding: 0.1rem 0.45rem;
  cursor: pointer;
}
@media (hover: hover) {
  .tag.is-mini:hover { border-color: var(--accent); color: var(--accent); }
}
.tag.is-mini:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* Selected from a post row, mirroring the chip above it. */
.tag.is-mini[aria-pressed="true"] {
  color: var(--accent);
  border-color: var(--accent);
}

/* --------------------------------------------------------------------------
   404
   -------------------------------------------------------------------------- */

.fourohfour {
  max-width: var(--measure);
  margin: 0 auto;
  /* 4rem of bottom padding was sized for two links on this row. With one
     button left it read as the page trailing off.

     .page-content contributes 24px below this on its own, which is the floor —
     so the block's own bottom padding is what remains adjustable, and it is
     negative here to bring the total to 18px, half of what it was. */
  /* Top padding is 0.75rem, not 2rem: .page-content already contributes 16px
     above this, so 2rem put 48px of nothing between the header rule and the
     first line of a page that is three short paragraphs long. */
  padding: 0.75rem 0 0;
  margin-bottom: -6px;
  text-align: center;
}

.eyebrow-404 {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--c-red);
  margin: 0 0 0.8rem;
}

.fourohfour h1 {
  font-family: var(--font-mono);
  font-size: clamp(1.7rem, 4.5vw, 2.4rem);
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--text-strong);
  margin: 0 0 0.9rem;
  font-weight: 650;
}

.lede-404 {
  font-size: 1.05rem;
  color: var(--text);
  margin: 0 0 1.4rem;
}

/* Same 1.4rem below the photo as below the lede, so the three blocks sit on
   one rhythm rather than drifting apart down the page. */
.cat {
  margin: 0 0 1.4rem;
}
/* A definite box, so the space is reserved before the file arrives.

   With `width: auto` and no dimensions on the <img> there was nothing for the
   browser to reserve: the image measured 2px tall until it decoded and then
   jumped to ~446px, shoving the cat's name down the page with it. Chromium
   scored the shift at 0.28 on every visit. The dimensions cannot come from the
   markup either — Jekyll runs no plugins here, so it cannot know how big a
   file in this folder is — which leaves the ratio, declared once.

   `contain`, never `cover`: a photo is shown whole. All three cats are 3:4 and
   fill this frame exactly; anything else letterboxes rather than crops. The
   height cap keeps a tall one from pushing the links off screen, and is itself
   definite, so it reserves space just as well. */
.cat img {
  display: block;
  margin: 0 auto;
  /* One width, one ratio, and nothing that depends on the viewport's height.

     The box has to stay 3:4 or `contain` letterboxes it — that was the photo
     "cropped at the sides". The first fix for that spent the height budget as
     a width (`45vh`), which keeps the ratio but makes a document-flow image's
     size a function of viewport *height*. On a desktop that is stable. On a
     phone it is not: the height changes as the URL bar comes and goes, so the
     photo resizes on both axes, the caption and the button move with it, and
     the document height changes underneath a scroll the browser is trying to
     restore. It read as the page glitching on every refresh, and it was the
     one difference left between a Mac, where this page settled, and an iPhone,
     where it did not.

     No height cap now. A 340px-wide photo is 453px tall and the page scrolls
     on a short window — which is the size George asked to keep. Scrolling is
     not a defect; moving is. */
  width: min(100%, 340px);
  aspect-ratio: 3 / 4;
  object-fit: contain;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-elev);
}
.cat figcaption {
  margin-top: 0.8rem;
  font-family: var(--font-mono);
  font-size: 0.9rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  color: var(--text-strong);
}

/* The line under the name. Quieter than the name and in the body face rather
   than the mono one, so the two read as a caption and its aside rather than as
   two competing headings. */
.cat-note {
  display: block;
  margin-top: 0.25rem;
  font-family: var(--font-sans);
  font-size: 0.78rem;
  font-weight: 400;
  letter-spacing: 0;
  color: var(--text-muted);
}

.back-404 {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 1.4rem;
  margin: 0;
}
.back-404 .btn .arrow { transition: transform 0.15s ease; }
@media (hover: hover) {
  .back-404 .btn:hover .arrow { transform: translateX(-3px); }
}

/* --------------------------------------------------------------------------
   Contact block
   -------------------------------------------------------------------------- */

.prose .contact {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  gap: 0.6rem 1.5rem;
  margin: 1.1rem 0;
}
.prose .contact dt {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
  padding-top: 0.3rem;
  white-space: nowrap;
  margin: 0;
}
.prose .contact dd {
  margin: 0;
  padding: 0;
  border: 0;
  min-width: 0;
}

/* Fingerprints must never re-wrap mid-group — a mis-read group defeats the
   whole point of printing one. */
.fpr {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  line-height: 1.9;
  color: var(--text-strong);
  word-spacing: 0.15em;
}


@media (max-width: 620px) {
  .prose .contact { grid-template-columns: 1fr; gap: 0.15rem; }
  .prose .contact dd { margin-bottom: 0.8rem; }
}

/* --------------------------------------------------------------------------
   Advisories

   A scannable table, with the verbatim vendor / programme text folded into a
   details row beneath each entry so the record stays complete without the
   table becoming unreadable.
   -------------------------------------------------------------------------- */

/* Year heading above each group of advisories. */
.adv-year {
  font-family: var(--font-mono);
  font-size: 1.1rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  color: var(--text-strong);
  margin: 0 0 0.9rem;
}
/* Separates one year's table from the next year's heading. The last table on
   the page has no next year, and its margin was stacking on the page's own
   bottom padding — 80px under the final row against 36px everywhere else. */
.adv-year + .table-scroll { margin-bottom: 2.75rem; }
.adv-year + .table-scroll:last-child { margin-bottom: 0; }

.adv-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.92rem;
  border: 1px solid var(--border);
}
.adv-table th {
  text-align: left;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-strong);
  font-weight: 700;
  padding: 0.6rem 0.75rem;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  white-space: nowrap;
}

/* Sortable headers. The chevron is always present at low opacity so it is
   obvious the column can be sorted before anyone clicks anything.

   The room it sits in is reserved from the first paint, for every column that
   could be sorted, rather than waiting for the attribute. site.js adds
   aria-sort to seven of these once the document has parsed, and keying the
   padding on the attribute meant seven columns each grew by 13.6px a frame
   after the table first appeared. The ~95px came out of Impact, whose cells
   visibly reflowed from two lines to three.

   Only the geometry is unconditional. The arrow, the pointer cursor and the
   text selection lock still wait for aria-sort, because with JavaScript off
   there is nothing to click and a column advertising a sort it cannot perform
   is worse than a little unused padding. */
.adv-table th:not([data-nosort]) {
  position: relative;
  padding-right: 1.6rem;
}
.adv-table th[aria-sort] {
  cursor: pointer;
  user-select: none;
}
.adv-table th[aria-sort]::after {
  content: "\2195";                 /* up-down arrow: sortable, unsorted */
  position: absolute;
  right: 0.6rem;
  top: 50%;
  transform: translateY(-50%);
  font-size: 0.9em;
  opacity: 0.35;
  transition: opacity 0.12s ease, color 0.12s ease;
}

.adv-table th[aria-sort]:focus-visible::after { opacity: 0.8; }
@media (hover: hover) {
  .adv-table th[aria-sort]:hover::after { opacity: 0.8; }
  .adv-table th[aria-sort]:hover { color: var(--accent); }
}
.adv-table th[aria-sort="ascending"]::after  { content: "\25B2"; opacity: 1; color: var(--accent); font-size: 0.7em; }
.adv-table th[aria-sort="descending"]::after { content: "\25BC"; opacity: 1; color: var(--accent); font-size: 0.7em; }
.adv-table th[aria-sort="ascending"],
.adv-table th[aria-sort="descending"] { color: var(--accent); }
.adv-table td {
  padding: 0.7rem 0.75rem;
  vertical-align: top;
  color: var(--text);
  border: 1px solid var(--border-soft);
}
@media (hover: hover) {
  /* Tinted apart from the archive rail's hover by weight, not by hue: the
     rail uses --bg-elev, and a neutral one step heavier reads as a different
     kind of thing without bringing a colour into a table that is neutral
     everywhere else. The accent edge on the leading cell carries the "this row
     is a link" signal.

     On the cells rather than the <tr>, because a table row does not paint its
     own background over cells that declare one. */
  .adv-table tbody tr:hover > td {
    background: color-mix(in srgb, var(--border-soft) 35%, var(--bg));
  }
  .adv-table tbody tr:hover > td:first-child {
    box-shadow: inset 2px 0 0 color-mix(in srgb, var(--accent) 70%, transparent);
  }
}

.adv-table td.cve {
  font-family: var(--font-mono);
  font-size: 0.86rem;
  white-space: nowrap;
  font-weight: 600;
}

/* Per-row deep links: /advisories/#cve-2026-20675. The '#' stays hidden until
   the row is hovered or the anchor is focused, the same affordance the prose
   headings use. */
.row-anchor {
  margin-left: 0.35rem;
  color: var(--accent);
  opacity: 0;
  transition: opacity 0.12s ease;
}

.row-anchor:focus-visible { opacity: 1; text-decoration: none; }
@media (hover: hover) {
  .adv-table tbody tr:hover .row-anchor { opacity: 1; text-decoration: none; }
}

/* Landing on a row should be unmistakable — the header is sticky, so the row
   also needs clearance to not arrive underneath it. */
.adv-table tbody tr[id] { scroll-margin-top: 6rem; }
/* Gated on a root class rather than on :target alone, so a click elsewhere on
   the page can dismiss the highlight. Clearing the fragment with
   history.replaceState() is not enough on its own: it rewrites location.hash
   without firing a navigation, and whether that also drops the document's
   target element is left to the engine. The class is decided here, so both
   engines behave the same — and with scripting off it is never added, which
   leaves the plain :target behaviour a deep link needs. */
html:not(.target-cleared) .adv-table tbody tr:target > td {
  background: var(--accent-bg);
  border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
html:not(.target-cleared) .adv-table tbody tr:target .row-anchor { opacity: 1; }
/* Advisory links: one per source, stacked so the column stays narrow.
   Both are underlined — in a dense table, colour alone is too weak a cue
   that a cell is clickable. */
.adv-table td.adv-links {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  white-space: nowrap;
}
.adv-table td.adv-links a {
  display: inline-block;
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
}
@media (hover: hover) {
  .adv-table td.adv-links a:hover { text-decoration-thickness: 2px; }
}
.adv-table td.adv-links a + a { margin-top: 0.3rem; font-size: 0.76rem; }

.adv-table td.writeup a,
.adv-table td.rel a {
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.adv-table td.writeup .pending,
.adv-table td.adv-links .pending { color: var(--text-muted); }

.adv-table td.writeup {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  white-space: nowrap;
}
/* Impact carries the vendor's full sentence, so it is the column that flexes.
   Everything else stays nowrap; giving this one a min-width set a ~1270px
   floor on the whole table and forced a scrollbar on normal laptop widths. */
.adv-table td.impact { min-width: 0; }
.adv-table td.rel { white-space: normal; }
.adv-table td.num,
.adv-table td.rel,
.adv-table td.date {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  white-space: nowrap;
}
.adv-table td.date { color: var(--text); }


@media (max-width: 760px) {
  .adv-table { font-size: 0.86rem; }
  .adv-table th, .adv-table td { padding: 0.55rem 0.6rem; }
}

/* Centred inside its dashed box. The box says "empty on purpose" rather than
   "failed to load", and centring the one line inside it reads as a state
   rather than as content that happens to be short. */
.empty {
  /* No bottom margin as the last thing on a page: it stacked on .page-content's
     own bottom padding, putting 51px under the box against 36px on every other
     page. The top margin stays for the case where prose precedes it. */
  margin: 0.9rem 0 0;
  color: var(--text-faint);
  font-family: var(--font-mono);
  font-size: 0.9rem;
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  padding: 1.5rem;
  text-align: center;
  /* Full content width, not the reading measure. --measure exists to keep
     running prose at a comfortable line length; this is a one-line status box,
     and capping it left the box stopping short of the right edge while every
     other block on the page reached it. */
}

/* --------------------------------------------------------------------------
   Image zoom
   Figures in a write-up are usually wider than the column they sit in. site.js
   makes body images open full size; these are the styles for that.
   -------------------------------------------------------------------------- */

.prose img.zoomable { cursor: zoom-in; }
.prose img.zoomable:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

body.lb-open { overflow: hidden; }

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(1rem, 4vw, 3rem);
  background: rgba(4, 6, 9, 0.92);
  cursor: zoom-out;
}
.lightbox[hidden] { display: none; }

.lightbox figure {
  margin: 0;
  /* Without this the figure shrink-wraps to the caption's 60ch and the image
     is sized against that instead of against the viewport. */
  width: 100%;
  max-width: 100%;
  max-height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.9rem;
}
.lightbox img {
  max-width: 100%;
  max-height: 82vh;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 8px;
  /* Not white: the diagrams are theme-aware SVGs, and a white slab behind one
     rendered in dark-mode ink would be unreadable. */
  background: var(--bg-elev);
  cursor: default;
}
/* An SVG with only a viewBox has no intrinsic size, so `width: auto` leaves it
   at the browser's 300px default — which would make "enlarge" shrink the
   diagram. Vectors scale losslessly, so let them fill the frame. */
.lightbox img[src$=".svg"] { width: 100%; }
.lightbox figcaption {
  max-width: 60ch;
  text-align: center;
  font-size: 0.86rem;
  line-height: 1.5;
  color: #cbd3dd;
}
.lightbox figcaption[hidden] { display: none; }

.lb-close {
  position: absolute;
  top: 1rem;
  right: 1.2rem;
  width: 2.2rem;
  height: 2.2rem;
  font-size: 1.5rem;
  line-height: 1;
  color: #e9eef4;
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 50%;
  cursor: pointer;
}
@media (hover: hover) {
  .lb-close:hover { background: rgba(255, 255, 255, 0.16); }
}
.lb-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* --------------------------------------------------------------------------
   Print
   Long write-ups get printed and saved as PDFs. On paper the page is always
   light, the chrome is noise, and a link that only exists as a colour is lost —
   so URLs are spelled out and code blocks are kept off page breaks.
   -------------------------------------------------------------------------- */

@media print {
  /* `:root, :root[data-theme]`, not a bare `:root`.

     A bare `:root` is (0,1,0). The two theme blocks at the top of this file
     are `:root[data-theme="light"]` and `:root:not([data-theme])`, both
     (0,2,0). Specificity is settled before media type or source order is
     considered, so for any reader who had explicitly chosen a theme this
     entire palette lost and the screen colours printed instead — grey panel
     fills and #dfe3e8 hairlines that most printers drop altogether. Adding
     the attribute form makes this (0,2,0) *and* later, so it wins on both.

     The property list has to cover everything the printed page reads, not
     just the obvious backgrounds. --callout-bg was missing, so a callout
     kept its dark #12151b while the text went to #14181d — 1.03:1, a black
     rectangle — on a component that carries `break-inside: avoid` precisely
     because it is meant to survive printing. The four status hues were
     missing too and printed as neon on white (green 2.02:1, red 3.43:1). */
  :root,
  :root[data-theme] {
    --bg: #fff;
    --bg-elev: #fff;
    --bg-code: #f6f8fa;
    --callout-bg: #fff;
    --text: #14181d;
    --text-strong: #000;
    --text-muted: #444c56;
    --text-faint: #57606a;
    --border: #c8ced6;
    --border-soft: #dde2e8;
    --accent: #0a5d78;
    --accent-dim: #0a5d78;
    /* Darkened for paper: the screen values are tuned for a dark ground. */
    --c-green: #1a7f37;
    --c-red: #b3261e;
    --c-purple: #6639ba;
    --warn: #8a5a00;
  }

  @page { margin: 18mm 16mm; }

  html, body { background: #fff !important; color: #14181d; }

  /* Navigation, controls and anything that only works on screen. */
  .site-header,
  .site-footer,
  .progress,
  .toc,
  .copy-btn,
  .theme-toggle,
  .nav-toggle,
  .skip-link,
  .pager,
  .lightbox,
  .row-anchor,
  .prose .anchor { display: none !important; }

  .wrap { max-width: none; padding: 0; }
  .article-shell { display: block; }
  .prose { max-width: none; }

  /* Spell out destinations — a printed link is otherwise a dead end. Internal
     anchors and mailto: are left alone; the URL adds nothing there. */
  .prose a[href^="http"]::after {
    content: " (" attr(href) ")";
    font-family: var(--font-mono);
    font-size: 0.75em;
    word-break: break-all;
    color: #57606a;
  }

  .prose h1, .prose h2, .prose h3 { break-after: avoid-page; }
  .prose pre,
  .prose figure,
  .prose table,
  .callout,
  .series,
  .artifacts { break-inside: avoid; }

  .prose div.highlighter-rouge,
  .prose figure.highlight {
    border: 1px solid #c8ced6;
    background: #f6f8fa;
  }
  /* Code that scrolls sideways on screen has to wrap on paper. */
  .prose pre { overflow: visible; white-space: pre-wrap; word-break: break-word; }
  .highlight .ln { min-width: 0; }

  .prose img { max-width: 100%; }
  .prose img.zoomable { cursor: default; }

  /* Rouge's dark token colours are unreadable on white. */
  .highlight, .highlight * { color: #14181d !important; }
  .highlight .c, .highlight .c1, .highlight .cm { color: #57606a !important; font-style: italic; }
  .highlight .k, .highlight .kd, .highlight .kt { color: #8250df !important; }
  .highlight .s, .highlight .s1, .highlight .s2 { color: #0a3069 !important; }
  .highlight .ln.hl { background: #fff4d6 !important; box-shadow: inset 3px 0 0 #b78100; }

  .adv-table { font-size: 0.8rem; }
  .adv-table th { color: #000; }

  /* Every .prose table and the advisories table are wrapped in a
     .table-scroll, which is `overflow-x: auto` so a wide table scrolls
     sideways on screen. An overflow box prints at its layout width and
     silently drops whatever hangs outside it, so /advisories/ came off the
     printer missing its right-hand columns — including Disclosed and the
     advisory link, which are most of the reason to print that page. Let it
     lay out at its natural width and wrap instead. */
  .table-scroll { overflow: visible !important; }
  .prose table, .adv-table { width: 100%; table-layout: auto; }

  /* The players and facades inside these are hidden further down, but the
     enclosing <figure> and its <figcaption> are not — so a printed page
     carried captions like "Figure 4 — qlmanage crashing on the crafted file"
     with nothing above them. Hide the whole figure, caption included.

     Only the embeds whose content cannot print at all. A hexdump, a timeline,
     a pull quote and an image comparison all render fine on paper and are
     deliberately left in. */
  .embed-video-remote,
  .embed-video,
  .embed-godbolt,
  .embed-cast,
  .embed-anim { display: none !important; }

  /* The tag chips printed as a row of dead buttons above the list they
     filter. Everything else that does nothing on paper is already hidden by
     the print rules that sit with each component. */
  .tag-filter { display: none !important; }

  /* Where the page came from. Header, footer and the back link are all hidden,
     which left a printed page with nothing on it to say what site it is from
     or where to find it again — the one thing a PDF passed around actually
     needs. The canonical URL is put on the element by _layouts/post.html
     rather than written here, so it stays right if the domain changes and
     points at the exact post rather than just the site. */
  .post-header[data-canonical]::after {
    content: attr(data-canonical);
    display: block;
    margin-top: 0.9rem;
    padding-top: 0.5rem;
    border-top: 1px solid #dde2e8;
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: #57606a;
  }
}

/* --------------------------------------------------------------------------
   Search
   -------------------------------------------------------------------------- */

/* Header field: an icon that grows into an input. The form is a real GET to
   /search/, so this is decoration over something that already works. */
.nav-search {
  display: flex;
  align-items: center;
  gap: 0.35rem;
}
/* No `position: relative` on the form. The inline field sits in the row, and
   the card is positioned against the *header* — making the form a positioned
   ancestor captures the card instead, which put it 290px from the left edge
   and 56px from the right rather than centred in the gutter. Same trap as the
   144px panel, same cause. */

.nav-controls { min-width: auto; }

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

.nav-search.open { flex: 0 1 auto; min-width: 0; }
/* Closed, the field is zero wide but the form's own gap still sits after the
   icon — dead space that pushed the theme toggle away and left the hairline
   between them off-centre. */
.nav-search:not(.open) { gap: 0; }

.nav-search-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;          /* never shrink; pairs with .theme-toggle */
  width: var(--icon-btn);
  height: var(--icon-btn);
  padding: 0;
  color: var(--text-muted);
  background: none;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
  transition: color 0.15s ease, background 0.15s ease;
}
@media (hover: hover) {
  .nav-search-toggle:hover { color: var(--accent); background: var(--bg-elev); }
}
.nav-search-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Collapsed by default and opened only by the icon. `visibility: hidden` keeps
   it out of the tab order while closed, so nothing can focus a zero-width
   field and leave it half-open. */
.nav-search-input {
  width: 0;
  padding: 0;
  /* border-width 0, not a transparent border: a 1px transparent border on each
     side survives `width: 0` under border-box and leaves a 2px sliver sitting
     in the header — visible as two short lines once the field has been opened
     and closed again. */
  border: 0 solid transparent;
  border-radius: 6px;
  background: var(--bg-elev);
  color: var(--text-strong);
  font-family: var(--font-mono);
  font-size: 0.82rem;
  line-height: 1.9;
  opacity: 0;
  visibility: hidden;
  /* Width is animated again, and safely this time. It used to be measured
     while it moved: the ResizeObserver watched this element and the header
     re-decided its layout on every frame of the animation, and the decision
     discounted the field's own width, so the row was being planned against a
     number the animation was still changing. Both are gone — the width is a
     declared constant and this element is no longer observed — so the
     transition is now only a transition. */
  transition: width 0.18s ease, opacity 0.18s ease, padding 0.18s ease,
              visibility 0s linear 0.18s;
}
/* Keyed on a class the script puts on the input itself rather than on an
   ancestor. The descendant form of this rule did not win the cascade reliably —
   the field stayed a 2px sliver with the parent correctly marked open — and a
   header control that sometimes refuses to open is not worth the elegance. */
.nav-search-input.is-open,
.nav-search.open .nav-search-input {
  /* One width, no give, and in the row.

     It opens where it sits, so the row grows by --search-w and everything to
     its left — the menu or the hamburger — shifts to make room. That shift is
     deliberate. The alternative, opening out of flow into the space that
     already exists, was tried and cannot work here: all the slack in this bar
     is between the wordmark and the right-hand group, because .site-nav
     carries `margin-left: auto` and pushes the nav and the controls right
     together. A field growing leftward from its icon therefore lands on its
     neighbour — 35px of hamburger below 1080, 171px of nav links above 1300 —
     and covering the hamburger makes the menu untappable, which
     `elementFromPoint` confirmed and two tests caught by timing out on it.

     What does not happen any more is the row running out of space and
     squeezing: when --search-w plus --nav-clearance will not fit,
     measureFieldShape() opens the card below the bar instead. */
  flex: 0 0 auto;
  width: var(--search-w);
  padding: 0 0.6rem;
  border-width: 1px;
  border-color: var(--border);
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}
.nav-search-input:focus { outline: none; border-color: var(--accent); }
/* And again, one class deeper, or the inline field never shows focus at all.
   The open-state rule above sets border-color at two and three classes, which
   beats a bare `:focus` at one class and a pseudo — so the inline field sat on
   --border whether it had the caret in it or not, while the card (which has
   its own four-class focus rule) turned accent correctly. Measured: focused
   and blurred were the same colour inline, different in the card. */
.nav-search-input.is-open:focus,
.nav-search.open .nav-search-input:focus { border-color: var(--accent); }

/* iOS Safari zooms the whole page in when a form control it is focusing has
   text smaller than 16px; at 16px it focuses in place and raises the keyboard.

   The other way to stop it is `maximum-scale=1` on the viewport meta, which
   would take pinch-zoom away from every reader on every page to fix one input.
   Not a trade worth making, and it fails WCAG 1.4.4.

   pointer: coarse rather than a width, because the trigger is the touch
   keyboard, not the screen size — an iPad at 1024px zooms too. */
@media (pointer: coarse) {
  .nav-search-input { font-size: 16px; }
  /* 16px text needs more room than 13px did before it reads as a query box
     rather than a slot. One constant here too, for the same reason as above. */
  .site-header .wrap { --search-w: 14rem; }
}
/* Safari draws its own clear button; it fights the border radius. */
.nav-search-input::-webkit-search-decoration,
.nav-search-input::-webkit-search-cancel-button { -webkit-appearance: none; }

@media (prefers-reduced-motion: reduce) {
  .nav-search-input { transition: none; }
}

/* Narrow-screen behaviour now lives with the header rules, since the field sits
   in the bar rather than inside the collapsed menu. */

/* The /search/ page */
.search-form { margin: 0 0 1.25rem; max-width: var(--measure); }
.search-form input {
  width: 100%;
  padding: 0.7rem 0.9rem;
  font-family: var(--font-mono);
  font-size: 1rem;
  color: var(--text-strong);
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.search-form input:focus { outline: none; border-color: var(--accent); }
.search-form input::-webkit-search-decoration,
.search-form input::-webkit-search-cancel-button { -webkit-appearance: none; }

.search-status {
  /* A full line, reserved whether or not there is anything to say yet.

     The status is empty in the markup and filled by script, so anything less
     than a full line — or a margin that only exists once it has content —
     changes the page height the moment the first result arrives, and the
     footer moves. One blank line on an untouched search page is the price. */
  min-height: 1.7em;               /* one line at this font-size */
  min-height: 1lh;                 /* exact, where supported */
  margin: 0 0 1.5rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-muted);
}

#search-results mark {
  padding: 0 0.15em;
  color: var(--text-strong);
  background: color-mix(in srgb, var(--accent) 26%, transparent);
  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;
}

/* The post byline sits between the date and the read time. */
.meta .byline { color: var(--text-muted); }

@media print {
  .nav-search, .search-form, .search-status { display: none !important; }
}

/* --------------------------------------------------------------------------
   Comments (giscus)
   -------------------------------------------------------------------------- */

/* Full article width, not the reading measure. Comment threads are a UI, not
   running prose — the line-length argument that caps the body text does not
   apply, and the same goes for the footer above it. */
.comments {
  margin: 2.75rem 0 0;
  padding-top: 1.5rem;
  border-top: 1px solid var(--border-soft);
}

/* The mount starts empty, and an element with no height never triggers an
   IntersectionObserver — which is what stopped the lazy loader from firing.
   The min-height also reserves space so the page does not jump on load. */
#giscus { min-height: 150px; }
.comments-title {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 0.5rem;
  font-weight: 500;
}
/* Sits under the comment box rather than above it, so it needs its space on
   the other side. */
.comments-colophon { margin: 1.25rem 0 0; }

.comments-note {
  margin: 0 0 1.5rem;
  font-size: 0.86rem;
  color: var(--text-faint);
}
/* giscus sizes its own iframe; this just stops it inheriting prose margins. */
.comments .giscus, .comments .giscus-frame { width: 100%; margin: 0; }

@media print { .comments { display: none !important; } }

/* The row that carries a list's "read more" link. Was an inline
   `style="margin:.6rem 0 0"` repeated in blog.html and research/index.html —
   the only inline styles in the row markup, and a magic number kept in two
   places while /ctf/ had neither the link nor the spacing. */
.readmore-row { margin: 0.6rem 0 0; }

/* --------------------------------------------------------------------------
   Advisories on narrow screens
   A nine-column table cannot be read on a phone. Side-scrolling technically
   works, but the Impact sentence wraps in its narrow column and drags every
   row to ~450px of mostly empty space. Below tablet width each row becomes a
   card instead, with the column name printed beside its value.
   -------------------------------------------------------------------------- */

@media (max-width: 760px) {
  .adv-year + .table-scroll { overflow: visible; }

  .adv-table,
  .adv-table tbody,
  .adv-table tr,
  .adv-table td { display: block; width: auto; }

  /* The table's own frame goes with the grid it framed. Stacked, every row
     draws its own card border, so the table's 1px outline is a second box
     around all of them — and because the last card keeps its 1rem of bottom
     margin *inside* the table, the outline showed as an empty bordered strip
     hanging below the last advisory with nothing in it. */
  .adv-table { border: 0; }
  .adv-table tbody tr:last-child { margin-bottom: 0; }

  /* Headers are the sort control; with the grid gone they have no meaning. */
  .adv-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }

  .adv-table tr {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--bg-elev);
    padding: 0.35rem 0.9rem 0.7rem;
    margin-bottom: 1rem;
  }
  @media (hover: hover) {
  /* Stacked, each row is already its own card — so it hovers like one, with
       the same three tokens every other card on the site uses. */
    .adv-table tbody tr:hover {
      background: var(--card-hover-bg);
      border-color: var(--card-hover-border);
      box-shadow: var(--card-hover-shadow);
    }
  .adv-table tbody tr:hover > td { background: transparent; }
  /* And without the table layout's accent edge. That edge is an inset shadow
       on the row's leading cell, which in a table is the full height of the row
       and reads as a bar down its side. Here the leading cell is the CVE at the
       top of the card, so the same declaration drew a 2px blue tick against one
       line of it and nothing else. The card's own border carries the signal. */
    .adv-table tbody tr:hover > td:first-child { box-shadow: none; }
}

  .adv-table td {
    border: 0;
    border-bottom: 1px solid var(--border-soft);
    padding: 0.6rem 0;
    white-space: normal;
  }
  .adv-table tr td:last-child { border-bottom: 0; }

  .adv-table td::before {
    content: attr(data-label);
    display: block;
    margin-bottom: 0.2rem;
    font-family: var(--font-mono);
    font-size: 0.64rem;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* The CVE is the card's title, so it loses its label and gains weight. */
  .adv-table td.cve {
    font-size: 1rem;
    padding-top: 0.5rem;
  }
  .adv-table td.cve::before { content: none; }
  .row-anchor { opacity: 1; }

  .adv-table td.impact { font-size: 0.92rem; }
}

/* On a phone the post meta wraps to three rows and the "·" separators end up
   stranded at the end of each line. Drop them there and let the gap do the
   separating. */
@media (max-width: 560px) {
  .post-header .meta > span[aria-hidden="true"] { display: none; }
  .post-header .meta { gap: 0.15rem 0.85rem; }
}

/* --------------------------------------------------------------------------
   Back to top
   -------------------------------------------------------------------------- */

.to-top {
  position: fixed;
  right: clamp(1rem, 3vw, 2rem);
  bottom: clamp(1rem, 3vw, 2rem);
  z-index: 40;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 2.6rem;
  color: var(--text-muted);
  background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
  backdrop-filter: saturate(180%) blur(10px);
  -webkit-backdrop-filter: saturate(180%) blur(10px);
  border: 1px solid var(--border);
  border-radius: 50%;
  box-shadow: 0 4px 14px -8px rgba(0, 0, 0, 0.5);
  transition: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.to-top[hidden] { display: none; }
@media (hover: hover) {
  .to-top:hover {
    color: var(--accent);
    border-color: var(--accent);
    text-decoration: none;
    transform: translateY(-2px);
  }
}
.to-top:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .to-top { transition: none; }
  @media (hover: hover) {
  .to-top:hover { transform: none; }
}
}

/* --------------------------------------------------------------------------
   Maths (KaTeX) and diagrams (Mermaid)
   Both load only on pages whose front matter opts in.
   -------------------------------------------------------------------------- */

/* Long display equations scroll rather than widening the page. */
.prose .katex-display {
  overflow-x: auto;
  overflow-y: hidden;
  padding: 0.35rem 0;
}
.prose .katex { font-size: 1.05em; }

.prose pre.mermaid {
  margin: 1.75rem 0;
  padding: 1.25rem;
  overflow-x: auto;
  text-align: center;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
/* Before mermaid has run, the block is still raw text — keep it legible
   rather than flashing a wall of unstyled syntax. */
.prose pre.mermaid:not([data-processed]) {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-faint);
  text-align: left;
}
.prose pre.mermaid svg { max-width: 100%; height: auto; }

@media print {
  .to-top { display: none !important; }
}

/* ==========================================================================
   Embeds
   Each of these is produced by an {% include embed/… %}. Shared frame first.
   ========================================================================== */

.prose .embed { margin: 2rem 0; }
.prose .embed > figcaption { margin-top: 0.7rem; }

/* ---- self-hosted video / gif ------------------------------------------- */
.embed-video video,
.embed-video img {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: #000;
}

/* ---- click-to-load remote video / iframes ------------------------------ */
.video-facade,
.iframe-facade {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  aspect-ratio: 16 / 9;
  padding: 1.5rem;
  text-align: center;
  background: var(--bg-elev) center/cover no-repeat;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  cursor: pointer;
}
.iframe-facade { aspect-ratio: 4 / 3; }

.video-facade-play,
.iframe-facade-play {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 3.6rem;
  height: 3.6rem;
  color: var(--bg);
  background: var(--accent);
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  transition: transform 0.15s ease;
}
@media (hover: hover) {
  .video-facade:hover .video-facade-play,
  .iframe-facade:hover .iframe-facade-play { transform: scale(1.08); }
}

.video-facade-note,
.iframe-facade-note {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--text-muted);
  max-width: 34ch;
}
.iframe-facade-alt { font-size: 0.78rem; }

.embed-video-remote iframe,
.embed-godbolt iframe {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: #000;
}
.embed-godbolt iframe { aspect-ratio: 4 / 3; }

/* ---- pdf ---------------------------------------------------------------- */
.embed-pdf object {
  display: block;
  width: 100%;
  height: min(70vh, 640px);
  border: 1px solid var(--border);
  border-radius: var(--radius) var(--radius) 0 0;
  background: var(--bg-elev);
}
.embed-pdf-card {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 0.85rem 1rem;
  color: var(--text);
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 0 0 var(--radius) var(--radius);
}
.embed-pdf object + .embed-pdf-card { border-top: 0; }
.embed-pdf figure > .embed-pdf-card:first-child { border-radius: var(--radius); }
@media (hover: hover) {
  .embed-pdf-card:hover { border-color: var(--accent); text-decoration: none; }
}
.embed-pdf-card svg { flex: none; color: var(--accent); }
.embed-pdf-card span { display: flex; flex-direction: column; min-width: 0; }
.embed-pdf-card strong { color: var(--text-strong); font-weight: 600; }
.embed-pdf-card em { font-style: normal; font-size: 0.82rem; color: var(--text-muted); }
.embed-pdf-action {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--accent);
  white-space: nowrap;
}

/* ---- quoted post -------------------------------------------------------- */
.embed-quote blockquote {
  margin: 0;
  padding: 1.1rem 1.3rem;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
}
.embed-quote blockquote p { margin: 0; }
.embed-quote figcaption { text-align: left; }
.embed-quote .handle { color: var(--text-muted); font-family: var(--font-mono); }

/* ---- gist --------------------------------------------------------------- */
.embed-gist-link { font-family: var(--font-mono); font-size: 0.82rem; }

/* ---- terminal cast ------------------------------------------------------ */
.embed-cast .asciinema-player {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

/* ---- graphviz ----------------------------------------------------------- */
.prose .graphviz {
  margin: 1.75rem 0;
  padding: 1.25rem;
  overflow-x: auto;
  text-align: center;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.prose .graphviz svg { max-width: 100%; height: auto; }

/* Graphviz bakes its palette into the SVG as presentation attributes: an
   opaque white background polygon, black strokes, black arrowheads, black
   label text. None of that follows the theme, which is how a dark page ends up
   showing pale text on a white slab.

   Remapped here rather than in dark-mode-only overrides, because the white
   background is wrong in light mode too — it sits as a white rectangle on the
   figure's --bg-elev card. CSS properties beat SVG presentation attributes, so
   this needs no post-processing and survives a theme toggle without a
   re-render.

   Only Graphviz's own defaults are targeted. An explicit color= in the DOT
   source (fill="red", stroke="#00f") carries no black or white attribute and
   is left exactly as the author wrote it, as is fill="none". */
.prose .graphviz svg :is(polygon, path, ellipse)[fill="white"],
.prose .graphviz svg :is(polygon, path, ellipse)[fill="#ffffff"] { fill: transparent; }
.prose .graphviz svg text { fill: var(--text-strong); }
.prose .graphviz svg :is(polygon, path, ellipse)[stroke="black"],
.prose .graphviz svg :is(polygon, path, ellipse)[stroke="#000000"] { stroke: var(--text-muted); }
/* arrowheads are filled, not stroked */
.prose .graphviz svg :is(polygon, path)[fill="black"],
.prose .graphviz svg :is(polygon, path)[fill="#000000"] { fill: var(--text-muted); }

/* ---- disassembly listings ( | addr ) ------------------------------------- */
/* The address column, dimmed into a gutter. Kept selectable: the whole point
   of leaving addresses in is that a reader can copy one and go look at it. */
.highlight .addr {
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}
@media (hover: hover) {
  .has-addr .highlight .ln:hover .addr { color: var(--text-muted); }
}

/* ---- screenshots -------------------------------------------------------- */
/* For screen captures rather than drawings — an IDA graph, a debugger, a crash
   dialog. A capture carries its own background, which will fight one of the
   two themes whatever it is; the frame and mat make that read as deliberate
   instead of as an image that failed to load its surroundings. */
.prose figure.screenshot img {
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-elev);
  display: block;
  margin-inline: auto;
}

/* ---- annotated hexdump -------------------------------------------------- */
.hexdump {
  padding: 1rem 1.1rem;
  overflow-x: auto;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  line-height: 1.7;
  background: var(--bg-code);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.hexdump-raw { margin: 0; white-space: pre-wrap; color: var(--text-muted); }
.hexdump-row { display: flex; gap: 1.25rem; white-space: nowrap; }
.hexdump-offset { color: var(--text-faint); user-select: none; }
.hexdump-bytes .hb { padding: 0 0.18rem; border-radius: 2px; }
.hexdump-bytes .hb + .hb { margin-left: 0.12rem; }
.hexdump-ascii { color: var(--text-muted); }
.hexdump-ascii .ha { border-radius: 2px; }

/* Six-colour cycle, reused by the legend swatches. */
.hexdump .f0, .hexdump-legend .f0 i { background: color-mix(in srgb, var(--accent) 26%, transparent); }
.hexdump .f1, .hexdump-legend .f1 i { background: color-mix(in srgb, var(--warn) 26%, transparent); }
.hexdump .f2, .hexdump-legend .f2 i { background: color-mix(in srgb, var(--c-green) 26%, transparent); }
.hexdump .f3, .hexdump-legend .f3 i { background: color-mix(in srgb, var(--c-purple) 26%, transparent); }
.hexdump .f4, .hexdump-legend .f4 i { background: color-mix(in srgb, var(--c-red) 24%, transparent); }
.hexdump .f5, .hexdump-legend .f5 i { background: color-mix(in srgb, var(--text-muted) 30%, transparent); }
.hexdump [data-field].on { outline: 1px solid var(--accent); }

.hexdump-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.1rem;
  margin: 0.9rem 0 0;
  padding: 0.85rem 0 0;
  border-top: 1px solid var(--border);
  list-style: none;
  font-size: 0.76rem;
  color: var(--text);
}
.hexdump-legend li { display: flex; align-items: center; gap: 0.4rem; cursor: default; }
.hexdump-legend i { width: 0.8rem; height: 0.8rem; border-radius: 2px; }
.hexdump-legend em { font-style: normal; color: var(--text-faint); }
.hexdump-legend li.on { color: var(--accent); }

/* ---- before / after comparison ------------------------------------------ */
.img-compare {
  --split: 50%;
  position: relative;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  touch-action: none;
  user-select: none;
}
.img-compare img { display: block; width: 100%; height: auto; }
/* Without JS both images just stack, labelled — still makes the point. */
.img-compare.ready .img-compare-after {
  position: absolute;
  inset: 0;
  clip-path: inset(0 0 0 var(--split));
}
.img-compare-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--split);
  width: 2px;
  background: var(--accent);
  cursor: ew-resize;
}
.img-compare-handle::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 2rem;
  height: 2rem;
  transform: translate(-50%, -50%);
  background: var(--accent);
  border-radius: 50%;
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 25%, transparent);
}
.img-compare-handle:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.img-compare-label {
  position: absolute;
  top: 0.6rem;
  padding: 0.15rem 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-strong);
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  border-radius: 4px;
}
.img-compare-label.is-before { left: 0.6rem; }
.img-compare-label.is-after { right: 0.6rem; }

/* ---- timeline ----------------------------------------------------------- */
.timeline { list-style: none; margin: 0; padding: 0 0 0 1.4rem; position: relative; }
.timeline::before {
  content: "";
  position: absolute;
  left: 0.32rem;
  top: 0.5rem;
  bottom: 0.5rem;
  width: 1px;
  background: var(--border);
}
.timeline-item {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: 0.2rem 1rem;
  padding: 0.4rem 0;
}
.timeline-item::before {
  content: "";
  position: absolute;
  left: -1.4rem;
  top: 0.95rem;
  width: 0.66rem;
  height: 0.66rem;
  background: var(--bg);
  border: 1px solid var(--text-faint);
  border-radius: 50%;
}
.timeline-item.is-fix::before { background: var(--c-green); border-color: var(--c-green); }
.timeline-item.is-report::before { background: var(--warn); border-color: var(--warn); }
.timeline-item.is-you::before { background: var(--accent); border-color: var(--accent); }
.timeline-item time {
  flex: none;
  width: 10.5rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-muted);
}
@media (max-width: 560px) { .timeline-item time { width: 100%; } }

/* ---- sidenotes ----------------------------------------------------------
   There is no margin to put a margin note in: on a post the right-hand column
   is the sticky TOC rail, and a floated note lands on top of it. So the
   default is an inline aside revealed by the marker, which never collides and
   behaves identically on a phone.

   The floated version is kept for the one case where the gutter is genuinely
   free — a page with no TOC on a wide screen.
   -------------------------------------------------------------------------- */

.sidenote-wrap { position: relative; }
.sidenote-marker {
  color: var(--accent);
  cursor: pointer;
  font-size: 0.85em;
  vertical-align: super;
  padding: 0 0.15em;
}
@media (hover: hover) {
  .sidenote-marker:hover { color: var(--accent-dim); }
}

/* The toggle is .sr-only rather than hidden, so it keeps its place in the tab
   order — the marker is a <label>, which cannot take focus itself. This puts
   the focus ring on the marker the reader can actually see. */
.sidenote-toggle:focus-visible + .sidenote-marker {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
}

.sidenote {
  display: none;
  margin: 0.7rem 0;
  padding: 0.6rem 0.9rem;
  font-size: 0.85rem;
  line-height: 1.55;
  color: var(--text-muted);
  background: var(--bg-elev);
  border-left: 2px solid var(--accent);
  border-radius: 0 var(--radius) var(--radius) 0;
}
.sidenote-toggle:checked ~ .sidenote { display: block; }

/* No TOC rail on this page and room to spare: use the real margin. */
@media (min-width: 1200px) {
  .article-shell:not(:has(> .toc:not([hidden]))) .sidenote,
  .page-content:not(:has(.article-shell)) .sidenote {
    display: block;
    float: right;
    clear: right;
    width: 13rem;
    margin: 0.2rem -15rem 0.8rem 0;
    padding: 0;
    background: none;
    border: 0;
    text-align: left;
  }
  .article-shell:not(:has(> .toc:not([hidden]))) .sidenote-marker,
  .page-content:not(:has(.article-shell)) .sidenote-marker { cursor: default; }
}

/* ---- side-by-side diff --------------------------------------------------- */
.diff-split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1px;
  background: var(--border);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  line-height: 1.6;
  overflow-x: auto;
}
.diff-col { background: var(--bg-code); padding: 0.6rem 0; min-width: 0; }
.diff-line {
  display: block;
  padding: 0 0.8rem;
  white-space: pre;
  color: var(--text);
}
.diff-line.is-del { background: color-mix(in srgb, var(--c-red) 15%, transparent); }
.diff-line.is-add { background: color-mix(in srgb, var(--c-green) 15%, transparent); }
.diff-line.is-meta { color: var(--text-faint); }
.diff-line.is-nil { background: color-mix(in srgb, var(--text-faint) 8%, transparent); }
@media (max-width: 700px) { .diff-split { grid-template-columns: 1fr; } }

/* ---- linkable code lines ------------------------------------------------- */
.has-lineno .ln { position: relative; padding-left: 3.4rem; }
.ln-num {
  position: absolute;
  left: 0;
  width: 2.4rem;
  text-align: right;
  color: var(--text-faint);
  user-select: none;
  font-variant-numeric: tabular-nums;
}
@media (hover: hover) {
  .ln-num:hover { color: var(--accent); text-decoration: none; }
}
.has-lineno .ln:target {
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  scroll-margin-top: 6rem;
}
.has-lineno .ln:target .ln-num { color: var(--accent); }

@media print {
  .video-facade, .iframe-facade, .img-compare-handle { display: none !important; }
  .embed-pdf object { display: none !important; }
}

/* ---- pagination ---------------------------------------------------------- */
.pagination {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  align-items: center;
  /* Centred under the list. Left-aligned they sat under the first column of
     text and read as another list item; centred they read as the controls for
     the whole list above them. */
  justify-content: center;
  /* Spans every column of .page-shell, so "centred" means centred on the page
     and not on the text column with the archive rail excluded. */
  grid-column: 1 / -1;
  margin: 3.25rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.8rem;
}
.pagination-item {
  padding: 0.35rem 0.7rem;
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
@media (hover: hover) {
  a.pagination-item:hover { color: var(--accent); border-color: var(--accent); text-decoration: none; }
}
.pagination-item.is-current { color: var(--bg); background: var(--accent); border-color: var(--accent); }
span.pagination-item:not(.is-current) { opacity: 0.4; }
@media print { .pagination { display: none !important; } }

/* ---- inline SVG animation ------------------------------------------------ */
.anim {
  position: relative;
  padding: 1.25rem 1.25rem 1.5rem;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.anim svg { display: block; width: 100%; height: auto; }
.anim-replay {
  position: absolute;
  right: 0.7rem;
  bottom: 0.7rem;
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.28rem 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-faint);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s ease, color 0.15s ease;
}

.anim-replay:focus-visible { opacity: 1; }
@media (hover: hover) {
  .anim:hover .anim-replay { opacity: 1; }
  .anim-replay:hover { color: var(--accent); border-color: var(--accent); }
}

@media print { .anim-replay { display: none !important; } }

/* ==========================================================================
   Modern platform additions
   Everything here is Baseline (Chrome, Edge, Firefox and Safari all ship it)
   or degrades to exactly the current behaviour where it is not.
   ========================================================================== */

/* Headings wrap into even lines instead of leaving one orphaned word. Only
   applies up to ~6 lines by spec, which is why it is scoped to headings and
   the lede rather than body copy. Baseline since Safari 17.5 / Firefox 121. */
h1, h2, h3,
.post-header .lede,
.featured h3,
.post-item :is(h2, h3) { text-wrap: balance; }

/* Body copy: prevents a single short word on the final line. Cheaper than
   `balance` and safe on long text. Ignored by browsers that lack it. */
.prose p,
.prose li,
.excerpt p { text-wrap: pretty; }

/* `content-visibility: auto` was here, on embeds and code blocks, to let the
   browser skip layout and paint for sections far offscreen. It is gone, and
   the reason is that it caused the thing it was added to prevent.

   The estimate has to be a single number, and a post's blocks are not one
   height. Every one of them was guessed at 420px and then collapsed to its
   real size as it rendered, so the document *shrank* after first paint —
   measured at 4621px to 4293px, 2683px to 2017px, 21870px to 21404px on the
   longer drafts. The footer rose by up to 700px under the reader, Chromium
   scored the shift at up to 0.281 (0.1 is already "needs improvement"), and a
   refresh restored a scroll position computed against the old height into a
   document that was still collapsing, landing up to 328px away from where the
   reader had been.

   Removing the two declarations cut the load-time defects on the two CVE
   drafts from 64 to 17: height-changed-after-restore 12 to 2, landmarks moved
   44 to 12, stepped restorations 3 to 0.

   The cost is real — long posts do more layout up front — but it is paid once
   per load, whereas the shift was paid in front of the reader every time. If
   it is ever wanted back, it needs per-block heights rather than one constant;
   `auto` remembers the true size, so only the first visit is wrong, and only
   the first visit is what anyone sees. */


/* No cross-document view transitions here, deliberately.

   `@view-transition { navigation: auto; }` needs the opt-in to be
   render-blocking on the *incoming* document to have any effect; without that
   the transition is skipped on every navigation and Chrome reports the
   skipped promise as an uncaught error, which the page cannot catch because
   the opt-in is declarative and nothing here calls startViewTransition.

   To restore, add that one line back along with the
   ::view-transition-old/new(root) rules that shaped it — and make the opt-in
   render-blocking at the same time. */

/* The scrollbar should match the page, not flash white on a dark theme.
   Paired with <meta name="color-scheme">, which handles it before first paint. */
:root { color-scheme: dark light; }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

/* ---- related posts ------------------------------------------------------
   Compact by design: this sits between the post footer and the comments, and
   a second blog index there competes with both. Three rows, no excerpts, each
   saying why it is related.
   -------------------------------------------------------------------------- */
.related {
  margin: 3rem 0 0;
  padding-top: 1.5rem;
  border-top: 1px solid var(--border);
}
.related-title {
  text-align: center;           /* always centred, at every width */
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 0.8rem;
}
.related ul {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 0.6rem;
  list-style: none;
  margin: 0;
  padding: 0;
}
.related li { display: flex; }
.related a {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  flex: 1;
  padding: 0.85rem 1rem;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  transition: border-color 0.15s ease, transform 0.15s ease;
}
@media (hover: hover) {
  .related a:hover {
    border-color: var(--accent);
    text-decoration: none;
    transform: translateY(-2px);
  }
}
.related-why {
  font-family: var(--font-mono);
  font-size: 0.64rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent);
}
.related-name {
  font-size: 0.94rem;
  font-weight: 600;
  line-height: 1.35;
  color: var(--text-strong);
  text-wrap: pretty;
}
.related-meta {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-faint);
}
@media (prefers-reduced-motion: reduce) {
  .related a { transition: none; }
  @media (hover: hover) {
  .related a:hover { transform: none; }
}
}
@media print { .related { display: none !important; } }

/* --------------------------------------------------------------------------
   Press feedback on touch

   Every hover rule in this file is now behind `@media (hover: hover)`, because
   a touchscreen has no pointer to move away: iOS Safari applies :hover to
   whatever was last tapped and leaves it there until something else is
   tapped. The search icon kept its chip after the panel closed, an advisory
   card stayed lifted after you had moved on.

   Guarding them alone would leave touch with no feedback at all, so the press
   takes over the job. Same tokens the hover states use — the same signal,
   only it lasts as long as the finger does.

   Backgrounds and colours rather than opacity, deliberately: :active applies
   to the whole activation chain, so a link inside a card matches at both
   levels, and two stacked opacities would darken the card twice as much as
   either rule asked for.
   -------------------------------------------------------------------------- */

@media (hover: none) {
  /* Whole-card surfaces. */
  .post-item:active,
  .card-link:active,
  .featured:active,
  .adv-table tbody tr:active {
    background: var(--card-hover-bg);
    border-color: var(--card-hover-border);
  }

  /* Icon buttons in the bar and the corner. */
  .nav-toggle:active,
  .nav-search-toggle:active,
  .theme-toggle:active,
  .to-top:active,
  .socials a:active {
    color: var(--accent);
    background: var(--bg-elev);
  }

  /* Outlined controls. */
  .btn:active,
  .tag:active {
    border-color: var(--card-hover-border);
    background: var(--card-hover-bg);
  }

  /* Plain text links, where colour is the only thing to change. */
  .prose a:active,
  .nav-link:active,
  .site-footer a:active,
  .toc a:active {
    color: var(--accent);
  }
}

/* Outside the query above, deliberately. A laptop with a touchscreen matches
   `hover: hover`, so anything about touch that lives in a `hover: none` block
   never reaches it — and both of these are about the finger, not the pointer.
   Neither does anything at all on a device without one. */
a, button, summary, [role="button"], .post-item, .adv-table tbody tr, .card-link {
  /* Tapping the search icon twice — open, then close — is two taps inside the
     double-tap window, so iOS zoomed the page instead of closing the field.
     `manipulation` says this element handles taps and scrolls but has no
     double-tap gesture of its own, which drops the zoom and the 300ms wait
     the browser used to spend deciding.

     This is the narrow fix. The blunt one is `user-scalable=no` on the viewport
     meta, which would take pinch-zoom away from every reader on every page to
     settle one gesture on the controls, and fails WCAG 1.4.4. The viewport meta
     stays `width=device-width, initial-scale=1`. */
  touch-action: manipulation;
  /* iOS paints its own grey rectangle over a tapped element. With a press
     state of our own that is a second, uglier flash on top of the first. */
  -webkit-tap-highlight-color: transparent;
}

/* --------------------------------------------------------------------------
   Trailing margins
   Last, deliberately. A trailing margin on the final block inside .prose
   escapes the container by margin collapse and adds to whatever spacing the
   page already provides — /about/ ended 64px above the footer rule against
   36px everywhere else, because `.prose .contact` sets `margin: 1.75rem 0`.
   That rule and this one have identical specificity, so the only thing that
   decides the winner is source order: this has to come after every component.
   -------------------------------------------------------------------------- */

.prose > *:first-child { margin-top: 0; }
.prose > *:last-child { margin-bottom: 0; }
