/* ═══════════════════════════════════════════════════════════════════════════
   sacatani.com — the public blog.

   The brief, in one line: paper, almost no ornament, plain-spoken, and full of
   diagrams. Those pull in the same direction — if the page recedes far enough,
   the figures become the only things on it with visual weight, which is where
   the weight belongs.

   So: one ground, one ink, one link colour, no cards, and two hairlines on the
   whole page. Everything that looks like design here is typography and space.

   THE TWO VOICES
   Source Serif 4 is Ryan talking. IBM Plex Sans and Plex Mono are the machine
   talking — dates, figure numbers, captions, code, tags, filenames. Keeping
   them apart is what lets a post full of IPv6 addresses still read like a
   person explaining something.

   THE TWO VIEWS                                            added 16 Sep 2026
   This file used to end with "committed to paper, no dark theme, deliberately:
   a charcoal version is a second design, not this one adapted." That was true
   and it is now overruled, by Ryan, who asked for both: a PROFESSIONAL view for
   anyone here to read, and a HACKER view for anyone here because of what the
   reading is about. Both are the same document; see "The two views" at the foot
   of this file.

   What kept the objection honest is that the second design costs almost nothing
   here. Every diagram under static/img was hand-drawn in THESE EXACT TOKEN
   HEXES — thirteen colours across all nine files — and build.mjs inlines them
   into the document, so a remap at the bottom of this file re-grounds all of
   them at once. No SVG was edited and the paper view is byte-for-byte the
   design it was.

   PAPER IS STILL THE DEFAULT, and prefers-color-scheme is deliberately not
   consulted. A stranger arriving cold gets the reading view; the terminal is
   something you choose.
   ═══════════════════════════════════════════════════════════════════════ */

:root {
  --paper:     #fbfaf8;
  --paper-sunk:#f2f1ec;
  --ink:       #1c1b1a;
  --ink-soft:  #56534e;
  --ink-faint: #8b877f;
  --hair:      #e4e2db;

  /* The one colour on the page. Quiet manuscript blue: obvious as a link,
     invisible as a brand. */
  --link:      #26456b;
  --link-rule: rgba(38, 69, 107, 0.32);

  /* Three inks that were written as bare hexes until the second view existed.
     The lede and a pull quote sit a shade off --ink on purpose: large type at
     full ink weight reads heavier than the same colour at 19px. */
  --ink-lede:  #33312e;
  --ink-quote: #2c2a27;
  --select:    #e8e4d8;

  /* The terminal block. One dark rectangle on a paper page, and the only place
     the design raises its voice. --shot-hair is unused in this view: the
     rectangle is its own edge against the paper. */
  --shot-bg:   #14181b;
  --shot-ink:  #c8d0cd;
  --shot-hair: transparent;

  /* The category accents, and in THIS view all six are the same one ink.
     That is not a placeholder, it is the paper brief: one ground, one ink, one
     link colour. The filing is shown here with rules, labels and space, and the
     colour is spent in the hacker view instead.

     They are still declared, for two reasons. A token that exists in one view
     and not the other is the failure buildtest.mjs looks for, because it falls
     back to nothing and silently invalidates the declaration that used it. And
     if a shared rule ever reaches for an accent, this is what it should get on
     paper rather than an unset property. */
  --c1: var(--ink-faint);
  --c2: var(--ink-faint);
  --c3: var(--ink-faint);
  --c4: var(--ink-faint);
  --c5: var(--ink-faint);
  --c6: var(--ink-faint);

  /* ── The diagram palette ──────────────────────────────────────────────────
     Its own namespace, and NOT an alias of the page tokens above, because a
     diagram's ground is not the page's ground the moment a figure sits on a
     panel — and because a diagram needs ink relationships the prose never
     does: an attacker red, a purple team, a safe green.

     In this view every one of these is the hex the diagrams were drawn in, so
     the remap at the foot of this file is an identity mapping and the paper
     view is provably unchanged. The hacker view is where they earn their keep.

     Any NEW diagram must be drawn from this list. A stray hex is not an error
     anything would show you on a paper page; it simply stays baked and then
     glows on a dark ground. buildtest.mjs fails the build if one appears. */
  --dg-ink:       #1c1b1a;
  --dg-soft:      #56534e;
  --dg-faint:     #8b877f;
  --dg-hair:      #c9c5bc;
  --dg-hair-soft: #dcd9d1;
  --dg-hair-warm: #dedad1;
  --dg-ground:    #fbfaf8;
  --dg-panel:     #f6f5f1;
  --dg-sunk:      #f2f1ec;
  --dg-link:      #26456b;
  --dg-red:       #8a2f2f;
  --dg-purple:    #5a3f7a;
  --dg-green:     #3d5c43;

  --serif: "Source Serif 4", Charter, Georgia, "Times New Roman", serif;
  --sans:  "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
  --mono:  "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
  --hacker-body: var(--serif);
  --hacker-display: var(--serif);

  /* Two widths and no others: one to read at, one for figures that need room.
     The reading column sits hard against the left of the content lane so text
     keeps one edge and figures bleed right into the space. */
  --measure: 640px;
  --rail:    232px;
}

* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--serif);
  font-size: 19px;
  line-height: 1.7;
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

a { color: var(--link); }
:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
::selection { background: var(--select); }

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

.skip {
  position: absolute;
  left: -9999px;
  font-family: var(--sans);
  font-size: 14px;
}
.skip:focus { left: 16px; top: 16px; background: var(--paper); padding: 8px 12px; z-index: 10; }

/* ── Shell ──────────────────────────────────────────────────────────────────
   Rail on the left, one content lane on the right. The rail is sticky, so the
   rest of the writing stays in reach from anywhere in a post. */

.shell {
  max-width: 1216px;
  margin: 0 auto;
  padding-inline: 32px;
  display: grid;
  grid-template-columns: var(--rail) minmax(0, 1fr);
  column-gap: 64px;
  align-items: start;
}

/* Placed explicitly, because the rail comes after <main> in the document —
   see the comment in build.mjs. Grid placement is what puts it on the left
   without putting it first in the tab order. */
.rail {
  grid-column: 1;
  grid-row: 1;
  position: sticky;
  top: 0;
  padding-block: 40px 40px;
  max-height: 100vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--hair) transparent;
}

main { grid-column: 2; grid-row: 1; padding-block: 40px 0; min-width: 0; }

/* ── The rail ─────────────────────────────────────────────────────────────── */

/* ⚠ Photo, name and per-page nav lived here as `.rail-id` / `.portrait` /
   `.wordmark` / `.rail nav` until 17 Sep 2026, when they moved into the global
   `.site-header` (see the foot of this file). Removed rather than left dead:
   this codebase's own rule, restated in CLAUDE.md, is that a selector nobody
   reads any more is worse than no selector — the .rail-list .d incident a few
   sections up is exactly what happens when one survives a redesign. */
.tagline {
  font-size: 15.5px;
  line-height: 1.5;
  color: var(--ink-soft);
  margin: 0;
}

.rail-label {
  font-family: var(--sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 12px;
}

.rail-list { list-style: none; margin: 0 0 20px; padding: 0; }
.rail-list li { margin-bottom: 13px; }
.rail-list a {
  display: block;
  font-size: 15.5px;
  line-height: 1.35;
  color: var(--ink);
  text-decoration: none;
  letter-spacing: -0.004em;
}
.rail-list a:hover { color: var(--link); }
.rail-list li[aria-current] a { color: var(--ink-faint); }
/* ⚠ .rail-list .d — the period/date badge above each title — removed 17 Sep
   2026. It repeated near-identical text across every row of a daily series
   ("DAY 3 / 100 · AI CYBERSECURITY", "DAY 2 / 100 · AI CYBERSECURITY"...),
   which is what Ryan meant by "way too cluttered." The period still shows
   once, on the post's own page. Two other rules for this same class, at other
   breakpoints, are removed alongside this one rather than left to rot. */

.rail-more {
  font-family: var(--sans);
  font-size: 12.5px;
  color: var(--ink-soft);
  text-decoration: none;
  display: inline-block;
  margin-bottom: 30px;
}
.rail-more:hover { color: var(--link); }

.rail-foot {
  font-family: var(--sans);
  font-size: 12px;
  line-height: 1.6;
  color: var(--ink-faint);
  border-top: 1px solid var(--hair);
  padding-top: 16px;
}
.rail-foot p { margin: 0 0 5px; }
.rail-foot a { color: var(--ink-faint); text-decoration: none; }
.rail-foot a:hover { color: var(--link); }
.rail-foot .promise { color: var(--ink-soft); margin-bottom: 9px; }
/* Ryan's sign-off. Set in the serif, not the machine face — it is him talking,
   like the tagline, and the sans around it is for dates and labels. */
.rail-foot .signoff {
  font-family: var(--serif);
  font-size: 15px;
  line-height: 1.45;
  color: var(--ink);
  margin: 0 0 12px;
}
/* The door. Must be indistinguishable from a full stop — no colour, no
   underline, no cursor change, nothing on hover. See promiseLine() in
   build.mjs for what it is and what it is not. */
.rail-foot .door { color: inherit; text-decoration: none; cursor: text; }

/* ── A post ───────────────────────────────────────────────────────────────── */

.mermaid {
  overflow-x: auto;
  margin: 24px 0;
  padding: 14px;
  background: var(--paper, #fbfaf8);
}
.mermaid svg { max-width: 100%; height: auto; }
.diagram-source { margin: -14px 0 24px; font: 12px/1.5 var(--mono, monospace); }
.diagram-source summary { cursor: pointer; color: var(--ink-faint); }

.post > * { max-width: var(--measure); }

/* ── The header image ─────────────────────────────────────────────────────
   Added 16 Sep 2026, Ryan's rule from here on: every post gets one, funny and
   cybersecurity-related, related to what the post is actually about — a
   cartoon companion for the hacker view, a professional one for paper.

   ⚠ SIZED DOWN 16 Sep 2026, same day it shipped: Ryan's reaction on sight was
   "way too big." It first ran FULL LANE WIDTH like a `.wide` figure — reasoned
   at the time as "the cover, not evidence, so it should not read small and
   apologetic" — and that reasoning was wrong at this design's actual measure:
   an 880px-wide 16:9 image is ~495px tall, taller than a screen's worth of text
   before a reader has read a single word. Capped at 420px now, a third of that
   height, closer to a header than a hero banner. Left-aligned in the column
   like everything else on this page, not centred, so it does not become the
   one element that breaks the "hard against the left edge" rule the measure
   comment above describes.

   BOTH IMAGES ARE ALWAYS IN THE MARKUP; CSS PICKS ONE. See headerImages() and
   the img pair in build.mjs for why — the view can flip with no reload, and
   swapping a <img src> on click would mean a refetch and a flash of nothing on
   every single toggle. Two requests paid once, on the pattern the diagram
   remap and the stylesheet's own dark-view tokens already use elsewhere on
   this page. `.hdr-hacker` is absent-by-default and only shown once
   `[data-theme="hacker"]` is set, matching every other view-scoped rule in
   this file.

   `alt=""` on both: a decorative header sitting directly above a title that
   already says the same thing in words needs no separate announcement to a
   screen reader. */
.post-header {
  max-width: 420px;
  margin: 0 0 2em;
  border-radius: 3px;
  overflow: hidden;
  border: 1px solid var(--hair);
}
.post-header img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  /* ⚠ Added 16 Sep 2026 with the pixel-art header images. Every browser
     smooths an image when it is displayed smaller than its native size, which
     is exactly what happens here: a 1600px source shown at 420px wide. Without
     this, a crisp hand-pixelated block turns back into a blur, undoing the
     entire point of the style. `pixelated` is the standard name; `crisp-edges`
     is the fallback Firefox understood first. Harmless on a photograph too —
     these are the only two image contexts on the site, and both are pixel art
     now, so nothing here needs to be conditional. */
  image-rendering: pixelated;
  image-rendering: crisp-edges;
}
/* ⚠ SPECIFICITY, NOT SOURCE ORDER. `.post-header img { display: block; }` above
   is one class plus one type selector — (0,1,1). A bare `.hdr-hacker { display:
   none; }` is one class alone — (0,1,0), LOWER, so the base rule would win and
   both images would render stacked regardless of which came second in the file.
   Caught in a screenshot: paper view showed the professional illustration
   sitting directly above its own cartoon twin, doubling the page's height and
   its weight. Every rule here repeats `.post-header img` so it can only ever be
   beaten by something written to be more specific still. */
.post-header img.hdr-hacker { display: none; }
:root[data-theme="hacker"] .post-header img.hdr-pro { display: none; }
:root[data-theme="hacker"] .post-header img.hdr-hacker { display: block; }

@media (max-width: 620px) {
  .post-header { margin-bottom: 1.6em; border-radius: 2px; }
}

.meta {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 14px;
}

.post h1 {
  font-size: clamp(32px, 5vw, 43px);
  font-weight: 600;
  line-height: 1.12;
  letter-spacing: -0.021em;
  margin: 0 0 20px;
  text-wrap: balance;
}

.lede {
  font-size: 21.5px;
  line-height: 1.58;
  margin: 0 0 1.5em;
  color: var(--ink-lede);
}

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

.post h2 {
  font-size: 25px;
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.014em;
  margin: 2.1em 0 0.55em;
  text-wrap: balance;
}
.post h3 { font-size: 19.5px; font-weight: 600; margin: 1.7em 0 0.4em; }

.post a {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2.5px;
  text-decoration-color: var(--link-rule);
  transition: text-decoration-color 120ms ease;
}
.post a:hover { text-decoration-color: var(--link); }

.post ul, .post ol { margin: 0 0 1.3em; padding-left: 1.15em; }
.post li { margin-bottom: 0.42em; padding-left: 0.15em; }
.post li::marker { color: var(--ink-faint); }

/* Inline machine things: a path, a flag, a hostname. Sunk, never boxed. */
.post code, .archive code, .page-body code {
  font-family: var(--mono);
  font-size: 0.855em;
  background: var(--paper-sunk);
  padding: 0.1em 0.32em;
  border-radius: 2px;
}

/* An aside is still Ryan talking, so it stays serif — smaller, softer, set in
   from the margin. A sans aside would read as a system message. */
.post blockquote {
  margin: 1.9em 0;
  padding: 0 1.7em;
  font-size: 22px;
  line-height: 1.5;
  color: var(--ink-quote);
  border: 0;
}

.post pre {
  font-family: var(--mono);
  background: var(--paper-sunk);
  padding: 16px 18px;
  margin: 0 0 1.4em;
  font-size: 14.5px;
  line-height: 1.66;
  overflow-x: auto;
  border-radius: 2px;
}
.post pre code { background: none; padding: 0; font-size: inherit; }

.post hr { border: 0; border-top: 1px solid var(--hair); margin: 2.4em 0; max-width: var(--measure); }

/* ── The figure system ──────────────────────────────────────────────────────
   Three widths and nothing else: text (inside the measure), wide (the whole
   content lane), and pair (two side by side). Diagrams, screenshots and
   photographs all use the same three, always numbered, always captioned in the
   sans face so a caption is never mistaken for prose.

   In markdown:  ![caption](/img/thing.png)        → text
                 ![caption](/img/thing.png#wide)   → wide
                 ![caption](/img/a.png#pair) ![caption](/img/b.png#pair)
   ─────────────────────────────────────────────────────────────────────────── */

.post figure { margin: 2em 0 2.3em; max-width: var(--measure); }
.post figure.wide, .post figure.pair { max-width: 100%; }
.post figure svg, .post figure img { display: block; width: 100%; }

.pair-inner { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }

.post figcaption {
  font-family: var(--sans);
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--ink-soft);
  margin-top: 11px;
  max-width: var(--measure);
}
.post figcaption b { font-weight: 600; color: var(--ink-faint); }

/* A terminal is content, not a component — one dark rectangle, no fake window
   chrome. It is the only dark thing on the page, which is exactly why it always
   reads as "here is the machine". Written in markdown as a ```term block. */
.shot {
  background: var(--shot-bg);
  color: var(--shot-ink);
  border: 1px solid var(--shot-hair);
  font-family: var(--mono);
  font-size: 13px;
  line-height: 1.75;
  padding: 18px 20px;
  overflow-x: auto;
  border-radius: 2px;
  white-space: pre;
}

.endnote {
  font-family: var(--sans);
  font-size: 13.5px;
  line-height: 1.62;
  color: var(--ink-faint);
  margin: 3.2em 0 0;
  padding-top: 22px;
  border-top: 1px solid var(--hair);
  max-width: var(--measure);
}
.endnote a { color: var(--ink-soft); }

/* The publication date, at the very bottom of a post. Small and faint: it is a
   fact for the record, not something a reader needs before they start. */
.colophon-date {
  font-family: var(--sans);
  font-size: 12.5px;
  color: var(--ink-faint);
  margin-top: 2.4em;
}


/* ── Archive and standalone pages ─────────────────────────────────────────── */

.archive > *, .page-body > * { max-width: var(--measure); }

.archive h1, .page-body h1 {
  font-size: 32px;
  font-weight: 600;
  letter-spacing: -0.018em;
  margin: 0 0 8px;
}
.archive .blurb { font-size: 18px; color: var(--ink-soft); margin: 0 0 44px; }

.entries { list-style: none; margin: 0; padding: 0; }
.entries li { margin-bottom: 26px; }

/* ── The thumbnail ────────────────────────────────────────────────────────
   Added 16 Sep 2026: Ryan wanted the header pictures reachable by scrolling the
   archive alone, not only after clicking into a post — "so that people could
   really understand what the blog is about just based on looking at the
   pictures."

   ⚠ SIZED UP THE SAME DAY: a 96px square crop was Ryan's next reaction —
   "too small, not even visible." Widened to 160×90, the same 16:9 shape the
   post header itself uses, rather than a bigger SQUARE crop: cropping to a
   square cuts even further into art that already has generous empty space
   around its subject, which was his other complaint about the same images.
   Matching the header's own aspect at least shows the full composition,
   proportionally shrunk, instead of showing less of it at a bigger size.

   `li.has-thumb` only — a post with no header images keeps the plain single-
   column row exactly as it was, no empty gap where a thumbnail would be. */
.entries li.has-thumb {
  display: grid;
  grid-template-columns: 160px minmax(0, 1fr);
  gap: 20px;
  align-items: start;
}
.entry-thumb {
  display: block;
  width: 160px;
  height: 90px;
  border-radius: 3px;
  overflow: hidden;
  border: 1px solid var(--hair);
}
.entry-thumb img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Same reasoning as .post-header img: shrunk further here (1600px source
     into a 160px box), so the smoothing this prevents would be worse, not
     better. */
  image-rendering: pixelated;
  image-rendering: crisp-edges;
}
/* Same specificity fight as the post header, same fix: the toggle rule must
   repeat the full `.entry-thumb img` chain or the base `display:block` above
   outranks it and both crops render stacked inside the one box. */
.entry-thumb img.hdr-hacker { display: none; }
:root[data-theme="hacker"] .entry-thumb img.hdr-pro { display: none; }
:root[data-theme="hacker"] .entry-thumb img.hdr-hacker { display: block; }

@media (max-width: 620px) {
  .entries li.has-thumb { grid-template-columns: 108px minmax(0, 1fr); gap: 14px; }
  .entry-thumb { width: 108px; height: 61px; }
}

.entry-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 6px 20px;
  align-items: baseline;
}
.entry-head a {
  font-size: 19.5px;
  font-weight: 600;
  letter-spacing: -0.008em;
  color: var(--ink);
  text-decoration: none;
}
.entry-head a:hover {
  color: var(--link);
  text-decoration: underline;
  text-decoration-color: var(--link-rule);
  text-underline-offset: 2.5px;
}
.entry-date {
  font-family: var(--sans);
  font-size: 12.5px;
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.entry-sum { font-size: 17px; color: var(--ink-soft); margin: 2px 0 4px; }
/* When the work happened, as against when the post went up. Set in the machine
   face because it is a fact about the piece, not part of the writing. */
.entry-period {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  color: var(--ink-faint);
  margin: 3px 0 0;
}
.entry-tags { font-family: var(--sans); font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint); margin: 0; }

.page-body p { margin: 0 0 1.15em; }
.page-body h2 { font-size: 25px; font-weight: 600; margin: 2em 0 0.5em; letter-spacing: -0.014em; }
.page-body a { text-decoration: underline; text-decoration-color: var(--link-rule); text-underline-offset: 2.5px; }

/* Nothing written yet. Honest rather than apologetic. */
.empty {
  font-size: 18px;
  color: var(--ink-soft);
  margin: 0;
}



/* Tables. Set on the paper, hairlines only, monospace figures, and always
   inside a .tablewrap that scrolls on a phone rather than widening the page. */
.tablewrap { overflow-x: auto; margin: 1.6em 0; max-width: var(--measure); }
.post table {
  border-collapse: collapse;
  width: 100%;
  font-size: 15px;
  line-height: 1.45;
}
.post th, .post td {
  text-align: left;
  vertical-align: top;
  padding: 9px 14px 9px 0;
  border-bottom: 1px solid var(--hair);
}
.post th {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-faint);
  border-bottom: 1px solid var(--ink-faint);
}
.post td code { font-size: 0.82em; }

/* ── Narrow ─────────────────────────────────────────────────────────────────
   The rail unstacks into: identity and nav above the post, the list of other
   writing below it. Putting the whole list above the article would mean
   scrolling past a table of contents to reach the thing you clicked. */

@media (max-width: 1000px) {
  .shell {
    grid-template-columns: minmax(0, 1fr);
    padding-inline: 24px;
    max-width: 720px;
  }
  .rail {
    display: contents;
  }
  /* display:contents promotes these to grid items, so `order` can put the
     article back between them without moving anything in the document. */
  .rail-top { order: 1; grid-column: 1; padding-block: 32px 4px; }
  main { order: 2; grid-column: 1; grid-row: auto; padding-block: 20px 0; }
  .rail-bottom {
    order: 3;
    grid-column: 1;
    padding-block: 48px 0;
    margin-top: 48px;
    border-top: 1px solid var(--hair);
  }
  .rail-top nav { flex-direction: row; flex-wrap: wrap; gap: 20px; margin-bottom: 0; }
  .rail-list { columns: 2; column-gap: 28px; }
  .rail-list li { break-inside: avoid; }
  .rail-foot { margin-top: 30px; }
}

@media (max-width: 620px) {
  body { font-size: 18px; }
  .shell { padding-inline: 20px; column-gap: 0; }
  .lede { font-size: 20px; }
  .post blockquote { padding: 0 0 0 1.1em; font-size: 20px; }
  .pair-inner { grid-template-columns: 1fr; gap: 18px; }
  .entry-head { grid-template-columns: 1fr; gap: 2px; }
  .rail-list { columns: 1; }
}

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; }
}

@media print {
  .rail, .rail-top, .rail-bottom { display: none; }
  .shell { display: block; max-width: none; }
  body { font-size: 11pt; background: #fff; }
}

.contact a { overflow-wrap: anywhere; }

/* Rail sidebar: introduction and contact stay together, identity and per-page
   nav live in .site-header now (see the foot of this file). */
:root { --rail: 264px; }
.tagline { font-size: 16px; line-height: 1.55; margin: 0; }
.rail-top .signoff {
  font-size: 18px;
  line-height: 1.45;
  color: var(--link);
  margin: 16px 0;
}
.contact { margin: 16px 0 0; }
.contact a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font: 12px var(--sans);
  text-decoration: none;
  color: var(--ink);
}
.rail-bottom { border-top: 1px solid var(--hair); margin-top: 22px; padding-top: 24px; }
/* ⚠ The child combinator is load-bearing. Each writing category became a
   nested <details> on 16 Sep 2026, so a descendant selector here also matched
   every category summary and beat .rail-cat on specificity: the categories
   inherited cursor:default on desktop and 14px non-uppercase type on the phone.
   Both of those looked plausible enough in a screenshot to survive. */
.rail-bottom > summary {
  list-style: none;
  font: 600 11px var(--sans);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin-bottom: 16px;
  cursor: default;
}
.rail-bottom > summary::-webkit-details-marker { display: none; }
.mobile-label, .menu-plus { display: none; }
.rail-list li { display: flex; flex-direction: column; margin-bottom: 21px; }
.rail-list a { font-size: 17px; line-height: 1.4; }
.rail-list li[aria-current] { border-left: 2px solid var(--link); padding-left: 12px; }
.rail-list li[aria-current] a { color: var(--link); }
.rail-foot { border: 0; margin: 0; padding: 0; }
.rail-foot a { display: inline-flex; align-items: center; min-height: 44px; color: var(--ink-soft); }
.post p, .post li { overflow-wrap: anywhere; }

@media (max-width: 1000px) {
  .shell { padding-inline: 22px; }
  .rail { display: block; position: static; max-height: none; overflow: visible; grid-row: 1; padding: 24px 0 0; }
  .rail-top { padding: 0; }
  main { grid-row: 2; padding-top: 26px; }
  .tagline { font-size: 15px; margin: 0; }
  .rail-top .signoff { font-size: 16px; margin: 10px 0 0; }
  .rail-bottom { margin: 8px 0 0; padding: 0; border-block: 1px solid var(--hair); }
  .rail-bottom > summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    min-height: 44px;
    margin: 0;
    font: 14px var(--sans);
    text-transform: none;
    letter-spacing: normal;
    cursor: pointer;
  }
  .desktop-label { display: none; }
  .mobile-label, .menu-plus { display: inline; }
  .rail-bottom[open] .menu-plus { transform: rotate(45deg); }
  .rail-content { padding-top: 16px; }
  .rail-list { columns: 1; }
  .rail-foot { margin: 0; }
  .post .meta { font-size: 10px; line-height: 1.6; letter-spacing: .07em; margin-bottom: 12px; }
  .post h1 { font-size: 32px; line-height: 1.13; }
  .post .lede { font-size: 18px; line-height: 1.6; }
}
@media print { .rail { display: none; } }


/* ═══════════════════════════════════════════════════════════════════════════
   THE TWO VIEWS                                              added 16 Sep 2026

   Ryan asked for two: PROFESSIONAL, the paper design above, for anyone here to
   read; and HACKER, a terminal, for anyone here because of what the reading is
   about. Both are the same HTML. There is no second build, no second template
   and no duplicated markup, so nothing that guards a post — the em dash check,
   the word ceiling, figure numbering — has a second path it could miss.

   Everything below this line is either a token redefinition or chrome. If a
   rule here changes layout, it is in the wrong place.

   PAPER IS THE DEFAULT AND IT IS THE ABSENCE OF THE ATTRIBUTE, not
   data-theme="paper". One spelling for the default means this file only ever
   describes the exception, and a reader with JavaScript off or storage blocked
   lands on it by doing nothing.
   ═══════════════════════════════════════════════════════════════════════ */

:root[data-theme="hacker"] {
  /* Not black. #000 under 19px serif at 1.7 line-height glares and the
     descenders bloom; a cool near-black holds the type without ringing. */
  --paper:     #0e1116;
  --paper-sunk:#171d24;
  --ink:       #d7dde3;
  --ink-soft:  #99a4ae;
  /* #6f7a85 was the first try and it measures 4.1:1 against this ground, which
     fails AA for the 12px sans this token is mostly used on — dates, figure
     numbers, tags. This is 5.18:1, measured. The faintest ink is the one to
     check, and buildtest.mjs measures all of them now.

     ⚠ The PAPER view's --ink-faint is 3.43:1 and has been since launch, which
     is under AA for the same small sans. That is a pre-existing decision about
     the paper design and not something to quietly change from in here; see the
     note in buildtest.mjs section 7. */
  --ink-faint: #7c8794;
  --hair:      #242b33;

  /* The link carries the whole personality of this view, so it is the one place
     the terminal is allowed to be literal: phosphor. Reads as a link, and at
     12.16:1 it reads at all. */
  --link:      #7ee3b0;
  --link-rule: rgba(126, 227, 176, 0.34);

  /* Inverted relationships, not inverted values. On paper the lede sits a shade
     LIGHTER than the body ink because large type carries more weight at the same
     colour; here that same reasoning makes it a shade DARKER. */
  --ink-lede:  #c3ccd4;
  --ink-quote: #c8d1d9;
  --select:    #2a3f36;

  /* A terminal block on a terminal page cannot be "the one dark rectangle" any
     more, so it stops trying. One step lighter than the ground plus a hairline
     makes it an inset panel: still visibly the machine speaking, without
     pretending to be the only machine on the page. */
  --shot-bg:   #151b22;
  --shot-ink:  #c8d0cd;
  --shot-hair: #262e37;

  /* ── The accents ──────────────────────────────────────────────────────────
     Ryan, 16 Sep 2026: the hacker view "needs to be more colourful, it
     shouldn't just change from white to black."

     So this view has a palette rather than one link colour, and it is spent on
     the MACHINE half of the page: the category a piece is filed under, the
     dates, the tags, the figure numbers, the inline code. The serif is still one
     ink, because a paragraph in six colours is not a terminal, it is a ransom
     note. What this borrows from a terminal is the idea that different KINDS of
     thing are different colours, which is also what makes the page scannable.

     Six, because that is the ANSI set minus the ones that cannot hold on a dark
     ground, and because categories are Ryan's to add. Assigned BY POSITION, not
     by name: see the `i` field in build.mjs. A seventh category cycles back to
     the first rather than losing its colour. */
  --c1: #ff7a85;   /* coral   — the offensive one, and the DRAFT badge */
  --c2: #7ee3b0;   /* green   — also the link colour */
  --c3: #e8c07d;   /* amber   — tags, inline code */
  --c4: #6fd3e8;   /* cyan    — dates, periods, figure numbers, table heads */
  --c5: #c792ea;   /* magenta */
  --c6: #82aaff;   /* blue    — spare, and the diagram blue */

  /* ── The diagram palette, re-grounded ──────────────────────────────────────
     Light-on-dark, and note this is NOT a numeric inversion: the accents were
     dark and saturated to hold against paper, and the same hues have to be
     light and calmer to hold against this. An inverted #8a2f2f is a neon that
     drags the eye off the diagram it is part of.

     --dg-link stays BLUE while the page links go green, on purpose. Inside a
     diagram, blue / red / purple / green are four distinct meanings — see
     red-vs-purple.svg, which uses three of them at once. Recolouring one to
     match the page chrome would collapse two of the diagram four channels. */
  --dg-ink:       #d7dde3;
  --dg-soft:      #9aa5b0;
  --dg-faint:     #79838e;
  --dg-hair:      #333c46;
  --dg-hair-soft: #2a323b;
  --dg-hair-warm: #2a323b;
  --dg-ground:    #0e1116;
  --dg-panel:     #171d25;
  --dg-sunk:      #141a21;
  --dg-link:      #7fb2e8;
  --dg-red:       #e28c8c;
  --dg-purple:    #b79ad8;
  --dg-green:     #7fb98d;

  /* Hacker mode is a different reading surface, not only a dark palette.
     Space Mono makes the prose feel instrumented and deliberate while the
     existing IBM Plex Mono remains available for compact machine labels. */
  --hacker-body:    "Space Mono", var(--mono);
  --hacker-display: "Space Mono", var(--mono);
}

/* ── The diagram remap ──────────────────────────────────────────────────────
   Nine hand-drawn SVGs, inlined into the document by embed() in build.mjs, each
   one carrying its colours as `fill=` and `stroke=` presentation attributes.

   This is what makes the second view nearly free. Every diagram was drawn in
   the token hexes at the top of this file, so thirteen pairs of rules re-ground
   all nine at once, in both views, with NO SVG FILE EDITED. In the paper view
   each token still holds the hex it is replacing, so the mapping is an identity
   and the diagrams are pixel-identical to what they were.

   WHY AN ATTRIBUTE SELECTOR AND NOT A REWRITE IN build.mjs. Substituting
   fill="var(--dg-ink)" at inline time was the obvious move and it is the
   fragile one: var() inside an SVG presentation attribute has a patchy history
   across engines, and where it is not honoured the fill is invalid and the shape
   renders BLACK — which on this ground is an invisible diagram. A presentation
   attribute has specificity zero, lower than any author rule, so a plain CSS
   declaration beats it everywhere, with no exceptions and nothing to feature
   detect. It also keeps the inlined markup byte-identical to the file on disk.

   Safe because these files carry no style= attribute and no <style> block, so
   nothing outranks these rules. buildtest.mjs asserts that, asserts every hex in
   every diagram is covered here, and fails the build on a fourteenth colour.

   The `i` flag is future-proofing: a diagram exported in uppercase hex would
   otherwise slip through silently.
   ─────────────────────────────────────────────────────────────────────────── */

svg [fill="#1c1b1a" i] { fill: var(--dg-ink); }
svg [stroke="#1c1b1a" i] { stroke: var(--dg-ink); }
svg [fill="#56534e" i] { fill: var(--dg-soft); }
svg [stroke="#56534e" i] { stroke: var(--dg-soft); }
svg [fill="#8b877f" i] { fill: var(--dg-faint); }
svg [stroke="#8b877f" i] { stroke: var(--dg-faint); }
svg [fill="#c9c5bc" i] { fill: var(--dg-hair); }
svg [stroke="#c9c5bc" i] { stroke: var(--dg-hair); }
svg [fill="#dcd9d1" i] { fill: var(--dg-hair-soft); }
svg [stroke="#dcd9d1" i] { stroke: var(--dg-hair-soft); }
svg [fill="#dedad1" i] { fill: var(--dg-hair-warm); }
svg [stroke="#dedad1" i] { stroke: var(--dg-hair-warm); }
svg [fill="#fbfaf8" i] { fill: var(--dg-ground); }
svg [stroke="#fbfaf8" i] { stroke: var(--dg-ground); }
svg [fill="#f6f5f1" i] { fill: var(--dg-panel); }
svg [stroke="#f6f5f1" i] { stroke: var(--dg-panel); }
svg [fill="#f2f1ec" i] { fill: var(--dg-sunk); }
svg [stroke="#f2f1ec" i] { stroke: var(--dg-sunk); }
svg [fill="#26456b" i] { fill: var(--dg-link); }
svg [stroke="#26456b" i] { stroke: var(--dg-link); }
svg [fill="#8a2f2f" i] { fill: var(--dg-red); }
svg [stroke="#8a2f2f" i] { stroke: var(--dg-red); }
svg [fill="#5a3f7a" i] { fill: var(--dg-purple); }
svg [stroke="#5a3f7a" i] { stroke: var(--dg-purple); }
svg [fill="#3d5c43" i] { fill: var(--dg-green); }
svg [stroke="#3d5c43" i] { stroke: var(--dg-green); }

/* ── The switch ─────────────────────────────────────────────────────────────
   Two named buttons rather than one toggle, because a single control has to be
   labelled either with the state it is in or the state it goes to, and every
   reader guesses the other one.

   See viewSwitch() in build.mjs for why it renders `hidden` and why the active
   state is driven by [data-theme] rather than by aria-pressed. */

/* [hidden] is display:none in the UA sheet, which a class selector outranks.
   Without this line the switch would be visible with JavaScript off, doing
   nothing, which is the one outcome rendering it hidden was meant to avoid. */
.viewswap[hidden] { display: none; }

.viewswap {
  display: inline-flex;
  margin: 18px 0 0;
  border: 1px solid var(--hair);
  border-radius: 3px;
  overflow: hidden;
}
.viewswap button {
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  background: none;
  cursor: pointer;
  font: 500 10.5px var(--mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
  padding: 7px 11px;
  min-height: 30px;
  transition: color 120ms ease, background-color 120ms ease;
}
.viewswap button + button { border-left: 1px solid var(--hair); }
.viewswap button:hover { color: var(--ink-soft); }
:root:not([data-theme="hacker"]) .viewswap [data-view="paper"],
:root[data-theme="hacker"] .viewswap [data-view="hacker"] {
  background: var(--dg-green);
  color: var(--paper);
  font-weight: 600;
}
:root[data-theme="hacker"] .viewswap [data-view="hacker"] {
  background: var(--c2);
  color: var(--ground);
}

/* ── Hacker-view chrome ─────────────────────────────────────────────────────
   The whole of it. Colour is already handled by the tokens above; this is the
   small amount of terminal that is shape rather than shade.

   Every prompt uses the content: "…" / "" form, whose second half is the
   alternative text a screen reader gets. It is empty on purpose: a nav item
   must not be announced as "dollar Writing". Where the two-value syntax is not
   supported the whole declaration is invalid and the prompt simply does not
   render, which is the right way for decoration to fail. */

/* Moved from the rail's own nav to .site-nav on 17 Sep 2026, when navigation
   moved into the global header. Same idea, same reason: a nav item must not
   be announced as "dollar Writing" to a screen reader, hence the empty
   alternative text in the two-value content form. */
:root[data-theme="hacker"] .site-nav a {
  font-family: var(--mono);
  font-size: 12.5px;
}
:root[data-theme="hacker"] .site-nav a::before {
  content: "$ " / "";
  color: var(--ink-faint);
}
:root[data-theme="hacker"] .site-nav a[aria-current]::before { color: var(--link); }

:root[data-theme="hacker"] .page-body h2::before {
  content: "$ " / "";
  font-family: var(--mono);
  font-weight: 400;
  font-size: 0.66em;
  color: var(--link);
  vertical-align: 0.14em;
  margin-right: 0.3em;
}

/* Hacker mode changes the voice of the page as well as its ground. The paper
   view is an essay; this view is a field console. Keep the body readable, but
   let the monospaced rhythm, squared headings, and command-line cursor make the
   mode unmistakable before a reader reaches the first code block. */
:root[data-theme="hacker"] body {
  font-family: var(--hacker-body);
  font-size: 17px;
  line-height: 1.78;
  letter-spacing: -0.018em;
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M12 1v7M12 16v7M1 12h7M16 12h7' stroke='%237ee3b0' stroke-width='2'/%3E%3Ccircle cx='12' cy='12' r='3' fill='%230e1116' stroke='%237ee3b0' stroke-width='2'/%3E%3C/svg%3E") 12 12, crosshair;
}
:root[data-theme="hacker"] a,
:root[data-theme="hacker"] button,
:root[data-theme="hacker"] summary {
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M12 1v7M12 16v7M1 12h7M16 12h7' stroke='%237ee3b0' stroke-width='2'/%3E%3Ccircle cx='12' cy='12' r='3' fill='%230e1116' stroke='%237ee3b0' stroke-width='2'/%3E%3C/svg%3E") 12 12, pointer;
}
:root[data-theme="hacker"] .post h1,
:root[data-theme="hacker"] .archive h1,
:root[data-theme="hacker"] .page-body h1,
:root[data-theme="hacker"] .post h2,
:root[data-theme="hacker"] .page-body h2,
:root[data-theme="hacker"] .post h3 {
  font-family: var(--hacker-display);
  font-weight: 700;
  letter-spacing: 0.015em;
}
:root[data-theme="hacker"] .post h1,
:root[data-theme="hacker"] .archive h1,
:root[data-theme="hacker"] .page-body h1 {
  text-shadow: 0 0 18px color-mix(in srgb, var(--link) 18%, transparent);
}
:root[data-theme="hacker"] .post h2,
:root[data-theme="hacker"] .page-body h2 {
  text-transform: uppercase;
  letter-spacing: 0.07em;
}
:root[data-theme="hacker"] .post .lede {
  font-family: var(--hacker-body);
  font-size: 19px;
  line-height: 1.72;
  color: var(--ink-lede);
}
:root[data-theme="hacker"] .post blockquote {
  border-left: 2px solid var(--c2);
  padding-left: 1.25em;
  font-family: var(--hacker-body);
  font-size: 18px;
  color: var(--ink-quote);
}
:root[data-theme="hacker"] .entry-summary,
:root[data-theme="hacker"] .blurb,
:root[data-theme="hacker"] .tagline {
  font-family: var(--hacker-body);
}

/* Screenshots are the one thing on the page that cannot be re-grounded: they
   are photographs of light interfaces and they stay that way, because a
   screenshot here is evidence and evidence does not get a filter over it. A
   hairline and a small radius make a bright rectangle read as an inset object
   rather than a hole in the page. Diagrams are exempt — they have already been
   re-grounded and a border would box them for the first time. */
:root[data-theme="hacker"] .post figure img {
  border: 1px solid var(--hair);
  border-radius: 2px;
}

/* ── The About page, and anything long-form that is not a post ──────────────
   A standalone page is one long read with no dateline, so its sections have to
   do the work a post title and meta line do: tell a stranger where they are and
   let them skip. A rule above each h2 and room around it turns the page into
   parts that can be scanned, which is the whole reason the page exists. */

.page-body h2 {
  margin: 2.4em 0 0.6em;
  padding-top: 1.1em;
  border-top: 1px solid var(--hair);
}
.page-body h2:first-of-type { margin-top: 1.6em; }

@media (max-width: 620px) {
  /* Thumb reach, the same 44px the contact link and the menu already use. */
  .viewswap button { min-height: 44px; padding-inline: 14px; }
}

/* ── Print ──────────────────────────────────────────────────────────────────
   Paper, whichever view is on screen. Printing the hacker view would put light
   grey type on white and hand back blank sheets, and the token names are shared
   so the fix is to put the paper values back. */
@media print {
  :root[data-theme="hacker"] {
    --paper: #fff;
    --paper-sunk: #f2f1ec;
    --ink: #1c1b1a;
    --ink-soft: #56534e;
    --ink-faint: #8b877f;
    --hair: #e4e2db;
    --link: #26456b;
    --link-rule: rgba(38, 69, 107, 0.32);
    --ink-lede: #33312e;
    --ink-quote: #2c2a27;
    --shot-bg: #14181b;
    --shot-ink: #c8d0cd;
    --shot-hair: transparent;
    --dg-ink: #1c1b1a;
    --dg-soft: #56534e;
    --dg-faint: #8b877f;
    --dg-hair: #c9c5bc;
    --dg-hair-soft: #dcd9d1;
    --dg-hair-warm: #dedad1;
    --dg-ground: #fff;
    --dg-panel: #f6f5f1;
    --dg-sunk: #f2f1ec;
    --dg-link: #26456b;
    --dg-red: #8a2f2f;
    --dg-purple: #5a3f7a;
    --dg-green: #3d5c43;
  }
  .viewswap { display: none; }
}


/* ═══════════════════════════════════════════════════════════════════════════
   THE WRITING, FILED                                          added 16 Sep 2026

   Ryan: "organise the writing into different categories, because I eventually
   want to put all my notes regarding cyber security here and all our exploits
   and all the different boxes that we've hacked."

   The rail was ten posts newest-first and the archive was one list grouped by
   the year the work happened. Both were right for seventeen posts and neither
   survives what is coming: fifty box writeups under a year heading bury the
   career entirely, and a flat "ten most recent" means a reader only ever sees
   last week.

   So both are filed by category now, from one ordered list in site.json. The
   timeline is not gone, it moved one level down: inside a category the posts are
   still in work order and every row still carries its own period.

   The layout here is shared by both views. Colour is at the bottom.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── In the rail ─────────────────────────────────────────────────────────── */

.rail-group { margin-bottom: 26px; }
.rail-group:last-of-type { margin-bottom: 18px; }

summary.rail-cat {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  font: 600 10.5px var(--sans);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 12px;
  padding-bottom: 7px;
  border-bottom: 1px solid var(--hair);
}
.rail-cat a { color: inherit; text-decoration: none; }
.rail-cat a:hover { color: var(--link); }
/* The count is the size of the category, not the length of the list under it.
   Tabular so the column of numbers does not jitter between groups. */
.rail-cat .n {
  font-weight: 500;
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}

/* Tighter than a standalone list: inside a group the heading already provides
   the separation that the old bottom margin was doing. */
.rail-group .rail-list { margin-bottom: 8px; }
.rail-group .rail-more { margin-bottom: 0; font-size: 11.5px; }

/* ── On the archive ──────────────────────────────────────────────────────── */

.archive .cat { margin: 0 0 52px; }
.archive .cat:last-of-type { margin-bottom: 0; }

/* Set in the machine face and not as a headline. These are labels on a filing
   cabinet; the writing is what should carry the weight on this page, and an h2
   at heading size would compete with sixteen post titles for it. */
.cat-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  font: 600 11px var(--sans);
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 22px;
  padding-bottom: 9px;
  border-bottom: 1px solid var(--hair);
}
.cat-head .n {
  font-weight: 500;
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}

/* An anchor from the rail lands here. Without this the heading sits hard against
   the top of the window with no sign of what was scrolled past. */
.archive .cat { scroll-margin-top: 24px; }

/* ── Colour, hacker view only ────────────────────────────────────────────────
   The paper view keeps its one link colour and one ink; it organises with rules,
   labels and space, which is what the brief at the top of this file says and
   what it is good at. Everything below is scoped to the hacker view.

   --accent is set from the POSITION of the category, which build.mjs emits as
   data-i. Keying it on the category name would mean a renamed category silently
   losing its colour, and the names are Ryan's to rewrite. */

:root { --accent: var(--ink); }

:root[data-theme="hacker"] [data-i="1"] { --accent: var(--c1); }
:root[data-theme="hacker"] [data-i="2"] { --accent: var(--c2); }
:root[data-theme="hacker"] [data-i="3"] { --accent: var(--c3); }
:root[data-theme="hacker"] [data-i="4"] { --accent: var(--c4); }
:root[data-theme="hacker"] [data-i="5"] { --accent: var(--c5); }
:root[data-theme="hacker"] [data-i="6"] { --accent: var(--c6); }

:root[data-theme="hacker"] .cat-head,
:root[data-theme="hacker"] .rail-cat { color: var(--accent); }
:root[data-theme="hacker"] .cat-head,
:root[data-theme="hacker"] .rail-cat {
  border-bottom-color: color-mix(in srgb, var(--accent) 32%, transparent);
}
:root[data-theme="hacker"] .rail-cat a:hover { color: #fff; }

/* The meta line above a title: category, when the work happened, how long it
   takes to read, and whether it is a draft. Four different KINDS of fact, which
   is exactly the thing this view colours. In the paper view they are one grey,
   as they have always been. */
:root[data-theme="hacker"] .m-cat { color: var(--accent); }
:root[data-theme="hacker"] .m-when { color: var(--c4); }
:root[data-theme="hacker"] .m-read { color: var(--ink-faint); }
:root[data-theme="hacker"] .m-draft { color: var(--c1); }
:root[data-theme="hacker"] .m-sep { color: var(--hair); }

/* Dates and periods are cyan wherever they appear, tags are amber wherever they
   appear. Consistency is the whole point: once a reader has learnt that cyan is
   "when", they have learnt it for the rail, the archive and every post. */
:root[data-theme="hacker"] .entry-date,
:root[data-theme="hacker"] .post figcaption b,
:root[data-theme="hacker"] .post th,
:root[data-theme="hacker"] .colophon-date { color: var(--c4); }

:root[data-theme="hacker"] .entry-tags { color: var(--c3); }

/* Inline machine things: a path, a flag, a hostname. Amber on the sunk ground
   rather than plain ink, which is what a terminal does with a literal and what
   makes a hostname visibly not a word in the sentence. */
:root[data-theme="hacker"] .post code,
:root[data-theme="hacker"] .archive code,
:root[data-theme="hacker"] .page-body code { color: var(--c3); }

/* A post title in the archive picks up its own category's colour on hover, so
   the section a link belongs to is visible from the link itself. */
:root[data-theme="hacker"] .entry-head a:hover { color: var(--accent); }
:root[data-theme="hacker"] .entry-head a:hover {
  text-decoration-color: color-mix(in srgb, var(--accent) 55%, transparent);
}

/* The current post in the rail is marked with a rule down its left edge. In this
   view it takes the category's colour rather than the link green, which is what
   makes the rail readable as a filing system at a glance. */
:root[data-theme="hacker"] .rail-list li[aria-current] { border-left-color: var(--accent); }
:root[data-theme="hacker"] .rail-list li[aria-current] a { color: var(--accent); }

/* ⚠ The two .viewswap sizing overrides that used to live here and further down
   this file ("spans the rail" and "sits in the lane top-right") are both gone,
   replaced by the header's own layout rules at the foot of this file — the
   switch has one home now, not two contexts it had to adapt to. */


/* ═══════════════════════════════════════════════════════════════════════════
   MINIMISED, AND THE TWO CONTROLS                             added 16 Sep 2026

   Three changes, all of them Ryan's:

   "There should be a way to minimise the writings."  Each category is a
   <details> now. Only the one holding the post you are reading is open, so the
   rail is four shut lines and one list instead of twenty links.

   "It should be very clear that that's clickable."  The way to the CV is the one
   boxed thing on the page.

   "Make the button to change the view maybe not on the left hand side."  It sits
   at the top right of the reading lane instead.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── The collapsible categories ─────────────────────────────────────────────
   Overrides the .rail-group rules further up, which were written for a plain
   div. The summary is the whole control: no link inside it, because one target
   with two jobs sends the reader who wanted to expand to another page. */

.rail-group { margin-bottom: 4px; }
.rail-group:last-of-type { margin-bottom: 14px; }

summary.rail-cat {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  font: 600 10.5px var(--sans);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0;
  padding: 10px 0;
  border-bottom: 1px solid var(--hair);
  cursor: pointer;
  list-style: none;
  /* Thumb reach, the same 44px the contact link and the menu already use. */
  min-height: 44px;
  align-content: center;
}
summary.rail-cat::-webkit-details-marker { display: none; }
summary.rail-cat:hover { color: var(--ink); }
.rail-group[open] summary.rail-cat { color: var(--ink); }

/* The affordance. A plus that becomes a minus, in the machine face, on the far
   right where the count is: two glyphs rather than a rotating triangle, because
   a triangle at 10px on a hairline reads as dirt. */
.rail-cat .n { font-weight: 500; letter-spacing: 0; font-variant-numeric: tabular-nums; opacity: 0.7; }
.rail-cat .n::after {
  content: " +" / "";
  font-family: var(--mono);
  opacity: 0.8;
}
.rail-group[open] .rail-cat .n::after { content: " −" / ""; }

.rail-group-body { padding-top: 14px; }
.rail-group .rail-list { margin-bottom: 6px; }
.rail-group .rail-more { margin-bottom: 14px; font-size: 11.5px; }

/* ⚠ `.aboutbtn` (the boxed CV button) and `.lanetop` (the switch's old home in
   the reading lane) both removed 17 Sep 2026. About sits in `.site-nav` now,
   an equal peer to Writing and Skills rather than a boxed exception — a global
   header is already the "this is clickable" signal a portfolio needed a box
   for. The switch moved into the same header; see the foot of this file. */

@media (max-width: 620px) {
  .viewswap button { min-height: 44px; padding-inline: 12px; font-size: 10px; }
}

/* ── Colour, hacker view ─────────────────────────────────────────────────── */

:root[data-theme="hacker"] .rail-group[open] .rail-cat,
:root[data-theme="hacker"] summary.rail-cat:hover { color: var(--accent); }
:root[data-theme="hacker"] .rail-cat { border-bottom-color: color-mix(in srgb, var(--accent) 26%, transparent); }
:root[data-theme="hacker"] .rail-group[open] summary.rail-cat { border-bottom-color: color-mix(in srgb, var(--accent) 50%, transparent); }


/* ═══════════════════════════════════════════════════════════════════════════
   TOPICS, NOT CATEGORIES                                      added 16 Sep 2026

   Four category schemes were designed and rejected. Each died the same way: a
   post about mobile security is a writeup AND an explainer AND a thing he built,
   so any scheme giving a post one home has to guess, and the reader cannot
   predict the guess. Tags do not have that problem, because a post can carry
   five and every one is true.

   So: one stream, and the tags do the filtering. The rules above this block that
   styled category sections (.cat, .cat-head, the collapsible .rail-group
   summaries) are no longer emitted by build.mjs. They are left in place rather
   than deleted because this decision has been revisited four times in one day
   and the markup to bring them back is in git; nothing renders them today.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── The topic list ──────────────────────────────────────────────────────────
   In the sidebar it is the MAP: with no categories it is the only thing telling
   a stranger what this site is about before they read a word. So it is ordered
   by how many posts carry each topic, and the count is always visible. */

.taglist { list-style: none; margin: 0 0 8px; padding: 0; }
.taglist li {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding: 5px 0;
}
.taglist a {
  font-family: var(--sans);
  font-size: 13.5px;
  color: var(--ink);
  text-decoration: none;
  letter-spacing: -0.002em;
}
.taglist a:hover { color: var(--link); }
.taglist li[aria-current] a { color: var(--ink-faint); }
.taglist .n {
  font-family: var(--sans);
  font-size: 11px;
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}

/* The skills map is a real hierarchy: branches explain the shape of the
   practice, while leaves remain direct links to evidence. */
.skill-tree {
  list-style: none;
  margin: 0;
  padding: 0;
}
.skill-tree .skill-branch > details > .skill-tree {
  margin: 3px 0 8px 10px;
  padding-left: 12px;
  border-left: 1px solid var(--hair);
}
.skill-branch-label,
.skill-leaf {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}
.skill-branch-label {
  list-style: none;
  padding: 8px 0 4px;
  color: var(--ink-soft);
  font-family: var(--sans);
  font-size: 11px;
  font-weight: 650;
  letter-spacing: .09em;
  text-transform: uppercase;
  cursor: pointer;
}
.skill-branch-label::-webkit-details-marker { display: none; }
.skill-branch-label::before {
  content: "+";
  display: inline-block;
  width: 15px;
  color: var(--ink-faint);
  font-family: var(--mono);
  font-weight: 400;
}
.skill-branch details[open] > .skill-branch-label::before { content: "−"; }
.skill-branch-label > span:first-child { flex: 1; }
.skill-leaf { padding: 4px 0; }
.skill-leaf a {
  color: var(--ink);
  font-family: var(--sans);
  font-size: 13.5px;
  text-decoration: none;
}
.skill-leaf a:hover { color: var(--link); }
.skill-leaf[aria-current] a { color: var(--ink-faint); }
.skill-tree .n {
  color: var(--ink-faint);
  font-family: var(--sans);
  font-size: 11px;
  font-variant-numeric: tabular-nums;
}
.skill-tree .n.none { opacity: .6; }
.skill-tree .s-dead { color: var(--ink-soft); font-family: var(--sans); font-size: 13.5px; }
.skills-map {
  max-width: var(--measure);
  margin: 0 0 54px;
  padding: 18px 20px 20px;
  border: 1px solid var(--hair);
  border-radius: 4px;
  background: var(--paper-sunk);
}
.skills-map h2,
.skills-notes h2 {
  margin: 0 0 8px;
  font-size: 21px;
}
.skills-map-intro { margin: 0 0 20px; color: var(--ink-soft); }
.skills-map > .skill-tree > .skill-branch > details > .skill-branch-label {
  padding-top: 0;
  font-size: 12px;
}
.skills-notes { max-width: var(--measure); }

@media (max-width: 620px) {
  .skills-map { padding-inline: 16px; }
}

/* The full list on /topics/, which runs to twenty-odd entries and wants columns
   rather than one long ladder. */
.taglist.wide { columns: 2; column-gap: 40px; max-width: var(--measure); }
.taglist.wide li { break-inside: avoid; }

@media (max-width: 620px) {
  .taglist.wide { columns: 1; }
  /* Thumb reach in the sidebar, where these are the main navigation now. */
  .taglist a { padding-block: 6px; }
}

/* ── Tags on a post row ──────────────────────────────────────────────────────
   These were plain text and are links now. They keep the quiet tag colour rather
   than the link colour: a row already has one obvious link in its title, and
   eight blue words underneath would fight it. */

.entry-tags a {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid transparent;
}
.entry-tags a:hover { color: var(--link); border-bottom-color: var(--link-rule); }

/* The first tag, in the meta line above a post title. Same idea: it is the way
   sideways to everything else on the subject, without going back to the archive. */
.m-tag {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid transparent;
}
.m-tag:hover { border-bottom-color: currentColor; }

/* The small label above a topic page heading, back to the full list. */
.kicker {
  font-family: var(--sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 10px;
}
.kicker a { color: inherit; text-decoration: none; }
.kicker a:hover { color: var(--link); }

/* ── The sidebar sections ────────────────────────────────────────────────────
   "Recent" and "Topics" are plain paragraphs now, not collapsible summaries, so
   the pointer and the plus that belonged to <details> must not follow them. */

/* ⚠ These carry the full label styling rather than inheriting it. The base
   `.rail-cat` rule further up this file was narrowed to `summary.rail-cat` when
   the categories were collapsible, and the sidebar headings are <p> again now,
   so nothing matched them: "Recent" and "Topics" rendered as 19px serif body
   text in the middle of the sidebar. A rule that stops matching is invisible in
   a diff and obvious in a screenshot, which is where this was caught. */
.rail-group > p.rail-cat {
  display: block;
  font: 600 10.5px var(--sans);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
  cursor: default;
  margin: 0 0 12px;
  padding: 0 0 7px;
  min-height: 0;
  border-bottom: 1px solid var(--hair);
}
.rail-topics { margin-top: 30px; }

/* ── Colour, hacker view ─────────────────────────────────────────────────────
   The accents moved from categories to topics. --accent comes from a hash of the
   slug, so a topic keeps its colour for as long as the tag exists rather than
   repainting every time the post counts reorder the list. */

:root[data-theme="hacker"] .taglist li a { color: var(--accent); }
:root[data-theme="hacker"] .taglist li a:hover { color: #fff; }
:root[data-theme="hacker"] .m-tag { color: var(--accent); }
:root[data-theme="hacker"] .kicker { color: var(--accent); }
:root[data-theme="hacker"] .archive[data-i] h1 { color: var(--accent); }

/* The outer "Writing" summary is the collapse control on a phone and pure
   decoration on desktop, where sidebar.js already suppresses its clicks. Now
   that the groups inside carry their own labels it read as "WRITING / RECENT",
   the same word twice. Hidden above the breakpoint only; the phone still needs
   it to open the menu, and `open` is in the markup so the panel stays visible
   with JavaScript off. */
@media (min-width: 1001px) {
  .rail-bottom > summary { display: none; }
  .rail-bottom { padding-top: 6px; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   THE MAP                                                     added 16 Sep 2026

   Six category schemes were designed and rejected over one afternoon. Each died
   the same way: a taxonomy must file EVERY post somewhere, so it fills up with
   buckets that exist to catch leftovers, and those are exactly the ones that
   tell a reader nothing. The filing requirement was corrupting the map.

   So they are separate jobs now. The filing is the tags. The map is a short
   list Ryan writes of what he actually does, each line one click from its
   evidence. It does not have to be total and nothing can be mis-filed, because
   nothing is filed.

   ⚠ IT SAT ABOVE THE WRITING FIRST, AND HE REJECTED IT ON SIGHT. A six-row
   table of raw tag names was the first thing anyone saw, in a design with no
   tables anywhere else, before a single word of his own. Moved into the rail as
   "Browse" on 16 Sep 2026, below "Recent" — his correction: the writing outranks
   the map that helps you find it. It reuses `.taglist`, the same list styling
   the old per-topic sidebar used, because that shape (label left, count right,
   one hairline per section not per row) was already right; only its position
   and its content were wrong.
   ═══════════════════════════════════════════════════════════════════════ */

/* A skill he does but has not written up. Not a link: a claim that looks
   clickable and goes nowhere is worse than one line of honest plain text.
   `.taglist li a` already carries the colour rules (paper and hacker, line
   1306/1396) that a skill link needs — nothing new to declare for the live
   ones, only for the dead ones, which are a <span> and reach none of them. */
.taglist .s-dead { color: var(--ink-soft); cursor: default; }
.taglist .n.none { opacity: 0.6; }

.rail-browse { margin-top: 4px; }

:root[data-theme="hacker"] .taglist .s-dead { color: var(--ink-faint); }

/* ═══════════════════════════════════════════════════════════════════════════
   THE GLOBAL HEADER                                            added 17 Sep 2026

   Every page gains a header row for the first time. Before this, the whole
   site was rail-and-main, full stop, and the rail's own top had grown four
   different-looking things deep: a photo, a name, a plain "Writing" link, a
   boxed "About" button. Ryan on sight: "the top area feels quite scattered...
   maybe we can have it as a top header."

   It sits ABOVE `.shell` as a sibling in the document, spans the full page
   width with its own background and hairline, and its INNER content is capped
   at the same 1216px / 32px padding as `.shell` below it, so the name on the
   left lines up with the rail's left edge and the switch on the right lines up
   with the reading column's right edge — one page, one set of margins.

   NOT STICKY, on purpose, for now: it scrolls away with the rest of the page,
   the same way the site has always behaved. The rail's own `position: sticky`
   further down still keeps the writing navigable while reading a long post;
   this header is the introduction, not a persistent toolbar. Revisit if that
   turns out to be wrong once there is real reading to scroll through.
   ═══════════════════════════════════════════════════════════════════════ */

.site-header {
  background: var(--paper);
  border-bottom: 0;
  /* ⚠ Needed once matrix-bg.js exists. A `position: fixed` element (the
     ambient hacker-view canvas, z-index: 0) paints ABOVE ordinary in-flow
     content regardless of DOM order — that is what let the same script get
     away with promoting only `.shell` to `position: relative; z-index: 1`
     earlier. This header is a separate sibling that script never touches, so
     without its own stacking context here, the canvas would paint over it,
     not behind it, the moment the hacker view was live. */
  position: relative;
  z-index: 2;
}
.site-header-inner {
  max-width: 1216px;
  margin: 10px auto;
  padding: 10px 14px;
  display: flex;
  align-items: center;
  gap: 28px;
  border: 1px solid var(--hair);
  border-radius: 4px;
}
.site-header-primary {
  display: flex;
  align-items: center;
  gap: 30px;
  min-width: 0;
}
.site-header-actions {
  margin-left: auto;
  flex: 0 0 auto;
}

/* The name goes home, same as any site name always has — "Writing" sits in
   .site-nav too, deliberately redundant with it: the logo click is the
   convention a RETURNING visitor assumes, the word "Writing" is what a
   STRANGER reads on first landing. */
.site-id {
  display: flex;
  align-items: center;
  gap: 11px;
  text-decoration: none;
  color: var(--ink);
  margin-right: 0;
  justify-self: start;
}
.site-mark {
  width: 30px;
  height: 30px;
  flex: 0 0 30px;
  display: block;
}
.site-mark-img {
  width: 30px;
  height: 30px;
  display: block;
  border-radius: 7px;
}
.site-mark-img.mark-hacker { display: none; }
:root[data-theme="hacker"] .site-mark-img.mark-pro { display: none; }
:root[data-theme="hacker"] .site-mark-img.mark-hacker { display: block; }
.site-name {
  font-size: 16px;
  font-weight: 600;
  letter-spacing: -0.006em;
}
.site-id:hover .site-name { color: var(--link); }

/* The portrait is an author introduction, not a tiny brand icon. It belongs in
   the rail beside the tagline, where it has room to be recognisable and does
   not compete with the header's navigation. */
.rail-portrait {
  width: 76px;
  height: 76px;
  display: block;
  margin: 0 0 18px;
  border-radius: 50%;
  object-fit: cover;
}

.site-nav {
  display: flex;
  align-items: center;
  gap: 22px;
  min-width: 0;
}
.site-nav a {
  font-family: var(--sans);
  font-size: 13.5px;
  font-weight: 500;
  color: var(--ink-soft);
  text-decoration: none;
  padding-block: 4px;
}
.site-nav a:hover { color: var(--link); }
.site-nav a[aria-current] { color: var(--ink); font-weight: 600; }

/* Identity and navigation form one left-hand group. The view switch is the one
   deliberate right-hand control, separated by the available space rather than
   by an artificial centered column. */
.site-header .viewswap { margin: 0; }
.viewswap-label {
  color: var(--ink-faint);
  font: 500 10px var(--mono);
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

/* The view switch is a control, so it gets enough weight to be read as part of
   the header instead of a tiny label floating inside it. The active half keeps
   the same quiet paper palette, but the larger hit area makes the relationship
   between the two views obvious at a glance. */
.site-header .viewswap {
  margin-top: 0;
  border-radius: 4px;
  background: var(--paper-sunk);
}
.site-header .viewswap button {
  min-height: 36px;
  padding: 8px 14px;
  font-size: 10px;
}

/* ⚠ FOUND BY SCREENSHOT, NOT BY REASONING: `flex-wrap` alone let "About" clip
   past the right edge of a 390px viewport instead of dropping to a new line —
   `.site-id`'s `margin-right: auto` keeps computing leftover space against the
   line's UNWRAPPED width, so the row overflows before the wrap decision
   catches it. A column layout sidesteps the ambiguity outright: each of the
   three pieces gets its own full-width row, so nothing can run out of room to
   its right regardless of how long the nav or the switch's labels get. */
@media (max-width: 620px) {
  .site-header-inner { flex-direction: column; align-items: flex-start; padding: 10px 12px; gap: 10px; margin: 8px 12px; }
  .site-header-primary { flex-direction: column; align-items: flex-start; gap: 4px; width: 100%; }
  .site-id { margin-right: 0; }
  .site-name { font-size: 15px; }
  .site-nav { flex-wrap: wrap; gap: 4px 20px; margin-left: 0; }
  /* Thumb reach, the same 44px rule as the contact link and the collapsible
     menu summary elsewhere on this page. */
  .site-nav a { min-height: 44px; display: inline-flex; align-items: center; }
  .site-header-actions { margin-left: 0; }
}

/* Printing the header is pure navigation chrome — none of it is the writing. */
@media print { .site-header { display: none; } }

/* ── Colour, hacker view ─────────────────────────────────────────────────── */

:root[data-theme="hacker"] .site-id:hover .site-name { color: var(--link); }
