/* =====================================================
   Fonts
   font-display: optional avoids the reflow "swap" causes when a font arrives late — all
   four files are preloaded (see layout.html), so the short optional window is normally enough.
   ===================================================== */
@font-face {
  font-family: 'Commit Mono';
  src: url('/fonts/CommitMono-400-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: optional;
}
@font-face {
  font-family: 'Commit Mono';
  src: url('/fonts/CommitMono-400-Italic.woff2') format('woff2');
  font-weight: 400;
  font-style: italic;
  font-display: optional;
}
@font-face {
  font-family: 'Commit Mono';
  src: url('/fonts/CommitMono-700-Regular.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: optional;
}
@font-face {
  font-family: '0xProto';
  src: url('/fonts/0xProto-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: optional;
}
@font-face {
  font-family: '0xProto';
  src: url('/fonts/0xProto-Bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: optional;
}

/* =====================================================
   Tokens
   WCAG AAA contrast targets: 7:1 normal text, 4.5:1 large text
   --text   light ~18:1  dark ~17:1  ✓ AAA
   --muted  light  ~7.1:1 dark  ~7.0:1  ✓ AAA
   --subtle decorative only — never on interactive or informational text
   ===================================================== */
:root {
  /* Source-of-truth hex values — --bg/--text/etc. below just point at whichever is active. */
  --bg-light:           #f8f8f6;
  --bg-raised-light:    #f0f0ed;
  --text-light:         #141413;
  --muted-light:        #4e4e4a;
  --subtle-light:       #a4a49e;
  --accent-light:       #1244b4;
  --accent-hover-light: #0e3690;
  --border-light:       #e4e4e0;
  --border-2-light:     #ccccca;

  --bg-dark:           #0e0e0c;
  --bg-raised-dark:    #181815;
  --text-dark:         #edecea;
  --muted-dark:        #969692;
  --subtle-dark:       #6e6e66;
  --accent-dark:       #7aadff;
  --accent-hover-dark: #a3c4ff;
  --border-dark:       #242420;
  --border-2-dark:     #333330;

  --bg:           var(--bg-light);
  --bg-raised:    var(--bg-raised-light);
  --text:         var(--text-light);
  --muted:        var(--muted-light);
  --subtle:       var(--subtle-light);
  --accent:       var(--accent-light);
  --accent-hover: var(--accent-hover-light);
  --border:       var(--border-light);
  --border-2:     var(--border-2-light);

  --font: 'Commit Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --font-code: '0xProto', ui-monospace, SFMono-Regular, Menlo, monospace;

  /* rem, not px — otherwise this stays fixed while --zoom scales the text, so the column
     just wraps more/denser instead of scaling proportionally like the rest of the page. */
  --w: 42.5rem;

  --t-xs:  0.75rem;
  --t-sm:  0.875rem;
  --t-md:  1rem;
  --t-lg:  1.25rem;
  --t-xl:  1.5rem;
  --t-2xl: 2rem;
  --t-3xl: 2.5rem;

  --s1: 0.25rem;
  --s2: 0.5rem;
  --s3: 0.75rem;
  --s4: 1rem;
  --s5: 1.25rem;
  --s6: 1.5rem;
  --s8: 2rem;
  --s10: 2.5rem;
  --s12: 3rem;
  --s16: 4rem;
  --s18: 4.75rem;
  --s20: 5rem;
  --s24: 6rem;

  /* Page zoom (see a11y.js) — the sync script in layout.html applies a saved level inline
     on <html> before first paint, so a returning visitor never waits for a11y.js's `defer`. */
  --zoom: 1;
}
/* Scales via root font-size, not CSS zoom — zoom skews getBoundingClientRect() coordinates,
   which broke the Shift-hold hint mode's badge positioning (see a11y.js). */
html {
  font-size: calc(16px * var(--zoom));
  /* Reserves scrollbar space unconditionally — otherwise 100vw (used by img-wide/img-medium's
     breakout width) shifts when the scrollbar appears/disappears as content loads. */
  scrollbar-gutter: stable;
}

/* :not([data-theme="light"]) lets an explicit light choice (a11y.js) keep winning even
   when the OS prefers dark. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg:           var(--bg-dark);
    --bg-raised:    var(--bg-raised-dark);
    --text:         var(--text-dark);
    --muted:        var(--muted-dark);
    --subtle:       var(--subtle-dark);
    --accent:       var(--accent-dark);
    --accent-hover: var(--accent-hover-dark);
    --border:       var(--border-dark);
    --border-2:     var(--border-2-dark);
  }
}
/* The reverse direction — forcing dark even when the OS prefers light — needs its own
   unconditional override, since there's no media query to gate it on. */
:root[data-theme="dark"] {
  --bg:           var(--bg-dark);
  --bg-raised:    var(--bg-raised-dark);
  --text:         var(--text-dark);
  --muted:        var(--muted-dark);
  --subtle:       var(--subtle-dark);
  --accent:       var(--accent-dark);
  --accent-hover: var(--accent-hover-dark);
  --border:       var(--border-dark);
  --border-2:     var(--border-2-dark);
}

/* =====================================================
   Reset
   ===================================================== */
*, *::before, *::after { box-sizing: border-box; }
body, h1, h2, h3, h4, p, ul, ol, figure, blockquote { margin: 0; }
ul, ol { padding: 0; list-style: none; }
img, svg { max-width: 100%; display: block; }

/* =====================================================
   Base
   ===================================================== */
body {
  font-family: var(--font);
  /* 0.9375rem == 15px at the default 16px root — rem, not px, so this scales with --zoom too. */
  font-size: 0.9375rem;
  line-height: 1.75;
  color: var(--text);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  font-feature-settings: 'calt' 1;
  font-variant-ligatures: contextual;
  display: flex;
  flex-direction: column;
  min-height: 100svh;
}

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

::selection { background: color-mix(in srgb, var(--accent) 28%, var(--bg)); color: var(--text); }

/* Focus ring — shown only for keyboard navigation, never for mouse */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none; }

p { margin-bottom: var(--s4); }
p:last-child { margin-bottom: 0; }

pre, code, kbd {
  font-family: var(--font-code);
  font-feature-settings: 'calt' 1;
  font-variant-ligatures: contextual;
}
code {
  font-size: 0.88em;
  padding: 0.15em 0.35em;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: 3px;
}
pre {
  padding: var(--s4) var(--s5);
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow-x: auto;
  margin-bottom: var(--s6);
  scrollbar-width: thin;
  scrollbar-color: var(--border-2) transparent;
}
pre::-webkit-scrollbar { height: 6px; }
pre::-webkit-scrollbar-track { background: transparent; }
pre::-webkit-scrollbar-thumb {
  background: var(--border-2);
  border-radius: 3px;
}
pre::-webkit-scrollbar-thumb:hover { background: var(--subtle); }
pre[tabindex]:focus-visible { border-radius: 6px; }
pre code { background: none; border: none; padding: 0; }

blockquote {
  border-left: 2px solid var(--border-2);
  padding-left: var(--s6);
  color: var(--muted);
  margin: var(--s6) 0;
  font-style: italic;
}

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

/* =====================================================
   Skip link
   ===================================================== */
.skip-link {
  position: absolute;
  top: -100%;
  left: var(--s4);
  z-index: 100;
  padding: var(--s2) var(--s4);
  background: var(--text);
  color: var(--bg);
  font-size: var(--t-sm);
  font-weight: 700;
  text-decoration: none;
  border-radius: 0 0 4px 4px;
  transition: top 0.1s;
}
.skip-link:focus { top: 0; outline: none; }

/* =====================================================
   Keyboard hints (Shift-hold overlay)
   Keycap style — always dark, like a physical key
   ===================================================== */
.kbd-hint {
  position: fixed;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px;
  height: 18px;
  padding: 0 3px;
  padding-top: 2.3px;

  background: #d4d4d0;
  color: #141413;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-bottom: 3px solid rgba(0, 0, 0, 0.28);
  border-radius: 0;
  box-shadow:
    0 2px 5px rgba(0, 0, 0, 0.14),
    inset 0 1px 0 rgba(255, 255, 255, 0.65);

  font-family: var(--font);
  font-size: 9.5px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.02em;
  pointer-events: none;
  user-select: none;
  z-index: 9999;
  animation: hint-in 0.08s ease both;
}

@media (prefers-color-scheme: dark) {
  .kbd-hint {
    background: #252523;
    color: #e8e8e2;
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-bottom: 3px solid rgba(0, 0, 0, 0.55);
    box-shadow:
      0 3px 6px rgba(0, 0, 0, 0.35),
      inset 0 1px 0 rgba(255, 255, 255, 0.1);
  }
}

/* Previous-element badge — same keycap style as 1–9, small ↑ in corner */
.kbd-hint--prev { min-width: 22px; }
.kbd-hint--prev::after {
  content: '↑';
  position: absolute;
  top: 0;
  right: 1px;
  font-size: 6px;
  line-height: 1;
  opacity: 0.7;
}

@keyframes hint-in {
  from { opacity: 0; scale: 0.65; }
  to   { opacity: 1; scale: 1; }
}

/* =====================================================
   Shift-hint badge — desktop-only discoverability nudge
   Hidden on touch devices via (hover: hover) and (pointer: fine):
   mobile/tablet cannot hold a physical Shift key, so the hint
   would be irrelevant (and undismissable) there.
   ===================================================== */
#shift-hint {
  display: none;
  position: fixed;
  bottom: var(--s8);
  left: var(--s8);
  z-index: 200;
  align-items: center;
  gap: 6px;
  font-size: var(--t-xs);
  color: var(--muted);
  pointer-events: none;
  user-select: none;
}
.hint-key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px;
  height: 18px;
  padding: 0 3px;
  padding-top: 2.3px;

  background: #d4d4d0;
  color: #141413;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-bottom: 3px solid rgba(0, 0, 0, 0.28);
  box-shadow:
    0 2px 5px rgba(0, 0, 0, 0.14),
    inset 0 1px 0 rgba(255, 255, 255, 0.65);

  font-family: var(--font);
  font-size: 9px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.02em;
}
@media (prefers-color-scheme: dark) {
  .hint-key {
    background: #252523;
    color: #e8e8e2;
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-bottom: 3px solid rgba(0, 0, 0, 0.55);
    box-shadow:
      0 3px 6px rgba(0, 0, 0, 0.35),
      inset 0 1px 0 rgba(255, 255, 255, 0.1);
  }
}
@media (hover: hover) and (pointer: fine) {
  #shift-hint { display: inline-flex; }
}
.hints-active #shift-hint { display: none; }

/* (hover: hover)/(pointer: fine) alone isn't reliable — some touch devices still report a
   fine pointer at narrow viewports, so hide unconditionally below 600px too. */
@media (max-width: 600px) {
  #shift-hint { display: none !important; }
}

/* Zoom hint — same convention as #shift-hint, stacked above it (the opposite corner is
   #back-to-top's). +/- scale the page via --zoom; 0 resets it. */
#zoom-hint {
  display: none;
  position: fixed;
  bottom: calc(var(--s8) + 26px);
  left: var(--s8);
  z-index: 200;
  align-items: center;
  gap: 6px;
  font-size: var(--t-xs);
  color: var(--muted);
  pointer-events: none;
  user-select: none;
}
@media (hover: hover) and (pointer: fine) {
  #zoom-hint { display: inline-flex; }
}
.hints-active #zoom-hint { display: none; }
/* See #shift-hint above — same unconditional mobile hide, same reasoning. */
@media (max-width: 600px) {
  #zoom-hint { display: none !important; }
}

/* Theme hint — same convention, stacked above #zoom-hint. Label shows the mode currently
   in effect (not the action), same as #zoom-hint's percentage. */
#theme-hint {
  display: none;
  position: fixed;
  bottom: calc(var(--s8) + 52px);
  left: var(--s8);
  z-index: 200;
  align-items: center;
  gap: 6px;
  font-size: var(--t-xs);
  color: var(--muted);
  pointer-events: none;
  user-select: none;
}
@media (hover: hover) and (pointer: fine) {
  #theme-hint { display: inline-flex; }
}
.hints-active #theme-hint { display: none; }
/* See #shift-hint above — same unconditional mobile hide, same reasoning. */
@media (max-width: 600px) {
  #theme-hint { display: none !important; }
}

/* Transient "112%"-style readout after each +/-/0 press. Created on demand by a11y.js;
   fades via opacity so it never needs a display toggle. */
#zoom-status {
  position: fixed;
  bottom: var(--s8);
  left: 50%;
  transform: translateX(-50%);
  z-index: 300;
  padding: var(--s2) var(--s4);
  font-family: var(--font);
  font-weight: 700;
  font-size: var(--t-sm);
  background: #d4d4d0;
  color: #141413;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-bottom: 3px solid rgba(0, 0, 0, 0.28);
  box-shadow:
    0 2px 5px rgba(0, 0, 0, 0.14),
    inset 0 1px 0 rgba(255, 255, 255, 0.65);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
}
#zoom-status.is-visible { opacity: 1; }
@media (prefers-color-scheme: dark) {
  #zoom-status {
    background: #252523;
    color: #e8e8e2;
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-bottom: 3px solid rgba(0, 0, 0, 0.55);
    box-shadow:
      0 3px 6px rgba(0, 0, 0, 0.35),
      inset 0 1px 0 rgba(255, 255, 255, 0.1);
  }
}

/* =====================================================
   Header
   ===================================================== */
/* min-height reserves the header's box up front — without it, a late-settling content
   metric (e.g. a font swap) could shift its height and everything below it. */
.site-header { border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 58px; }
.site-header-inner {
  max-width: var(--w);
  margin: 0 auto;
  padding: var(--s4) var(--s6);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s8);
}
.site-title {
  font-weight: 700;
  font-size: var(--t-sm);
  color: var(--text);
  text-decoration: none;
  letter-spacing: 0.02em;
}
.site-title:hover { color: var(--accent); text-decoration: none; }
.site-nav { display: flex; gap: var(--s6); }
.site-nav a {
  font-size: var(--t-sm);
  color: var(--muted);
  text-decoration: none;
  transition: color 0.1s;
}
.site-nav a:hover { color: var(--text); text-decoration: none; }

/* =====================================================
   Main
   ===================================================== */
main {
  flex: 1;
  max-width: var(--w);
  width: 100%;
  margin: 0 auto;
  padding: var(--s12) var(--s6) var(--s20);
}

/* =====================================================
   Footer
   ===================================================== */
.site-footer { border-top: 1px solid var(--border); flex-shrink: 0; }
.site-footer-inner {
  max-width: var(--w);
  margin: 0 auto;
  padding: var(--s5) var(--s6);
  display: flex;
  gap: var(--s6);
}
.site-footer a {
  font-size: var(--t-xs);
  color: var(--muted);
  text-decoration: none;
}
.site-footer a:hover { color: var(--text); text-decoration: none; }

/* Theme toggle — a real clickable control, so touch devices without the "L" shortcut can
   still switch modes. margin-left: auto sets it apart from the identity links on the same
   row. Text is the current mode; a11y.js keeps it in sync with the real active theme. */
.site-footer button#theme-toggle {
  margin-left: auto;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  font-size: var(--t-xs);
  color: var(--muted);
  cursor: pointer;
}
.site-footer button#theme-toggle:hover { color: var(--text); }

/* =====================================================
   Homepage — Intro
   ===================================================== */
.intro {
  padding-bottom: var(--s12);
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--s12);
}
.intro h1 {
  font-size: var(--t-3xl);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.01em;
  margin-bottom: var(--s4);
}
.intro p { font-size: var(--t-md); color: var(--muted); max-width: 68ch; margin-bottom: 0; }

/* Prose links get a quiet underline, accent reserved for hover, so a paragraph full of
   references doesn't read as a wall of blue. Scoped to actual prose tags, not the whole
   .article-body/.intro subtree, so component links (cards, anchors, footnotes) keep their
   own treatment. */
:is(.article-body, .intro) :is(p, li, dd, blockquote, td) a {
  color: var(--text);
  text-decoration: underline;
  text-decoration-color: var(--border-2);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color 0.15s ease, text-decoration-color 0.15s ease;
}
:is(.article-body, .intro) :is(p, li, dd, blockquote, td) a:hover {
  color: var(--accent);
  text-decoration-color: var(--accent);
}

/* =====================================================
   Homepage — Sections
   ===================================================== */
.home-section { margin-bottom: var(--s12); }
.home-section + .home-section { padding-top: var(--s12); }
.home-section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s3);
  margin-bottom: var(--s5);
}
.home-section-head h2 {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  min-width: 0;
  font-size: var(--t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--subtle);
}
/* Truncates only the title text (not the icon next to it) when there's no room — the
   "All X[n]" link stays on one line and fully visible at all times; text-overflow:ellipsis
   doesn't reliably truncate a flex container with multiple children, so it's scoped to
   this inner span instead of .home-section-head h2 itself. */
.home-section-title {
  overflow: hidden;
  min-width: 0;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.home-section-head a { flex-shrink: 0; white-space: nowrap; font-size: var(--t-xs); color: var(--muted); text-decoration: none; }
.home-section-head a:hover { color: var(--text); text-decoration: none; }

/* =====================================================
   Homepage — Project rows
   ===================================================== */
.home-projects { display: flex; flex-direction: column; }
.home-project-row {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-rows: auto auto;
  column-gap: var(--s6);
  row-gap: var(--s1);
  padding: var(--s4) 0;
  border-bottom: 1px solid var(--border);
  position: relative;
}
.home-project-row:first-child { border-top: 1px solid var(--border); }
.home-project-row a {
  font-weight: 700;
  font-size: var(--t-md);
  color: var(--text);
  text-decoration: none;
  grid-column: 1;
  grid-row: 1;
}
.home-project-row a:hover { color: var(--accent); text-decoration: none; }
.home-project-row a::after { content: ''; position: absolute; inset: 0; cursor: pointer; }
.home-project-row:hover a { color: var(--accent); }
.home-project-year {
  font-size: var(--t-xs);
  color: var(--subtle);
  grid-column: 2;
  grid-row: 1;
  align-self: center;
}
.home-project-summary {
  font-size: var(--t-sm);
  color: var(--muted);
  grid-column: 1 / -1;
  grid-row: 2;
  margin: 0;
  line-height: 1.6;
}

/* =====================================================
   Homepage — Post rows
   ===================================================== */
.home-posts { display: flex; flex-direction: column; }
.home-post-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s6);
  padding: var(--s3) 0;
  border-bottom: 1px solid var(--border);
  position: relative;
}
.home-post-row:first-child { border-top: 1px solid var(--border); }
.home-post-row a { color: var(--text); font-size: var(--t-md); text-decoration: none; }
.home-post-row a:hover { color: var(--accent); text-decoration: none; }
.home-post-row a::after { content: ''; position: absolute; inset: 0; cursor: pointer; }
.home-post-row:hover a { color: var(--accent); }
.home-post-row time { font-size: var(--t-xs); color: var(--subtle); white-space: nowrap; flex-shrink: 0; }
.home-book-row { justify-content: flex-start; align-items: center; gap: var(--s4); }
.home-book-body { display: flex; flex-direction: column; gap: var(--s1); }
.home-book-meta { font-size: var(--t-xs); color: var(--subtle); }

/* =====================================================
   List pages (/projects, /writing)
   ===================================================== */
.list-page-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--s6);
}
.list-page-title {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  font-size: var(--t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--subtle);
}
/* Section icons (pixelarticons.com, MIT) — blocky pixel-grid glyphs, same
   crispEdges treatment as .rss-icon so they read as pixel art, not smoothed vectors. */
.list-page-icon {
  width: 0.875rem;
  height: 0.875rem;
  flex-shrink: 0;
  shape-rendering: crispEdges;
}
/* RSS icon — phosphor amber, Alien: Isolation terminal aesthetic */
.rss-link {
  display: inline-flex;
  align-items: center;
  color: #b07812;
  text-decoration: none;
  line-height: 1;
}
.rss-link:hover { color: #d4980e; text-decoration: none; }
.rss-icon {
  /* crispEdges keeps this pixel-art icon's straight edges from anti-aliasing blur at this
     small (16px) render size. */
  width: 1rem;
  height: 1rem;
  shape-rendering: crispEdges;
  filter:
    drop-shadow(0 0 2px #b07812)
    drop-shadow(0 0 5px rgba(176, 120, 18, 0.4));
  transition: filter 0.15s ease, color 0.15s ease;
}
.rss-link:hover .rss-icon {
  filter:
    drop-shadow(0 0 3px #d4980e)
    drop-shadow(0 0 8px rgba(212, 152, 14, 0.65))
    drop-shadow(0 0 16px rgba(212, 152, 14, 0.25));
}
@media (prefers-color-scheme: dark) {
  .rss-link { color: #c98e14; }
  .rss-link:hover { color: #e8a820; }
  .rss-icon {
    filter:
      drop-shadow(0 0 3px #c98e14)
      drop-shadow(0 0 7px rgba(201, 142, 20, 0.55));
  }
  .rss-link:hover .rss-icon {
    filter:
      drop-shadow(0 0 4px #e8a820)
      drop-shadow(0 0 10px rgba(232, 168, 32, 0.75))
      drop-shadow(0 0 20px rgba(232, 168, 32, 0.35));
  }
}
.item-list { display: flex; flex-direction: column; }
.item-row { padding: var(--s5) 0; border-bottom: 1px solid var(--border); position: relative; }
.item-row:first-child { border-top: 1px solid var(--border); }
.item-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s4);
  margin-bottom: var(--s2);
}
.item-heading { font-size: inherit; font-weight: inherit; line-height: inherit; margin: 0; }
.item-title { font-weight: 700; font-size: var(--t-md); color: var(--text); text-decoration: none; }
.item-title:hover { color: var(--accent); text-decoration: none; }
/* Stretched link — expands click area to the full .item-row without extra DOM anchors.
   ::after is invisible and has no AT presence; keyboard focus stays on the <a>. */
.item-title::after {
  content: '';
  position: absolute;
  inset: 0;
  cursor: pointer;
}
.item-row:hover .item-title { color: var(--accent); }
.item-label { font-size: var(--t-xs); color: var(--subtle); white-space: nowrap; flex-shrink: 0; }
.item-meta { display: flex; align-items: baseline; gap: var(--s2); flex-shrink: 0; }
.item-summary { font-size: var(--t-sm); color: var(--muted); margin: 0; line-height: 1.65; }
.book-status { font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; white-space: nowrap; display: inline-block; padding: 0 3px; color: var(--text); }
.book-status::before { content: '['; }
.book-status::after { content: ']'; }
/* Semi-transparent wash + asymmetric pixel shadows → rough hand-highlighter feel */
.book-status--planned  { background: rgba(255, 193,   7, 0.38); box-shadow:  3px 0 0 rgba(255, 193,   7, 0.38), -2px 0 0 rgba(255, 193,   7, 0.38), 0 2px 0 rgba(255, 193,   7, 0.20); }
.book-status--finished { background: rgba( 76, 175,  80, 0.38); box-shadow:  3px 0 0 rgba( 76, 175,  80, 0.38), -2px 0 0 rgba( 76, 175,  80, 0.38), 0 2px 0 rgba( 76, 175,  80, 0.20); }
.book-status--dnf      { background: rgba(220,  70,  60, 0.38); box-shadow:  3px 0 0 rgba(220,  70,  60, 0.38), -2px 0 0 rgba(220,  70,  60, 0.38), 0 2px 0 rgba(220,  70,  60, 0.20); }
.book-eyebrow { margin-bottom: var(--s2); }
.item-heading .book-status { vertical-align: middle; }

/* 2:3 matches standard book-cover proportions (Goodreads/Open Library). The frame (not the
   img) carries size/background/border, so the box is fully defined before the cover loads;
   object-fit: contain letterboxes rather than cropping/distorting mismatched art. */
.book-item-row { display: flex; align-items: center; gap: var(--s4); }
.item-cover-frame {
  display: inline-block;
  width: 44px;
  height: 66px;
  background: var(--bg-raised);
  border-radius: 3px;
  border: 1px solid var(--border);
  overflow: hidden;
  flex-shrink: 0;
}
/* Two-class selector, not just .item-cover, so this reliably outranks .article-body img's
   rules when a book_card:: renders inside prose, regardless of source order. */
.item-cover-frame .item-cover {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: contain;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.item-cover.is-loaded { opacity: 1; }
.item-body { flex: 1; min-width: 0; }

/* =====================================================
   Breadcrumb (WCAG 2.4.8 Location — AAA)
   ===================================================== */
.breadcrumb { margin-bottom: var(--s10); }
.breadcrumb ol {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: 0;
  list-style: none;
  padding: 0;
  margin: 0;
}
.breadcrumb li { display: flex; align-items: center; font-size: var(--t-sm); flex-shrink: 0; }
.breadcrumb li + li::before {
  content: '/';
  padding: 0 var(--s2);
  color: var(--subtle);
  aria-hidden: true;
  flex-shrink: 0;
}
.breadcrumb a { color: var(--muted); text-decoration: none; }
.breadcrumb a:hover { color: var(--text); }
.breadcrumb [aria-current="page"] { color: var(--text); }
/* The current-page crumb (variable-length title) truncates with ellipsis instead of
   wrapping — wrapping would orphan the "/" separator onto its own line. */
.breadcrumb li:last-child { flex-shrink: 1; min-width: 0; }
.breadcrumb li:last-child > [aria-current="page"] {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* =====================================================
   Book cover
   ===================================================== */
.book-cover {
  width: 200px;
  max-width: 40%;
  margin: 0 0 var(--s8);
}
/* aspect-ratio: 2/3 is just a loading placeholder shape; :has() below releases it once the
   real image loads, so the frame reflows to the image's true proportions. */
.book-cover-frame {
  position: relative;
  aspect-ratio: 2 / 3;
  background: var(--bg-raised);
  border-radius: 4px;
  border: 1px solid var(--border);
  overflow: hidden;
}
.book-cover-frame:has(img.is-loaded) { aspect-ratio: auto; }
.book-cover-frame img {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: contain;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.book-cover-frame img.is-loaded { height: auto; opacity: 1; }
/* Shown when the cover proxy 404s (bad ISBN, Open Library outage) — keeps the placeholder
   shape instead of a mysteriously empty box. */
.book-cover-frame:has(img.is-error)::after {
  content: 'Cover unavailable';
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--s4);
  font-size: var(--t-xs);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--subtle);
}
.book-cover:has(.book-cover-frame img.is-error) .book-cover-credit { display: none; }
.book-cover-credit {
  font-size: var(--t-xs);
  color: var(--subtle);
  margin-top: var(--s2);
}
.book-cover-credit a { color: inherit; }
.book-cover-credit a:hover { color: var(--muted); }

/* "Why I recommend this book" — a personal note, distinct from the
   publisher's blurb (which moves into the collapsible summary below). */
.article-body .book-commentary-label {
  font-size: var(--t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--subtle);
  margin-bottom: var(--s2);
}

/* Longer, AsciiDoc-rendered write-up beneath the one-line blurb above */
.book-commentary-html { margin-top: var(--s4); }
.book-commentary-html :is(h2, h3, h4) { margin: var(--s6) 0 var(--s2); }
.book-commentary-html p { margin: 0 0 var(--s4); }
.book-commentary-html :is(ul, ol) { margin: 0 0 var(--s4) var(--s6); }
.book-commentary-html li { margin-bottom: var(--s1); }

/* Rich preview embedded in AsciiDoc prose (project_card::/writing_card::/book_card::) —
   same left-accent-border treatment as blockquote, not a boxed panel; this site avoids
   bordered/shadowed boxes in favor of flat, hairline-divided rows. */
.content-card {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--s4);
  margin: var(--s5) 0;
  padding: var(--s3) 0 var(--s3) var(--s5);
  border-left: 2px solid var(--border-2);
}
.content-card:hover { border-left-color: var(--accent); }
.content-card-body { flex: 1; min-width: 0; }
/* Undoes .article-body img's prose margin — otherwise the cover gets pushed down and
   cropped inside its fixed-height frame. */
.content-card .item-cover { margin: 0; }
.content-card-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s4);
}
.content-card-title {
  font-weight: 700;
  color: var(--text);
  text-decoration: none;
}
/* Stretched link — expands click area to the full .content-card without extra
   DOM anchors, same pattern as .item-title. ::after is invisible and has no
   AT presence; keyboard focus stays on the <a>. */
.content-card-title::after {
  content: '';
  position: absolute;
  inset: 0;
  cursor: pointer;
}
.content-card:hover .content-card-title { color: var(--accent); }
.content-card-label {
  font-size: var(--t-xs);
  color: var(--subtle);
  white-space: nowrap;
}
.content-card-summary {
  margin: var(--s2) 0 0;
  font-size: var(--t-sm);
  color: var(--muted);
  line-height: 1.65;
}

/* Native <details>/<summary> — works without JS, unlike the <button onclick> it replaced.
   display:contents on .book-summary drops its own box so <summary> and the revealed <p>
   become flex items of .book-meta-row directly, alongside author/date. */
.book-summary { display: contents; }
.book-summary-toggle {
  /* <summary> defaults to block-level list-item with marker-reserved indent even once
     hidden — inline-block gets it to sit on the row with author/date instead. */
  display: inline-block;
  font-family: inherit;
  font-size: var(--t-sm);
  line-height: inherit;
  color: var(--muted);
  cursor: pointer;
  list-style: none;
}
.book-summary-toggle::-webkit-details-marker { display: none; }
.book-summary-toggle::marker { content: none; }
.book-summary-toggle:hover { color: var(--accent); }
/* [open] is the browser's own state, toggled natively with no JS involved. */
.book-summary[open] .book-summary-toggle-closed,
.book-summary:not([open]) .book-summary-toggle-open { display: none; }
.book-summary-text {
  width: 100%; /* forces its own line within .book-meta-row's wrapping flex row */
  margin: var(--s3) 0 0;
  color: var(--muted);
  font-size: var(--t-sm);
  line-height: 1.65;
}

/* Terminal keycap button — same chunky, pixelated, grayscale
   treatment as .kbd-hint (the Shift-hold badges), echoing
   Alien: Isolation's blocky retro-terminal UI. */
.book-buy {
  margin-top: var(--s8);
  padding-top: var(--s8);
  border-top: 1px solid var(--border);
}
.book-buy-link {
  display: inline-block;
  font-family: var(--font);
  font-weight: 700;
  font-size: var(--t-xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-decoration: none;
  color: #141413;
  padding: var(--s1) var(--s3);
  background: #d4d4d0;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-bottom: 3px solid rgba(0, 0, 0, 0.28);
  border-radius: 0;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.book-buy-link:hover { background: #c8c8c3; color: #141413; text-decoration: none; }
.book-buy-link:active {
  border-bottom-width: 1px;
  margin-top: 2px;
}
@media (prefers-color-scheme: dark) {
  .book-buy-link {
    background: #252523;
    color: #e8e8e2;
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-bottom: 3px solid rgba(0, 0, 0, 0.55);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
  }
  .book-buy-link:hover { background: #2e2e2b; color: #e8e8e2; }
}
.book-buy-disclosure {
  font-size: var(--t-xs);
  color: var(--muted);
  margin: 0 0 var(--s3);
}

/* =====================================================
   Article — shared (detail pages)
   ===================================================== */
.article-header {
  margin-bottom: var(--s10);
  padding-bottom: var(--s8);
  border-bottom: 1px solid var(--border);
}
.article-header--writing { border-bottom: none; padding-bottom: 0; margin-bottom: var(--s16); }
.article-header h1 {
  font-size: var(--t-2xl);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.01em;
  margin-bottom: var(--s2);
}
/* Writing posts get a bigger title than project/book pages — same --t-3xl the homepage
   bio's own h1 uses. */
.article-header--writing h1 { font-size: var(--t-3xl); }
.article-meta { font-size: var(--t-sm); color: var(--muted); }
.article-meta-row { display: flex; flex-direction: column; gap: var(--s1); }
.article-tech { font-size: var(--t-xs); color: var(--subtle); }

/* Book byline — author, date, and the summary toggle share a row, each separated by a
   middot (skipped for whichever ends up first). */
.book-meta-row { flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--s2); }
.book-meta-row > :not(:first-child)::before {
  content: '·';
  margin-right: var(--s2);
  color: var(--subtle);
}
/* .book-summary keeps the dot but drops the margin-right — display:contents makes
   <summary> the actual flex item, so the container's own `gap` already provides spacing. */
.book-meta-row > .book-summary::before { margin-right: 0; }
/* Same treatment as .book-cover-credit a — a reference link, not a call to action. */
.book-author-link { color: inherit; }
.book-author-link:hover { color: var(--text); }

/* =====================================================
   Article body — markdown-rendered content
   ===================================================== */
:is(.article-body, .intro) { font-size: var(--t-md); line-height: 1.85; }

:is(.article-body, .intro) h2 {
  /* Long-form essay posts want a bigger, more confident scale than a typical blog:
     h2 --t-2xl (2rem), h3 --t-xl (1.5rem), body --t-md (1rem). */
  font-size: var(--t-2xl);
  font-weight: 700;
  line-height: 1.2;
  color: var(--text);
  margin-top: var(--s20);
  margin-bottom: var(--s4);
  scroll-margin-top: var(--s8);
}
:is(.article-body, .intro) h3 {
  font-size: var(--t-xl);
  font-weight: 700;
  line-height: 1.3;
  color: var(--text);
  margin-top: var(--s16);
  margin-bottom: var(--s3);
  scroll-margin-top: var(--s8);
}
/* Mobile steps margin-top down one notch (h2 s20->s16, h3 s16->s12) — a heading still
   needs to read as a section break, just not as much as on desktop. */
@media (max-width: 600px) {
  :is(.article-body, .intro) h2 { margin-top: var(--s16); }
  :is(.article-body, .intro) h3 { margin-top: var(--s12); }
}
/* Direct-child combinator on purpose — a descendant selector would also match an AsciiDoc
   heading, since every section wraps its own <div class="sect1"><h2>, making it a
   :first-child regardless of position. This matches only Markdown's flat structure;
   AsciiDoc's equivalent is handled by .sect1:first-child below. */
:is(.article-body, .intro) > h2:first-child,
:is(.article-body, .intro) > h3:first-child { margin-top: 0; }

/* display: inline, not inline-block — inline-block is its own atomic box that can wrap onto
   a phantom line when the heading text just fills a line, leaving invisible extra spacing
   below (the anchor is opacity:0). Plain inline has no such break point. */
.heading-anchor {
  display: inline;
  margin-left: var(--s2);
  color: var(--subtle);
  text-decoration: none;
  font-weight: 400;
  font-size: 0.85em;
  opacity: 0;
  transition: opacity 0.15s, color 0.15s;
}
:is(.article-body, .intro) :is(h2, h3, h4):hover .heading-anchor,
.heading-anchor:focus-visible,
.hints-active .heading-anchor {
  opacity: 1;
  color: var(--accent);
}

.article-body p { margin-bottom: var(--s4); }
.article-body p:last-child { margin-bottom: 0; }

/* font-weight alone reads weak in a monospace face, especially in dark mode — a faint
   double-struck text-shadow reinforces stroke weight without touching color, so it can only
   add contrast, never subtract from the AAA-verified body text. */
:is(.article-body, .intro) strong,
:is(.article-body, .intro) b { font-weight: 700; text-shadow: 0 0 0.4px currentColor; }

/* Asciidoctor wraps every paragraph in its own <div class="paragraph">, which defeats the
   :last-child reset above (every AsciiDoc <p> is trivially its wrapper's last child).
   Restored here for AsciiDoc's wrapper shape only; Markdown's flat structure is unaffected. */
.article-body .paragraph > p:last-child { margin-bottom: var(--s4); }
.article-body > .sect1:first-child > h2:first-child,
.article-body > .sect1:first-child > h3:first-child { margin-top: 0; }

:is(.article-body, .intro) ul,
:is(.article-body, .intro) ol {
  margin-bottom: var(--s4);
  /* 2rem — matches anthropic.com's post-body list indent; --s6 (1.5rem) left the marker
     hanging almost flush with the page's own edge margin on narrow mobile screens. */
  padding-left: var(--s8);
}
:is(.article-body, .intro) ul { list-style: disc; }
:is(.article-body, .intro) ol { list-style: decimal; }
:is(.article-body, .intro) li { margin-bottom: var(--s2); line-height: 1.75; }
:is(.article-body, .intro) li:last-child { margin-bottom: 0; }

:is(.article-body, .intro) table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: var(--s6);
  font-size: var(--t-sm);
}
:is(.article-body, .intro) th,
:is(.article-body, .intro) td {
  text-align: left;
  padding: var(--s2) var(--s4);
  border-bottom: 1px solid var(--border);
  vertical-align: top;
}
:is(.article-body, .intro) th {
  font-weight: 700;
  color: var(--text);
  border-bottom-color: var(--border-2);
}
:is(.article-body, .intro) td { color: var(--text); }
:is(.article-body, .intro) tr:last-child td { border-bottom: none; }
:is(.article-body, .intro) td strong { font-weight: 700; }

:is(.article-body, .intro) pre {
  font-size: var(--t-xs);
  line-height: 1.6;
  margin-top: var(--s2);
  margin-bottom: var(--s6);
}

:is(.article-body, .intro) img {
  /* Natural size by default, capped to the column, centered when narrower — forcing 100%
     width would upscale a deliberately small image. role="img-fill" (see .img-fill below)
     opts in per-image to always filling the column instead. */
  max-width: 100%;
  height: auto;
  display: block;
  margin: var(--s24) auto;
}
:is(.article-body, .intro) img:not(.img-transparent) {
  border-radius: 4px;
  border: 1px solid var(--border);
}

/* AsciiDoc inline images (image:...[]) sit inside running text, not their own figure —
   sized like a small icon instead. lightbox.js skips these (see the .closest('.image') check). */
:is(.article-body, .intro) .image { display: inline; }
:is(.article-body, .intro) .image img {
  display: inline-block;
  width: 1.1em;
  height: 1.1em;
  margin: 0 0.15em;
  vertical-align: text-bottom;
  border: none;
  border-radius: 0;
}

/* Figure wrapping each article image + its caption */
/* contain: layout isolates this subtree's reflow from the rest of the page — safe since the
   figure's size is already fully determined (aspect-ratio + static margins). Not contain:
   size, which would collapse the figure to zero height. */
.img-figure { margin: var(--s24) 0; contain: layout; }
/* --s24 (96px) of whitespace above and below every image reads fine on desktop but eats a
   full phone screen's worth of empty space between short paragraphs on mobile — anthropic.com
   uses the same 4x reduction (6em desktop / 1.5rem mobile) for the equivalent element. */
@media (max-width: 600px) {
  .img-figure { margin: var(--s6) 0; }
}

/* Sized to fit the image (fit-content), matching the natural-size img rule above — a
   percentage width on the img would otherwise resolve against this button, not the column,
   so .img-fill/.img-wide below override both explicitly. */
.img-zoom {
  display: block;
  width: fit-content;
  max-width: 100%;
  margin: 0 auto;
  /* Placeholder panel while the image loads (same idea as .book-cover-frame); skipped for
     a transparent-marked image, which should blend into the page instead. */
  background: var(--bg-raised);
  border-radius: 4px;
  border: none;
  padding: 0;
  cursor: zoom-in;
  overflow: hidden;
}
.img-zoom:has(img.img-transparent) { background: none; border-radius: 0; }
/* Same opacity-0-until-.is-loaded fade the book covers use (lightbox.js adds the class on
   load/error) — a soft reveal instead of an abrupt pop. */
.img-zoom img {
  margin: 0;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.img-zoom img.is-loaded { opacity: 1; }
.img-zoom:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 5px;
}

/* Author opt-in (role="img-fill" on image::[]) for an image that should always fill and
   scale with the column instead of its own intrinsic size. Pure CSS — nothing reads it in JS. */
:is(.article-body, .intro) .imageblock.img-fill img,
:is(.article-body, .intro) .imageblock.img-fill .img-zoom {
  width: 100%;
}

/* Image caption — --muted gives ~7:1 contrast (WCAG AAA) */
.img-caption {
  font-size: var(--t-xs);
  color: var(--muted);
  margin-top: var(--s2);
}
/* On wide images keep caption flush with the image's left edge. */
.img-figure.img-wide .img-caption {
  max-width: 100%;
}

/* Wide image — breaks out of the content column. 68.75rem (1100px at 16px root) scales
   with --zoom instead of capping at a fixed pixel width. */
.img-figure.img-wide {
  --wide: min(68.75rem, calc(100vw - 3rem));
  width: var(--wide);
  max-width: none;
  margin-left: calc((100% - var(--wide)) / 2);
}
.img-figure.img-wide .img-zoom { width: 100%; max-width: none; }
.img-figure.img-wide .img-zoom img { width: 100%; height: auto; margin: 0; }

/* Medium image — breaks out of the content column to 55rem (880px). */
.img-figure.img-medium {
  --medium: min(55rem, calc(100vw - 3rem));
  width: var(--medium);
  max-width: none;
  margin-left: calc((100% - var(--medium)) / 2);
}
.img-figure.img-medium .img-zoom { width: 100%; max-width: none; }
.img-figure.img-medium .img-zoom img { width: 100%; height: auto; margin: 0; }

/* =====================================================
   Article body — AsciiDoc-only block types
   Markdown has no equivalent for these; styled with the same monochrome-plus-accent
   language as the rest of the site, reusing existing tokens.
   ===================================================== */

/* Shared caption style for any block's optional .Title — Asciidoctor emits the same
   <div class="title"> across exampleblock/sidebarblock/quoteblock/imageblock alike. */
:is(.article-body, .intro) .exampleblock > .title,
:is(.article-body, .intro) .sidebarblock .title,
:is(.article-body, .intro) .quoteblock > .title {
  font-size: var(--t-sm);
  font-weight: 700;
  color: var(--muted);
  margin-bottom: var(--s3);
}

/* Admonitions (NOTE/TIP/IMPORTANT/WARNING/CAUTION) — Asciidoctor renders these as a <table>
   (icon cell + content cell); icons=font is disabled (see pom.xml), so the icon cell only
   holds a text label. Styled as the same terminal-keycap recipe as .book-buy-link/.kbd-hint,
   self-colored independent of the page theme. display:contents turns the icon <td> into a
   header-row flex context ahead of .content. WARNING/CAUTION use amber (.rss-link's glow)
   rather than --accent, so an alert reads as an alert, not a tinted info box. */
:is(.article-body, .intro) .admonitionblock {
  margin: var(--s6) 0;
  padding: var(--s5);
  background: #d4d4d0;
  color: #141413;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-bottom: 3px solid rgba(0, 0, 0, 0.28);
  border-radius: 0;
  box-shadow:
    3px 3px 0 rgba(0, 0, 0, 0.12),
    inset 0 1px 0 rgba(255, 255, 255, 0.65);
  font-size: var(--t-md);
  line-height: 1.8;
}
@media (prefers-color-scheme: dark) {
  :is(.article-body, .intro) .admonitionblock {
    background: #252523;
    color: #e8e8e2;
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-bottom: 3px solid rgba(0, 0, 0, 0.55);
    box-shadow:
      3px 3px 0 rgba(0, 0, 0, 0.3),
      inset 0 1px 0 rgba(255, 255, 255, 0.1);
  }
}
:is(.article-body, .intro) .admonitionblock table,
:is(.article-body, .intro) .admonitionblock tr { display: contents; }
:is(.article-body, .intro) .admonitionblock td { display: block; border: none; padding: 0; }
/* display:contents removes the table/tr's box but not inheritance — the general table{}
   font-size rule above still cascades through and overrides var(--t-md). Reassert it here. */
:is(.article-body, .intro) .admonitionblock table { font-size: var(--t-md); }
:is(.article-body, .intro) .admonitionblock .icon {
  display: flex;
  align-items: center;
  gap: var(--s2);
  margin-bottom: var(--s2);
}
:is(.article-body, .intro) .admonitionblock .icon::before {
  content: '';
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.4em;
  height: 1.4em;
  flex-shrink: 0;
  font-size: 0.7em;
  font-weight: 700;
  line-height: 1;
  border-radius: 0;
  background: #141413;
  color: #d4d4d0;
  border: 1px solid rgba(0, 0, 0, 0.3);
}
@media (prefers-color-scheme: dark) {
  :is(.article-body, .intro) .admonitionblock .icon::before {
    background: #e8e8e2;
    color: #252523;
    border-color: rgba(255, 255, 255, 0.15);
  }
}
:is(.article-body, .intro) .admonitionblock.note .icon::before { content: 'i'; }
:is(.article-body, .intro) .admonitionblock.tip .icon::before { content: '✓'; }
:is(.article-body, .intro) .admonitionblock.important .icon::before { content: '!'; }
:is(.article-body, .intro) .admonitionblock.warning .icon::before { content: '‼'; }
:is(.article-body, .intro) .admonitionblock.caution .icon::before { content: '¡'; }
:is(.article-body, .intro) .admonitionblock .icon .title {
  font-size: var(--t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
/* Explicit .content color, not inherited — otherwise warning/caution's amber below would
   tint the whole paragraph, not just the icon+label header. */
:is(.article-body, .intro) .admonitionblock .content { color: #141413; }
@media (prefers-color-scheme: dark) {
  :is(.article-body, .intro) .admonitionblock .content { color: #e8e8e2; }
}
/* Red, not the RSS icon's amber — amber is reserved for the "always-on" feel, red for an
   actual alert. Badge fill/glyph flips per theme since one red can't contrast both. */
:is(.article-body, .intro) .admonitionblock.warning .icon::before,
:is(.article-body, .intro) .admonitionblock.caution .icon::before {
  background: #b3261e;
  color: #f5f0e8;
  border-color: rgba(0, 0, 0, 0.3);
}
:is(.article-body, .intro) .admonitionblock.warning .icon .title,
:is(.article-body, .intro) .admonitionblock.caution .icon .title {
  color: #7a1414;
}
@media (prefers-color-scheme: dark) {
  :is(.article-body, .intro) .admonitionblock.warning,
  :is(.article-body, .intro) .admonitionblock.caution { color: #ff7a70; }
  :is(.article-body, .intro) .admonitionblock.warning .icon .title,
  :is(.article-body, .intro) .admonitionblock.caution .icon .title {
    color: #ff7a70;
  }
  :is(.article-body, .intro) .admonitionblock.warning .icon::before,
  :is(.article-body, .intro) .admonitionblock.caution .icon::before {
    background: #ff5c4d;
    color: #141413;
  }
}

/* Definition lists (term:: definition) */
:is(.article-body, .intro) dl { margin-bottom: var(--s4); }
:is(.article-body, .intro) dt {
  font-weight: 700;
  color: var(--text);
  margin-top: var(--s3);
}
:is(.article-body, .intro) dt:first-child { margin-top: 0; }
:is(.article-body, .intro) dd { margin: var(--s1) 0 0; padding-left: var(--s4); color: var(--muted); }

/* Collapsible blocks — native <details>/<summary>, same disclosure language as
   .book-summary-toggle. [+]/[-] text markers, no box — reads as prose disclosure, not a
   callout, unlike the bordered/beveled treatment of admonitions/sidebar/example. */
:is(.article-body, .intro) details { margin: var(--s6) 0; }
:is(.article-body, .intro) details > summary {
  /* inline-block (not block): the box shrinks to fit the marker + text, so the
     open-state highlight below only ever covers the actual text, never the empty
     space out to the end of the line. */
  display: inline-block;
  cursor: pointer;
  font-weight: 400;
  color: var(--text);
  list-style: none;
  padding: 1px 0;
}
:is(.article-body, .intro) details > summary::-webkit-details-marker { display: none; }
:is(.article-body, .intro) details > summary::before {
  content: '[+] ';
  color: var(--subtle);
  margin-right: 7px;
  padding: 0px 1px 0px;
}
:is(.article-body, .intro) details[open] > summary::before { content: '[-] '; }
/* Halftone-dot highlight (20% fill) on the open question's text — a stippled texture so
   text stays legible, not a solid fill. ::before punches the [-] marker back out with an
   opaque background matching the page, since the highlight marks the text, not the glyph. */
:is(.article-body, .intro) details[open] > summary {
  background: repeating-conic-gradient(var(--border-2) 0% 20%, transparent 0% 100%) 0 0 / 4px 4px;
}
:is(.article-body, .intro) details[open] > summary::before {
  background: var(--bg);
  padding: 12px 1px 8px;
  margin-right: 7px;
}
:is(.article-body, .intro) details > summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
:is(.article-body, .intro) details > .content {
  margin-top: var(--s3);
  padding-left: var(--s5);
}

/* Sidebar — a dashed-border aside with its own "ASIDE" eyebrow, so it reads as a
   digression you can skip, clearly distinct from an admonition (solid accent border,
   never skippable) or a collapsible block (has its own disclosure affordance). */
:is(.article-body, .intro) .sidebarblock {
  margin: var(--s6) 0;
  padding: var(--s5);
  border: 1px dashed var(--border-2);
  border-radius: 6px;
}
:is(.article-body, .intro) .sidebarblock .content::before {
  content: 'Aside';
  display: block;
  font-size: var(--t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--subtle);
  margin-bottom: var(--s3);
}

/* Example block — a titled, auto-numbered ("Example 1.") sample, visually a plain bordered
   box so it reads as neutral/reference material rather than a callout or a digression. */
:is(.article-body, .intro) .exampleblock {
  margin: var(--s6) 0;
  padding: var(--s5);
  border: 1px solid var(--border);
  border-radius: 4px;
}

/* Asciidoctor wraps the quote in a real <blockquote>, already styled by the site-wide
   blockquote{} rule — only .quoteblock's own margin and caption/attribution need rules here. */
:is(.article-body, .intro) .quoteblock { margin: var(--s6) 0; }
:is(.article-body, .intro) .quoteblock blockquote { margin: 0; }
:is(.article-body, .intro) .quoteblock .attribution {
  margin-top: var(--s3);
  font-style: normal;
  font-size: var(--t-sm);
  color: var(--subtle);
}
:is(.article-body, .intro) .quoteblock .attribution cite { font-style: italic; }

/* Inside these padded boxes, the last paragraph's margin-bottom stacks on top of the box's
   own bottom padding — reset it to 0. The :last-child qualifier on .paragraph keeps earlier
   paragraphs in a multi-paragraph box unaffected; 3-class specificity beats the general
   .paragraph > p:last-child rule above without !important. */
:is(.article-body, .intro) .admonitionblock .paragraph:last-child > p:last-child,
:is(.article-body, .intro) .sidebarblock .paragraph:last-child > p:last-child,
:is(.article-body, .intro) .exampleblock .paragraph:last-child > p:last-child,
:is(.article-body, .intro) details .paragraph:last-child > p:last-child,
:is(.article-body, .intro) .quoteblock blockquote .paragraph:last-child > p:last-child {
  margin-bottom: 0;
}

/* Same keycap treatment as #shift-hint, inline instead of fixed. The "+" joining a sequence
   like Ctrl+Alt+Del is literal text in .keyseq, not its own element — color set here (not on
   kbd) reaches just that character. */
:is(.article-body, .intro) .keyseq { color: var(--subtle); }
:is(.article-body, .intro) .keyseq kbd { margin: 0 0.25em; }
:is(.article-body, .intro) kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.6em;
  padding: 0.15em 0.45em;
  font-family: var(--font);
  font-size: 0.8em;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.02em;
  background: #d4d4d0;
  color: #141413;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-bottom: 3px solid rgba(0, 0, 0, 0.28);
  border-radius: 0;
  box-shadow:
    0 2px 5px rgba(0, 0, 0, 0.14),
    inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
@media (prefers-color-scheme: dark) {
  :is(.article-body, .intro) kbd {
    background: #252523;
    color: #e8e8e2;
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-bottom: 3px solid rgba(0, 0, 0, 0.55);
    box-shadow:
      0 3px 6px rgba(0, 0, 0, 0.35),
      inset 0 1px 0 rgba(255, 255, 255, 0.1);
  }
}

/* Button macro (btn:[...]) */
:is(.article-body, .intro) b.button {
  display: inline-block;
  font-weight: 700;
  font-size: 0.85em;
  padding: 0.15em 0.6em;
  background: var(--bg-raised);
  border: 1px solid var(--border-2);
  border-radius: 3px;
  color: var(--text);
}

/* Menu macro (menu:...[]) */
:is(.article-body, .intro) b.menu,
:is(.article-body, .intro) b.menuitem,
:is(.article-body, .intro) b.menuref,
:is(.article-body, .intro) b.submenu { font-weight: 700; color: var(--text); }
:is(.article-body, .intro) b.caret { font-weight: 400; color: var(--subtle); }

/* Highlighted text (#...#) — an accent underline rather than a filled highlighter block,
   to stay inside the site's monochrome-plus-one-accent palette. */
:is(.article-body, .intro) mark {
  background: none;
  color: inherit;
  border-bottom: 2px solid var(--accent);
}

/* Asciidoctor bakes literal "[" / "]" around the footnote number, so they can't be
   recolored independently — collapsed to font-size:0 on the <sup> and replaced with
   ::before/::after on the <a>, same generated-marker approach as the [+]/[-] disclosure.
   scroll-margin-top matches h2/h3's, so the anchor jump lands with breathing room. */
:is(.article-body, .intro) sup.footnote,
:is(.article-body, .intro) sup.footnoteref {
  font-size: 0;
  scroll-margin-top: var(--s8);
}
:is(.article-body, .intro) sup.footnote a,
:is(.article-body, .intro) sup.footnoteref a {
  font-size: 0.75rem;
  color: var(--accent);
  text-decoration: none;
}
:is(.article-body, .intro) sup.footnote a:hover,
:is(.article-body, .intro) sup.footnoteref a:hover {
  color: var(--accent-hover);
  text-decoration: underline;
}
:is(.article-body, .intro) sup.footnote a::before,
:is(.article-body, .intro) sup.footnoteref a::before { content: '['; color: var(--subtle); }
:is(.article-body, .intro) sup.footnote a::after,
:is(.article-body, .intro) sup.footnoteref a::after { content: ']'; color: var(--subtle); }
/* No border-top — the "[Footnotes]" label is the section break already. margin-bottom is
   generous since this is the last thing in the article. */
#footnotes {
  margin-top: var(--s24);
  margin-bottom: var(--s12);
  font-size: var(--t-sm);
  color: var(--muted);
}
#footnotes::before {
  content: '[Footnotes]';
  display: block;
  font-weight: 700;
  color: var(--subtle);
  margin-bottom: var(--s4);
}
#footnotes hr { display: none; }
#footnotes .footnote {
  margin-bottom: var(--s2);
  scroll-margin-top: var(--s8);
}
/* The "N." back-reference is the only bare link directly in .footnote (the rest is prose
   with no wrapping <p>), so it can't pick up the prose-link rule above — spelled out here. */
#footnotes .footnote > a:first-child { color: var(--accent); text-decoration: none; }
#footnotes .footnote > a:first-child:hover { color: var(--accent-hover); text-decoration: underline; }
#footnotes .footnote a:not(:first-child) {
  color: var(--muted);
  text-decoration: underline;
  text-decoration-color: var(--border-2);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
#footnotes .footnote a:not(:first-child):hover {
  color: var(--accent);
  text-decoration-color: var(--accent);
}

/* Thematic break (''') */
:is(.article-body, .intro) hr {
  border: none;
  border-top: 1px solid var(--border);
  margin: var(--s10) 0;
}

/* =====================================================
   Lightbox
   ===================================================== */
.lightbox {
  --lb-pad: clamp(var(--s8), 14vw, 16rem);
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--lb-pad);
}
.lightbox[hidden] { display: none; }

.lightbox-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.88);
  cursor: zoom-out;
}

.lightbox-close {
  position: absolute;
  top: var(--s4);
  right: var(--s4);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 4px;
  color: #fff;
  min-width: 44px;
  min-height: 44px;
  padding: var(--s3) var(--s4);
  cursor: pointer;
  opacity: 0.9;
  transition: opacity 0.15s, background 0.15s;
}
.lightbox-close svg { width: var(--t-lg); height: var(--t-lg); }
.lightbox-close:hover { opacity: 1; background: rgba(255, 255, 255, 0.2); }
.lightbox-close:focus-visible {
  opacity: 1;
  outline: 2px solid rgba(255, 255, 255, 0.7);
  outline-offset: 2px;
}

.lightbox-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* justify-content: center — lightbox.js sizes .lightbox-img to the image's actual
     rendered rect (see resizeLightboxImage) instead of letting it flex-grow to fill this
     box, so centering must be explicit. */
  justify-content: center;
  gap: var(--s3);
  width: calc(100vw - var(--lb-pad) * 2);
  height: calc(100dvh - var(--lb-pad) * 2);
  cursor: zoom-out;
}

.lightbox-img {
  display: block;
  flex: 1;
  min-height: 0;
  width: 100%;
  object-fit: contain;
  /* touch-action: none hands pinch/pan gestures on the image to lightbox.js's own pointer
     handlers instead of letting the browser's native scroll/zoom intercept them first. */
  touch-action: none;
  cursor: zoom-in;
  border-radius: 4px;
  border: 2px solid transparent;
  transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.1s ease;
}
.lightbox-img:hover {
  border-color: rgba(255, 255, 255, 0.25);
  background-color: rgba(255, 255, 255, 0.06);
}
/* A plain click resets zoom (lightbox.js), so the cursor stays zoom-out even mid-drag,
   rather than a grab hand implying dragging is the only thing a click does. */
.lightbox-img.is-zoomed { cursor: zoom-out; }
.lightbox-img.is-zoomed.is-dragging { transition: none; }

.lightbox-caption {
  color: rgba(255, 255, 255, 0.9);
  font-size: clamp(var(--t-xs), 0.7vw, var(--t-md));
  text-align: center;
  width: 100%;
  max-width: 80ch;
  margin: 0;
  cursor: text;
}
.lightbox-caption[hidden] { display: none; }

/* --lb-pad's 14vw scales fine on desktop but eats close to a third of a phone's width on
   every side — on a landscape image that leaves a tiny box floating in a mostly-empty dark
   screen. var(--s4) matches the edge margin #back-to-top already uses at this breakpoint. */
@media (max-width: 600px) {
  .lightbox { --lb-pad: var(--s4); }
}

/* =====================================================
   Back to top
   --text on --bg-raised: ~18:1 light / ~15.8:1 dark (WCAG AAA 7:1)
   44×44px touch target satisfies WCAG 2.5.5 Target Size (AAA)
   ===================================================== */
#back-to-top {
  position: fixed;
  bottom: var(--s8);
  right: var(--s8);
  z-index: 200;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  background: var(--bg-raised);
  border: 1px solid var(--border-2);
  border-radius: 4px;
  color: var(--text);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transform: translateY(8px);
  transition: opacity 0.2s ease, transform 0.2s ease, border-color 0.1s;
}
#back-to-top.is-visible {
  opacity: 1;
  pointer-events: auto;
  transform: translateY(0);
}
#back-to-top:hover { border-color: var(--muted); }
#back-to-top svg { width: 1.125rem; height: 1.125rem; }

@media (max-width: 600px) {
  #back-to-top { bottom: var(--s4); right: var(--s4); }
}

/* =====================================================
   Project pages — minor variant adjustments
   ===================================================== */
.project-page .article-body > p:first-child {
  font-size: var(--t-md);
  color: var(--muted);
  margin-bottom: var(--s8);
  padding-bottom: var(--s8);
  border-bottom: 1px solid var(--border);
}

/* =====================================================
   Writing posts — minor variant adjustments
   ===================================================== */
.post .article-body > p:first-child {
  font-size: var(--t-md);
}
/* ".article-body > :first-child" is the whole AsciiDoc preamble div, not just the opening
   image — ".imageblock:first-child" narrows it back to only the image that's actually first.
   Markdown has no preamble wrapper, so its first-child *is* the .img-figure directly (the
   compound selector variant below). */
.post .article-body > :first-child .imageblock:first-child .img-figure,
.post .article-body > :first-child.img-figure { margin-top: 0; margin-bottom: 0; }
/* Matches the pre-script appearance of the opening image (before lightbox.js wraps it) —
   otherwise it jumps by the margin difference the moment the script runs. */
.post .article-body > :first-child .imageblock:first-child img { margin-top: 0; margin-bottom: var(--s12); }
.post .article-body > :first-child .imageblock:first-child .img-caption,
.post .article-body > :first-child.img-figure .img-caption { display: none; }
.post .article-body > :first-child.img-show-caption .img-figure .img-caption,
.post .article-body > :first-child .imageblock:first-child.img-show-caption .img-caption { display: block; }

/* =====================================================
   404
   ===================================================== */
.not-found { padding-top: var(--s8); }
.not-found-code {
  font-size: var(--t-xl);
  color: var(--subtle);
  margin-bottom: var(--s4);
}
.not-found-heading {
  font-size: var(--t-3xl);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.01em;
  margin-bottom: var(--s4);
}
.not-found p { color: var(--muted); margin-bottom: var(--s6); }

/* =====================================================
   Responsive
   ===================================================== */
@media (max-width: 600px) {
  :root { --t-3xl: 1.75rem; --t-2xl: 1.5rem; }
  main { padding: var(--s8) var(--s4) var(--s16); }
  .site-header-inner, .site-footer-inner { padding-left: var(--s4); padding-right: var(--s4); }
  .intro h1 { font-size: var(--t-2xl); }
  .item-head { flex-direction: column; gap: var(--s1); }
  .home-project-row { grid-template-columns: 1fr; }
  .home-project-year { display: none; }
  .home-post-row time { display: none; }
  /* Mobile padding is var(--s4) not var(--s6) — disable breakout entirely */
  .img-figure.img-wide,
  .img-figure.img-medium { width: 100%; max-width: 100%; margin-left: 0; }
  .img-figure.img-wide .img-zoom,
  .img-figure.img-medium .img-zoom { width: 100%; max-width: 100%; }
  .img-figure.img-wide .img-caption,
  .img-figure.img-medium .img-caption { max-width: 100%; }
}

/* =====================================================
   Reduced motion
   ===================================================== */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}
