/* ==========================================================================
   Self-hosted logo typeface (Victor Mono, OFL) - no external font request,
   consistent with the no-npm/self-hosted ethos elsewhere on this site.
   Used only for the "srdusr" wordmark (.logo-text) - everything else
   still uses JetBrains Mono. Variable font, weight axis covers 100-700.
   ========================================================================== */
@font-face {
  font-family: 'Victor Mono';
  src: url('/fonts/VictorMono-Variable.woff2') format('woff2-variations'), url('/fonts/VictorMono-Variable.woff2') format('woff2');
  font-weight: 100 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Victor Mono';
  src: url('/fonts/VictorMono-Italic-Variable.woff2') format('woff2-variations'), url('/fonts/VictorMono-Italic-Variable.woff2') format('woff2');
  font-weight: 100 700;
  font-style: italic;
  font-display: swap;
}

/* ==========================================================================
   Design tokens (ported verbatim from the Next.js app's globals.css)
   ========================================================================== */
:root {
  --rgbBlack: 0 0 0;
  --rgbWhite: 255 255 255;
  --rgbPrimary: 139 124 255;
  --rgbAccent: 255 107 129;
  /* One source of truth for INSERT-mode state, used by the footer badge,
     the footer ruler, and the terminal prompt when it's the thing that
     put you in INSERT in the first place - was hardcoded #a855f7 in three
     places with no relationship between them. Moved off purple entirely:
     that specific shade is the default "AI tool" purple at this point
     (Claude's own brand color among others), which undercuts a page whose
     whole point is reading as handmade, not generated. Blue also matches
     the common vim-statusline convention of insert-mode-is-blue. */
  --rgbInsert: 56 232 255;
  --rgbText: 255 255 255;
  --rgbTextTitle: 255 255 255;
  --rgbTextLight: 158 158 158;
  --rgbBackground: 17 17 17;
  --rgbBackgroundLight: 26 26 26;
  --rgbOnPrimary: 11 6 32;
  --rgbWater: 139 124 255;
  --themeIsLight: 0;
  /* The statusline is the site's own furniture, not a surface the theme
     repaints: NORMAL is violet and INSERT is cyan in all eight themes, so
     the vim bar reads as the one constant while everything above it
     changes. Deliberately NOT overridden per theme. */
  --rgbStatus: 139 124 255;
  --rgbStatusInsert: 56 232 255;
  --rgbOnStatus: 11 6 32;

  --durationXS: 0.1s;
  --durationS: 0.2s;
  --durationM: 0.3s;
  --durationL: 0.6s;
  --durationXL: 1.2s;
  --easeFunctionDefault: cubic-bezier(0.8, 0.2, 0.2, 0.8);
  --bezierFastoutSlowin: cubic-bezier(0.8, 0.2, 0.2, 0.8);

  --navbarHeight: 72px;

  --systemFontStack: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;

  --fontWeightMedium: 500;
  --fontWeightBold: 700;
  --fontSizeH0: 8.75rem;
  --fontSizeH1: 6.25rem;
  --fontSizeH2: 3.625rem;
  --fontSizeH3: 2.375rem;
  --fontSizeH4: 1.75rem;
  --fontSizeH5: 1.5rem;
  --fontSizeBodyXL: 1.375rem;
  --fontSizeBodyL: 1.25rem;
  --fontSizeBodyM: 1.125rem;
  --fontSizeBodyS: 1rem;
  --fontSizeBodyXS: 0.875rem;
  --lineHeightTitle: 1.1;
  --lineHeightBody: 1.6;
  --maxWidthS: 540px;
  --maxWidthM: 720px;
  --maxWidthL: 1096px;
  --maxWidthXL: 1680px;
  --spaceOuter: 64px;
  --spaceXS: 4px;
  --spaceS: 8px;
  --spaceM: 16px;
  --spaceL: 24px;
  --spaceXL: 32px;
  --space2XL: 48px;
  --space3XL: 64px;
  --space4XL: 96px;
  --space5XL: 128px;
  --zIndex0: 0;
  --zIndex1: 4;
  --zIndex2: 8;
  --zIndex3: 16;
  --zIndex4: 32;
  --zIndex5: 64;
}

@media (max-width: 2080px) {
  :root { --fontSizeH0: 7.5rem; --fontSizeH1: 5rem; }
}
@media (max-width: 1680px) {
  :root {
    --maxWidthS: 480px; --maxWidthM: 640px; --maxWidthL: 1000px; --maxWidthXL: 1100px;
    --spaceOuter: 48px;
    --fontSizeH0: 6.25rem; --fontSizeH1: 4.375rem; --fontSizeH2: 3.125rem;
    --fontSizeH3: 2.25rem; --fontSizeH4: 1.625rem; --fontSizeH5: 1.375rem;
  }
}
@media (max-width: 1040px) {
  :root {
    --fontSizeH0: 5rem; --fontSizeH1: 3.75rem; --fontSizeH2: 3rem;
    --fontSizeH3: 2rem; --fontSizeH4: 1.5rem; --fontSizeH5: 1.25rem;
  }
}
@media (max-width: 696px) {
  :root {
    --spaceOuter: 24px;
    --fontSizeH0: 3.5rem; --fontSizeH1: 2.5rem; --fontSizeH2: 2.125rem;
    --fontSizeH3: 1.75rem; --fontSizeH4: 1.375rem; --fontSizeH5: 1.125rem;
    --fontSizeBodyL: 1.0625rem; --fontSizeBodyM: 1rem; --fontSizeBodyS: 0.875rem;
  }
}
@media (max-width: 400px) {
  :root {
    --spaceOuter: 16px;
    --fontSizeH0: 2.625rem; --fontSizeH1: 2.375rem; --fontSizeH2: 1.75rem; --fontSizeH3: 1.5rem; --fontSizeH4: 1.25rem;
  }
}

/* ==========================================================================
   Base
   ========================================================================== */
* { box-sizing: border-box; }

html {
  font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
  font-feature-settings: 'liga', 'kern';
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-size-adjust: 100%;
  font-size: 16px;
  line-height: 1.5;
}

body {
  margin: 0;
  background: rgb(var(--rgbBackground));
  color: rgb(var(--rgbText));
  min-height: 100vh;
  overflow-x: hidden;
  font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  transition: background var(--durationM) var(--bezierFastoutSlowin), color var(--durationM) var(--bezierFastoutSlowin);
}

html.menu-open, body.menu-open { overflow: hidden; }
/* The navbar is raised above the open menu so the hamburger stays
   clickable, which also put its drop-shadowed wordmark on top of the
   menu's own copy a few pixels off - two "srdusr" stacked, which is the
   embossed look. The menu has its own logo; this one steps aside. */
body.menu-open .navbar-left { opacity: 0; pointer-events: none; }

.app-shell { display: grid; grid-template-rows: 1fr auto; min-height: 100vh; isolation: isolate; }

:focus-visible { outline: 2px solid rgb(var(--rgbPrimary)); outline-offset: 2px; }

.skip-link {
  position: absolute; top: -40px; left: 6px;
  background: rgb(var(--rgbPrimary)); color: rgb(var(--rgbOnPrimary));
  padding: 8px; text-decoration: none; border-radius: 0; z-index: 1000;
}
.skip-link:focus { top: 6px; }

/* ==========================================================================
   Themes
   ========================================================================== */
/* Every theme sets the SAME eight colour tokens. A theme that only sets
   five inherits the rest from :root, which is the dark palette - that is
   how the light variants ended up with a near-white muted text and how
   the /git page's borders vanished on them.
   The contract:
     --rgbText        body copy. Never the same hue as --rgbPrimary.
     --rgbTextLight   muted/secondary copy. Readable on --rgbBackground.
     --rgbPrimary     the one saturated hue of the theme.
     --rgbAccent      the ERROR/WARN colour, not a second brand hue.
                      Every theme keeps its own palette's red here.
     --rgbInsert      INSERT-mode state, where a page uses it.
     --rgbOnPrimary   text drawn ON a --rgbPrimary fill. Not the page
                      background: on a light theme the page background on
                      a mid-tone primary is unreadable.
     --rgbWater       the hue the hero water animation is tinted with.
     --themeIsLight   1 or 0. Read by JS (see initWaveBackground) so the
                      light-theme list is not duplicated there.

   Eight themes, chosen to sit far apart rather than to collect variants:
   three of them used to be a dark and a light of the same palette, and
   the darks were near enough to each other that switching barely showed.
   Each one now owns a different ground AND a different hue - #111 violet,
   white violet, pure black cyan, cream sienna, blue-grey frost, warm
   brown yellow, deep blue teal, purple-dark pink.
   ========================================================================== */
body[data-theme='dark'] {
  --rgbBackground: 17 17 17; --rgbBackgroundLight: 26 26 26;
  --rgbText: 255 255 255; --rgbTextLight: 158 158 158;
  --rgbPrimary: 139 124 255; --rgbAccent: 255 107 129; --rgbInsert: 56 232 255;
  --rgbOnPrimary: 11 6 32; --rgbWater: 139 124 255; --themeIsLight: 0;
}
body[data-theme='light'] {
  --rgbBackground: 255 255 255; --rgbBackgroundLight: 248 248 248;
  --rgbText: 17 17 17; --rgbTextLight: 102 102 102;
  --rgbPrimary: 91 70 224; --rgbAccent: 200 30 60; --rgbInsert: 14 116 144;
  --rgbOnPrimary: 255 255 255; --rgbWater: 124 108 224; --themeIsLight: 1;
}
/* True black for an OLED panel, and the highest-contrast primary of the
   set to go with it. */
body[data-theme='void'] {
  --rgbBackground: 0 0 0; --rgbBackgroundLight: 10 10 12;
  --rgbText: 240 240 245; --rgbTextLight: 130 130 140;
  --rgbPrimary: 0 229 255; --rgbAccent: 255 61 87; --rgbInsert: 124 108 255;
  --rgbOnPrimary: 0 20 24; --rgbWater: 0 229 255; --themeIsLight: 0;
}
/* Ink on paper rather than a white screen: a warm ground, a sienna that
   reads as printed rather than lit, and a water wash the colour of tea. */
body[data-theme='paper'] {
  --rgbBackground: 246 241 232; --rgbBackgroundLight: 238 232 220;
  --rgbText: 28 26 23; --rgbTextLight: 110 103 92;
  --rgbPrimary: 155 62 34; --rgbAccent: 168 26 26; --rgbInsert: 43 90 120;
  --rgbOnPrimary: 246 241 232; --rgbWater: 176 150 110; --themeIsLight: 1;
}
body[data-theme='nord'] {
  --rgbBackground: 46 52 64; --rgbBackgroundLight: 59 66 82;
  --rgbText: 236 239 244; --rgbTextLight: 168 179 199;
  --rgbPrimary: 136 192 208; --rgbAccent: 213 119 128; --rgbInsert: 129 161 193;
  --rgbOnPrimary: 23 34 43; --rgbWater: 136 192 208; --themeIsLight: 0;
}
body[data-theme='gruvbox'] {
  --rgbBackground: 40 40 40; --rgbBackgroundLight: 29 32 33;
  --rgbText: 235 219 178; --rgbTextLight: 146 131 116;
  --rgbPrimary: 250 189 47; --rgbAccent: 251 73 52; --rgbInsert: 131 165 152;
  --rgbOnPrimary: 40 40 40; --rgbWater: 142 192 124; --themeIsLight: 0;
}
/* Solarized's own cyan rather than its yellow, so it does not read as a
   second gruvbox. */
body[data-theme='solarized'] {
  --rgbBackground: 0 43 54; --rgbBackgroundLight: 7 54 66;
  --rgbText: 147 161 161; --rgbTextLight: 101 123 131;
  --rgbPrimary: 42 161 152; --rgbAccent: 220 50 47; --rgbInsert: 38 139 210;
  --rgbOnPrimary: 0 43 54; --rgbWater: 42 161 152; --themeIsLight: 0;
}
/* Catppuccin's pink rather than its mauve, which sat almost on top of the
   default violet. */
body[data-theme='catppuccin'] {
  --rgbBackground: 30 30 46; --rgbBackgroundLight: 24 24 37;
  --rgbText: 205 214 244; --rgbTextLight: 166 173 200;
  --rgbPrimary: 245 194 231; --rgbAccent: 243 139 168; --rgbInsert: 137 180 250;
  --rgbOnPrimary: 30 30 46; --rgbWater: 148 226 213; --themeIsLight: 0;
}

/* ==========================================================================
   404
   ========================================================================== */
.notfound { max-width: var(--maxWidthM); font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.notfound-line { margin: 0; color: rgb(var(--rgbText) / 0.8); overflow-wrap: anywhere; }
.notfound-prompt { color: rgb(var(--rgbPrimary)); }
.notfound-error { margin: var(--spaceXS) 0 var(--space2XL); color: rgb(var(--rgbAccent)); }
.notfound-title { font-size: var(--fontSizeH0); font-weight: 700; margin: 0; line-height: 1; color: rgb(var(--rgbText)); }
.notfound-lead { font-family: var(--systemFontStack); font-size: var(--fontSizeBodyL); color: rgb(var(--rgbText) / 0.7); margin: var(--spaceL) 0 var(--space2XL); line-height: var(--lineHeightBody); }
.notfound-links { display: flex; flex-wrap: wrap; gap: var(--spaceL); }
.notfound-links a { color: rgb(var(--rgbPrimary)); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color var(--durationS) ease; }
.notfound-links a:hover { border-bottom-color: rgb(var(--rgbPrimary)); }

/* ==========================================================================
   Motion
   ========================================================================== */
/* Reveal on scroll. The hidden half is scoped to html.reveal-active,
   which only JS adds, so a reader whose JS never ran sees the page at
   full opacity rather than a blank one. initReveal skips the whole thing
   under prefers-reduced-motion, and the query below is a second guard
   for a preference that changes after load. */
.reveal-active .reveal { opacity: 0; transform: translateY(10px); }
.reveal-active .reveal.is-revealed {
  opacity: 1; transform: none;
  transition: opacity var(--durationL) var(--bezierFastoutSlowin) var(--revealDelay, 0s),
              transform var(--durationL) var(--bezierFastoutSlowin) var(--revealDelay, 0s);
}
@media (prefers-reduced-motion: reduce) {
  .reveal-active .reveal { opacity: 1; transform: none; transition: none; }
}

/* Navigation progress. Above everything except the loading screen, and
   pointer-events: none so a 2px strip can never eat a click on the
   navbar underneath it. */
.page-progress { position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 9998; pointer-events: none; opacity: 0; transition: opacity var(--durationS) ease; }
.page-progress.is-active { opacity: 1; }
.page-progress-bar { display: block; height: 100%; width: 0; background: rgb(var(--rgbPrimary)); box-shadow: 0 0 8px rgb(var(--rgbPrimary) / 0.6); transition: width var(--durationM) var(--bezierFastoutSlowin); }

/* The side-nav cut: one bright segment that slides to the link you are
   on. There is no track behind it - a static full-height hairline down
   the nav read as a stray rule rather than as part of the mark. */
.side-nav-cut {
  position: absolute; left: 50%; width: 2px; top: 0; height: 0;
  background: rgb(var(--rgbPrimary)); transform: translateX(-1px);
  opacity: 0; box-shadow: 0 0 6px rgb(var(--rgbPrimary) / 0.5);
  transition: top var(--durationM) var(--bezierFastoutSlowin),
              height var(--durationM) var(--bezierFastoutSlowin),
              opacity var(--durationS) ease;
}
.side-nav.has-cut .side-nav-cut { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .side-nav-cut { transition: none; }
}

/* ==========================================================================
   Animations
   ========================================================================== */
@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideIn { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
@keyframes footerSlideIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }

/* ==========================================================================
   Navbar
   ========================================================================== */
/* Fully transparent, on purpose - navigation on this site lives in the
   side rail, not here, so this strip was never meant to read as its own
   "navbar bar" the way giving it a unified background made it look. Each
   small thing that sits in it (search, media toggle, theme toggle) gets
   its own softly-blended backdrop below instead of the whole strip
   getting one shared panel. */
.navbar {
  display: grid; grid-template-columns: 1fr 1fr 1fr; align-items: center;
  gap: var(--spaceXS); padding: 0 var(--spaceXL) 0 12px; width: 100%; height: var(--navbarHeight);
  background: transparent;
  position: fixed; top: 0; z-index: var(--zIndex4);
}
.navbar-left { display: flex; align-items: center; justify-content: flex-start; }
.navbar-right { display: flex; align-items: center; justify-content: flex-end; gap: var(--spaceM); }
/* The navbar grid is three equal 1fr tracks, so the middle one is already
   centred on the page - the search was pushed off it by a margin-left of
   space2XL, nothing more. Taking the element out of flow to centre it
   instead collapsed the icon group into the middle track, because the
   grid then had only two in-flow children. */
.navbar-context {
  display: flex; align-items: center; justify-content: center; gap: var(--spaceS); width: 100%;
  /* The navbar's padding is asymmetric (12px left so the logo can sit near
     the edge, spaceXL right), so its 1fr tracks are centred on the padding
     box rather than on the page - half that difference, put back. */
  margin-left: calc((var(--spaceXL) - 12px) / 2);
}
.logo-link { display: flex; align-items: center; height: 100%; text-decoration: none; }
.nav-icons { display: flex; align-items: center; gap: var(--spaceM); margin-right: -23px; padding: 4px 10px; }

/* Mobile/tablet stand-in for .navbar-context's search box (that bar hides
   below 1024px) - hidden by default since desktop already has the real
   thing inline. Search only - filters are page content, not nav chrome,
   see each page's own template. */
.nav-search-toggle { display: none; width: 48px; height: 48px; align-items: center; justify-content: center; background: none; border: none; cursor: pointer; color: rgb(var(--rgbText) / 0.8); transition: color var(--durationM) ease; }
.nav-search-toggle:hover { color: rgb(var(--rgbPrimary)); }
/* The search expands leftward out of its own icon, in the navbar row,
   rather than dropping a full-width panel underneath it. It is a flex
   item to the left of .nav-icons, so growing its width pushes its left
   edge left; the page's filters sit after the input, which puts them
   between the field and the icon that opened it.

   overflow: hidden is what makes the width animation read as an expand.
   Once open it is also the scroller: Projects has four filter buttons
   and they do not fit beside a usable input on a 390px phone, so the
   strip scrolls sideways instead of shrinking the field to nothing. */
.nav-search-panel {
  position: fixed; top: 0; height: var(--navbarHeight);
  /* Right edge pinned just left of the icon that opens it: .nav-icons
     carries a 48px right margin to clear the fixed hamburger and is
     68px wide with one button in it, plus a gap. Fixed, not a flex item,
     because as a flex item inside .nav-icons its growth fought the
     navbar grid's own track sizing and it ended up under the hamburger. */
  right: calc(48px + 68px + var(--spaceL));
  display: flex; align-items: center; gap: var(--spaceS);
  width: 0; min-width: 0; opacity: 0; pointer-events: none;
  overflow-x: hidden; overflow-y: visible;
  z-index: var(--zIndex4);
  transition: width var(--durationM) var(--bezierFastoutSlowin), opacity var(--durationS) ease;
}
/* Sized against the space actually left in the bar: the logo needs about
   150px and the toggle plus hamburger about 110, so the field takes what
   is between them rather than a fraction of the viewport that overlaps
   both. */
/* The wordmark steps aside while the field is open (see setOpen in
   app.js), so the width only has to clear the toggle and the hamburger. */
.nav-search-panel.open {
  width: calc(100vw - 48px - 68px - var(--spaceL) - var(--spaceM));
  max-width: 560px; opacity: 1; pointer-events: auto;
  overflow-x: auto; scrollbar-width: none;
  /* Same legibility backing the logo and the icons carry: the bar itself
     is transparent and page content scrolls under it, so without this the
     filter chips sat straight on a project screenshot. */
  background: rgb(var(--rgbBackground));
}
body.nav-search-open .navbar-left { opacity: 0; pointer-events: none; transition: opacity var(--durationS) ease; }
@media (min-width: 1025px) { body.nav-search-open .navbar-left { opacity: 1; pointer-events: auto; } }
.nav-search-panel.open::-webkit-scrollbar { display: none; }
.nav-search-panel .global-search { position: relative; flex: 1 1 140px; min-width: 120px; }
/* .global-search .widget-input sets min-width: 220px for the desktop bar
   and is declared later in the file, so this needs the extra class to
   win - without it the field refused to shrink and the filter buttons
   rendered on top of it. */
.nav-search-panel .global-search .widget-input { width: 100%; min-width: 0; padding-right: var(--spaceM); }
.nav-search-panel .nav-search-filters { flex: 0 0 auto; margin-top: 0; flex-wrap: nowrap; }
/* Fixed, so the results list escapes the panel's own overflow clip and
   uses the full width under the navbar rather than the width of a field
   that may be 120px wide. */
.nav-search-panel .global-search-results {
  position: fixed; top: var(--navbarHeight); left: var(--spaceM); right: var(--spaceM);
  width: auto; max-height: 60vh;
}

@media (max-width: 768px) {
  .navbar { grid-template-columns: auto 1fr auto auto; padding: 0 var(--spaceM); }
  /* Search moves to the icon-triggered .nav-search-panel below (see
     .nav-search-toggle) - no room left here once the hamburger button is
     also showing. Page-specific filters live on the page itself instead. */
  .navbar-context { display: none !important; }
  /* Matched to the same breakpoint navbar-context hides at, not the
     1024px one .hamburger/.side-nav use - showing this any wider than
     that put the icon-triggered search next to the real inline search
     bar at the same time (769-1024px tablet range), two searches on
     screen at once. */
  .nav-search-toggle { display: flex; }
  /* .hamburger is position:fixed (right: var(--spaceM), ~40px wide), so
     it reserves no space in .nav-icons' normal flex flow - without this,
     adding a second icon here just grew nav-icons rightward straight
     into the hamburger's fixed screen position, rendering underneath it
     (invisible, unclickable) rather than beside it. */
  .nav-icons { margin-right: 48px; }
}

/* Side navigation - vertical, aligned under logo's 'd'/'u' */
.side-nav {
  position: fixed;
  left: var(--sideNavLeft, calc(var(--spaceXL) - 5.375em));
  transform: translateX(-40%);
  top: 100px; z-index: 100; display: flex; flex-direction: column; gap: var(--spaceS);
}
.side-nav-link {
  display: block; text-decoration: none; color: rgb(var(--rgbText) / 0.6);
  transition: all var(--durationM) ease; position: relative;
  writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);
  font-size: var(--fontSizeBodyM); font-weight: var(--fontWeightMedium);
  padding: var(--spaceM) var(--spaceS); letter-spacing: 0.05em;
}
.side-nav-link.active { color: rgb(var(--rgbPrimary)); font-weight: var(--fontWeightBold); }
/* Colour only. The 2px nudge made the nav shift under the pointer,
   which reads as the page being unstable rather than as feedback. */
.side-nav-link:hover { color: rgb(var(--rgbPrimary)); }

/* The wordmark and the search icon take the same backing plate the
   hamburger has, so the three controls over a transparent bar read as one
   set rather than one solid and two floating. Only at the widths the
   hamburger exists; the desktop mark keeps its glyph-shaped shadow. */
@media (max-width: 1024px) {
  /* Opaque, not translucent. At 0.92 the page text underneath still read
     through the plates and through the search field's own box - a scrim
     over body copy has to actually cover it. */
  .logo { background: rgb(var(--rgbBackground)); filter: none; }
  .nav-search-toggle { background: rgb(var(--rgbBackground)); padding: var(--spaceS); }
}

@media (max-width: 1024px) {
  .side-nav { display: none; }
  .navbar-desktop-theme { display: none; }
}

/* Context widget (search/filter bar embedded in navbar) */
/* A thin drawn border rather than a soft blob. The row is the one piece
   of navbar chrome with an edge, so the edge should be a line: 1px at low
   contrast, full pill radius, and the fill just opaque enough to hold the
   controls off the page behind it. */
.widget-row {
  display: flex; align-items: center; gap: var(--spaceS);
  padding: 3px 6px;
  background: rgb(var(--rgbBackground) / 0.92);
  border: 1px solid rgb(var(--rgbText) / 0.12);
  border-radius: 999px;
}
/* The input inside the row has no fill and no radius of its own: two
   nested pills made the outer one read as a thick border rather than as
   the edge of one control. */
.widget-row .global-search .widget-input { background: none; border-radius: 999px; }

.global-search { position: relative; }
/* Soft, blended backdrop instead of the flat opaque box .widget-input
   uses elsewhere - navbar has no background of its own (see .navbar
   above), so this specific input needs to stay legible over whatever the
   wave canvas is doing without reading as a hard-edged box sitting on
   top of it. A diffused box-shadow halo blends its edges into the page
   rather than cutting a sharp rectangle. */
.global-search .widget-input {
  min-width: 220px; padding-right: 52px;
  background: rgb(var(--rgbBackgroundLight) / 0.8);
  /* Rounded, not the sitewide border-radius:0 - a soft box-shadow halo
     around a hard right-angled corner still reads as an edge right at
     that corner (the blur has nowhere to go at a 90 degree turn); a
     softly-rounded corner blends into its own halo instead of fighting
     it. */
  border-radius: 999px;
}
/* Plain terminal-style bracketed hint, not a bordered/filled pill - a
   little kbd chip with a border+fill is one of the most common generic
   "keyboard shortcut" UI patterns and read as exactly that ("very AI
   like"). Brackets via ::before/::after keep the "Ctrl K" text itself
   unchanged (still what initSearch's focus/blur toggling targets) while
   giving it the same quiet, bracket-notation voice as the rest of the
   site's terminal language ($ prompts, # comments) instead of a boxed
   badge. */
.global-search-hotkey {
  position: absolute; top: 50%; right: var(--spaceS); transform: translateY(-50%);
  display: flex; align-items: center; pointer-events: none;
  font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.68rem; letter-spacing: 0.02em;
  color: rgb(var(--rgbText) / 0.4);
}
.global-search-hotkey::before { content: '['; }
.global-search-hotkey::after { content: ']'; }
.global-search .widget-input:focus ~ .global-search-hotkey,
.global-search .widget-input:not(:placeholder-shown) ~ .global-search-hotkey { display: none; }
.global-search-results { position: absolute; top: calc(100% + 6px); left: 0; width: 320px; max-height: 360px; overflow-y: auto; background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbPrimary) / 0.25); box-shadow: 0 12px 32px rgb(0 0 0 / 0.4); z-index: 20; }
.global-search-result { display: flex; align-items: center; gap: var(--spaceS); padding: var(--spaceS) var(--spaceM); text-decoration: none; border-bottom: 1px solid rgb(var(--rgbText) / 0.08); transition: background var(--durationS) ease; }
.global-search-result:last-child { border-bottom: none; }
.global-search-result:hover { background: rgb(var(--rgbPrimary) / 0.1); }
.global-search-type { flex-shrink: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.62rem; letter-spacing: 0.05em; text-transform: uppercase; color: rgb(var(--rgbText) / 0.6); background: rgb(var(--rgbText) / 0.08); padding: 2px 6px; }
.global-search-title { color: rgb(var(--rgbText) / 0.9); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.global-search-empty { padding: var(--spaceM); color: rgb(var(--rgbText) / 0.5); font-size: 0.8rem; }
.widget-input { flex: 1; min-width: 160px; padding: 8px 12px; background: rgb(var(--rgbBackgroundLight)); border: none; color: rgb(var(--rgbText)); border-radius: 0; font-size: 0.875rem; transition: all 0.2s ease; outline: none; }
.widget-input:focus { box-shadow: 0 0 0 2px rgb(var(--rgbPrimary) / 0.35); }
.widget-input::placeholder { color: rgb(var(--rgbText) / 0.4); }
.widget-select { padding: 8px 12px; background: rgb(var(--rgbBackgroundLight)); border: none; color: rgb(var(--rgbText)); border-radius: 0; font-size: 0.875rem; cursor: pointer; transition: all 0.2s ease; outline: none; }
.widget-select:focus { box-shadow: 0 0 0 2px rgb(var(--rgbPrimary) / 0.35); }
.widget-button { padding: 8px 16px; background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbPrimary) / 0.15); color: rgb(var(--rgbText)); border-radius: 0; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; white-space: nowrap; }
.widget-button:hover { background: rgb(var(--rgbPrimary) / 0.15); border-color: rgb(var(--rgbPrimary) / 0.4); transform: translateY(-1px); }
.widget-button.active { background: rgb(var(--rgbPrimary) / 0.25); border-color: rgb(var(--rgbPrimary) / 0.5); }

/* ==========================================================================
   Logo
   ========================================================================== */
/* Navbar itself stays fully transparent (page content bleeds through),
   but the logo text sitting directly over that bleeding content needs
   its own small blurred backing to stay legible - scoped tight to the
   logo, not a bar-wide blur (that was tried and explicitly undone). */
/* padding matters here as much as the background color - without it the
   backing box exactly hugs the icon/text glyphs (a thin sliver, not a
   legibility block), unlike .nav-icons which has always had breathing
   room around its own icons. */
/* No background/box-shadow at all - even a heavily-blended rectangle is
   still a rectangle (wide, since "srdusr" is several characters), and a
   soft-edged wide rectangle still reads as "a bar" often enough that it
   kept coming back as a complaint. drop-shadow, unlike box-shadow, hugs
   the actual rendered alpha shape of the text/icon glyphs themselves
   (not their bounding box) - stacking a tight one with a wider one gives
   a soft halo shaped like "srdusr" and the sword, never a box, so there
   is no rectangle left to read as a bar in the first place. */
/* No box behind the wordmark, of any kind. Both a flat rectangle and a
   soft radial fade of the page background paint over the hero water,
   which sits behind everything, so the plate reads as a dark bar around
   the mark rather than as its ground.

   drop-shadow, unlike box-shadow or a pseudo-element, hugs the rendered
   alpha of the glyphs themselves - the sword included - so stacking a
   tight one with two wider ones gives legibility over scrolling content
   in the shape of the mark, and there is no rectangle to read as a bar.
   Uses --rgbBackground directly. It went through an alias declared on
   :root, and a custom property's var() is resolved where it is DECLARED -
   so the alias resolved against :root's own dark default on every theme
   and painted a dark glow around the mark on all four light ones. */
.logo {
  position: relative; display: flex; align-items: center; margin-left: -8px; padding: 4px 10px; color: rgb(var(--rgbText)); transition: color var(--durationM) ease;
  /* Two tight passes, not three widening ones. The 14px pass was
     invisible on a dark theme and a visible bright cloud on a light one,
     where a light halo around dark glyphs punches a hole through the
     hero water's tint. Two short passes still lift the mark off whatever
     scrolls under the transparent navbar without spreading far enough to
     read as a smudge. */
  filter:
    drop-shadow(0 0 1px rgb(var(--rgbBackground)))
    drop-shadow(0 0 4px rgb(var(--rgbBackground)));
}
/* The mark replaces the "d" in "srdusr" inline, sitting on the "u" the
   same way it sat on the guard's stub in the traced source. Text paints
   instantly but stays invisible until initLogoSync() (app.js) confirms
   the sword image is ready, so it doesn't pop in after the rest of the
   wordmark already has. .visually-hidden carries the real "srdusr" text
   for assistive tech; the sword + "d" fallback are decorative siblings.

   Reveal is a single top-to-bottom mask wipe on .logo-text (icon + text
   together, since .logo-sword-icon lives inside it), not a per-element
   opacity fade - the icon and wordmark are meant to read as one mark, so
   they need to appear as one sweep rather than two things fading in
   independently. It's on .logo-text specifically, not the outer .logo -
   em units always resolve against the element's *own* font-size, and
   .logo never sets one of its own (so it inherits a much smaller
   ambient size than the H4-scale .logo-text uses); .logo-text's
   font-size is also what the icon's own em-based dimensions
   (.logo-sword-icon) are relative to, so bounds picked in that em space
   line up with it.

   Two earlier attempts here both failed for different reasons, worth
   recording: a mask-image on .logo (not .logo-text) resolved its em
   units against the wrong, smaller font-size. Fixing that still didn't
   work, because (a) mask-clip defaults to border-box, silently clipping
   the masked result back down to .logo-text's own ~1em-tall box no
   matter what the mask-image said, and (b) browsers do not smoothly
   interpolate between two linear-gradient() values with different stop
   positions - measured directly, the computed mask-image jumped in one
   step partway through the animation instead of tweening.

   clip-path: inset() sidesteps both problems: its numeric edge
   arguments are reliably interpolated by every engine, and there's no
   separate clip-only-the-box-not-the-mask step to fight. top/left stay
   fixed; only the bottom inset animates, sweeping down from a value
   large enough to clip everything away to one small enough (even
   negative - inset() permits it) to reveal all of it with margin. */
@keyframes logoReveal {
  from { clip-path: inset(-3em 0 5em 0); }
  to { clip-path: inset(-3em 0 -3em 0); }
}
.logo-text { clip-path: inset(-3em 0 5em 0); }
.logo.logo-ready .logo-text { animation: logoReveal 1.1s cubic-bezier(.4, 0, .2, 1) forwards; }
@media (prefers-reduced-motion: reduce) {
  .logo.logo-ready .logo-text { animation: none; clip-path: none; }
}
.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.logo-text {
  font-family: 'Victor Mono', 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
  font-size: calc(var(--fontSizeH4) * 0.85); font-weight: 700; color: inherit;
  text-decoration: none; letter-spacing: 0.1em; text-transform: lowercase; line-height: 1;
  position: relative; top: 46px; user-select: none;
}
.logo-sr { position: relative; left: 1px; }
.logo-icon-slot { width: 1.3ch; height: 1em; display: inline-block; position: relative; overflow: visible; margin-left: -0.1em; }
.logo-sword-icon {
  display: block; position: absolute; left: -0.52em; bottom: -0.534em; height: 3.85em; width: 1.72em;
  /* The signature colour, not currentColor. The blade is the one part of
     the wordmark that carries the accent - which is how the colour was
     chosen, every candidate having been judged with it. */
  background-color: rgb(var(--rgbPrimary));
  -webkit-mask-image: url('/images/sabre-mark-inline.svg');
  mask-image: url('/images/sabre-mark-inline.svg');
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-position: top left; mask-position: top left;
}
.logo-d-fallback { display: none; }
@media (max-width: 768px) {
  .logo { margin-left: 0; }
  .logo-icon-slot { display: none !important; }
  .logo-d-fallback { display: inline; }
  .logo-text { font-size: var(--fontSizeH4); top: 2px; }
}

/* ==========================================================================
   Hamburger menu
   ========================================================================== */
/* Same legibility backing as .nav-icons/.logo (the navbar bar itself is
   fully transparent by design, page content bleeds through) - this was
   the one icon in the bar without it, invisible against busy scrolled
   content behind it. */
.hamburger { display: none; background: rgb(var(--rgbBackground)); border: none; cursor: pointer; padding: var(--spaceS); z-index: 5001; position: fixed; top: var(--spaceM); right: var(--spaceM); }
.hamburger-sword { width: 24px; height: 18px; position: relative; }
/* All three blades share transform-origin:center and converge on the
   same top offset (8px) before rotating, rather than the old approach
   of rotating around the left edge from three different top/left
   offsets - that required the numbers to line up exactly to form a
   clean X, and didn't. Centered rotation can't produce a lopsided X
   regardless of the exact pixel values. */
.hamburger-blade { display: block; position: absolute; height: 2px; width: 100%; background: rgb(var(--rgbText)); left: 0; transform-origin: center; transition: top 0.3s cubic-bezier(.66,-0.4,.38,1.4), transform 0.35s cubic-bezier(.66,-0.4,.38,1.4), opacity 0.2s ease; }
.hamburger-blade:nth-child(1) { top: 0px; }
.hamburger-blade:nth-child(2) { top: 8px; }
.hamburger-blade:nth-child(3) { top: 16px; }
.hamburger.open .hamburger-blade:nth-child(1) { top: 8px; transform: rotate(45deg); }
.hamburger.open .hamburger-blade:nth-child(2) { opacity: 0; transform: scaleX(0); }
.hamburger.open .hamburger-blade:nth-child(3) { top: 8px; transform: rotate(-45deg); }

.menu-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100dvh; background: rgb(var(--rgbBackground) / 0.94); z-index: 4999; opacity: 0; visibility: hidden; transition: all var(--durationM) ease; }
.menu-overlay.open { opacity: 1; visibility: visible; }

.mobile-menu { position: fixed; left: 0; top: -100dvh; width: 100vw; height: 100dvh; background: rgb(var(--rgbBackground)); z-index: 5000; transition: top var(--durationM) ease; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: var(--space2XL) var(--spaceL); padding-bottom: calc(var(--space5XL) + env(safe-area-inset-bottom)); overflow-x: hidden; overflow-y: auto; }
.mobile-menu.open { top: 0; }
.mobile-menu-header { position: absolute; top: var(--spaceL); left: var(--spaceL); }
.mobile-menu-logo { font-family: 'JetBrains Mono', monospace; font-size: var(--fontSizeH4); font-weight: var(--fontWeightBold); color: rgb(var(--rgbText)); text-decoration: none; letter-spacing: 0.1em; }
.mobile-menu-logo:hover { color: rgb(var(--rgbPrimary)); }
.mobile-menu-content { display: flex; flex-direction: column; gap: var(--space2XL); align-items: center; justify-content: center; flex: 1; }
.mobile-menu-item { color: rgb(var(--rgbText)); text-decoration: none; font-size: var(--fontSizeH2); font-weight: var(--fontWeightMedium); padding: var(--spaceM) 0; transition: color var(--durationM) ease; }
.mobile-menu-item:hover { color: rgb(var(--rgbPrimary)); }
.mobile-menu-footer { position: fixed; bottom: 0; left: 0; right: 0; padding: var(--spaceL); padding-bottom: calc(var(--spaceL) + env(safe-area-inset-bottom)); display: flex; justify-content: space-between; align-items: flex-end; pointer-events: none; z-index: 5001; opacity: 0; visibility: hidden; transition: all var(--durationM) ease; }
.mobile-menu-footer.open { opacity: 1 !important; visibility: visible !important; pointer-events: auto; }
/* A vertically centred column on the right edge - the same shape these
   icons take when they float over a scrolling page. It used to grow
   upward out of the footer, which ran it into the menu items. */
.mobile-menu-social {
  position: fixed; right: var(--spaceS); top: 50%; transform: translateY(-50%);
  display: flex; flex-direction: column; gap: var(--spaceXS); align-items: center;
  z-index: 5002; opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity var(--durationM) ease, visibility var(--durationM) ease;
}
.mobile-menu-social.open { opacity: 1; visibility: visible; pointer-events: auto; }
/* Theme and sound sit together bottom left, opposite the icons. */
/* Source order is sound then theme, so a plain column puts sound on top.
   column-reverse put it underneath. */
.mobile-menu-controls { display: flex; flex-direction: column; align-items: center; gap: var(--spaceM); pointer-events: auto; }
.mobile-menu-social a { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; color: rgb(var(--rgbText) / 0.7); transition: all var(--durationS) ease; border-radius: 0; }
.mobile-menu-social a:hover { color: rgb(var(--rgbPrimary)); background: rgb(var(--rgbPrimary) / 0.15); transform: scale(1.1); }

@media (max-width: 1024px) {
  .hamburger { display: block; }
  .hamburger.open { z-index: 5002; }
  .mobile-menu-footer { flex-direction: row; }
}

/* ==========================================================================
   Theme toggle
   ========================================================================== */
/* Round + a soft diffused halo (not a flat square patch) so it reads as
   blended into the page rather than a box sitting on top of the wave -
   .navbar itself carries no background (see above), so each icon needs
   its own quiet backdrop. */
.media-toggle { position: relative; display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: rgb(var(--rgbBackground) / 0.4); border-radius: 50%; box-shadow: 0 0 20px 12px rgb(var(--rgbBackground) / 0.4); border: none; cursor: pointer; color: rgb(var(--rgbText) / 0.8); transition: all var(--durationM) ease; }
.media-toggle:hover { color: rgb(var(--rgbPrimary)); transform: scale(1.1); }
.media-icon-off { display: none; }
.media-toggle[aria-pressed="true"] .media-icon-on { display: block; color: rgb(var(--rgbPrimary)); }
.media-toggle[aria-pressed="true"] .media-icon-off { display: none; }
.media-toggle[aria-pressed="false"] .media-icon-on { display: none; }
.media-toggle[aria-pressed="false"] .media-icon-off { display: block; }

.theme-toggle { position: relative; }
.theme-toggle-btn { position: relative; display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; background: rgb(var(--rgbBackground) / 0.4); border-radius: 50%; box-shadow: 0 0 20px 12px rgb(var(--rgbBackground) / 0.4); border: none; cursor: pointer; transition: all var(--durationM) ease; user-select: none; -webkit-tap-highlight-color: transparent; }
/* .media-toggle highlights to --rgbPrimary on hover (see above) - this
   didn't, always staying plain rgbText regardless of hover state, which
   is the "media flashes color but theme doesn't" inconsistency. */
.theme-toggle-btn:hover { transform: scale(1.1); }
.theme-toggle-btn:hover .theme-toggle-icon { stroke: rgb(var(--rgbPrimary)); }
.theme-toggle-icon { stroke: rgb(var(--rgbText)); transition: all var(--durationM) ease; }
.theme-picker { position: absolute; top: calc(100% + var(--spaceS)); right: 0; min-width: 240px; max-height: 450px; overflow-y: auto; background: rgb(var(--rgbBackgroundLight)); border: 2px solid rgb(var(--rgbPrimary) / 0.3); z-index: 6000; border-radius: 0; animation: fadeIn var(--durationS) ease; box-shadow: 0 8px 32px rgba(0,0,0,0.4); display: none; }
.theme-picker.open { display: block; }
@media (max-width: 768px) { .theme-picker { position: fixed; top: auto; bottom: calc(var(--spaceL) + 60px); left: var(--spaceL); right: auto; max-width: calc(100vw - var(--spaceL) * 2); } }
.theme-tooltip { position: absolute; top: 100%; right: 0; left: auto; margin-top: 8px; padding: 8px 12px; font-size: 13px; color: rgb(var(--rgbOnPrimary)); background: rgb(var(--rgbPrimary)); border-radius: 0; pointer-events: none; white-space: nowrap; z-index: 6001; display: none; }
.theme-tooltip.show { display: block; animation: fadeIn var(--durationS) ease; }
/* In the hamburger menu the picker sits in the bottom-left corner, so a
   tooltip hanging below it is off the bottom of the screen and one
   anchored right runs off the left. Flip it above and to the left edge. */
.mobile-menu-theme .theme-tooltip { top: auto; bottom: 100%; right: auto; left: 0; margin: 0 0 8px; }
.theme-scheme-list { padding: var(--spaceM); display: flex; flex-direction: column; gap: var(--spaceXS); }
.theme-scheme-option { display: flex; align-items: center; gap: var(--spaceM); width: 100%; padding: var(--spaceM); background: rgb(var(--rgbBackground) / 0.5); border: 1px solid transparent; border-radius: 0; cursor: pointer; transition: all var(--durationS) ease; color: rgb(var(--rgbText)); font-size: var(--fontSizeBodyS); font-weight: var(--fontWeightMedium); text-align: left; }
.theme-scheme-option:hover { background: rgb(var(--rgbPrimary) / 0.15); border-color: rgb(var(--rgbPrimary) / 0.3); transform: translateX(4px); }
.theme-scheme-option.active { background: rgb(var(--rgbPrimary) / 0.25); border-color: rgb(var(--rgbPrimary) / 0.5); color: rgb(var(--rgbPrimary)); font-weight: var(--fontWeightBold); }
/* A miniature of the theme, not a dot. The dot told you the accent and
   nothing about the ground, which is the larger half of the choice - the
   set now runs from pure black to cream and a colour chip cannot show
   that. Page background, a bar of the theme's own hue, and the violet
   statusline every theme shares. */
.theme-swatch {
  position: relative; width: 46px; height: 30px; flex-shrink: 0;
  border: 1px solid rgb(var(--rgbText) / 0.22); overflow: hidden;
  display: block;
}
.theme-swatch-bar { position: absolute; left: 5px; top: 7px; width: 26px; height: 4px; display: block; }
.theme-swatch-status { position: absolute; left: 0; right: 0; bottom: 0; height: 7px; background: rgb(var(--rgbStatus)); display: block; }
.theme-scheme-name { font-size: var(--fontSizeBodyS); }
.theme-scheme-option[aria-pressed="true"] { border-color: rgb(var(--rgbPrimary)); }
.theme-scheme-option[aria-pressed="true"] .theme-scheme-name { color: rgb(var(--rgbPrimary)); font-weight: 600; }

/* ==========================================================================
   Footer
   ========================================================================== */
/* One footer, one line - the status line is the footer. */
.footer { position: fixed; bottom: 0; left: 0; right: 0; background: transparent; padding: 0; z-index: 100; height: 36px; width: 100vw; overflow: hidden; }

.footer-statusline { display: flex; align-items: stretch; height: 36px; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.85rem; letter-spacing: 0.01em; white-space: nowrap; overflow: hidden; }
/* width, not min-width: NORMAL/INSERT's own content+padding (~112px) is
   already wider than a 108px floor, so min-width was a no-op here and
   the box was sizing to content while .footer-ruler (shorter content:
   Top/Bot/All/a percentage) actually clamped to its floor - matching
   numbers, mismatched rendered widths. An explicit width forces both to
   the same box regardless of which one's text is longer. */
/* Normal-mode color is --rgbPrimary, insert-mode is --rgbInsert (below) -
   the same pair every mode-dependent color on the site draws from, not a
   hardcoded hex sitting next to a variable it's supposed to match. */
.footer-mode { background: rgb(var(--rgbStatus)); color: rgb(var(--rgbOnStatus)); font-weight: 700; font-size: 1.05rem; padding: 0 var(--spaceL); line-height: 36px; letter-spacing: 0.04em; flex-shrink: 0; width: 116px; text-align: center; }
.footer-mode.insert { background: rgb(var(--rgbStatusInsert)); }
/* flex:1 + min-width:0 is what makes this actually fill the bar and
   ellipsis-truncate instead of leaving dead space on the right - without
   min-width:0 a flex item won't shrink past its content size. */
.footer-identity { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; color: rgb(var(--rgbText) / 0.9); overflow: hidden; text-overflow: ellipsis; padding: 0 var(--spaceL); border-left: 1px solid rgb(var(--rgbText) / 0.15); }
/* Just the text itself gets a tight blurred backing for legibility over
   bleeding page content - not the whole flex:1 identity strip around it.
   Same visibility gate as .footer-links-inline below: only shown once
   there's nothing left to scroll (page fits in one view, or you've hit
   the bottom) - otherwise it'd sit blurred over page content scrolling
   past underneath it. */
/* Soft blended halo (same technique as the navbar's search/media/theme
   backing), not a flat 0.92-opacity box - a hard-edged rectangle on top
   of scrolled page content read as an obvious "legibility box" instead
   of just quietly staying readable. */
.footer-copyright { display: none; height: 36px; line-height: 36px; color: rgb(var(--rgbText) / 0.6); background: rgb(var(--rgbBackground) / 0.35); box-shadow: 0 0 20px 12px rgb(var(--rgbBackground) / 0.28); padding: 0 var(--spaceS); margin-left: calc(var(--spaceS) * -1); }
body[data-footer-icons="inline"] .footer-copyright { display: inline-block; }

/* Contact info: exactly one of these two is visible at a time, switched
   by body[data-footer-icons] in initFooterScroll (app.js).
   - floating column (icons, unchanged long-standing behavior)
   - inline in the bar (text links, not icons - matches how this read
     before the icon treatment was added) */
.footer-links-inline { display: none; align-items: stretch; flex-shrink: 0; background: rgb(var(--rgbBackground) / 0.35); box-shadow: 0 0 20px 12px rgb(var(--rgbBackground) / 0.28); }
body[data-footer-icons="inline"] .footer-links-inline { display: flex; }
body[data-footer-icons="inline"] .footer-fixed-icons { display: none !important; }
.footer-links-inline .footer-link { display: flex; align-items: center; color: rgb(var(--rgbText) / 0.7); text-decoration: none; font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; transition: color var(--durationS) ease; padding: 0 var(--spaceS); border-left: 1px solid rgb(var(--rgbText) / 0.15); }
.footer-links-inline .footer-link:hover { color: rgb(var(--rgbPrimary)); }


.footer-ruler { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 116px; padding: 0 var(--spaceL); background: rgb(var(--rgbStatus)); color: rgb(var(--rgbOnStatus)); font-weight: 700; font-variant-numeric: tabular-nums; border: none; border-left: 1px solid rgb(var(--rgbBackground) / 0.3); font-family: inherit; font-size: inherit; letter-spacing: inherit; cursor: pointer; transition: filter var(--durationS) ease; }
.footer-ruler:hover { filter: brightness(1.12); }
.footer-ruler.insert { background: rgb(var(--rgbStatusInsert)); }

/* bottom offset clears the 36px footer bar plus a gap - was tuned for
   the old 20px-tall footer and started overlapping it once that grew. */
/* right offset meets the navbar theme/media buttons halfway (32px vs
   6px -> 19px each) rather than the two columns sitting at different
   distances from the edge. */
.footer-fixed-icons { position: fixed; bottom: 48px; right: 19px; display: flex; flex-direction: column; gap: 12px; z-index: 100; transition: transform var(--durationM) ease, opacity var(--durationM) ease; }
.footer-fixed-icons a { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; color: rgb(var(--rgbText) / 0.7); transition: all var(--durationS) ease; background: rgb(var(--rgbBackground) / 0.92); }
.footer-fixed-icons a:hover { color: rgb(var(--rgbPrimary)); background: rgb(var(--rgbPrimary) / 0.15); }

body.menu-open .footer { transform: translateY(100%) !important; opacity: 0 !important; pointer-events: none !important; visibility: hidden !important; }
body.menu-open .footer-fixed-icons { transform: translateX(100px) !important; opacity: 0 !important; pointer-events: none !important; visibility: hidden !important; }
/* The hamburger's z-index:5002 (see .hamburger.open above) only wins
   comparisons *inside* .navbar's own stacking context - .navbar itself
   (position:fixed + z-index:32) is what actually gets compared against
   .mobile-menu (z-index:5000) when they're stacked, and 32 loses
   regardless of what z-index the button carries internally. That's why
   the close (X) state was invisible: the menu was painting over the
   whole navbar, hamburger included, every time. Raising .navbar itself
   above the menu while it's open is what actually fixes it. */
body.menu-open .navbar { z-index: 5003; }

@media (max-width: 768px) {
  .footer-links-inline { display: none !important; }
  .footer-fixed-icons { display: none !important; }
  /* Keep the mode badge and ruler visible on mobile - only the
     copyright text shrinks/truncates to make room, rather than the
     right-side status disappearing entirely. Both badges shrink together
     to the same explicit width (see .footer-mode/.footer-ruler above for
     why it has to be width, not min-width, to actually match). */
  .footer-identity { font-size: 0.7rem; padding: 0 var(--spaceS); }
  .footer-copyright { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
  .footer-mode { width: 100px; padding: 0 var(--spaceM); }
  .footer-ruler { width: 100px; padding: 0 var(--spaceM); }
}

/* ==========================================================================
   Home / Intro
   ========================================================================== */
/* overflow-x: CLIP, not hidden, and no overflow-y at all. `hidden` on one
   axis computes the other to `auto`, which makes this element a scroll
   container - and a sticky descendant then resolves against THIS box
   instead of the viewport, so the article preview simply never stuck in
   one-page mode while working fine on /blog/. `clip` clips without
   creating a scroll container. */
.main-home { min-height: 100dvh; overflow-x: clip; position: relative; }
.home-container { min-height: 100dvh; overflow: visible; position: relative; z-index: 1; display: flex; flex-direction: column; }

.intro { --sectionPadding: var(--spaceM); min-height: calc(100dvh - var(--navbarHeight)); max-height: calc(100dvh - var(--navbarHeight)); display: flex; align-items: center; justify-content: center; flex-direction: column; position: relative; width: 100%; padding: var(--sectionPadding) var(--spaceOuter); padding-top: var(--navbarHeight); isolation: isolate; }
/* .intro-text is simply centered in whatever width .intro has, with no
   awareness of .side-nav (position: fixed, so it takes no space in
   normal flow) - at some viewport widths the centered block's left edge
   ends up landing right next to the rail instead of clearing it. This
   guarantees a floor: never closer than the rail's own (JS-computed,
   already resize/zoom-aware - see initSideNavPosition in app.js)
   position plus a fixed gap, whatever --spaceOuter alone would have
   given at that width. Side-nav only exists above 1024px, so below that
   the normal responsive padding (next media query down) is untouched. */
@media (min-width: 1025px) {
  .intro { padding-left: max(var(--spaceOuter), calc(var(--sideNavLeft, 80px) + 96px)); }
}
.intro-text { display: flex; flex-direction: column; align-items: flex-start; text-align: left; max-width: 1080px; width: 100%; position: relative; z-index: 1; gap: var(--spaceXS); margin-top: 24px; }
/* The name is the kicker above the line that matters, not a peer of it.
   Three elements all at full-strength text made the hero read as one
   undifferentiated block of white. */
.intro-name { font-size: clamp(1.1rem, 2.2vw, var(--fontSizeH4)); font-weight: var(--fontWeightMedium); color: rgb(var(--rgbTextLight)); margin: 0; line-height: var(--lineHeightTitle); opacity: 0; transform: translateY(20px); transition: opacity var(--durationL) ease var(--durationS), transform var(--durationL) ease var(--durationS); letter-spacing: -0.02em; z-index: 10; position: relative; }
.intro-name.visible { opacity: 1; transform: translateY(0); }
.intro-tagline { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: clamp(1.5rem, 4.2vw, 2.75rem); font-weight: 700; margin: 0; line-height: 1.1; position: relative; z-index: 10; letter-spacing: -0.01em; color: rgb(var(--rgbText)); }
.intro-tagline .plus { color: rgb(var(--rgbPrimary)); }

@media (max-width: 1024px) { .intro { --sectionPadding: var(--space4XL); padding-left: var(--spaceL); padding-right: var(--spaceL); } }
@media (max-width: 696px) { .intro { --sectionPadding: var(--space3XL); padding-top: calc(var(--navbarHeight) + var(--space3XL)); } }

.typewriter { display: inline-block; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-weight: 600; }
.typewriter-cursor { display: inline-block; animation: blink 1s infinite; color: rgb(var(--rgbPrimary)); margin-left: 2px; }

/* filter: blur is resolution-independent smoothing on top of the coarse
   simulation's own upscale - GPU-composited, so it costs nothing in the
   JS render loop, and it hides any residual low-res cell edges no matter
   how the grid resolution/DPR math above works out on a given screen. */
.wave-canvas { position: fixed; inset: 0; width: 100vw; height: 100vh; z-index: -1; opacity: 0.6; pointer-events: none; background: transparent; }

@media (max-width: 768px) { .wave-canvas { opacity: 0.4; } }

.hero-sections { margin-top: var(--spaceXS); width: 100%; position: relative; z-index: 10; }
.hero-section-nav { display: flex; gap: var(--spaceS); margin-bottom: var(--spaceM); justify-content: flex-start; flex-wrap: nowrap; overflow-x: auto; }
.hero-section-btn { background: transparent; border: none; color: rgb(var(--rgbText) / 0.6); padding: var(--spaceS) var(--spaceM); font-size: var(--fontSizeBodyS); font-weight: var(--fontWeightMedium); cursor: pointer; transition: all var(--durationM) ease; white-space: nowrap; }
.hero-section-btn:hover { color: rgb(var(--rgbText) / 0.8); }
.hero-section-btn.active { color: rgb(var(--rgbPrimary)); }
/* min-height is set live by initHeroSections() in app.js, sized to the
   tallest of the four outputs at the current viewport - a fixed pixel
   guess clipped text at some widths and under-reserved at others. */
.hero-section-content { text-align: left; max-width: 840px; width: 100%; margin: 0; display: flex; align-items: flex-start; overflow: visible; }

.home-cta { display: flex; gap: var(--spaceM); margin-top: var(--spaceS); width: 100%; max-width: var(--homeCtaWidth, 840px); }
/* .home-cta-btn/-primary: only the <noscript> fallback uses these now
   (plain link to /contact/) - the JS-enabled path is .terminal-cta below.
   Same corner-cut clip-path already used for the article reader's controls
   (.article-reader-control-button/-select) - reusing the site's own
   existing convention instead of inventing a new one. */
.home-cta-btn { position: relative; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyS); font-weight: 700; letter-spacing: 0.02em; text-decoration: none; padding: var(--spaceM) var(--spaceXL); clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); transition: all var(--durationS) ease; }
.home-cta-primary { background: rgb(var(--rgbPrimary)); color: rgb(var(--rgbOnPrimary)); border: 1px solid rgb(var(--rgbPrimary)); }
.home-cta-primary:hover { background: transparent; color: rgb(var(--rgbPrimary)); }

/* Terminal-style contact prompt: type a message, hit enter, get asked
   "whoami" for a reply address, hit enter again to hand off to mailto
   (see the HTML's own comment - no backend exists yet). No box/border/
   background/clip-path - that read as a button, not a terminal. Sits
   directly on the page like a real prompt line. Three things make this
   actually behave like a terminal instead of just look like one:
   (1) the input's width is set from JS to hug its own content (ch units,
   monospace so this is exact), not flex:1 - a stretched input pushed the
   cursor far from the text, nothing like a real prompt;
   (2) the "instructions" are separate comment lines (# ...), swapped by
   data-step, not placeholder text sitting inside the input - a real shell
   doesn't put hints inside the thing you're typing;
   (3) the fake blinking cursor only shows before the input is focused;
   once you're actually typing, the input's own native caret (colored to
   match) takes over immediately after the last character, same as it
   would in a terminal. */
.terminal-cta {
  display: flex; flex-direction: column; gap: 0.35em; flex: 1;
  font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyS);
  width: 100%; max-width: var(--homeCtaWidth, 840px);
}
/* Indented to the same column as .terminal-cta-status/-output below -
   comments and real output are both "not a command you typed", so they
   share the offset that sets them apart from the flush-left $ lines. */
/* Flush with the prompt column, not indented past it. The 1.2em was
   meant to line the comment up with the typed text, but a shell comment
   sits where the prompt sits, and on a phone the offset just read as a
   stray indent. */
.terminal-cta-comment { margin: 0; color: rgb(var(--rgbTextLight)); }
/* The message-step comment IS the call to action - it needs to read at a
   glance, not blend in as a muted aside the way the later whoami comment
   correctly does. Bumped size/weight only, not color: comments stay grey
   like every other comment (that's the terminal convention - a comment is
   never the same color as real output), the size/weight carries the
   emphasis instead. */
[data-comment-for="message"] { font-size: var(--fontSizeBodyM); font-weight: 700; }
/* align-items: center, not baseline - a text <input>'s baseline is
   computed inconsistently across browsers/platforms (font stack, form
   control UA styles), so two people can see the same page with the caret
   sitting at a different height next to $. Centering doesn't depend on
   any of that: every child's actual box height decides its position. */
.terminal-cta-line { display: flex; align-items: center; gap: 1ch; cursor: text; }
.terminal-cta-line-email, [data-comment-for="email"] { display: none; }
.terminal-cta[data-step="email"] .terminal-cta-line-email { display: flex; }
.terminal-cta[data-step="email"] [data-comment-for="email"] { display: block; }
.terminal-cta[data-step="email"] [data-comment-for="message"] { display: none; }
/* Reuses .terminal-cta-status's own stdout indent/color/size below, kept as
   its own element (rather than reusing #homeCtaStatus for this text) since
   the two need to sit at different points in the DOM - this one right after
   the message line, the shared status further down after the email line. */
.terminal-cta-output { display: none; margin: 0.2em 0 0 1.2em; font-size: var(--fontSizeBodyXS); color: rgb(var(--rgbText)); }
.terminal-cta[data-step="email"] .terminal-cta-output { display: block; }
.terminal-cta[data-step="email"] #homeCtaMessage { opacity: 0.5; pointer-events: none; }
.terminal-cta-prompt { color: rgb(var(--rgbPrimary)); flex-shrink: 0; }
/* While this is what's actually putting the page in INSERT (footer badge
   included), its own prompt/cursor pick up the same insert color - only
   while status is idle, so a warn/error/success result still reads as
   itself instead of being overridden by "you're typing" blue. */
.terminal-cta[data-status="idle"]:focus-within .terminal-cta-prompt { color: rgb(var(--rgbInsert)); }
.terminal-cta[data-status="idle"]:focus-within .terminal-cta-cursor { background: rgb(var(--rgbInsert)); }
.terminal-cta[data-status="idle"]:focus-within .terminal-cta-input { caret-color: rgb(var(--rgbInsert)); }
.terminal-cta-input-wrap { display: inline-flex; align-items: center; }
.terminal-cta-input {
  /* Genuinely 0 width when empty - no min-width floor, that was itself
     adding back the gap it was meant to avoid. Clickability instead comes
     from the whole .terminal-cta-line being a click target (JS), not from
     padding out this element. */
  width: 0; background: transparent; border: none; outline: none; padding: 0; margin: 0;
  /* Explicit, not the browser's own form-control default - that default
     is what varies enough between platforms/font stacks to put the caret
     visibly above the prompt on some setups. Matches .terminal-cta-cursor's
     own height below (1.3em) so the native caret you get once actually
     typing is the same height, at the same start position, as the idle
     block cursor it replaces - both are centered on the same flex row
     (.terminal-cta-line), so equal heights is what makes their tops (not
     just their centers) line up too. */
  line-height: 1.3;
  font-family: inherit; font-size: inherit; color: rgb(var(--rgbText)); caret-color: rgb(var(--rgbPrimary));
}
.terminal-cta-cursor {
  /* Narrower and taller than a monospace cell's natural 1:1-ish block -
     a slimmer, longer cursor reads more like an intentional terminal
     cursor and less like a stray highlighted character. No manual
     translateY nudge any more: that was compensating for baseline
     alignment, which .terminal-cta-line no longer uses (align-items:
     center above) - centering now does the vertical placement on its
     own, a hardcoded offset on top of it would just throw it off again. */
  display: inline-block; width: 0.35em; height: 1.3em; flex-shrink: 0; margin-left: 1px;
  background: rgb(var(--rgbPrimary)); animation: blink 1s step-end infinite;
}
.terminal-cta-input:focus ~ .terminal-cta-cursor { display: none; }
/* Once the step has moved on, the earlier line is history, not a still-
   active prompt - its cursor shouldn't come back just because focus left
   the (disabled) input, which :focus alone can't express. */
.terminal-cta[data-step="email"] .terminal-cta-line-message .terminal-cta-cursor { display: none; }
/* Output reads like real stdout: left-aligned under where the command
   text starts (prompt + gap), not under the $ itself, and no prompt glyph
   of its own since it's not a command. */
.terminal-cta-status { margin: 0.2em 0 0 1.2em; min-height: 1.2em; font-size: var(--fontSizeBodyXS); color: rgb(var(--rgbText)); }
.terminal-cta[data-status="warn"] .terminal-cta-prompt,
.terminal-cta[data-status="warn"] .terminal-cta-status { color: rgb(var(--rgbAccent)); }
.terminal-cta[data-status="warn"] .terminal-cta-cursor { background: rgb(var(--rgbAccent)); }
.terminal-cta[data-status="error"] .terminal-cta-prompt,
.terminal-cta[data-status="error"] .terminal-cta-status { color: rgb(var(--rgbAccent)); }
.terminal-cta[data-status="error"] .terminal-cta-cursor { background: rgb(var(--rgbAccent)); }
.terminal-cta[data-status="success"] .terminal-cta-status { color: rgb(var(--rgbPrimary)); }
/* Off-screen, not display:none - some bots skip fields the layout engine
   never renders, but still walk the DOM for anything with a name/value. */
.terminal-cta-honeypot { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
/* Slightly held back from the tagline above it, so the two headings do
   not compete at the same weight and colour. */
.hero-section-title { font-size: var(--fontSizeH2); font-weight: var(--fontWeightMedium); color: rgb(var(--rgbText) / 0.82); line-height: var(--lineHeightTitle); margin: 0; animation: fadeIn 0.4s ease forwards; }
/* Each hard-coded line is its own block so four spans stack as four
   lines, and nowrap so a line chosen to fit at one font-size can't
   silently wrap again into a fifth/sixth line at a larger one (see
   linesToHTML in app.js). */
.hero-line { display: block; white-space: nowrap; }
/* Last-resort fallback for copy too long to fit even at MIN_SCALE
   (see resizeToFit in app.js) - a wrapped line reads; a clipped one
   does not. */
.hero-section-title.hero-title-wrap .hero-line { white-space: normal; }

@media (max-width: 768px) {
  .hero-section-title { font-size: var(--fontSizeH3); }
  /* The four audience buttons are nowrap in a nowrap row, so at phone
     widths the last one used to be cut off mid-word ("Product M") by the
     row's own overflow. Wrapping it to a second line fixed the clipping
     but left one lonely button under three, which is worse to look at.
     They fit on one line instead: 400px of buttons measured in a 342px
     row at 390px, so the type and the padding come down by about a fifth
     and the gap goes to zero. */
  .hero-section-nav { flex-wrap: nowrap; overflow-x: visible; gap: 0; }
  .hero-section-btn { padding: var(--spaceXS) 6px; font-size: 0.75rem; letter-spacing: -0.01em; }
}
@media (max-width: 360px) {
  .hero-section-btn { padding: var(--spaceXS) 4px; font-size: 0.6875rem; }
}

/* ==========================================================================
   Generic page content shell (about / contact / projects / tools / blog / services / git)
   ========================================================================== */
/* Symmetric inline padding. It was 120px left / 40px right, which put
   80px more empty space down the left of every page than the right -
   the left value was sized to clear the fixed side-nav (which ends at
   ~77px) and the right was never brought up to match. 96px clears the
   side-nav on its own, so both sides can use it. */
/* No centred max-width cap. .page-main used to be capped at
   --maxWidthXL (1100px below a 1680px viewport) and centred, which at
   1440px inset it 170px a side BEFORE its own padding - so content
   started ~266px in while the fixed side-nav ends at ~77px, leaving a
   190px dead strip between the two on every page. It now spans the
   viewport: the left padding clears the side-nav, the right is the
   matching visual gap, and individual pages cap their own measure
   (.page-body, .article-reader-content) where a long line would hurt. */
/* The bottom padding has to clear the fixed 36px footer bar as well as
   leave a margin, or the last thing on the page sits right on top of it -
   About's "Get in Touch" prompt ended 64px above the page bottom, which
   is 28px above the bar. */
.page-main { padding-top: calc(var(--navbarHeight) + var(--spaceXL)); padding-left: 128px; padding-right: 72px; padding-bottom: calc(var(--space4XL) + 36px); min-height: 100vh; max-width: var(--maxWidthXL2, 1900px); margin: 0 auto; }
@media (max-width: 1024px) { .page-main { padding-left: var(--spaceL); padding-right: var(--spaceL); } }
@media (max-width: 768px) { .page-main { padding-left: var(--spaceM); padding-right: var(--spaceM); } }

.page-title { font-size: var(--fontSizeH2); font-weight: var(--fontWeightBold); margin: 0 0 var(--spaceL) 0; }
.page-subtitle { font-size: var(--fontSizeBodyL); color: rgb(var(--rgbText) / 0.7); margin: 0 0 var(--space2XL) 0; max-width: var(--maxWidthM); line-height: var(--lineHeightBody); }
.page-section { margin-bottom: var(--space2XL); }
.page-section h2 { font-size: var(--fontSizeH3); margin-bottom: var(--spaceM); }
.page-body { max-width: var(--maxWidthM); line-height: var(--lineHeightBody); color: rgb(var(--rgbText) / 0.85); }
.page-body a { color: rgb(var(--rgbPrimary)); }

.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--spaceL); align-items: start; }
/* auto-fill/minmax rather than a fixed column count + breakpoint: collapses
   itself down to one column on narrow viewports without needing a media
   query, and without ever overflowing the viewport (which was pushing the
   fixed-position hamburger off-screen on mobile - see app.js/CSS history). */
/* Tools cards. The dataset is 2899 third-party names and descriptions of
   wildly different lengths, so the card decides the size, not the copy:
   one 9-line description used to set the height of its whole grid row
   (350px for a row whose other three cards needed 145px), and a long
   unbroken name ran under the arrow glyph. Name clamps to two lines,
   description to three, and grid-auto-rows: 1fr makes every card on the
   page the same height.

   Every rule below is written .tools-card FIRST, because .card h3 and
   .card p are (0,1,1) and a bare .tools-card-desc would lose to them on
   specificity - the same trap that silently overrode the project card
   descriptions' line-height. */
.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--spaceL); margin-bottom: var(--spaceXL); grid-auto-rows: 1fr; }
.tools-card { display: flex; flex-direction: column; min-width: 0; overflow: hidden; text-decoration: none; color: inherit; cursor: pointer; }
.tools-card .tools-card-head { display: flex; align-items: flex-start; gap: var(--spaceS); margin: 0 0 var(--spaceS); font-size: var(--fontSizeH5); font-weight: 600; color: rgb(var(--rgbText)); }
.tools-card .tools-card-name { flex: 1; min-width: 0; overflow: hidden; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; }
.tools-card .tools-card-arrow { font-size: 14px; opacity: 0.6; flex-shrink: 0; }
.tools-card .tools-card-desc { margin: 0; font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText) / 0.7); line-height: var(--lineHeightBody); overflow: hidden; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; }
/* Below roughly 520px the grid is one column (two need 240 + 24 + 240),
   and a row of one has no neighbour to line up with - equal heights there
   only buy a screenful of empty card under every short description. */
@media (max-width: 520px) { .tools-grid { grid-auto-rows: auto; } }
/* Neutral by default - the accent is reserved for the hover moment (a
   left rule that grows in, like a diff marker or an active list
   selection) rather than tinting every card green at rest. */
.card { background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbText) / 0.1); border-left: 2px solid transparent; border-radius: 0; padding: var(--spaceL); transition: border-color var(--durationM) ease; }
.card:hover { border-color: rgb(var(--rgbText) / 0.18); border-left-color: rgb(var(--rgbPrimary)); }
.card h3 { margin: 0 0 var(--spaceS) 0; font-size: var(--fontSizeBodyL); font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.card p { margin: 0 0 var(--spaceM) 0; color: rgb(var(--rgbText) / 0.7); font-size: var(--fontSizeBodyS); line-height: var(--lineHeightBody); }
.card-tags { display: flex; flex-wrap: wrap; gap: var(--spaceXS); }
.card-tag { font-size: var(--fontSizeBodyXS); padding: 2px 8px; border-radius: 0; background: rgb(var(--rgbText) / 0.06); border: 1px solid rgb(var(--rgbText) / 0.12); color: rgb(var(--rgbText) / 0.7); }
.card[hidden] { display: none; }

/* Full-page background lit by whichever project is currently hovered
   or open in the modal - negative z-index puts it above the flat body
   color but behind every normal-flow element on the page (navbar and
   footer included, since both are transparent by design). Oversized
   (-5%/110%) so the blur doesn't leave transparent fringes at the
   viewport edges, and dimmed by the ::after scrim so it stays legible
   under foreground text. A terminal preview is mostly black canvas with
   small text, though, so blurring it alone all but disappears into the
   page - the radial glow underneath is what actually makes the effect
   read as "lit up" rather than a barely-there tint. */
.project-bg { position: fixed; inset: 0; z-index: -1; opacity: 0; pointer-events: none; overflow: hidden; transition: opacity var(--durationL) ease; background: radial-gradient(ellipse at 30% 20%, rgb(var(--rgbPrimary) / 0.35), transparent 65%); }
.project-bg.active { opacity: 1; }
.project-bg::after { content: ''; position: absolute; inset: 0; background: rgb(var(--rgbBackground) / 0.6); }
/* No blur. Every preview is now a real screenshot of the tool actually
   running, and blurring it threw away the one thing that makes it worth
   showing. The ::after scrim above already keeps foreground text
   legible, so the image can stay sharp underneath it. Still oversized
   (-5%/110%) so the cover-scaled image has no transparent fringe. */
.project-bg-canvas, .project-bg-image { position: absolute; inset: -5%; width: 110%; height: 110%; filter: saturate(1.15) brightness(1.1); }
.project-bg-canvas { display: none; }
.project-bg-image { background-size: cover; background-position: center; }

/* Project grid - fixed columns and a fixed card height (not the
   auto-fill/masonry .card-grid Tools uses) so every card is the same
   size regardless of how much description/tags a given project has.
   The preview dominates the card (xintra.org's course cards were the
   reference), with a clamped amount of room left underneath for
   title/description/tags so it never overflows the fixed height. */
.project-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spaceL); }
@media (max-width: 1200px) { .project-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 700px) { .project-grid { grid-template-columns: 1fr; } }

/* Cards stay the same size without a hardcoded total height: the preview
   is aspect-ratio driven (scales with card width, same at every column
   count) and every row below it has a deterministic height - single-line
   with ellipsis, or an explicit height on the multi-line ones (desc,
   tags) rather than just a line-clamp cap - so the body's total height
   comes out identical across cards on its own. */
.project-card { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
.project-card-body { display: flex; flex-direction: column; gap: 6px; padding: var(--spaceM) var(--spaceL); }
.project-card-title-row { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--spaceXS); }
/* --fontSizeBodyM, not -BodyL: at 1.25rem a nine-character mono title
   ("typerpunk", "packeteer") needed 128px in a 117px cell next to the
   "Early Development" badge, so both ellipsised to "typerpun…". The
   badge can't give the space back - it's flex-shrink:0 by design, since
   a truncated status reads as broken. Dropping the title one step fits
   every current name whole and keeps one size for all cards. */
.project-card-title-row h3 { margin: 0; font-size: var(--fontSizeBodyM); font-family: 'JetBrains Mono', 'Fira Code', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.project-card-title-row h3 a { color: inherit; text-decoration: none; }
.project-card-link-arrow { font-size: 14px; opacity: 0.6; }
/* Neutral by default (Planned/Private aren't accent-worthy); only a
   project that's actually in progress earns the accent color. */
.project-card-status { font-size: var(--fontSizeBodyXS); color: rgb(var(--rgbText) / 0.45); font-weight: 500; flex-shrink: 0; white-space: nowrap; }
.project-card-status[data-status="Early Development"] { color: rgb(var(--rgbPrimary)); }
.project-card-meta { font-size: var(--fontSizeBodyXS); color: rgb(var(--rgbText) / 0.5); display: flex; gap: var(--spaceS); flex-wrap: wrap; align-items: baseline; }
.project-card-meta a { color: rgb(var(--rgbPrimary)); text-decoration: none; }
/* Selector carries two classes on purpose: `.card p` (see the Cards
   section above) is more specific than a lone `.project-card-desc` and
   was overriding line-height with --lineHeightBody (1.6). The 2.6em
   height here is sized for 1.3, so every 2-line description was being
   sliced horizontally through the second row of glyphs. */
.project-card .project-card-desc { margin: 0; height: 2.6em; color: rgb(var(--rgbText) / 0.7); font-size: var(--fontSizeBodyS); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.project-card-tags { height: 1.7em; overflow: hidden; margin-top: 2px; }
.project-card .project-toggle { margin-top: var(--spaceXS); width: 100%; }

/* Preview - a canvas showing a looped, simulated terminal session (no
   real screen recordings exist for these tools, so this is a stand-in
   "video" rather than a static screenshot) or, where that doesn't fit
   (too early-stage to fake convincing output), a plain image. Dominant,
   not a small thumbnail - xintra.org's course cards were the reference -
   and taller than a plain 16:10 photo ratio so it visibly outweighs the
   text underneath rather than just edging it out. */
.project-preview { width: 100%; aspect-ratio: 16 / 9; height: auto; flex-shrink: 0; display: block; background: #0a0f0a; border: none; border-bottom: 1px solid rgb(var(--rgbPrimary) / 0.15); transition: background-color var(--durationM) ease, box-shadow var(--durationM) ease; }
.project-preview-img { background-size: cover; background-position: center; transition: filter var(--durationM) ease; }
.project-preview-planned { display: flex; align-items: center; justify-content: center; background-image: repeating-linear-gradient(135deg, rgb(var(--rgbPrimary) / 0.06) 0 10px, transparent 10px 20px); }
.project-preview-planned span { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyS); letter-spacing: 0.08em; text-transform: uppercase; color: rgb(var(--rgbText) / 0.4); }

/* Whichever card is currently hovered gets its whole preview area lit
   up - not just whatever the canvas happens to have drawn - so the
   "currently highlighted" project is unambiguous at a glance. */
.project-card:hover .project-preview { background-color: #0f1f16; box-shadow: inset 0 0 0 1px rgb(var(--rgbPrimary) / 0.3); }
.project-card:hover .project-preview-img { filter: brightness(1.1) saturate(1.1); }
.project-card:hover .project-preview-planned { background-color: rgb(var(--rgbPrimary) / 0.05); }

.project-modal-overlay { position: fixed; inset: 0; background: rgb(0 0 0 / 0.7); z-index: 5500; display: flex; align-items: center; justify-content: center; padding: var(--spaceL); }
.project-modal-overlay[hidden] { display: none; }
/* No internal scrolling - the preview below is capped to a fixed,
   modest height (rather than the card's full 3:2 aspect-ratio, which
   at 640px wide would run ~427px tall on its own) specifically so the
   whole modal fits within the overlay's padding without needing to
   scroll to read the rest. */
/* overflow-x: hidden, not visible: .project-modal-preview is deliberately
   full-bleed (100% + two paddings wide with a matching negative margin),
   and once the vertical scrollbar appears it shrinks the content box, so
   that width overshot and the modal grew a horizontal scrollbar of its
   own along the bottom. */
.project-modal { background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbPrimary) / 0.25); max-width: 640px; width: 100%; padding: var(--spaceXL); position: relative; max-height: calc(100vh - var(--space3XL)); overflow-y: auto; overflow-x: hidden; }
/* The preview bleeds to the modal's own edges (matching how it sits
   flush at the top of a card) instead of sitting inset inside the
   modal's text padding. */
/* Bleeds left and right to the modal's edges, but NOT upward: the
   preview lives inside .project-modal-body, which comes after
   .project-modal-title, so the negative top margin this used to carry
   dragged the image up over the title and the close button. Horizontal
   bleed only keeps the full-width look without the collision. */
.project-modal-preview { margin: 0 calc(var(--spaceXL) * -1) var(--spaceL); width: calc(100% + var(--spaceXL) * 2); height: auto; aspect-ratio: 16 / 9; background-size: cover; background-position: center; }
.project-modal-close { position: absolute; top: var(--spaceM); right: var(--spaceM); width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgb(var(--rgbBackground) / 0.9); border: 1px solid rgb(var(--rgbText) / 0.2); color: rgb(var(--rgbText) / 0.7); font-size: 1.25rem; line-height: 1; padding: 0; cursor: pointer; z-index: 1; transition: color var(--durationS) ease, border-color var(--durationS) ease; }
.project-modal-close:hover { color: rgb(var(--rgbPrimary)); border-color: rgb(var(--rgbPrimary) / 0.5); }
.project-modal-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH4); margin: 0 var(--space2XL) var(--spaceM) 0; }
body.project-modal-open { overflow: hidden; }

.form-group { margin-bottom: var(--spaceL); max-width: var(--maxWidthS); }
.form-group label { display: block; margin-bottom: var(--spaceXS); font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText) / 0.8); }
.form-group input, .form-group textarea { width: 100%; padding: var(--spaceM); background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbPrimary) / 0.2); color: rgb(var(--rgbText)); border-radius: 0; font-family: inherit; font-size: var(--fontSizeBodyS); outline: none; }
.form-group input:focus, .form-group textarea:focus { border-color: rgb(var(--rgbPrimary) / 0.6); box-shadow: 0 0 0 3px rgb(var(--rgbPrimary) / 0.1); }
/* Same corner-cut convention as .home-cta-btn/.about-contact-btn (see
   there for why) - was a plain rounded-corner rectangle, the one button
   on the site not using the established language. Only used on the
   Contact page, safe to change directly rather than layer a new class. */
.btn-primary { position: relative; padding: var(--spaceM) var(--spaceXL); background: rgb(var(--rgbPrimary)); color: rgb(var(--rgbOnPrimary)); border: 1px solid rgb(var(--rgbPrimary)); clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); font-family: 'JetBrains Mono', 'Fira Code', monospace; font-weight: var(--fontWeightBold); cursor: pointer; font-size: var(--fontSizeBodyS); transition: all var(--durationS) ease; }
.btn-primary:hover { background: transparent; color: rgb(var(--rgbPrimary)); }

/* ==========================================================================
   Contact page
   ========================================================================== */
/* Same heading language as .about-title/.about-subtitle (JetBrains Mono,
   same weights) - was inline-styled and literally reusing .about-title
   itself before, which is a different page's class borrowed out of
   convenience rather than Contact having its own. */
/* Contact. Two columns, left aligned, mono headings - the same language
   as About and Projects. The previous version was a centred hero over
   three stacked icons and a boxed form, which is the contact page every
   template ships with. */
/* The card takes the larger share and the whole layout is wider: the
   channel list is three short lines and does not need half the page.
   Centred in the viewport, because the page is barely one screen tall and
   top-aligning it left ~265px of void under the card and the same again
   beside the channel list. */
/* Symmetric padding, or the flex centring is centring inside a box that
   is 132px deep at the bottom and 104px at the top and the result still
   sits low. */
.contact-page { display: flex; align-items: center; padding-bottom: calc(var(--navbarHeight) + var(--spaceXL)); }
.contact-layout { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.35fr); gap: var(--space3XL); align-items: center; max-width: var(--maxWidthXL); width: 100%; }
.contact-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH2); font-weight: 700; margin: 0 0 var(--spaceL); color: rgb(var(--rgbPrimary)); }
.contact-lead { font-size: var(--fontSizeBodyL); color: rgb(var(--rgbText) / 0.8); line-height: var(--lineHeightBody); margin: 0 0 var(--space2XL); max-width: var(--maxWidthS); }

/* A definition list, not a row of glyphs: the floating icon column
   already carries the icon-only version on every page, so repeating it
   here would say the same thing twice and say it less clearly. */
.contact-channels { margin: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyS); }
.contact-channel { display: grid; grid-template-columns: 5.5rem minmax(0, 1fr); gap: var(--spaceM); padding: var(--spaceS) 0; border-top: 1px solid rgb(var(--rgbText) / 0.1); }
.contact-channel:last-child { border-bottom: 1px solid rgb(var(--rgbText) / 0.1); }
.contact-channel dt { color: rgb(var(--rgbText) / 0.5); }
.contact-channel dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.contact-channel a { color: rgb(var(--rgbText)); text-decoration: none; border-bottom: 1px solid transparent; transition: color var(--durationS) ease, border-color var(--durationS) ease; }
.contact-channel a:hover { color: rgb(var(--rgbPrimary)); border-bottom-color: rgb(var(--rgbPrimary)); }

/* The form as a shell session: a comment line, then a prompt per field.
   Inputs are bare and underlined rather than boxed, so the column reads
   as typed lines instead of a stack of widgets. */
/* A cut corner WITH a border. clip-path clips the border away along the
   diagonal, so the corner ended up open on exactly the edge the cut was
   meant to draw attention to - and the three other edges kept their line,
   which is what made it read as broken rather than as a shape. Two
   clipped layers instead: the element itself paints the border colour and
   a pseudo-element inset by 1px paints the fill, so every edge including
   the diagonal is a 1px line. --cut is the corner size. */
.contact-form {
  --cut: 14px;
  position: relative; display: flex; flex-direction: column; gap: var(--spaceS);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  border: none; padding: var(--spaceL) var(--spaceXL) var(--spaceXL);
  background: rgb(var(--rgbPrimary) / 0.28);
  clip-path: polygon(0 0, calc(100% - var(--cut)) 0, 100% var(--cut), 100% 100%, 0 100%);
}
.contact-form::before {
  content: ''; position: absolute; inset: 1px; z-index: 0;
  background: rgb(var(--rgbBackgroundLight));
  clip-path: polygon(0 0, calc(100% - var(--cut) + 1px) 0, 100% calc(var(--cut) - 1px), 100% 100%, 0 100%);
}
.contact-form > * { position: relative; z-index: 1; }
.contact-form-comment { margin: var(--spaceS) 0 0; color: rgb(var(--rgbTextLight)); font-size: var(--fontSizeBodyS); }
.contact-form-comment:first-child { margin-top: 0; }
.contact-field { display: grid; grid-template-columns: auto 1fr; align-items: baseline; column-gap: var(--spaceS); }
.contact-field-label { grid-column: 1 / -1; font-size: var(--fontSizeBodyXS); letter-spacing: 0.08em; text-transform: uppercase; color: rgb(var(--rgbText) / 0.45); margin-bottom: var(--spaceXS); }
.contact-field-prompt { color: rgb(var(--rgbPrimary)); }
.contact-field input, .contact-field textarea {
  width: 100%; min-width: 0; background: none; border: none;
  border-bottom: 1px solid rgb(var(--rgbText) / 0.18);
  color: rgb(var(--rgbText)); font-family: inherit; font-size: var(--fontSizeBodyM);
  padding: 2px 0 6px; caret-color: rgb(var(--rgbPrimary));
  transition: border-color var(--durationS) ease;
}
.contact-field textarea { resize: vertical; line-height: var(--lineHeightBody); }
.contact-field input:focus, .contact-field textarea:focus { outline: none; border-bottom-color: rgb(var(--rgbPrimary)); }
/* The prompt marks the first line of the message, so it sits at the top
   of a multi-line field rather than on its baseline. */
.contact-field-block { align-items: start; }
.contact-field-block .contact-field-prompt { padding-top: 2px; }

.contact-form-actions { display: flex; align-items: center; gap: var(--spaceM); flex-wrap: wrap; margin-top: var(--spaceS); }
.contact-submit {
  font-family: inherit; font-weight: 700; font-size: var(--fontSizeBodyM);
  background: rgb(var(--rgbPrimary)); color: rgb(var(--rgbOnPrimary));
  border: none; padding: var(--spaceS) var(--spaceXL); cursor: pointer;
  clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 0 100%);
  transition: filter var(--durationS) ease;
}
.contact-submit:hover { filter: brightness(1.1); }
/* Said out loud rather than left as a surprise: a mailto form that opens
   a mail client without warning is the usual complaint about one. */
.contact-form-note { font-size: var(--fontSizeBodyXS); color: rgb(var(--rgbText) / 0.5); }
.contact-form-status { margin: 0; font-size: var(--fontSizeBodyS); min-height: 1.4em; color: rgb(var(--rgbText) / 0.7); }
.contact-form-status[data-level="warn"], .contact-form-status[data-level="error"] { color: rgb(var(--rgbAccent)); }
.contact-form-status[data-level="success"] { color: rgb(var(--rgbPrimary)); }
.contact-submit:disabled { opacity: 0.45; cursor: not-allowed; }

@media (max-width: 900px) {
  .contact-layout { grid-template-columns: 1fr; gap: var(--space2XL); }
}
@media (max-width: 600px) {
  .contact-form { padding: var(--spaceL); }
  .contact-channel { grid-template-columns: 4.5rem minmax(0, 1fr); gap: var(--spaceS); }
}

/* ==========================================================================
   Loading animation (first visit only)
   ========================================================================== */
.loading-screen { position: fixed; inset: 0; background: rgb(var(--rgbBackground)); z-index: 99999; display: flex; align-items: center; justify-content: center; transition: opacity var(--durationL) ease; }
.loading-screen.fade-out { opacity: 0; pointer-events: none; }
/* "Drawing a blade" - the same sabre mark used for the navbar logo, not
   a generic spinning ring. Reveals bottom-to-top via
   clip-path (not transform:scaleY - squashing the actual mark vertically
   distorted its real proportions into something that just looked like a
   glitching logo, not a blade being drawn). clip-path instead uncovers
   the shape at full, correct proportions the whole time - only how much
   of it is visible changes, holds fully revealed briefly, covers back
   down, loops. */
.loading-spinner {
  width: 36px; height: 108px; position: relative;
}
.loading-spinner::before {
  content: ''; position: absolute; inset: 0;
  background-color: rgb(var(--rgbPrimary));
  -webkit-mask-image: url('/images/sabre-mark.svg');
  mask-image: url('/images/sabre-mark.svg');
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-position: center; mask-position: center;
  animation: swordDraw 1.6s cubic-bezier(.4,0,.2,1) infinite;
}
.loading-spinner.done { display: none; }
@keyframes swordDraw {
  0%, 100% { clip-path: inset(78% 0 0 0); opacity: 0.5; }
  45%, 55% { clip-path: inset(0 0 0 0); opacity: 1; }
}
/* Full-page diagonal split, not two stacked pill buttons - each half of
   the screen is one choice, click anywhere in it. The two clip-paths
   are complementary triangles sharing the same corner-to-corner seam,
   so together they tile the viewport exactly with no gap or overlap. */
.loading-choice { position: absolute; inset: 0; opacity: 0; pointer-events: none; transition: opacity var(--durationM) ease; }
.loading-choice.visible { opacity: 1; pointer-events: auto; }
.loading-btn {
  position: absolute; inset: 0; width: 100%; height: 100%;
  display: flex; padding: var(--space3XL);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: var(--fontSizeBodyL); font-weight: 700; letter-spacing: 0.02em;
  line-height: 1.3; text-align: left; cursor: pointer; border: none;
  transition: background var(--durationS) ease, color var(--durationS) ease;
}
.loading-btn-sound {
  clip-path: polygon(0 0, 100% 0, 0 100%);
  background: rgb(var(--rgbPrimary)); color: rgb(var(--rgbOnPrimary));
  align-items: flex-start; justify-content: flex-start;
}
.loading-btn-sound:hover { background: color-mix(in srgb, rgb(var(--rgbPrimary)) 78%, white); }
.loading-btn-silent {
  clip-path: polygon(100% 0, 100% 100%, 0 100%);
  background: rgb(var(--rgbBackgroundLight)); color: rgb(var(--rgbText) / 0.7);
  align-items: flex-end; justify-content: flex-end;
}
.loading-btn-silent:hover { background: rgb(var(--rgbPrimary) / 0.12); color: rgb(var(--rgbText)); }
/* A brightness filter alone read as no hover effect at all - barely
   perceptible against an already-saturated green and an already-dark
   panel. Explicit background swaps above (not just brightness) plus a
   label nudge here so hovering either half is unmistakable. */
.loading-btn-label { display: inline-block; transition: transform var(--durationS) ease; }
.loading-btn:hover .loading-btn-label { transform: translateX(6px); }
@media (max-width: 700px) {
  .loading-btn { padding: var(--spaceXL) var(--spaceL); font-size: var(--fontSizeBodyM); }
}

/* ==========================================================================
   About page
   ========================================================================== */
/* Bottom value only: the left/right 0 is load-bearing (.about-container
   supplies its own gutter) and the shorthand is what overrides
   .page-main below 1025px. Clears the fixed 36px footer bar, which the
   "Get in Touch" prompt was ending 28px above. */
.about-page { min-height: 100vh; padding: var(--space2XL) 0 calc(var(--space4XL) + 36px); }
/* Left-aligned within the page, not auto-centered - matches every other
   page's layout instead of floating as its own centered column. */
/* No horizontal padding: the page gutter is .page-main's job, and this
   was adding 24px more so About started further in than Blog. */
.about-container { max-width: 1400px; margin: 0 auto; padding: 0; }
/* Photo lives only alongside the intro/whoami text, in its own row with
   its own padding - not a sticky sidebar riding along the entire page.
   A page this long (Services, Skills, Current Focus...) made a
   page-height sticky photo drift next to whatever section happened to
   be scrolled alongside it, which read as orphaned rather than placed. */
/* align-items: stretch (the flex default - not overridden to flex-start
   any more) so the photo's height tracks whatever height the text
   column actually needs, rather than a fixed aspect-ratio box that's
   short next to a much taller bio. Width stays fixed (below), height
   comes from the row. */
/* align-items:flex-start, not stretch: stretching made the photo as
   tall as the whole bio column (478x776 measured), which cropped a
   portrait into a narrow strip. A fixed 4:5 frame keeps the subject
   intact and lets the text run past it. */
.about-hero { display: flex; align-items: flex-start; gap: var(--space3XL); margin-bottom: var(--space3XL); padding: var(--spaceXL) 0; border-bottom: 1px solid rgb(var(--rgbText) / 0.08); padding-bottom: var(--space2XL); }
.about-photo-wrap { flex-shrink: 0; width: 420px; }
.about-photo { width: 100%; aspect-ratio: 4 / 5; height: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--spaceM); background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbText) / 0.12); color: rgb(var(--rgbText) / 0.3); overflow: hidden; }
/* Ready for a real <img src="..."> to replace the placeholder mark/label
   below it - fills the same box, same aspect ratio, no template changes
   needed beyond swapping the element. */
.about-photo img { width: 100%; height: 100%; object-fit: cover; }
/* Same mark as the navbar logo, not a generic "no avatar" silhouette -
   reads as an intentional placeholder rather than a default component. */
.about-photo-mark { display: block; width: 32px; height: 96px; background-color: rgb(var(--rgbText) / 0.25); -webkit-mask-image: url('/images/sabre-mark.svg'); mask-image: url('/images/sabre-mark.svg'); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-size: contain; mask-size: contain; -webkit-mask-position: center; mask-position: center; }
.about-photo-label { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyXS); text-transform: uppercase; letter-spacing: 0.08em; color: rgb(var(--rgbText) / 0.35); }
/* Wider measure than before. The bio was capped at 860px next to a
   420px photo, leaving a large empty gutter on the right; the cap is
   lifted and the body type steps down one notch so the longer line
   length stays comfortable rather than sprawling. */
.about-hero-text { flex: 1; min-width: 0; max-width: none; }
.about-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH2); font-weight: 700; margin: 0 0 var(--spaceM); color: rgb(var(--rgbText)); }
.about-subtitle { font-size: var(--fontSizeBodyL); color: rgb(var(--rgbText) / 0.75); margin: 0 0 var(--spaceXL); font-weight: 400; }
.about-whoami-label { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyL); color: rgb(var(--rgbPrimary)); margin: 0 0 var(--spaceM); }
@media (max-width: 700px) {
  /* Stacked, not side-by-side, so height-matching the text column
     (desktop's stretch behavior) doesn't apply here - align-items:center
     instead of stretch (which would otherwise force the photo to full
     container width in a column flex), and the photo goes back to a
     fixed aspect ratio since there's no sibling height to match. */
  .about-hero { flex-direction: column; align-items: center; }
  .about-photo-wrap { width: 260px; }
  .about-photo { aspect-ratio: 3 / 4; height: auto; }
}
.about-content { display: flex; flex-direction: column; gap: var(--space3XL); min-width: 0; max-width: 100%; }
.about-text { font-size: var(--fontSizeBodyS); line-height: 1.65; }
.about-section-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH3); font-weight: 600; margin: 0 0 var(--spaceXL); color: rgb(var(--rgbText)); position: relative; display: inline-block; }
.about-section-title::after { content: ''; position: absolute; bottom: -8px; left: 0; width: 32px; height: 3px; background: rgb(var(--rgbPrimary)); }
.about-text { font-size: var(--fontSizeBodyM); line-height: 1.8; color: rgb(var(--rgbText) / 0.85); margin: 0 0 var(--spaceL); max-width: 780px; }
.about-text:last-child { margin-bottom: 0; }
/* Skill groups - a heading plus a row of pills, not boxed cards (that
   was tried for About/Services and explicitly rejected). Used by Skills
   & Expertise; the pill/tag styles (.skills-pills/.skill-pill) are also
   shared by Services' numbered .service-groups below it, so the two
   sections read as one visual language even though the group wrapper
   differs. */
.skills-groups { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: var(--space2XL); row-gap: var(--spaceXL); margin-top: var(--spaceXL); }
@media (max-width: 900px) { .skills-groups { grid-template-columns: 1fr; } }
.skills-group-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyL); font-weight: 600; margin: 0 0 var(--spaceM); color: rgb(var(--rgbText) / 0.7); text-transform: uppercase; letter-spacing: 0.05em; }
.skills-pills { display: flex; flex-wrap: wrap; gap: var(--spaceM); }
.skill-pill { font-size: var(--fontSizeBodyS); padding: 7px 14px; background: rgb(var(--rgbText) / 0.05); border: 1px solid rgb(var(--rgbText) / 0.12); color: rgb(var(--rgbText) / 0.85); }

/* Services - same numbered idiom as "How It Works" below it (see
   .process-steps), not the plain heading+pills shape Skills & Expertise
   uses further down - gives the page some rhythm instead of every
   section reading as the same list shape. */
.service-groups { list-style: none; counter-reset: service; padding: 0; margin: var(--spaceXL) 0 0; display: grid; grid-template-columns: repeat(2, 1fr); column-gap: var(--space2XL); row-gap: var(--spaceXL); }
@media (max-width: 900px) { .service-groups { grid-template-columns: 1fr; } }
.service-group { counter-increment: service; }
.service-group-title { display: flex; align-items: baseline; gap: var(--spaceM); font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyL); font-weight: 600; margin: 0 0 var(--spaceM); color: rgb(var(--rgbText)); }
.service-group-title::before { content: counter(service, decimal-leading-zero); font-size: var(--fontSizeBodyL); color: rgb(var(--rgbPrimary) / 0.5); flex-shrink: 0; width: 2.2em; }

/* Services "How It Works" - a numbered process, not cards either. */
.process-steps { list-style: none; counter-reset: process; padding: 0; margin: var(--spaceXL) 0 0; display: grid; grid-template-columns: repeat(2, 1fr); column-gap: var(--space2XL); row-gap: var(--spaceL); }
@media (max-width: 700px) { .process-steps { grid-template-columns: 1fr; } }
.process-steps li { counter-increment: process; display: flex; gap: var(--spaceL); align-items: baseline; }
.process-steps li::before { content: counter(process, decimal-leading-zero); font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyL); color: rgb(var(--rgbPrimary) / 0.5); flex-shrink: 0; width: 2.2em; }
.process-step-title { display: block; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-weight: 600; font-size: var(--fontSizeBodyL); color: rgb(var(--rgbText)); margin-bottom: 4px; }

.about-button-group { display: flex; gap: var(--spaceM); margin-top: var(--spaceL); flex-wrap: wrap; }
/* Same cut-corner + prompt-glyph treatment as the home page's CTA
   (.home-cta-btn) - one button language across the site instead of two
   different plain-rectangle styles. */
/* Same corner-cut convention as .home-cta-btn (see there for why). */
.about-contact-btn { display: inline-block; font-family: 'JetBrains Mono', 'Fira Code', monospace; padding: var(--spaceM) var(--space2XL); background: rgb(var(--rgbPrimary)); color: rgb(var(--rgbOnPrimary)); text-decoration: none; clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); font-weight: 600; font-size: var(--fontSizeBodyM); transition: all var(--durationM) ease; border: 2px solid rgb(var(--rgbPrimary)); }
.about-contact-btn:hover { background: transparent; color: rgb(var(--rgbPrimary)); transform: translateY(-2px); }

@media (max-width: 768px) {
  .about-page { padding: var(--spaceXL) 0 calc(var(--space3XL) + 36px); }
  .about-hero { margin-bottom: var(--space2XL); padding-top: var(--spaceL); padding-bottom: var(--spaceXL); }
  .about-title { font-size: var(--fontSizeH3); }
  .about-subtitle { font-size: var(--fontSizeBodyM); }
  .about-content { gap: var(--space2XL); }
  .about-section-title { font-size: var(--fontSizeH4); }
  .process-steps li { gap: var(--spaceM); }
}

/* ==========================================================================
   Blog tab strip - /blog/ itself is the Writing list; this is how you
   get to Gallery (and back).
   ========================================================================== */
.blog-tabs { display: inline-flex; width: fit-content; align-items: center; gap: var(--spaceL); margin-bottom: var(--spaceXL); border-bottom: 1px solid rgb(var(--rgbText) / 0.1); }
.blog-tab { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyM); color: rgb(var(--rgbText) / 0.55); text-decoration: none; padding-bottom: var(--spaceS); border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color var(--durationS) ease, border-color var(--durationS) ease; }
.blog-tab:hover { color: rgb(var(--rgbText) / 0.85); }
.blog-tab.active { color: rgb(var(--rgbPrimary)); border-color: rgb(var(--rgbPrimary)); }
/* Articles / Writeups / Gallery measured 307px inside a 320px
   viewport at the desktop gap, so the strip ran 3px past the edge. */
@media (max-width: 420px) {
  .blog-tabs { gap: var(--spaceM); }
  .blog-tab { font-size: var(--fontSizeBodyS); }
}

.gallery-back { display: inline-block; margin-bottom: var(--spaceL); font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText) / 0.6); text-decoration: none; }
.gallery-back:hover { color: rgb(var(--rgbPrimary)); }
.gallery-header { margin-bottom: var(--spaceXL); }
.gallery-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH2); font-weight: 700; margin: 0 0 var(--spaceS); color: rgb(var(--rgbText)); }
.gallery-subtitle { color: rgb(var(--rgbText) / 0.7); margin: 0; }

/* ==========================================================================
   Writeups
   ========================================================================== */
/* Same header type as Gallery on purpose - the three blog tabs are peers
   and should not each introduce a different heading treatment. */
.writeups-header { margin-bottom: var(--spaceXL); }
.writeups-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH2); font-weight: 700; margin: 0 0 var(--spaceS); color: rgb(var(--rgbText)); }
.writeups-subtitle { color: rgb(var(--rgbText) / 0.7); margin: 0; }

/* The rows sit inside .article-list now, so they inherit its column and
   its gap; only the row's own three-part grid is defined here. */
.writeup-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--spaceXS) var(--spaceL); align-items: baseline; padding-bottom: var(--spaceL); border-bottom: 1px solid rgb(var(--rgbText) / 0.1); }
.writeup-row:last-child { border-bottom: none; }
.writeup-row-date { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyXS); color: rgb(var(--rgbText) / 0.5); }
.writeup-row-name { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH5); font-weight: 700; color: rgb(var(--rgbText)); text-decoration: none; overflow-wrap: anywhere; }
.writeup-row-name:hover { color: rgb(var(--rgbPrimary)); }
.writeup-row-desc { grid-column: 1 / -1; color: rgb(var(--rgbText) / 0.7); font-size: var(--fontSizeBodyS); line-height: var(--lineHeightBody); }
.writeup-row-meta { grid-column: 1 / -1; display: flex; gap: var(--spaceS); align-items: center; flex-wrap: wrap; margin-top: var(--spaceXS); }
.writeup-chip { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyXS); padding: 2px 8px; background: rgb(var(--rgbText) / 0.06); border: 1px solid rgb(var(--rgbText) / 0.12); color: rgb(var(--rgbText) / 0.7); }
/* Difficulty is the one chip carrying a value rather than a label, so it
   is the one that earns colour. Accent and primary, never a new hex. */
.writeup-chip-easy { color: rgb(var(--rgbPrimary)); border-color: rgb(var(--rgbPrimary) / 0.4); }
.writeup-chip-hard, .writeup-chip-insane { color: rgb(var(--rgbAccent)); border-color: rgb(var(--rgbAccent) / 0.4); }

.writeup-empty { border: 1px solid rgb(var(--rgbText) / 0.12); padding: var(--space2XL); max-width: var(--maxWidthM); }
.writeup-empty-line { font-family: 'JetBrains Mono', 'Fira Code', monospace; color: rgb(var(--rgbText)); margin: 0 0 var(--spaceS); }
.writeup-empty-note { color: rgb(var(--rgbText) / 0.7); font-size: var(--fontSizeBodyS); margin: 0; line-height: var(--lineHeightBody); }
.writeup-empty-note code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.92em; background: rgb(var(--rgbText) / 0.06); padding: 1px 6px; }

.writeup-active-notice { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyS); color: rgb(var(--rgbAccent)); border: 1px solid rgb(var(--rgbAccent) / 0.4); padding: var(--spaceM); margin: 0 0 var(--spaceL); max-width: var(--maxWidthM); }
.writeup-single { max-width: var(--maxWidthM); }
.writeup-single-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH2); margin: 0 0 var(--spaceS); color: rgb(var(--rgbText)); }
.writeup-single-desc { color: rgb(var(--rgbText) / 0.8); margin: 0 0 var(--spaceL); line-height: var(--lineHeightBody); }
.writeup-facts { display: flex; flex-wrap: wrap; gap: var(--spaceXL); margin: 0 0 var(--spaceL); padding: var(--spaceM) 0; border-top: 1px solid rgb(var(--rgbText) / 0.1); border-bottom: 1px solid rgb(var(--rgbText) / 0.1); }
.writeup-fact { margin: 0; }
.writeup-fact dt { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyXS); text-transform: uppercase; letter-spacing: 0.08em; color: rgb(var(--rgbText) / 0.5); margin-bottom: 2px; }
.writeup-fact dd { margin: 0; color: rgb(var(--rgbText)); font-size: var(--fontSizeBodyS); }
.writeup-techniques { display: flex; flex-wrap: wrap; gap: var(--spaceXS); margin-bottom: var(--space2XL); }

@media (max-width: 768px) {
  .writeup-row { grid-template-columns: 1fr; gap: var(--spaceXS); }
  .writeup-facts { gap: var(--spaceL); }
}

.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--spaceM); }
.gallery-tile { position: relative; display: block; aspect-ratio: 4 / 5; background-size: cover; background-position: center; overflow: hidden; text-decoration: none; }
.gallery-tile-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; gap: 4px; padding: var(--spaceM); background: linear-gradient(to top, rgb(0 0 0 / 0.85), rgb(0 0 0 / 0) 60%); opacity: 0; transition: opacity var(--durationM) ease; }
.gallery-tile:hover .gallery-tile-overlay { opacity: 1; }
/* No stock photography stand-ins - an entry with no real image yet says
   so plainly instead of faking one. */
/* Every entry is still waiting on a photo, so the empty state is most of
   what this page is. A 22px mark at 0.15 alpha inside a 280x350 tile read
   as a rendering fault rather than as a deliberate placeholder: the mark
   is bigger and stronger here, and it says what it is waiting for. */
.gallery-tile-empty { background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbText) / 0.1); display: flex; align-items: center; justify-content: center; }
/* text-decoration: none because the tile is an <a> and the placeholder
   label was inheriting its underline, which read as a broken link. */
.gallery-tile-placeholder { display: flex; flex-direction: column; align-items: center; gap: var(--spaceM); text-decoration: none; }
.gallery-tile-pending { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyXS); text-transform: uppercase; letter-spacing: 0.1em; color: rgb(var(--rgbText) / 0.35); text-decoration: none; }
.gallery-tile-empty .gallery-tile-mark { width: 32px; height: 96px; background-color: rgb(var(--rgbText) / 0.4); }
.gallery-tile-empty .gallery-tile-overlay { opacity: 1; background: none; justify-content: flex-end; }
.gallery-tile-mark { display: block; width: 22px; height: 66px; background-color: rgb(var(--rgbText) / 0.15); -webkit-mask-image: url('/images/sabre-mark.svg'); mask-image: url('/images/sabre-mark.svg'); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-size: contain; mask-size: contain; -webkit-mask-position: center; mask-position: center; }
.gallery-single-image-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--spaceM); background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbText) / 0.1); }
.gallery-single-image-empty .gallery-tile-mark { width: 32px; height: 96px; background-color: rgb(var(--rgbText) / 0.25); }
.gallery-tile-medium { font-size: var(--fontSizeBodyXS); text-transform: uppercase; letter-spacing: 0.05em; color: rgb(var(--rgbPrimary)); }
/* #fff is correct over a photo - the tile's own gradient overlay
   guarantees a dark backdrop there regardless of theme. An empty tile
   has no photo and no gradient, so it has to follow the theme instead:
   in light mode this was white text on a white tile. */
.gallery-tile-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; color: #fff; font-size: var(--fontSizeBodyM); font-weight: 600; }
.gallery-tile-empty .gallery-tile-title { color: rgb(var(--rgbText)); }

.gallery-single { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spaceXL); align-items: start; }
.gallery-single-image { aspect-ratio: 4 / 5; background-size: cover; background-position: center; }
.gallery-single-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH2); margin: var(--spaceS) 0; }
.gallery-single-date { color: rgb(var(--rgbText) / 0.5); font-size: var(--fontSizeBodyS); }
.gallery-single-content { margin-top: var(--spaceL); color: rgb(var(--rgbText) / 0.8); line-height: var(--lineHeightBody); }
@media (max-width: 900px) { .gallery-single { grid-template-columns: 1fr; } }

/* ==========================================================================
   Articles list
   ========================================================================== */
/* The preview panel's geometry lives here as tokens because BOTH the
   panel and the list's width budget have to agree on it - they were two
   independent hardcoded numbers before (560px panel vs 660px reserved,
   400px panel vs 460px reserved) and drifted apart. */
/* One fluid width rather than two fixed tiers. 560px full desktop and a
   flat 400px for everything from 1025 to 1680px left the panel visibly
   narrower than the column it sits beside - at 1440px it was 400px wide
   with 147px of empty page between it and a 646px list. clamp() spends
   that gap on the panel and hands the floor and the ceiling back to the
   two numbers the tiers used to hold. .article-list's calc reads this
   token, so the reservation follows on its own. */
.article-list-container { --previewWidth: clamp(400px, 34vw, 560px); --previewInset: calc(var(--spaceOuter) + var(--space4XL)); display: flex; gap: var(--spaceXL); margin-top: var(--space2XL); }
/* The list and the panel are just two flex items now. The panel used to
   be position: fixed and centred in the VIEWPORT, which was fine on a
   standalone /blog/ page and wrong the moment the blog became one section
   of a longer document - it then floated over Projects and Tools as well,
   because a fixed element does not know which section it belongs to.
   Sticky keeps it beside its own list and scrolls it away with it. */
.article-list { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--space2XL); }
.article-preview {
  flex: 0 0 var(--previewWidth);
  /* A plain sticky offset, no transform. top: 50vh with a half-height
     shift did centre it in the viewport, but the shift also lifted it
     above its own list at the top of the page, which is what read as
     "not aligned". This sits with the list until the offset is reached,
     holds there while the list scrolls past, and releases at the end of
     the container so it leaves with its section. The offset is set so
     the held position sits near the middle of the space under the
     navbar rather than jammed against it. */
  position: sticky; top: calc(var(--navbarHeight) + var(--space2XL));
  align-self: flex-start;
  max-height: calc(100vh - var(--navbarHeight) - var(--space4XL));
}
.article-preview-content { background: rgb(var(--rgbBackgroundLight)); border-radius: 0; overflow: hidden; border: 1px solid rgb(var(--rgbPrimary) / 0.1); display: flex; flex-direction: column; height: auto; max-height: 100%; padding-bottom: var(--spaceXS); }
/* aspect-ratio, not a fixed height - the panel's own width now varies
   (560px full desktop, 400px medium tier, see .article-preview above),
   and a fixed 400px height against a narrower width changed the crop
   area's proportions unpredictably - a photo tuned to look right at a
   1.4:1 (560x400) crop showed a much more square, often much emptier-
   looking slice once the panel shrank to 400px wide against that same
   fixed height. Proportional height keeps the crop consistent at any
   panel width. */
.article-preview-image { width: 100%; aspect-ratio: 3 / 2; overflow: hidden; background: rgb(var(--rgbBackground)); flex-shrink: 0; }
.article-preview-image img { width: 100%; height: 100%; object-fit: cover; object-position: center 80%; }
/* No image on the post: show the sabre mark rather than an empty grey
   panel, matching how the gallery marks an entry with no photo yet. */
.article-preview-image .gallery-tile-mark { display: none; }
/* Collapse rather than mark it: the image panel is a 3:2 slot, so on a
   490px card an absent image is 326px of empty above three lines of
   text. The gallery marks an empty tile instead because there the tile
   IS the content; here the text is. */
.article-preview-image-empty { display: none; }
.article-preview-text { padding: var(--spaceL); display: flex; flex-direction: column; gap: var(--spaceS); flex: none; min-height: 0; }
.article-preview-text h3 { font-size: var(--fontSizeH4); color: rgb(var(--rgbText)); margin: 0; line-height: 1.3; flex-shrink: 0; }
.article-preview-text p { color: rgb(var(--rgbText) / 0.8); line-height: var(--lineHeightBody); margin: 0 0 var(--spaceS) 0; display: -webkit-box; -webkit-line-clamp: 8; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; }
.article-preview-meta { display: flex; gap: var(--spaceM); padding-top: var(--spaceM); border-top: 1px solid rgb(var(--rgbText) / 0.1); flex-shrink: 0; }
.article-preview-category, .article-preview-difficulty { padding: var(--spaceXS) var(--spaceS); background: rgb(var(--rgbText) / 0.06); color: rgb(var(--rgbText) / 0.7); border-radius: 0; font-size: var(--fontSizeBodyS); font-weight: var(--fontWeightMedium); }

.article-item { border-bottom: 1px solid rgb(var(--rgbText) / 0.1); padding-bottom: var(--spaceL); display: flex; flex-direction: column; gap: var(--spaceM); transition: all var(--durationM) ease; }
.article-item:last-child { border-bottom: none; }
.article-item-body { display: flex; flex-direction: column; gap: var(--spaceS); }
.article-item-hovered { color: rgb(var(--rgbText)); }
.article-item-hovered .article-item-title, .article-item-hovered .article-item-description, .article-item-hovered .article-item-date, .article-item-hovered .article-item-readtime { color: rgb(var(--rgbText)); text-shadow: 1px 1px 2px rgba(0,0,0,0.3); }
.article-item-hovered .article-item-link { color: rgb(var(--rgbText)); }
.article-item-hovered .article-tag { background: rgba(var(--rgbPrimary), 0.2); color: rgb(var(--rgbPrimary)); border: 1px solid rgba(var(--rgbPrimary), 0.3); }
.article-item-image { width: 100%; height: 300px; overflow: hidden; border-radius: 0; margin-bottom: var(--spaceM); display: none; }
.article-item-image img { width: 100%; height: 100%; object-fit: cover; transition: transform var(--durationM) ease; }
.article-item:hover .article-item-image img { transform: scale(1.05); }
.article-item-meta { display: flex; gap: var(--spaceM); margin-top: var(--spaceS); font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText) / 0.6); }
.article-item-date { color: rgb(var(--rgbText) / 0.6); }
.article-item-readtime { color: rgb(var(--rgbText) / 0.6); }
.article-item-title { font-size: var(--fontSizeH3); color: rgb(var(--rgbText)); margin-bottom: var(--spaceS); }
.article-item-link { color: inherit; text-decoration: none; transition: color var(--durationM) ease; }
.article-item-link:hover { color: rgb(var(--rgbPrimary)); }
.article-item-description { color: rgb(var(--rgbText) / 0.8); line-height: var(--lineHeightBody); margin-bottom: var(--spaceM); }
.article-item-tags { display: flex; gap: var(--spaceS); flex-wrap: wrap; }
.article-tag { background: rgb(var(--rgbText) / 0.06); color: rgb(var(--rgbText) / 0.65); padding: var(--spaceXS) var(--spaceS); font-size: var(--fontSizeBodyS); font-weight: var(--fontWeightMedium); border-radius: 0; }

/* .article-list's 660px-reserved-for-preview budget assumed the page's
   full 1680px-tier width. --maxWidthXL (the page's own content-width
   cap) drops to 1100px below that breakpoint - .page-main's actual
   content width then holds steady around ~940px for most of the
   practical desktop range (1180-1680px viewport), not the ~1400px+ the
   660px reservation was sized for. Squeezed the list into ~150-250px:
   titles wrapping three lines wide next to a preview panel with room to
   spare. First attempt at fixing this pushed the "stack into one column"
   breakpoint up to 1680px to dodge the squeeze - overcorrected, since
   that meant the always-visible side preview disappeared (buried below
   the full article list, thousands of pixels down) across the entire
   1024-1680px range instead of just being narrow. The actual fix is
   sizing the preview and the reserved gap to the medium tier's real
   ~940px budget instead, so both columns still comfortably coexist. */
@media (max-width: 1024px) {
  .article-list-container { flex-direction: column; }
  .article-list { max-width: 100%; }
  .article-preview { flex: none; position: static; display: block; order: 2; }
}
@media (max-width: 768px) {
  .article-list-container { margin-top: var(--spaceL); }
  .article-list { gap: var(--spaceL); max-width: 100%; }
  .article-item { padding: 0; padding-bottom: var(--spaceL); margin-bottom: var(--spaceL); }
  /* There is no hover on a phone, so the preview column is gone and the
     cover moves into the row itself. At the desktop 300px it made every
     row taller than the viewport and the list could not be scanned - an
     aspect ratio keeps it proportional on a 320 and a 430 alike. */
  .article-item-image { display: block; height: auto; aspect-ratio: 16 / 9; margin-bottom: var(--spaceM); }
  .article-preview { display: none; }
}

/* ==========================================================================
   Article reader (single article page)
   ========================================================================== */
.article-reader-container { max-width: 100%; margin: 0 auto; padding: var(--spaceL); padding-bottom: calc(var(--space2XL) * 3); min-height: 100vh; }
.article-reader-controls { position: fixed; top: var(--navbarHeight); left: 0; right: 0; background: rgb(var(--rgbBackground)); padding: var(--spaceS) var(--spaceM); margin: 0; width: 100%; display: flex; gap: var(--spaceM); flex-wrap: nowrap; align-items: center; justify-content: center; z-index: var(--zIndex3); pointer-events: auto; transform: translateY(0); transition: transform 0.3s ease, opacity 0.3s ease; opacity: 1; }
.article-reader-controls-hidden { transform: translateY(-100%); opacity: 0; pointer-events: none; }
.article-reader-control-group { display: flex; flex-direction: row; gap: var(--spaceXS); align-items: center; }
.article-reader-control-label { font-size: var(--fontSizeBodyS); font-weight: var(--fontWeightMedium); color: rgb(var(--rgbText) / 0.7); white-space: nowrap; }
.article-reader-font-size-controls { display: flex; align-items: center; gap: var(--spaceS); }
.article-reader-control-button { padding: var(--spaceXS) var(--spaceS); background: rgb(var(--rgbText) / 0.05); border: 1px solid rgb(var(--rgbText) / 0.15); color: rgb(var(--rgbText) / 0.8); font-size: var(--fontSizeBodyS); font-weight: var(--fontWeightMedium); cursor: pointer; transition: all var(--durationS) ease; clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); }
.article-reader-control-button:hover { background: rgb(var(--rgbPrimary) / 0.15); border-color: rgb(var(--rgbPrimary) / 0.4); color: rgb(var(--rgbPrimary)); }
.article-reader-font-size-display { font-size: var(--fontSizeBodyS); font-weight: var(--fontWeightMedium); color: rgb(var(--rgbText)); min-width: 40px; text-align: center; }
.article-reader-control-select { padding: var(--spaceXS) var(--spaceS); background: rgb(var(--rgbBackground)); border: 1px solid rgb(var(--rgbText) / 0.15); color: rgb(var(--rgbText)); font-size: var(--fontSizeBodyS); cursor: pointer; clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); }
.article-reader-control-select:focus { outline: none; border-color: rgb(var(--rgbPrimary)); }
/* appearance:none is what actually suppresses the native control - the
   track background alone did nothing, so Chrome kept painting its own
   system-blue track and thumb on top: the loudest thing on the article,
   in a color that appears nowhere else on the site. Thumb is squared
   off to match the site's other controls (no border-radius anywhere). */
.article-reader-control-range { -webkit-appearance: none; appearance: none; width: 100px; height: 4px; background: rgb(var(--rgbText) / 0.15); outline: none; cursor: pointer; }
.article-reader-control-range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 12px; height: 12px; background: rgb(var(--rgbPrimary)); border: none; cursor: pointer; }
.article-reader-control-range::-moz-range-thumb { width: 12px; height: 12px; background: rgb(var(--rgbPrimary)); border: none; border-radius: 0; cursor: pointer; }
.article-reader-control-range::-moz-range-track { height: 4px; background: rgb(var(--rgbText) / 0.15); }
/* outline:none above removes the only focus indicator these had - put a
   real one back rather than leaving them unreachable-looking by keyboard. */
.article-reader-control-range:focus-visible { outline: 2px solid rgb(var(--rgbPrimary) / 0.6); outline-offset: 3px; }
.article-reader-range-value { font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText) / 0.8); min-width: 40px; text-align: center; }

/* Clearance for two fixed bars, not a magic 60px. Measured at 1440px:
   the navbar owns 0-72 and .article-reader-controls 72-117, while the
   container's own 24px padding put the content at 84 - so the date and
   tag chips sat under the reader bar. It went unnoticed while every post
   led with a hero image, which absorbed the overlap. */
.article-reader-content { margin: 0 auto; margin-top: calc(var(--navbarHeight) + var(--space2XL)); color: rgb(var(--rgbText)); line-height: 1.6; }
.article-reader-header { margin-top: var(--spaceXL); margin-bottom: var(--space2XL); padding-top: var(--spaceL); text-align: center; }
.article-reader-image { width: 100%; max-width: 800px; height: 300px; margin: 0 auto var(--spaceL); overflow: hidden; clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 0 100%); }
.article-reader-image img { width: 100%; height: 100%; object-fit: cover; }
.article-reader-meta { display: flex; justify-content: center; gap: var(--spaceM); margin-bottom: var(--spaceL); font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText) / 0.6); flex-wrap: wrap; }
.article-reader-date, .article-reader-readtime, .article-reader-category { padding: var(--spaceXS) var(--spaceS); background: rgb(var(--rgbText) / 0.06); clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); }
.article-reader-title { font-size: clamp(2rem, 5vw, 3rem); font-weight: var(--fontWeightBold); margin-bottom: var(--spaceM); line-height: 1.2; }
.article-reader-description { font-size: var(--fontSizeBodyL); color: rgb(var(--rgbText) / 0.8); margin-bottom: var(--spaceL); max-width: 60ch; margin-left: auto; margin-right: auto; }
.article-reader-tags { display: flex; justify-content: center; gap: var(--spaceS); flex-wrap: wrap; }

.article-body { text-align: left; }
/* Ragged right, not justified. Justifying a 65ch measure with no
   hyphenation (hyphens defaults to manual, and none of this content
   carries soft hyphens) forces the spaces to absorb the whole line-
   length error, which is what produced the visible rivers running down
   the body copy. To go back to justified it needs `hyphens: auto` here
   as well, not `text-align` on its own. */
.article-body p { margin-bottom: var(--spaceL); }
.article-body p:last-child { margin-bottom: 0; }
.article-body h1 { font-size: 2.5rem; font-weight: var(--fontWeightBold); margin: var(--space2XL) 0 var(--spaceL) 0; line-height: 1.2; color: rgb(var(--rgbPrimary)); }
.article-body h2 { font-size: 2rem; font-weight: var(--fontWeightBold); margin: var(--spaceXL) 0 var(--spaceM) 0; line-height: 1.3; color: rgb(var(--rgbText)); border-bottom: 2px solid rgb(var(--rgbPrimary) / 0.2); padding-bottom: var(--spaceS); }
.article-body h3 { font-size: 1.5rem; font-weight: var(--fontWeightMedium); margin: var(--spaceL) 0 var(--spaceS) 0; line-height: 1.4; color: rgb(var(--rgbText)); }
.article-body p code, .article-body li code { background: rgb(var(--rgbPrimary) / 0.1); color: rgb(var(--rgbPrimary)); padding: 2px 6px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.9em; clip-path: polygon(0 0, calc(100% - 3px) 0, 100% 3px, 100% 100%, 0 100%); }
.article-body .highlight { background: rgb(var(--rgbText) / 0.05); border: 1px solid rgb(var(--rgbPrimary) / 0.2); margin: var(--spaceL) 0; overflow-x: auto; clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); }
.article-body .highlight pre { padding: var(--spaceM); margin: 0; }
/* Chroma only wraps a block in .highlight when it recognises the
   language, so a fence with no language, or one Chroma has no lexer for,
   came out as a bare <pre> with no scroll container - one long line then
   pushed the whole page wider than the viewport (measured 549px at a
   390px viewport). Wide content scrolls in its own box; the page never
   scrolls sideways. */
.article-body pre { overflow-x: auto; max-width: 100%; }
.article-body > pre { background: rgb(var(--rgbText) / 0.05); border: 1px solid rgb(var(--rgbPrimary) / 0.2); margin: var(--spaceL) 0; padding: var(--spaceM); clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); }
.article-body ul, .article-body ol { margin: var(--spaceM) 0; padding-left: var(--spaceL); }
.article-body li { margin-bottom: var(--spaceS); line-height: 1.6; }
.article-body blockquote { border-left: 4px solid rgb(var(--rgbPrimary)); margin: var(--spaceL) 0; font-style: italic; color: rgb(var(--rgbText) / 0.8); background: rgb(var(--rgbPrimary) / 0.05); padding: var(--spaceM); clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); }

/* The pager, off the inline styles it used to carry. */
.tools-pagination { display: flex; justify-content: center; align-items: center; gap: var(--spaceL); padding: var(--spaceL) 0; }
.tools-pagination-count { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyS); font-weight: 500; color: rgb(var(--rgbText) / 0.8); white-space: nowrap; }
/* Long lists on a phone: keep it reachable. Both Tools and Blog render it
   as the last element, so it sticks just above the fixed footer bar
   instead of sitting several screens down. No negative margin to bleed
   it full width - the page gutter is not the same value at every
   breakpoint, and guessing it put the bar 8px past the viewport. */
@media (max-width: 768px) {
  .tools-pagination {
    position: sticky; bottom: calc(36px + env(safe-area-inset-bottom));
    background: rgb(var(--rgbBackground));
    border-top: 1px solid rgb(var(--rgbText) / 0.12);
    padding: var(--spaceS) 0;
    gap: var(--spaceM);
    z-index: var(--zIndex2);
  }
  /* "Page 1 / 242 . 2899 tools" wraps to two lines in a 390px bar. The
     page number is the part being navigated by; the total is not. */
  .tools-pagination-count { font-size: var(--fontSizeBodyXS); }
  .tools-pagination-total { display: none; }
}

@media (max-width: 768px) {
  .article-reader-controls { position: sticky; top: var(--navbarHeight); flex-wrap: wrap; gap: var(--spaceS); padding: var(--spaceS); z-index: var(--zIndex3); }
  .article-reader-control-group { flex-direction: column; gap: 2px; }
  .article-reader-control-label { font-size: 0.7rem; }
  .article-reader-font-size-controls { justify-content: center; }
  /* The column is already narrower than the slider's own 50ch minimum at
     this width, so the control can only ever be a no-op. Dropping it is
     what lets the other three fit without turning the top of every
     article into a four-row settings panel. */
  .article-reader-control-group-width { display: none; }
  .article-reader-control-range { width: 72px; }
  /* 60px of clearance for a control bar that is fixed on desktop. Here
     the bar is sticky and in flow, so that clearance was dead space. */
  .article-reader-content { margin-top: 0; }
  /* The base rule already wraps and centres these. Forcing
     flex-direction: column on top of it put three short chips on three
     rows and cost 200px above the title for no gain - only the gap needs
     tightening here. */
  .article-reader-meta { gap: var(--spaceS); }
  .article-reader-tags { justify-content: center; }
}

/* ==========================================================================
   Git repository listing
   ========================================================================== */
.git-page { min-height: 100vh; padding: calc(var(--navbarHeight) + var(--spaceXL)) 0 var(--space3XL); margin-left: 40px; padding-left: var(--spaceXL); padding-right: var(--spaceXL); padding-bottom: 100px; }
.git-container { max-width: var(--maxWidthL); margin: 0 auto; padding: 0 var(--spaceL); }
.git-header { display: flex; align-items: center; gap: var(--spaceL); margin-bottom: var(--spaceXL); padding-bottom: var(--spaceXL); border-bottom: 1px solid rgb(var(--rgbText) / 0.1); }
/* JetBrains Mono like every other mono surface on the site, not the
   browser default, and sized to sit beside the title rather than shout
   over it. */
.git-logo { font-size: var(--fontSizeH2); font-weight: 700; color: rgb(var(--rgbPrimary)); font-family: 'JetBrains Mono', 'Fira Code', monospace; line-height: 1; }
/* Mono, like the Gallery, Writeups and Contact headings. This page
   was the only one whose title was set in the body face. */
.git-title h1 { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH3); margin: 0 0 var(--spaceS) 0; font-weight: 700; color: rgb(var(--rgbText)); }
.git-subtitle { margin: 0; color: rgb(var(--rgbTextLight)); font-size: var(--fontSizeBodyS); }
/* A breadcrumb, not a panel. As a filled bordered box holding one word
   it read as an empty widget. */
.git-nav { font-family: 'JetBrains Mono', 'Fira Code', monospace; padding: 0 0 var(--spaceM); margin-bottom: var(--spaceL); border: none; }
.git-nav-item { color: rgb(var(--rgbText)); font-size: var(--fontSizeBodyS); font-weight: 600; }
.git-nav-link { color: rgb(var(--rgbText) / 0.75); text-decoration: none; font-size: var(--fontSizeBodyS); font-weight: 600; transition: color var(--durationS) ease; }
.git-nav-link:hover { color: rgb(var(--rgbPrimary)); }
.git-nav-separator { color: rgb(var(--rgbText) / 0.3); margin: 0 var(--spaceS); }

/* Hairline rows, the same list language as Writeups, the home panes
   and the contact channels - not a bordered table, which was the one
   list on the site drawn as a box. */
.git-repo-list { border: none; border-top: 1px solid rgb(var(--rgbText) / 0.12); border-radius: 0; }
.git-repo-header { display: grid; grid-template-columns: 2fr 4fr 1.5fr; gap: var(--spaceL); background: none; padding: var(--spaceS) 0; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyXS); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; border-bottom: 1px solid rgb(var(--rgbText) / 0.12); color: rgb(var(--rgbText) / 0.5); }
.git-repo-row { display: grid; grid-template-columns: 2fr 4fr 1.5fr; gap: var(--spaceL); padding: var(--spaceM) var(--spaceM) var(--spaceM) var(--spaceM); margin-left: calc(var(--spaceM) * -1); border-left: 2px solid transparent; border-bottom: 1px solid rgb(var(--rgbText) / 0.12); transition: background var(--durationS) ease, border-color var(--durationS) ease; }
.git-repo-row:last-child { border-bottom: none; }
.git-repo-row:hover { background: rgb(var(--rgbText) / 0.03); border-left-color: rgb(var(--rgbPrimary)); }
.git-repo-name a { color: rgb(var(--rgbText) / 0.9); text-decoration: none; font-size: var(--fontSizeBodyS); font-weight: 600; transition: color var(--durationS) ease; }
.git-repo-name a:hover { color: rgb(var(--rgbPrimary)); }
.git-repo-desc { color: rgb(var(--rgbTextLight)); font-size: var(--fontSizeBodyS); }
.git-repo-status { color: rgb(var(--rgbText) / 0.4); font-size: var(--fontSizeBodyXS); text-align: right; }

.git-repo-content { margin-top: var(--spaceXL); }
.git-repo-info { display: flex; flex-direction: column; gap: var(--spaceM); margin-bottom: var(--space2XL); padding: var(--spaceL); background: rgb(var(--rgbBackgroundLight)); border: 1px solid rgb(var(--rgbText) / 0.1); border-radius: 0; }
.git-repo-info-label { color: rgb(var(--rgbTextLight)); font-size: var(--fontSizeBodyS); margin-bottom: var(--spaceXS); }
.git-repo-clone-command { font-family: monospace; font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText)); background: rgb(var(--rgbBackground)); border: 1px solid rgb(var(--rgbText) / 0.1); border-radius: 0; padding: var(--spaceM); overflow-x: auto; }
.git-repo-notice { border: 1px solid rgb(var(--rgbText) / 0.1); border-radius: 0; padding: var(--spaceL); color: rgb(var(--rgbTextLight)); font-size: var(--fontSizeBodyS); line-height: 1.6; }

@media (max-width: 768px) {
  .git-page { padding: calc(var(--navbarHeight) + var(--spaceXL)) 0 var(--space3XL); margin-left: 0; padding-left: var(--spaceL); padding-right: var(--spaceL); padding-bottom: 120px; }
  .git-header { flex-direction: column; align-items: flex-start; gap: var(--spaceM); }
  .git-logo { font-size: 3rem; }
  .git-title h1 { font-size: var(--fontSizeH4); }
  .git-repo-header, .git-repo-row { grid-template-columns: 1fr; gap: var(--spaceS); }
  .git-repo-header .git-repo-desc-label, .git-repo-header .git-repo-status-label { display: none; }
  .git-repo-status { text-align: left; font-size: var(--fontSizeBodyXS); }
}


/* ==========================================================================
   Project card: stack icons, star tally, fixed row heights
   ========================================================================== */
/* mask-image, not <img>: the same technique the navbar sabre mark and the
   gallery placeholder already use, so an icon inherits currentColor and
   follows the theme instead of shipping a fixed-colour raster per theme.
   Files are vendored Simple Icons (CC0) under /images/stack - no CDN. */
.project-card-stack { display: inline-flex; align-items: center; gap: 6px; }
.stack-icon {
  display: inline-block; width: 14px; height: 14px; flex-shrink: 0;
  background-color: currentColor;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-size: contain;     mask-size: contain;
  -webkit-mask-position: center;  mask-position: center;
}
.stack-icon[data-icon="rust"]        { -webkit-mask-image: url('/images/stack/rust.svg');        mask-image: url('/images/stack/rust.svg'); }
.stack-icon[data-icon="c"]           { -webkit-mask-image: url('/images/stack/c.svg');           mask-image: url('/images/stack/c.svg'); }
.stack-icon[data-icon="cplusplus"]   { -webkit-mask-image: url('/images/stack/cplusplus.svg');   mask-image: url('/images/stack/cplusplus.svg'); }
.stack-icon[data-icon="python"]      { -webkit-mask-image: url('/images/stack/python.svg');      mask-image: url('/images/stack/python.svg'); }
.stack-icon[data-icon="go"]          { -webkit-mask-image: url('/images/stack/go.svg');          mask-image: url('/images/stack/go.svg'); }
.stack-icon[data-icon="julia"]       { -webkit-mask-image: url('/images/stack/julia.svg');       mask-image: url('/images/stack/julia.svg'); }
.stack-icon[data-icon="gnubash"]     { -webkit-mask-image: url('/images/stack/gnubash.svg');     mask-image: url('/images/stack/gnubash.svg'); }
.stack-icon[data-icon="typescript"]  { -webkit-mask-image: url('/images/stack/typescript.svg');  mask-image: url('/images/stack/typescript.svg'); }
.stack-icon[data-icon="webassembly"] { -webkit-mask-image: url('/images/stack/webassembly.svg'); mask-image: url('/images/stack/webassembly.svg'); }
.stack-icon[data-icon="lua"]         { -webkit-mask-image: url('/images/stack/lua.svg');         mask-image: url('/images/stack/lua.svg'); }
.stack-icon[data-icon="sqlite"]      { -webkit-mask-image: url('/images/stack/sqlite.svg');      mask-image: url('/images/stack/sqlite.svg'); }
.stack-icon[data-icon="fastapi"]     { -webkit-mask-image: url('/images/stack/fastapi.svg');     mask-image: url('/images/stack/fastapi.svg'); }
.stack-icon[data-icon="wayland"]     { -webkit-mask-image: url('/images/stack/wayland.svg');     mask-image: url('/images/stack/wayland.svg'); }
.stack-icon[data-icon="linux"]       { -webkit-mask-image: url('/images/stack/linux.svg');       mask-image: url('/images/stack/linux.svg'); }

/* Star tally. Rendered for every repo that has one, zero included - a 0
   is a real answer, and hiding it would make the row inconsistent across
   cards for no stated reason. Projects with no public repo carry no
   `stars` key at all and print nothing. */
.project-card-stars { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; font-variant-numeric: tabular-nums; color: rgb(var(--rgbText) / 0.5); }
.project-card-stars::before { content: '★'; font-size: 0.9em; line-height: 1; }

/* Fixed heights so the Details button shares one baseline on every card:
   meta is always one line, and the website row is always present even
   when empty (only typerpunk has a site). */
.project-card-meta { height: 1.4em; align-items: center; flex-wrap: nowrap; overflow: hidden; }
.project-card-site { height: 1.3em; overflow: hidden; font-size: var(--fontSizeBodyXS); }
.project-card-site a { color: rgb(var(--rgbPrimary)); text-decoration: none; }

/* Status vocabulary. Accent goes to the states that mean "this runs and
   is in use"; everything earlier in its life stays neutral so the accent
   keeps meaning something. */
.project-card-status[data-status="Daily Driver"],
.project-card-status[data-status="Live"],
.project-card-status[data-status="Maintained"] { color: rgb(var(--rgbPrimary)); }
.project-card-status[data-status="Beta"] { color: rgb(var(--rgbText) / 0.75); }

/* The localised backdrop-filter blur behind the side-nav / nav-icons /
   contact column was removed: backdrop-filter inside a :has() selector,
   layered over fixed-position elements and a full-page background, made
   the page glitch and flicker during scroll and hover. The existing
   per-control scrims (.media-toggle / .theme-toggle-btn box-shadow, the
   logo's own backing, the footer's solid bar) already carry legibility
   over the preview without compositing a live blur every frame. */

/* ==========================================================================
   Project grid v2 - fewer, larger, image-led cards
   ==========================================================================
   Two columns instead of three. At three, a 16:9 screenshot of a whole
   desktop rendered about 300px wide, which is below the size at which any
   of it is readable; the preview is the substance of these cards, so the
   card is now sized around it. */
/* No inner centring: .page-main already caps and centres the column, and
   a second cap here started the grid 47px further in than the blog list,
   so no two pages shared a left edge. */
.project-grid { grid-template-columns: repeat(3, 1fr); gap: var(--spaceL); }
@media (max-width: 1024px) { .project-grid { grid-template-columns: 1fr; } }

.project-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--spaceM); }
.project-card-name { margin: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH5); font-weight: 700; color: rgb(var(--rgbText)); }
.project-card-body { gap: var(--spaceS); padding: var(--spaceL) var(--spaceL) var(--spaceM); }
.project-card-desc { height: auto; min-height: 2.9em; }

/* One row, always present, so the Details button sits on the same
   baseline whether a project has two links, one, or none. */
.project-card-links { display: flex; align-items: center; gap: var(--spaceM); min-height: 1.6em; font-size: var(--fontSizeBodyXS); }
.project-link { color: rgb(var(--rgbText) / 0.65); text-decoration: none; border-bottom: 1px solid rgb(var(--rgbText) / 0.2); transition: color var(--durationS) ease, border-color var(--durationS) ease; }
.project-link:hover { color: rgb(var(--rgbPrimary)); border-color: rgb(var(--rgbPrimary) / 0.5); }
.project-link-site { color: rgb(var(--rgbPrimary) / 0.85); }
.project-card-links .project-card-stars { margin-left: auto; }

.project-modal-h { font-size: var(--fontSizeBodyM); margin: var(--spaceM) 0 var(--spaceS); }
.project-modal-p { font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText) / 0.8); margin: 0; }
.project-modal-list { font-size: var(--fontSizeBodyS); color: rgb(var(--rgbText) / 0.8); padding-left: var(--spaceM); margin: 0; }
.project-modal-links { display: flex; gap: var(--spaceM); margin-top: var(--spaceL); font-size: var(--fontSizeBodyS); }

/* Brand colours, not currentColor. mask-image only ever paints one colour,
   so each icon gets its own - values are the language/project's own brand
   or linguist colour, lightened where the official one is too dark to read
   on this background (lua, sqlite, c). */
.stack-icon[data-icon="rust"]        { background-color: #dea584; }
.stack-icon[data-icon="c"]           { background-color: #a8b9cc; }
.stack-icon[data-icon="cplusplus"]   { background-color: #659ad2; }
.stack-icon[data-icon="python"]      { background-color: #ffd43b; }
.stack-icon[data-icon="go"]          { background-color: #00add8; }
.stack-icon[data-icon="julia"]       { background-color: #9558b2; }
.stack-icon[data-icon="gnubash"]     { background-color: #89e051; }
.stack-icon[data-icon="typescript"]  { background-color: #3178c6; }
.stack-icon[data-icon="webassembly"] { background-color: #8a7bff; }
.stack-icon[data-icon="lua"]         { background-color: #6f9bec; }
.stack-icon[data-icon="sqlite"]      { background-color: #4bb3d9; }
.stack-icon[data-icon="fastapi"]     { background-color: #05998b; }
.stack-icon[data-icon="wayland"]     { background-color: #ffbc00; }
.stack-icon[data-icon="linux"]       { background-color: #fcc624; }
.stack-icon[data-icon="apple"]       { background-color: #d6d6d6; }
.stack-icon[data-icon="xdotorg"]     { background-color: #f28834; }
.stack-icon { width: 16px; height: 16px; }
.stack-icon[data-icon="apple"]       { -webkit-mask-image: url('/images/stack/apple.svg');   mask-image: url('/images/stack/apple.svg'); }
.stack-icon[data-icon="xdotorg"]     { -webkit-mask-image: url('/images/stack/xdotorg.svg'); mask-image: url('/images/stack/xdotorg.svg'); }

/* Status vocabulary */
.project-card-status[data-status="In Use"],
.project-card-status[data-status="Released"],
.project-card-status[data-status="Maintained"] { color: rgb(var(--rgbPrimary)); }
.project-card-status[data-status="Beta"] { color: rgb(var(--rgbText) / 0.75); }

/* ==========================================================================
   Project grid v3 - three across, rounded, icon links
   ========================================================================== */
@media (max-width: 1200px) { .project-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 760px)  { .project-grid { grid-template-columns: 1fr; } }

/* Rounded corners. The card clips its own preview, so the radius has to be
   on .card with overflow:hidden or the image's square corners poke out. */
.project-card { border-radius: 12px; overflow: hidden; }
.project-card .project-preview { border-radius: 0; }
.project-modal { border-radius: 14px; }
.project-modal-close { border-radius: 8px; }
.project-card .widget-button { border-radius: 8px; }
.project-card .card-tag { border-radius: 6px; }
.project-card-stars { border-radius: 6px; }

/* Repo links as marks rather than words - GitHub's own and a plain git
   mark for the self-hosted mirror, which has no brand of its own. */
.project-link-icon { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-bottom: none; color: rgb(var(--rgbText) / 0.55); transition: color var(--durationS) ease, transform var(--durationS) ease; }
.project-link-icon:hover { color: rgb(var(--rgbPrimary)); transform: translateY(-1px); border-bottom: none; }
.project-link-icon .stack-icon { width: 16px; height: 16px; background-color: currentColor; }
.project-card-links { gap: var(--spaceS); }

.stack-icon[data-icon="github"]     { -webkit-mask-image: url('/images/stack/github.svg');     mask-image: url('/images/stack/github.svg'); }
.stack-icon[data-icon="git"]        { -webkit-mask-image: url('/images/stack/git.svg');        mask-image: url('/images/stack/git.svg'); }
.stack-icon[data-icon="windows"]    { -webkit-mask-image: url('/images/stack/windows.svg');    mask-image: url('/images/stack/windows.svg'); }
.stack-icon[data-icon="neovim"]     { -webkit-mask-image: url('/images/stack/neovim.svg');     mask-image: url('/images/stack/neovim.svg'); }
.stack-icon[data-icon="tmux"]       { -webkit-mask-image: url('/images/stack/tmux.svg');       mask-image: url('/images/stack/tmux.svg'); }
.stack-icon[data-icon="zsh"]        { -webkit-mask-image: url('/images/stack/zsh.svg');        mask-image: url('/images/stack/zsh.svg'); }
.stack-icon[data-icon="postgresql"] { -webkit-mask-image: url('/images/stack/postgresql.svg'); mask-image: url('/images/stack/postgresql.svg'); }
.stack-icon[data-icon="windows"]    { background-color: #4cb2ff; }
.stack-icon[data-icon="neovim"]     { background-color: #57a143; }
.stack-icon[data-icon="tmux"]       { background-color: #1bb91f; }
.stack-icon[data-icon="zsh"]        { background-color: #f15a24; }
.stack-icon[data-icon="postgresql"] { background-color: #6f9fd8; }

/* Three across means a narrower card again, so the body tightens up. */
.project-card-name { font-size: var(--fontSizeBodyL); }
.project-card-body { padding: var(--spaceM) var(--spaceM) var(--spaceM); gap: 6px; }
.project-card-desc { min-height: 2.6em; }

/* What I Do: copy and the desk photo side by side. */
.about-split { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: var(--space2XL); align-items: start; }
.about-split-media { margin: 0; }
.about-split-media img { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; border: 1px solid rgb(var(--rgbText) / 0.12); border-radius: 10px; display: block; }
@media (max-width: 860px) { .about-split { grid-template-columns: 1fr; } }
.about-photo { border-radius: 10px; }

/* Clear the floating contact-icon column on the right so page content
   never runs under it - the wider .page-main above brought the two close
   enough to touch at some widths. */
@media (min-width: 1025px) { .page-main { padding-right: 104px; } }

/* Both About photos identical in size, and smaller than the hero photo
   was - one 4:5 frame at one width, used in the hero and in What I Do. */
/* min(), not a bare 320px: below ~360px the fixed width was wider than
   the column it sits in, and a grid item's automatic minimum is its
   min-content, so the whole page grew to 376px and the hamburger went
   off-screen. */
.about-photo-wrap { width: min(320px, 100%); }
.about-photo { aspect-ratio: 4 / 5; }
.about-split { grid-template-columns: minmax(0, 1fr) 320px; }
.about-split-media img { aspect-ratio: 4 / 5; border-radius: 10px; }
@media (max-width: 860px) {
  .about-split { grid-template-columns: 1fr; }
  .about-split-media { max-width: min(320px, 100%); min-width: 0; }
}

/* 128px left cleared the side-nav but left only ~13px between the logo's
   right edge (115px) and the first column. The logo carries a soft glow
   that spills past its box, so that gap read as the logo sitting on the
   content. 160px clears the glow as well. */
/* Measured at 1440: the side-nav occupies 34-77px and the logo, which
   only exists at the very top, reaches 116 - and the page's own top
   padding already starts content below it. So the gutter only has to
   clear the nav. 160px, then 112px, both left an obvious dead strip. */
@media (min-width: 1025px) { .page-main { padding-left: 128px; } }

/* $ whoami larger again - it is the heading the removed "About Me" used
   to be, so it carries that weight now. */
.about-whoami-label { font-size: var(--fontSizeH5); margin-bottom: var(--spaceS); }

/* What I Do + Services beside one taller photo. Sticky so it stays with
   the reader down the length of the Services list instead of scrolling
   off after the first paragraph. */
.about-split-tall { grid-template-columns: minmax(0, 1fr) 400px; align-items: start; }
.about-split-tall .about-split-text { display: flex; flex-direction: column; gap: var(--space3XL); }
.about-split-tall .about-split-media { position: sticky; top: calc(var(--navbarHeight) + var(--spaceXL)); }
.about-split-tall .about-split-media img { aspect-ratio: 3 / 4; }
@media (max-width: 960px) {
  .about-split-tall { grid-template-columns: 1fr; }
  .about-split-tall .about-split-media { position: static; max-width: 360px; }
}

/* .about-text carried max-width: 780px, which is why the About copy sat in
   a narrow column no matter how wide the page got - and being declared
   later it was also undoing the smaller size set for the wider measure.
   Both settled here: a much longer line, one step down in size and a
   little more leading so the longer line stays readable. */
.about-text { max-width: 68rem; font-size: var(--fontSizeBodyS); line-height: 1.7; }

/* Not sticky. It followed the reader down the Services list, which reads
   as the image scrolling rather than the page - it just sits at the top
   of the row now. */
.about-split-tall .about-split-media { position: static; }

/* The close button is absolutely positioned against .project-modal, so
   when the modal itself was the scroll container the button scrolled away
   with the content. The modal no longer scrolls - its body does - which
   pins the button to the modal's own top-right corner at any scroll
   position. */
.project-modal { display: flex; flex-direction: column; overflow: visible; }
/* Same reason as .project-modal above: the full-bleed preview is wider
   than this box on purpose, so this box must clip sideways rather than
   grow a scrollbar along the bottom of the modal. */
.project-modal-body { overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; min-height: 0; }

/* Video preview sits in the same 16:9 frame as the image ones. */
.project-preview-video { object-fit: cover; background: #000; }
/* Reduced motion: JS pauses it (see below) so the poster frame shows
   rather than hiding the element and leaving an empty card. */

/* Skills now sit under Services rather than as their own section. */
.about-subhead { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyL); font-weight: 600; margin: var(--space2XL) 0 var(--spaceL); color: rgb(var(--rgbText) / 0.9); }

/* Hero: photo and copy share a vertical centre line so the text block
   reads as set against the portrait rather than floating above it. */
.about-hero { align-items: center; }

/* html5 was fetched but never given a mask rule, so it painted as a plain
   filled square (the grey block on dugout / longshot / wishlist). */
.stack-icon[data-icon="html5"] { -webkit-mask-image: url('/images/stack/html5.svg'); mask-image: url('/images/stack/html5.svg'); background-color: #e34f26; }

/* Rounded to match the project cards. */
.widget-input, .global-search-results { border-radius: 8px; }
.widget-button, .widget-select { border-radius: 8px; }
.footer-ruler { border-radius: 8px 0 0 8px; }
.footer-mode { border-radius: 0 8px 8px 0; }
.global-search-hotkey { border-radius: 6px; }

/* Services: no numbering (Services and Skills are one list now), and three
   columns so the merged set stays compact and fills the width under the
   What I Do photo instead of running down a narrow column. */
.about-services .service-groups { grid-template-columns: repeat(3, 1fr); column-gap: var(--space2XL); row-gap: var(--space2XL); }
.about-services .service-group-title::before { content: none; }
@media (max-width: 1100px) { .about-services .service-groups { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 760px)  { .about-services .service-groups { grid-template-columns: 1fr; } }

/* Global search: the rounding was on the input, but the wrapper draws the
   visible outer border, so the corners still squared off. */
.global-search { border-radius: 8px; overflow: hidden; }
.nav-search-panel .global-search { border-radius: 8px; }

/* About body: single flow with the photo floated right. A two column grid
   kept Services trapped in the narrow left column; floating lets the text
   sit beside the photo and then reclaim the full width beneath it. */
.about-flow { display: block; }
.about-flow .about-split-media { float: right; width: 400px; margin: 0 0 var(--spaceXL) var(--space2XL); }
.about-flow > section { margin-bottom: var(--space3XL); }
.about-flow > section:last-child { margin-bottom: 0; }
.about-flow::after { content: ''; display: block; clear: both; }

/* Full page video background, same box as .project-bg-image. */
.project-bg-video { position: absolute; inset: -5%; width: 110%; height: 110%; object-fit: cover; display: none; filter: saturate(1.15) brightness(1.1); }

/* Tighten the flow so Services starts right under What I Do, beside the
   photo, instead of being pushed past the bottom of the float. The photo
   then spans both headings and the group list wraps under it. */
.about-flow > section { margin-bottom: var(--spaceXL); }
.about-flow > section:first-of-type { margin-bottom: var(--spaceL); }
.about-flow .about-section-title { margin-bottom: var(--spaceM); }
.about-flow .about-split-media { margin-bottom: var(--spaceL); }
/* Groups sit tighter too, so the block does not sprawl. */
.about-services .service-groups { row-gap: var(--spaceXL); margin-top: var(--spaceL); }
.about-services .service-group-title { margin-bottom: var(--spaceS); }

/* The float has to outlast What I Do for Services to sit beside it and for
   the group list to wrap underneath, so the frame is taller than the 4:5
   used elsewhere. */
.about-flow .about-split-media { width: 380px; }
.about-flow .about-split-media img { aspect-ratio: 2 / 3; }

/* Narrower float leaves a wider text column, so What I Do wraps in fewer
   lines and ends while the photo is still running - which is what lets
   Services start alongside it and the groups wrap underneath. */
.about-flow .about-split-media { width: 330px; margin-left: var(--spaceXL); }
.about-flow .about-split-media img { aspect-ratio: 9 / 16; }

/* The narrow-viewport case has to come AFTER the three float widths
   above, not before them: it used to sit up beside the float rule it
   overrides, and every later re-declaration of the same selector won.
   The result was a 330px photo plus a 32px left margin inside a 272px
   column, so at 320px the page itself grew to 376px and took the
   hamburger off screen with it. */
@media (max-width: 900px) {
  .about-flow .about-split-media { float: none; width: 100%; max-width: 320px; margin: 0 0 var(--spaceL); }
}
.about-flow .about-text { margin-bottom: var(--spaceM); }

/* Services groups: balanced multi-column flow rather than a fixed grid.
   The grid put one group per cell, so Infrastructure's eleven pills made
   its cell tall while Development and Tools left most of theirs empty.
   Multi-column balances the total height across the columns and fills the
   space; break-inside keeps a group from splitting across a column. */
.about-services .service-groups {
  display: block;
  columns: 3;
  column-gap: var(--space2XL);
  margin-top: var(--spaceL);
}
.about-services .service-group {
  break-inside: avoid;
  -webkit-column-break-inside: avoid;
  page-break-inside: avoid;
  margin: 0 0 var(--spaceXL);
}
@media (max-width: 1100px) { .about-services .service-groups { columns: 2; } }
@media (max-width: 760px)  { .about-services .service-groups { columns: 1; } }

/* Section rhythm: real space between blocks and a hairline between them.
   Only the full-width sections get the rule - Services sits alongside the
   floated photo, so a rule there would run underneath the picture. */
.about-content > section {
  margin-top: var(--space3XL);
  padding-top: var(--space3XL);
  border-top: 1px solid rgb(var(--rgbText) / 0.1);
}
.about-flow { margin-top: var(--space2XL); }
.about-flow > section + section { margin-top: var(--space2XL); }

/* Every section lives inside .about-flow, so the earlier
   `.about-content > section` rule matched nothing and no separators drew.
   Sections three onward (Current Focus, Get in Touch) are the full-width
   ones below the floated photo, so they take the rule and the spacing;
   What I Do and Services sit alongside the picture and are separated by
   space alone, since a hairline there would run under the image. */
.about-flow > section:nth-of-type(n+3) {
  clear: both;
  margin-top: var(--space3XL);
  padding-top: var(--space3XL);
  border-top: 1px solid rgb(var(--rgbText) / 0.12);
}
.about-flow > section:nth-of-type(2) { margin-top: var(--space2XL); }

/* Explicit class rather than a positional selector: the sections' nesting
   made :nth-of-type and child selectors unreliable to target. */
.about-block {
  clear: both;
  margin-top: var(--space3XL);
  padding-top: var(--space3XL);
  border-top: 1px solid rgb(var(--rgbText) / 0.12);
}

/* The typewriter retypes "Trevor Gray" / "srdusr" character by character,
   so the h1's width changed every frame and nudged the tagline under it.
   A min-height plus a stable block keeps the line box fixed while the
   characters change. */
.intro-name { display: block; min-height: 1.15em; }
.intro-tagline { margin-top: 0; }

/* Global search: the rounded corner has to be on the element that draws
   the border, and the input inside must not paint square corners over it. */
.global-search { border-radius: 10px; overflow: hidden; }
.global-search .widget-input { border-radius: 10px; border: none; }
.navbar-context .global-search, .nav-search-panel .global-search { border-radius: 10px; }

/* L-shape around the photo. Multi-column fixes its column widths where the
   block starts, so the columns stayed narrow even after the float ended and
   nothing ever ran under the picture. Inline-block groups flow as inline
   content instead: they wrap in the narrowed space beside the photo, then
   the line boxes widen once past the float's bottom and the remaining
   groups use the full width underneath it. */
.about-services .service-groups {
  display: block;
  columns: auto;
  column-gap: normal;
  margin-top: var(--spaceL);
  font-size: 0;                 /* kills inline-block whitespace gaps */
}
.about-services .service-group {
  display: inline-block;
  vertical-align: top;
  width: 260px;
  margin: 0 var(--spaceXL) var(--space2XL) 0;
  font-size: var(--fontSizeBodyS);
}
@media (max-width: 760px) { .about-services .service-group { width: 100%; margin-right: 0; } }

/* The wrapper's overflow:hidden was clipping the input's diffused
   box-shadow halo into a hard rectangle, which is the square "blur
   border" that kept showing. No clipping, and a larger radius so the
   halo's outer corners (radius + spread) read as properly round. */
.global-search { overflow: visible; border-radius: 0; }
.global-search .widget-input { border-radius: 16px; }
.global-search-results { border-radius: 12px; }

/* Contact icons, floating column and mobile menu alike. */
.footer-fixed-icons a, .mobile-menu-social a { border-radius: 10px; }
.footer-fixed-icons { border-radius: 12px; }

/* Groups were a fixed 260px, so they tiled to whatever fitted and left the
   rest of the row empty - the dead strip down the right. Proportional
   widths make them fill the line instead, while staying inline-level so
   they still wrap around the floated photo into the L. */
/* The -24 and the +24 cancel, so the column PITCH is exactly this
   percentage of the container: width + margin-right = P% - 24 + 24.
   At 1440 the container is 1114 and the floated photo leaves 752 beside
   it, so three columns need a pitch under 250 (22.5%) and four below the
   photo need one over 223 (20%). At 33.333% only two fitted beside the
   photo and ~250px of dead space sat between the second group and the
   frame, which is the hole this closes. */
.about-services .service-group {
  width: calc(22% - var(--spaceL));
  margin: 0 var(--spaceL) var(--space2XL) 0;
}
@media (max-width: 1100px) { .about-services .service-group { width: calc(33.333% - var(--spaceL)); } }
@media (max-width: 860px)  { .about-services .service-group { width: calc(50% - var(--spaceL)); } }
@media (max-width: 560px)  { .about-services .service-group { width: 100%; margin-right: 0; } }

/* Pill halo: at radius 16 + 14px spread the corners still read squarish.
   Fully rounded leaves no corner for the blur to fight. */
.global-search .widget-input { border-radius: 999px; padding-left: 16px; }
.global-search { overflow: visible; }


/* ==========================================================================
   Motion, part two: the cut and the scroll indicator
   ========================================================================== */
/* The sword swing. Section separators are already a hairline; when the
   section arrives, a bright stroke sweeps along that same line and fades,
   leaving the hairline behind. The blade passes through the page rather
   than a new decoration appearing on it, which is the whole point - and
   it costs one pseudo-element, no extra markup and no library. */
@keyframes bladeCut {
  0%   { transform: scaleX(0); opacity: 1; }
  55%  { transform: scaleX(1); opacity: 1; }
  100% { transform: scaleX(1); opacity: 0; }
}
.reveal-active .about-block,
.reveal-active .about-flow > section:nth-of-type(n+3),
.reveal-active .article-item { position: relative; }
.reveal-active .about-block::before,
.reveal-active .about-flow > section:nth-of-type(n+3)::before {
  content: ''; position: absolute; left: 0; right: 0; top: -1px; height: 1px;
  background: rgb(var(--rgbPrimary)); transform: scaleX(0); transform-origin: left center;
  pointer-events: none;
}
.reveal-active .article-item::before {
  content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px;
  background: rgb(var(--rgbPrimary)); transform: scaleX(0); transform-origin: left center;
  pointer-events: none;
}
.reveal-active .about-block.is-revealed::before,
.reveal-active .about-flow > section:nth-of-type(n+3).is-revealed::before,
.reveal-active .article-item.is-revealed::before {
  animation: bladeCut var(--durationXL) var(--bezierFastoutSlowin) var(--revealDelay, 0s) forwards;
}
/* .article-item:last-child drops its border, so it must not draw a cut
   along an edge that has no line. */
.reveal-active .article-item:last-child::before { content: none; }
@media (prefers-reduced-motion: reduce) {
  .reveal-active .about-block::before,
  .reveal-active .about-flow > section:nth-of-type(n+3)::before,
  .reveal-active .article-item::before { content: none; }
}

/* The scroll cue. No label: a mouse with a falling wheel already says
   scroll, and a word under it said the same thing twice. It sits low,
   just clear of the statusline, and it BRIGHTENS as the gesture builds
   (--cueForce, set by initHomeScrollNav) so there is something to push
   against rather than a hair trigger. */
.scroll-cue {
  position: fixed; left: 50%; bottom: calc(36px + var(--spaceS)); transform: translateX(-50%);
  z-index: var(--zIndex3); display: flex; align-items: center; justify-content: center;
  /* It is a <button>, so the UA background and border have to be cleared -
     without this it renders as a light grey box around the mouse. */
  background: none; border: none;
  padding: var(--spaceS); cursor: pointer; text-decoration: none;
  color: rgb(var(--rgbPrimary) / calc(0.75 + 0.25 * var(--cueForce, 0)));
  transition: opacity var(--durationM) ease, color var(--durationS) ease;
}
.scroll-cue:hover { color: rgb(var(--rgbPrimary)); }
.scroll-cue.is-gone { opacity: 0; pointer-events: none; }
.scroll-cue-mouse { overflow: visible; }
.scroll-cue-mouse rect:first-child {
  filter: drop-shadow(0 0 calc(7px * var(--cueForce, 0)) rgb(var(--rgbPrimary) / var(--cueForce, 0)));
}
.scroll-cue-wheel { animation: scrollWheel 1.8s var(--bezierFastoutSlowin) infinite; }
@keyframes scrollWheel {
  0%   { transform: translateY(0); opacity: 0; }
  25%  { opacity: 1; }
  75%  { transform: translateY(11px); opacity: 0; }
  100% { transform: translateY(11px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .scroll-cue-wheel { animation: none; } }

/* ==========================================================================
   One-page mode
   ========================================================================== */
/* Hidden until the hard scroll unlocks it (initOnePage). The hero stays
   exactly one viewport until then, so the default really is a one-screen
   home page with separate pages behind the nav. */
/* Fade only. A transform on this element - even one animating to `none` -
   makes it the containing block for its sticky descendants, which was the
   second reason the preview would not stick inside one-page mode. */
.onepage { animation: onepageIn var(--durationL) var(--bezierFastoutSlowin) both; }
@keyframes onepageIn { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .onepage { animation: none; } }

/* The injected sections ARE the real pages, each carrying its own
   .page-main with its own padding, so this wrapper adds none - a second
   lot would double every gutter. min-height 0 and no rule between them:
   they should run into each other, not be filed. */
/* No padding of its own: the injected page's own .page-main already
   carries the site's gutters, and adding them again indented every
   section by a second full gutter. */
.onepage-section { position: relative; z-index: 1; padding: 0; }
.onepage-section:last-child .page-main { padding-bottom: calc(var(--space4XL) + 36px); }
/* .page-main's own min-height: 100vh is right for a standalone page and
   wrong for a section in a stack - it opens a screenful of gap wherever
   a page happens to be short. */
.onepage-section .page-main { min-height: 0; }
/* Writeups and Gallery are panes of the blog section, not sections. */
.onepage-pane { padding: 0; }
/* A pane sits INSIDE the blog section's own .page-main, so it must add
   no padding of its own - left included, or its content is indented by
   the gutter twice and the list column collapses. */
.onepage-pane .page-main { padding: 0; min-height: 0; max-width: none; }
.onepage-pane[hidden] { display: none; }
/* [hidden] is a UA rule at attribute specificity, so any class rule that
   sets display beats it - .article-list-container is display: flex, so
   hiding it with the attribute alone left it on screen while the DOM
   said it was hidden. */
.article-list-container[hidden] { display: none; }
/* The context filters sit in the navbar row, not in a dropdown. */
.navbar-context .widget-row .nav-search-filters { display: flex; align-items: center; gap: var(--spaceS); margin: 0; }
.navbar-context .widget-row .nav-search-filters:empty { display: none; }
.onepage-title { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeH3); font-weight: 700; margin: 0 0 var(--spaceXL); }
.onepage-title a { color: rgb(var(--rgbPrimary)); text-decoration: none; }
.onepage-title a:hover { color: rgb(var(--rgbText)); }
.onepage-lead { color: rgb(var(--rgbText) / 0.75); font-size: var(--fontSizeBodyL); line-height: var(--lineHeightBody); margin: 0 0 var(--spaceXL); max-width: var(--maxWidthM); }
.onepage-body { color: rgb(var(--rgbText) / 0.8); line-height: var(--lineHeightBody); margin: 0 0 var(--spaceM); max-width: var(--maxWidthM); }
.onepage-more { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyS); margin: var(--spaceXL) 0 0; }
.onepage-more a { color: rgb(var(--rgbPrimary)); text-decoration: none; border-bottom: 1px solid transparent; }
.onepage-more a:hover { border-bottom-color: rgb(var(--rgbPrimary)); }

.onepage-list { border-top: 1px solid rgb(var(--rgbText) / 0.12); max-width: var(--maxWidthXL); }
.onepage-row { display: grid; grid-template-columns: minmax(0, 18rem) minmax(0, 1fr) auto; gap: var(--spaceL); align-items: baseline; padding: var(--spaceM) 0; border-bottom: 1px solid rgb(var(--rgbText) / 0.12); }
.onepage-row-name { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-weight: 600; color: rgb(var(--rgbText)); text-decoration: none; overflow-wrap: anywhere; }
.onepage-row-name:hover { color: rgb(var(--rgbPrimary)); }
.onepage-row-desc { margin: 0; color: rgb(var(--rgbText) / 0.7); font-size: var(--fontSizeBodyS); }
.onepage-row-meta { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: var(--fontSizeBodyXS); color: rgb(var(--rgbText) / 0.5); white-space: nowrap; }
@media (max-width: 768px) {
  .onepage-row { grid-template-columns: 1fr; gap: var(--spaceXS); }
  .onepage-title { font-size: var(--fontSizeH4); }
}

/* ==========================================================================
   Mobile bar, menu and About density
   ========================================================================== */

/* The bar's controls are three plates over a transparent strip, so they
   have to read as the same object. Square corners made them read as
   patches of page background instead. */
.hamburger { border-radius: 12px; }
@media (max-width: 1024px) {
  .logo { border-radius: 12px; padding: 2px 10px; }
  .nav-search-toggle, .nav-icons .media-toggle { border-radius: 12px; }
  /* Sound is not navigation and it is wanted while reading the page, not
     while a full-screen menu covers it - so it stays in the bar next to
     search rather than moving into the menu at this width. */
  .nav-icons .media-toggle {
    display: flex; align-items: center; justify-content: center;
    width: 48px; height: 48px; padding: var(--spaceS);
    /* Flat plate, not the desktop control's round scrim - it is one of
       four buttons in a row here and they share a shape. */
    background: rgb(var(--rgbBackground)); box-shadow: none;
  }
  /* The menu owns the screen while it is open; the bar's own controls
     would be a second, competing set of buttons on top of it. */
  body.menu-open .nav-icons { opacity: 0; pointer-events: none; }
  /* Two modes, one row: the search takes the bar over, so the sound
     button steps aside for it rather than squeezing the field. */
  body.nav-search-open .nav-icons .media-toggle { display: none; }
}

/* The panel used to paint a full-height slab from the left edge to the
   icons, straight over where the wordmark sits - it read as a hole
   punched in the bar. A shorter, rounded, vertically centred pill reads
   as a control that opened. */
.nav-search-panel.open {
  height: 44px; top: calc((var(--navbarHeight) - 44px) / 2);
  border-radius: 999px; padding: 0 var(--spaceXS) 0 0;
}
/* No fade on the way out: the wordmark has to be gone BEFORE the panel
   grows over it, or the two are on screen together for the length of the
   transition and overlap. It fades back in when the panel closes. */
body.nav-search-open .navbar-left { transition: none; }
/* Search is a mode, and the bar becomes a search bar while it is on. The
   bar is transparent by design, so with only per-control plates the page
   text underneath went on showing through the gaps between the field and
   the icons - a row of body copy sliced into fragments. The same is true
   of any scrolled page, hence body.scrolled: the plates hold the controls
   off the hero at the top of the page, and the bar takes a ground of its
   own as soon as content starts passing under it. */
@media (max-width: 1024px) {
  body.scrolled .navbar,
  body.nav-search-open .navbar {
    background: rgb(var(--rgbBackground));
    border-bottom: 1px solid rgb(var(--rgbText) / 0.12);
  }
  body.scrolled .logo,
  body.scrolled .nav-search-toggle,
  body.scrolled .nav-icons .media-toggle,
  body.scrolled .hamburger,
  body.nav-search-open .nav-search-toggle,
  body.nav-search-open .hamburger { background: none; }
  /* The menu covers the bar completely, so a ground under it would just
     be a stripe of a slightly different colour across the overlay. */
  body.menu-open .navbar { background: none; border-bottom: none; }
}

/* Contact icons sit low on the right rather than centred, clear of the
   menu items. */
.mobile-menu-social {
  top: auto; transform: none;
  bottom: calc(var(--space4XL) + env(safe-area-inset-bottom));
}

/* Statusline section readout. One-page mode only - a normal page has its
   own title and the browser's own address bar. */
.footer-section { display: none; }
body.onepage-open .footer-section {
  display: inline-block; height: 36px; line-height: 36px;
  color: rgb(var(--rgbPrimary)); font-weight: 600;
  background: rgb(var(--rgbBackground) / 0.35);
  box-shadow: 0 0 20px 12px rgb(var(--rgbBackground) / 0.28);
  padding: 0 var(--spaceS); margin-left: calc(var(--spaceS) * -1);
}
/* Below 1024px the bar is too narrow for both, and the section is the
   half that carries information. */
@media (max-width: 1024px) {
  body.onepage-open .footer-copyright { display: none !important; }
}

/* About: 11 groups of pills at desktop sizing ran to 2600px on a phone,
   about three screens of nothing but chips. Same content, packed. */
@media (max-width: 760px) {
  .about-services .service-groups { font-size: 0; }
  .about-services .service-group { margin-bottom: var(--spaceL); }
  .about-services .service-group-title { font-size: var(--fontSizeBodyM); margin-bottom: var(--spaceS); }
  .skills-pills { gap: 6px; }
  .skill-pill { font-size: 0.72rem; padding: 4px 9px; }
}

/* About was the one page whose text ran edge to edge below 1025px. Both
   .about-page padding rules use the shorthand, which zeroes the
   horizontal padding; the desktop gutter is re-set later in the file and
   wins, but nothing re-set it for mobile. Same gutter as .page-main, at
   the same two breakpoints, and last so neither shorthand overrides it. */
@media (max-width: 1024px) { .about-page { padding-left: var(--spaceL); padding-right: var(--spaceL); } }
@media (max-width: 768px)  { .about-page { padding-left: var(--spaceM); padding-right: var(--spaceM); } }
