/* Shared design for the SSG bake-off — identical in all three prototypes,
   so the only variable under test is the toolkit. */

:root {
  color-scheme: light dark;

  --bg: light-dark(#fdfdfb, #141417);
  --surface: light-dark(#f5f5f1, #1d1d21);
  --text: light-dark(#191918, #e8e8e4);
  --muted: light-dark(#6c6c68, #97978f);
  --rule: light-dark(#e3e3dd, #2b2b31);
  --accent: light-dark(#2f5aa8, #8fb2ea);
  --warn-bg: light-dark(#fdf7ec, #241f14);
  --warn-edge: light-dark(#c99a3e, #b08434);

  /* Title text, not border colour. --warn-edge is an amber tuned to be seen
     as a 2px rule; as text on --warn-bg it lands near 2.3:1 and fails WCAG
     outright. These are the same hue darkened (light) and lightened (dark)
     until they clear 4.5:1 against the callout background. */
  --warn-title: light-dark(#7a5613, #e3bd6d);
  --note-title: light-dark(#24457f, #a8c4f0);

  /* Diagram chrome: the structural parts of an inlined SVG (panel, grid,
     rules, label/caption text). The coloured bars and nodes that encode a
     category -- ARC orange, Postgres blue, SvelteKit purple, Caddy pink,
     AthletOS API green, and each diagram's own legend colours -- are left as
     literal hex in the SVGs and never touch these tokens: a reader maps a
     colour to a meaning, and that mapping must survive the toggle. */
  --diagram-bg: light-dark(#f5f5f1, #12121b);
  --diagram-panel: light-dark(#eae9e3, #1b1b26);
  --diagram-text: light-dark(#33332f, #e8e8e4);
  --diagram-muted: light-dark(#6c6c68, #9a9aa6);
  --diagram-rule: light-dark(#d8d8d2, #2b2b36);

  --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, sans-serif;
  --mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo,
    Consolas, monospace;

  /* `ch` is the width of "0", which in this sans is wider than the average
     lowercase letter, so the column renders roughly 1.2 characters for every
     1ch. 68ch measured out at 79-81 characters on the longest lines, past the
     ~75 where the eye starts losing the return sweep; 62ch lands them at 72-74. */
  --measure: 62ch;
}

/* The toggle writes data-theme; these pin color-scheme so light-dark() resolves
   the chosen side regardless of the OS setting. */
:root[data-theme="light"] {
  color-scheme: light;
}
:root[data-theme="dark"] {
  color-scheme: dark;
}

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 17px;
  line-height: 1.7;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* `pretty` is the widow fix: it looks ahead at the last few lines and pulls a
   word down rather than leaving one stranded. Seven paragraphs across the
   three posts were ending on a single short word -- "120.", "installer.",
   "can verify." -- which reads as a typo in the rhythm rather than a
   sentence ending. */
p,
li,
figcaption,
dd {
  text-wrap: pretty;
}

.wrap {
  max-width: var(--measure);
  margin: 0 auto;
  padding: 0 1.5rem;
}

/* ---------- header / footer ---------- */

.site-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1.5rem;
  padding: 2.5rem 0 3.5rem;
  flex-wrap: wrap;
}

.site-head a {
  color: inherit;
  text-decoration: none;
}

.brand {
  font-weight: 620;
  letter-spacing: -0.015em;
}

.site-nav {
  display: flex;
  gap: 1.4rem;
  font-size: 0.94rem;
  color: var(--muted);
}

.site-nav a:hover {
  color: var(--accent);
}

.site-nav a[aria-current="page"] {
  color: var(--text);
  font-weight: 600;
}

/* ---- Day/night switch ----
   The visual state is driven by the theme itself, never by aria-checked, so
   the knob is already on the correct side at first paint. `--switch-x` is a
   unitless 0/1 the knob multiplies its travel by, which keeps the three
   theme-dependent declarations (knob position, sun emphasis, moon emphasis)
   as one variable set instead of three duplicated selector blocks. */
:root {
  --switch-x: 0;
  --sun-opacity: 1;
  --moon-opacity: 0.34;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --switch-x: 1;
    --sun-opacity: 0.34;
    --moon-opacity: 1;
  }
}

:root[data-theme="dark"] {
  --switch-x: 1;
  --sun-opacity: 0.34;
  --moon-opacity: 1;
}

/* Explicit light pin has to restate the defaults: it must win over the
   media query above when a dark-preferring reader chooses light. */
:root[data-theme="light"] {
  --switch-x: 0;
  --sun-opacity: 1;
  --moon-opacity: 0.34;
}

.theme-switch {
  --track-w: 3.25rem;
  --track-h: 1.7rem;
  --knob-d: 1.3rem;
  --knob-inset: 0.18rem;

  display: inline-flex;
  align-items: center;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text);
  cursor: pointer;
}

.theme-switch-track {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  box-sizing: border-box;
  width: var(--track-w);
  height: var(--track-h);
  padding: 0 0.34rem;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: var(--surface);
  transition: border-color 180ms ease;
}

.theme-switch:hover .theme-switch-track {
  border-color: var(--accent);
}

.theme-switch-icon {
  position: relative;
  z-index: 1;
  width: 0.92rem;
  height: 0.92rem;
  transition: opacity 200ms ease;
}

.theme-switch-sun {
  opacity: var(--sun-opacity);
}

.theme-switch-moon {
  opacity: var(--moon-opacity);
}

/* Sits behind the icons and slides under whichever one is active. Travel is
   the track's inner width less the knob, so the two resting positions stay
   correct if the sizes above are ever retuned. */
.theme-switch-knob {
  position: absolute;
  top: 50%;
  left: var(--knob-inset);
  z-index: 0;
  width: var(--knob-d);
  height: var(--knob-d);
  border-radius: 50%;
  background: var(--bg);
  border: 1px solid var(--rule);
  transform: translateY(-50%)
    translateX(calc(var(--switch-x) *
      (var(--track-w) - var(--knob-d) - 2 * var(--knob-inset) - 2px)));
  transition: transform 240ms cubic-bezier(0.4, 0, 0.2, 1);
}

.theme-switch:focus-visible {
  outline: none;
}

.theme-switch:focus-visible .theme-switch-track {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .theme-switch-knob,
  .theme-switch-icon,
  .theme-switch-track {
    transition: none;
  }
}

/* Removes it from the page visually while leaving it in the accessibility
   tree, so the switch keeps its name for screen readers. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Cross-document transitions, for moving between pages. Needs no JavaScript
   and is silently ignored where unsupported. The theme flip uses the
   same-document API instead — see the toggle handler. */
@view-transition {
  navigation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 220ms;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

.site-foot {
  margin-top: 6rem;
  padding: 2rem 0 3.5rem;
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-size: 0.88rem;
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.site-foot a {
  color: var(--muted);
}

.site-foot-links {
  display: flex;
  gap: 1.1rem;
}

/* ---------- links & type ---------- */

a {
  color: var(--accent);
  text-underline-offset: 0.18em;
  text-decoration-thickness: 1px;
}

/* Tracking is a function of size, so one value cannot serve 2.1rem display
   type and a 1.12rem list heading. Optical spacing grows as type shrinks:
   the larger the size, the more negative the tracking wants to be.

   `text-wrap: balance` is what stops a heading dropping one short word onto
   its own line -- "One box, two jails and a five second / deploy" was
   breaking 36 characters against 6. Browsers cap balancing at a handful of
   lines, which is exactly the range headings occupy, and anything that does
   not support it simply wraps as before. */
h1,
h2,
h3 {
  line-height: 1.2;
  font-weight: 640;
  text-wrap: balance;
}

h1 {
  font-size: 2.1rem;
  letter-spacing: -0.028em;
  margin: 0 0 0.6rem;
}

h2 {
  font-size: 1.3rem;
  letter-spacing: -0.019em;
  margin: 3rem 0 0.75rem;
}

h3 {
  letter-spacing: -0.012em;
}

.lede {
  font-size: 1.12rem;
  color: var(--muted);
  margin: 0 0 2.5rem;
}

/* The other end of the size/tracking curve. Below about 15px this sans sets
   tight, and these are the labels a reader scans rather than reads, so they
   get a little air instead of a little less. */
.meta,
.site-nav,
figcaption {
  letter-spacing: 0.008em;
}

.meta {
  color: var(--muted);
  font-size: 0.88rem;
  font-variant-numeric: tabular-nums;
}

/* ---------- home ---------- */

.intro p {
  margin: 0 0 0.4rem;
}

.intro .stack {
  color: var(--muted);
}

.social {
  display: flex;
  gap: 1.1rem;
  margin-top: 1.4rem;
  font-size: 0.94rem;
}

/* ---------- cv ---------- */

.cv-header {
  margin-bottom: 2rem;
}

/* Inline lists whose items are divided by a middle dot. The separator is
   generated after every item but the last and sits inside that item's
   nowrap box, so it can never be pushed onto the next line ahead of the word
   it belongs to. Written as literal text, "· FreeBSD" and "· LinkedIn" both
   ended up opening a line. */
.sep-list > * {
  white-space: nowrap;
}

.sep-list > *:not(:last-child)::after {
  content: " ·";
}

.cv-contact {
  color: var(--muted);
  font-size: 0.94rem;
}

.cv-contact a {
  color: var(--muted);
}

.cv-contact a:hover {
  color: var(--accent);
}

.cv-role {
  margin: 0 0 2.2rem;
  padding-bottom: 2.2rem;
  border-bottom: 1px solid var(--rule);
}

.cv-role:last-child {
  margin-bottom: 0;
  padding-bottom: 0;
  border-bottom: none;
}

.cv-role h3 {
  font-size: 1.05rem;
  margin: 0 0 0.2rem;
}

.cv-role .meta {
  margin: 0 0 0.5rem;
}

.cv-role ul {
  margin: 0.6rem 0 0;
  padding-left: 1.2rem;
}

.cv-role li {
  margin: 0.3rem 0;
}

.cv-skills {
  margin: 0;
  padding-left: 1.2rem;
}

.cv-skills li {
  margin: 0.3rem 0;
}

/* ---------- post list ---------- */

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

.post-list li {
  padding: 1.6rem 0;
  border-bottom: 1px solid var(--rule);
}

.post-list li:first-child {
  border-top: 1px solid var(--rule);
}

.post-list h2,
.post-list h3 {
  font-size: 1.12rem;
  /* Smaller than a section heading, so it wants back most of h2's tracking. */
  letter-spacing: -0.01em;
  margin: 0.2rem 0 0.35rem;
}

.post-list h2 a,
.post-list h3 a {
  color: var(--text);
  text-decoration: none;
}

.post-list h2 a:hover,
.post-list h3 a:hover {
  color: var(--accent);
}

.post-list p {
  margin: 0;
  color: var(--muted);
  font-size: 0.95rem;
}

/* ---------- prose ---------- */

.prose > * {
  margin-top: 0;
  margin-bottom: 1.15rem;
}

.prose img {
  max-width: 100%;
  height: auto;
  display: block;
  border-radius: 7px;
  border: 1px solid var(--rule);
}

/* The Trongate logo is a near-white artwork that disappears on the light
   page. Giving raster media the same frame as the diagrams makes
   dark-designed media read as one deliberate family. Inlined SVGs are not
   <img> and are handled by .diagram. */
.prose img[src$=".png"],
.prose img[src$=".webp"] {
  background: var(--diagram-bg);
}

.prose .diagram {
  margin: 1.8rem 0;
  border: 1px solid var(--rule);
  border-radius: 7px;
  overflow: hidden;
}

.prose .diagram svg {
  display: block;
  width: 100%;
  height: auto;
}

.prose blockquote {
  margin: 2rem 0;
  padding-left: 1.25rem;
  border-left: 2px solid var(--rule);
  color: var(--muted);
}

.prose blockquote p {
  margin: 0 0 0.5rem;
}

.prose code {
  font-family: var(--mono);
  font-size: 0.87em;
  background: var(--surface);
  padding: 0.12em 0.36em;
  border-radius: 3px;
}

/* code block with a filename bar */

/* Highlighter::to_html always emits <pre class="hl-code"><code>…</code></pre>,
   whether or not the block carries a title (djot::code_html only adds the
   .codeblock wrapper + filename bar when a title= attribute is present).
   Frame properties (padding, wrapping, type) live on pre.hl-code so an
   untitled block gets them too instead of being rendered bare. */
/* Solarized cream on a near-white page is 1.06:1, so an unwrapped block does
   not read as an object at all. A titled block gets its border from
   .codeblock instead, so .codeblock pre.hl-code cancels this one out. */
pre.hl-code {
  margin: 1.6rem 0;
  padding: 0.95rem 1.1rem;
  overflow-x: auto;
  border-radius: 7px;
  border: 1px solid var(--rule);
  font-family: var(--mono);
  font-size: 0.83rem;
  line-height: 1.6;
}

/* syntect writes both palettes into syntax.css keyed on .hl-code, so the
   frame must not impose its own background — only the filename bar tracks
   the theme. */
.codeblock {
  margin: 1.6rem 0;
  border-radius: 7px;
  overflow: hidden;
  border: 1px solid var(--rule);
  background: none;
}

/* When wrapped, the margin/radius belong to .codeblock instead: the pre
   sits flush below the filename bar, so its top corners must be square —
   the outer box's overflow:hidden + border-radius clip the whole frame
   (filename bar and pre together) into one rounded shape. */
.codeblock pre.hl-code {
  margin: 0;
  border-radius: 0;
  border: none;
}

.codeblock .filename {
  font-family: var(--mono);
  font-size: 0.76rem;
  padding: 0.5rem 0.9rem;
  color: light-dark(#93a1a1, #a7adba);
  background: light-dark(#eee8d5, #343d46);
  border-bottom: 1px solid
    light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.06));
}

/* The inner <code> must not re-pad or re-colour — that belongs to the frame
   and to the highlighter respectively. Applies whether or not the block is
   wrapped, since both cases share <pre class="hl-code"><code>…</code></pre>. */
pre.hl-code code {
  font-family: inherit;
  font-size: inherit;
  line-height: inherit;
  background: none;
  padding: 0;
}

/* ---- Djot output ----
   `::: warning` renders as <div class="warning">, and the heading inside it
   is the callout's title. */

.prose .warning,
.prose .note {
  margin: 2.4rem 0;
  padding: 1.25rem 1.5rem 1.35rem;
  border-left: 2px solid var(--warn-edge);
  background: var(--warn-bg);
  border-radius: 0 5px 5px 0;
  font-size: 0.95rem;
  line-height: 1.68;
}

.prose .note {
  border-left-color: var(--accent);
  background: var(--surface);
}

/* The title earns its own colour and tracking rather than just more weight:
   at 0.95rem body copy, a 1rem bold heading reads as an emphasised sentence,
   not as a label. Sentence case is deliberate — these titles carry acronyms
   ("ARC eats your RAM") that uppercasing would flatten. */
.prose .warning > h2,
.prose .note > h2 {
  margin: 0 0 0.7rem;
  font-size: 1.06rem;
  font-weight: 650;
  line-height: 1.3;
  letter-spacing: -0.006em;
  color: var(--warn-title);
}

.prose .note > h2 {
  color: var(--note-title);
}

/* A callout that opens with a title and runs several paragraphs needs the
   paragraphs held apart too, or the block reads as one grey slab. */
.prose .warning > * + *,
.prose .note > * + * {
  margin-top: 0.85rem;
}

.prose .warning > :last-child,
.prose .note > :last-child {
  margin-bottom: 0;
}

/* ---- Diagram lightbox ----
   The trigger is an overlay button covering the whole figure, added by
   script: the figure keeps its role="img" and label, the click target is the
   diagram itself, and the keyboard gets a real focusable control rather than
   a div with a click handler. */
.prose figure.is-zoomable {
  position: relative;
}

.lightbox-trigger {
  position: absolute;
  inset: 0;
  width: 100%;
  padding: 0;
  border: 0;
  border-radius: inherit;
  background: none;
  cursor: zoom-in;
}

.lightbox-trigger:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.lightbox {
  width: min(96vw, 1500px);
  max-height: 94vh;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: 8px;
  background: var(--bg);
  color: var(--text);
  overflow: hidden;
}

.lightbox::backdrop {
  background: rgb(0 0 0 / 0.74);
}

.lightbox-stage {
  padding: 1.25rem;
  max-height: 94vh;
  overflow: auto;
}

/* The diagrams carry their own viewBox, so width alone scales them; height
   auto keeps the aspect ratio the SVG declares. */
.lightbox-stage svg {
  display: block;
  width: 100%;
  height: auto;
}

.lightbox-close {
  position: absolute;
  top: 0.55rem;
  right: 0.55rem;
  z-index: 1;
  display: inline-flex;
  padding: 0.32rem;
  border: 1px solid var(--rule);
  border-radius: 6px;
  background: var(--surface);
  color: var(--text);
  cursor: pointer;
}

.lightbox-close svg {
  width: 1.15rem;
  height: 1.15rem;
}

.lightbox-close:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.lightbox-close:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.math-error {
  background: var(--warn-bg);
  border-bottom: 1px dotted var(--warn-edge);
}

math {
  font-size: 1.05em;
}

/* ---- focus ----
   The audit found no focus styles at all: keyboard users got only the browser
   default, which `text-decoration: none` on nav links made worse. */

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

/* Only suppress the ring for pointer users, never for keyboard. */
:focus:not(:focus-visible) {
  outline: none;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  padding: 0.6rem 1rem;
  background: var(--bg);
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 0 0 5px 0;
  z-index: 10;
}

.skip-link:focus {
  left: 0;
}

/* ---------- print (the CV, downloaded as a PDF or printed directly) ---------- */

@media print {
  .site-head,
  .site-foot,
  .skip-link,
  .theme-switch,
  .lightbox,
  .lightbox-trigger,
  .cv-download {
    display: none;
  }

  /* Print is always the light palette; a dark CV wastes toner and reads
     badly on paper. `:root[data-theme="dark"]` (set by the toggle's
     localStorage-backed script) is more specific than a bare `:root`, so a
     reader who last left the site in dark mode would otherwise still get a
     dark printout — hence !important, not just a higher-specificity rule. */
  :root {
    color-scheme: light !important;
  }

  body {
    font-size: 10.5pt;
    line-height: 1.45;
  }

  .wrap {
    max-width: none;
    padding: 0;
  }

  /* Links must not print as blue underlined web links, but the contact line
     (phone, email) still needs to be readable as plain text on paper. */
  a {
    color: inherit;
    text-decoration: none;
  }

  /* Roles must not be split across a page break mid-entry, and a role's
     heading/date line must never be orphaned alone at the foot of a page. */
  .cv-role {
    break-inside: avoid;
  }

  .cv-role h3,
  .cv-role .meta {
    break-after: avoid;
  }

  /* A section heading (Experience/Skills/Education) must not print alone at
     the foot of a page with its content pushed to the next one. */
  h2 {
    break-after: avoid;
  }

  @page {
    margin: 18mm 16mm;
  }
}
