/* archivo-regular - latin */
@font-face {
  font-display: swap;
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 400;
  src: url('../fonts/archivo-v25-latin-regular.woff2') format('woff2');
}

/* archivo-700 - latin */
@font-face {
  font-display: swap;
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 700;
  src: url('../fonts/archivo-v25-latin-700.woff2') format('woff2');
}

/* archivo-900 - latin */
@font-face {
  font-display: swap;
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 900;
  src: url('../fonts/archivo-v25-latin-900.woff2') format('woff2');
}

/* spline-sans-mono-regular - latin */
@font-face {
  font-display: swap;
  font-family: 'Spline Sans Mono';
  font-style: normal;
  font-weight: 400;
  src: url('../fonts/spline-sans-mono-v13-latin-regular.woff2') format('woff2');
}

:root {
  /* ---- palette ---- */
  --frame:    rgb(176, 180, 168);   /* sampled from the wave's paper/grid */
  --title:    rgb(242, 242, 242);
  --text:     rgb(191, 191, 189);
  --accent:   #e0a458;

  /* ---- Hero GitHub contributions ---- */
  --contrib:  #39d353;

  /* ---- channel triplets, for rgba() composition below ---- */
  --ink-rgb:   13, 13, 13;
  --steel-rgb: 134, 151, 166;
  --scrim-rgb: 49, 57, 64;

  /* ---- shared page column ----
     Every band (hero, about, timeline, coda, footer) is the same column.
     --edge is also where .lanes starts, so the rail lines up with the
     content inset instead of duplicating the number. */
  --col:  1200px;
  --edge: 1.5rem;

  /* ---- surfaces: the frosted-panel treatment ----
     Both alphas are contrast dials, measured against the composited wave:
     at 0.72 the worst pixel in the viewport gives --accent 4.95:1 and
     --text 5.86:1. Below ~0.68 the small accent type drops under 4.5:1. */
  --panel-alpha:  0.72;
  --pill-alpha:   0.72;
  --panel-bg:     rgba(var(--ink-rgb), var(--panel-alpha));
  --panel-border: rgba(var(--steel-rgb), 0.3);
  --pill-border:  rgba(var(--steel-rgb), 0.35);
  --panel-blur:   8px;
  --panel-radius: 12px;
  --panel-shadow: 0 12px 40px rgba(var(--ink-rgb), 0.55);

  /* ---- timeline gutter ----
     --gutter is SET BY JS from the number of active projects, so the
     rail always has exactly enough room. The value here only ever renders
     with JS off, and with JS off the SVG is empty, so it is just an
     indent. --lane-gap and --rail-x ARE read by the script, so change
     them here only. */
  --gutter:   88px;
  --lane-gap: 22px;
  --rail-x:   14px;   /* x of the base rail inside the gutter */

  /* --rail-lead is how far ABOVE the first entry the rail starts, and it
     is spent as .about's bottom padding. One number, two owners on
     purpose: the rail is meant to emerge out of the gap under the about
     panel. Changing it moves both. */
  --rail-lead: 5rem;

  /* cartographic casing: every line gets a dark outline so its local
     contrast never depends on what the wave is doing behind it */
  --casing:       rgb(var(--ink-rgb));   /* opaque now */
  --casing-alpha: 0.2;                    /* applied to the GROUP, not per line */
  --casing-grow:  5px;
  /* --casing, --rail-line and --thread-line are read ONLY by script.js,
     which writes them into SVG stroke/fill attributes. Searching this file
     for var(--rail-line) finds nothing; they are still live. */
  --rail-line:   rgba(176, 180, 168, 0.75);
  --thread-line: var(--accent);

  /* TEXT CASING: the same trick as the line casing, but for glyphs.
     Two recipes, because the blur radii have to scale with the type.
     Anything sitting directly on the wave gets one of them; anything on a
     panel or a pill gets neither, because the surface already did the job.

     --text-casing         body and UI type. Three shadows: tight for edge
                           definition, mid for separation, wide to kill the
                           wave's high-frequency detail around the glyph.
                           Measured 0.81 effective ink coverage at the
                           glyph edge -> --accent 6.05:1 worst case.
     --text-casing-display hero-scale type. Wider, softer, fewer stops:
                           a 3px shadow disappears against an 80px glyph.
                           Measured 0.64 coverage -> --title 7.30:1. */
  --text-casing:
    0 1px 3px rgba(var(--ink-rgb), 0.95),
    0 2px 10px rgba(var(--ink-rgb), 0.85),
    0 0 22px rgba(var(--ink-rgb), 0.75);
  --text-casing-display:
    0 2px 8px rgba(var(--ink-rgb), 0.9),
    0 4px 32px rgba(var(--ink-rgb), 0.7);

  /* Inline pill behind event text. Deliberately the SAME alpha as the
     panels: at 0.62 the accent date inside it measured 3.77:1, under AA.
     Events still read as the lighter tier because they are one line of
     mono type in a small rounded run, not because the ink is thinner. */
  --pill-bg: rgba(var(--ink-rgb), var(--pill-alpha));

  /* ==== THE DIALS - poke these to find the vibe ====
     wave-brightness: 1 = image as-is, lower = darker
     wave-saturate:   1 = full color, lower = greyer
     scrim-tint:      how much palette tint sits over the image (0 to 1)
     scrim-vignette:  edge darkening strength (0 to 1)
     wave-position:   which part of the image stays in frame. The X value
                      decides where `cover` takes its crop from: 100% pins
                      the image's RIGHT edge to the viewport's right edge,
                      so narrowing the window eats into the left and never
                      the right. Only bites below a 2:1 viewport, since
                      wider than that there is no horizontal overflow. */
  --wave-brightness: 0.9;
  --wave-saturate:   0.95;
  --scrim-tint:      0.18;
  --scrim-vignette:  0.35;
  --wave-position:   100% 30%;

  /* Two encodes of one 2:1 source, chosen on viewport ASPECT rather than on
     the layout's 760px stacking breakpoint - this is a question of how much
     of the image `cover` throws away, not of how the page reflows. Portrait
     tablets stay on the desktop asset because their screens earn it, and
     landscape phones stay on it because at 844x390 the viewport is 2.16
     against the image's 2.0, so almost nothing gets cropped.

     2160x1080 is not arbitrary: a 1920x1080 viewport under
     background-size:cover scales a 2:1 image to exactly that, so the
     dominant desktop case is 1:1 with no upscaling and no wasted pixels.
     Both files are exactly 2:1, which the --wave-position note above
     depends on.

     AVIF, not WebP. Measured against the old 3104px WebP, AVIF q70 matches
     its quality after the filter and scrim at 481 KB where WebP needed
     668 KB. Roughly 6% of visitors cannot decode AVIF (iOS 15 and older,
     pre-Ventura Safari, some locked-down corporate browsers); they get
     --wave-fallback below instead. WebP twins of both files ship in img/
     unreferenced if that ever needs revisiting. */
  --wave-src: url('../img/wave-2160.avif');
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: 'Archivo', sans-serif;
  color: var(--text);
  background: rgb(var(--ink-rgb));
  overflow-anchor: none;   /* we do our own scroll anchoring in JS */
}

html { scroll-behavior: smooth; }

/* for headings that exist to give a landmark an accessible name */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

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

/* ===== THE ONE FIXED WORLD ===== */
.world {
  position: fixed;
  inset: 0;
  z-index: 0;
  /* Sampled from the wave as a 1920x1080 viewport sees it, post-filter and
     pre-scrim, so a browser that cannot decode AVIF lands on a flat tone
     from the same palette with the live scrim still on top - a plainer
     backdrop rather than a broken one. */
  background-color: #4e5a54;
  background-image: var(--wave-src);
  background-size: cover;
  background-position: var(--wave-position);
  filter: brightness(var(--wave-brightness)) saturate(var(--wave-saturate));
}

/* Portrait phones only. To fill a 390x844 viewport, `cover` has to scale
   the image to 1688 CSS px wide and then shows 390 of them, so ~77% is
   discarded before anything is seen. Real sharpness would need a 5064px
   source, which is not shippable, so this variant is tuned for bytes: at
   DPR 3 the upscaling loss dwarfs anything a quality setting could recover
   (measured SSIM 0.796 at q72 vs 0.832 at q88, against 0.13 already lost
   to the upscale). */
@media (max-width: 600px) and (orientation: portrait) {
  :root { --wave-src: url('../img/wave-1600.avif'); }
}

/* A whisper of palette tint for cohesion, a vignette so edges recede,
   and the wave stays the star. Readability is the panels' job. */
.scrim {
  position: fixed;
  inset: 0;
  z-index: 1;
  background:
    radial-gradient(ellipse 120% 90% at 50% 45%,
      transparent 60%,
      rgba(var(--ink-rgb), var(--scrim-vignette)) 100%),
    linear-gradient(180deg,
      rgba(var(--scrim-rgb), var(--scrim-tint)),
      rgba(var(--ink-rgb), calc(var(--scrim-tint) * 1.5)));
}

.page {
  position: relative;
  z-index: 2;   /* all content explicitly above the backdrop */
}

/* ===== HERO ===== */
.hero {
  position: relative;
  min-height: 100vh;    /* fallback */
  min-height: 100svh;   /* wins where supported: fixes mobile URL-bar cutoff */
  max-width: var(--col);
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 2rem var(--edge);
  animation: heroIn 1.2s ease-out 0.8s both;
}
.hero .kicker {
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.85rem;
  letter-spacing: 0.25em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 1rem;
  text-shadow: var(--text-casing);
}
.hero h1 {
  font-weight: 900;
  font-size: clamp(2.6rem, 7vw, 5rem);
  color: var(--title);
  line-height: 1.0;
  max-width: 15ch;
  text-shadow: var(--text-casing-display);
}
/* The number is server-written: cron pulls it from the GitHub GraphQL API
   nightly, JS in the MISC block swaps it in. The value in the markup is a
   real fallback, not a placeholder, so a failed fetch or no JS still leaves
   a true number in the largest type on the page. Green is GitHub's own
   heatmap color, borrowed on purpose - the number is more legible as a
   quotation than as a decoration. */
.hero h1 #contrib { color: var(--contrib); }
.hero p {
  margin-top: 1.4rem;
  max-width: 48ch;
  color: var(--title);
  text-shadow: var(--text-casing-display);
}

@keyframes heroIn {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* outer: position + the fade. No animations here, so the transition
   has nothing competing with it. */
.scroll-cue {
  position: absolute;
  bottom: 2rem;
  left: 0;
  right: 0;
  pointer-events: none;
  opacity: 1;
  transition: opacity 0.5s ease;
}
body.scrolled .scroll-cue { opacity: 0; }

/* inner: the animations live here, isolated from the fade */
.scroll-cue-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.4rem;
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.7rem;
  letter-spacing: 0.25em;
  text-transform: uppercase;
  color: var(--accent);
  text-shadow: var(--text-casing);
  animation: heroIn 0.8s ease-out 2.2s both,
             cueBob 2.4s ease-in-out 3s infinite;
}
/* the chevron is a stroked path, so it takes the LINE casing treatment,
   not the text one - same role as .chev-casing on the year heads */
.scroll-cue-inner svg {
  filter: drop-shadow(0 0 2px rgb(var(--ink-rgb)))
          drop-shadow(0 1px 4px rgba(var(--ink-rgb), 0.95));
}
@keyframes cueBob {
  50% { transform: translateY(7px); }
}

/* ===== ABOUT: arko-style. Left column = greeting + photo,
   right column = the text, sharing the same vertical space. ===== */
.about {
  max-width: var(--col);
  margin: 0 auto;
  padding: 0 var(--edge) var(--rail-lead);
}
.about-panel {
  background: var(--panel-bg);
  backdrop-filter: blur(var(--panel-blur));
  border: 1px solid var(--panel-border);
  border-radius: var(--panel-radius);
  padding: 2rem 2.2rem 2.2rem;
}
.about-dev {
  display: grid;
  grid-template-columns: 300px 1fr;
  gap: 2.5rem;
  align-items: start;
}
/* stacked mode: text drops BELOW the photo; the photo does NOT resize.
   The decision is a MEASUREMENT in script.js, not a guessed breakpoint. */
.about-dev.stacked { grid-template-columns: 1fr; }
.about-dev.stacked .dev-left img { width: 300px; max-width: 100%; }
.about-dev.stacked .dev-right { max-width: none; }

.dev-left h2 {
  color: var(--title);
  font-size: 1.7rem;
  margin-bottom: 0.35rem;
}
.dev-left h2 .name { color: var(--accent); }
.dev-left .sub {
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.75rem;
  color: var(--frame);
  letter-spacing: 0.1em;
  margin-bottom: 1rem;
}
.dev-left img {
  width: 100%;
  max-width: 300px;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  border-radius: var(--panel-radius);
  border: 1px solid var(--panel-border);
}
.dev-right { max-width: 640px; }
.dev-right p { font-size: 0.95rem; line-height: 1.65; margin-bottom: 1rem; }
.dev-right p:last-child { margin-bottom: 0; }

.about-toaster { margin-top: 2rem; }
.about-toaster h2 {
  color: var(--title);
  font-size: 1.35rem;
  margin-bottom: 1rem;
}
.about-toaster p { font-size: 0.95rem; line-height: 1.65; margin-bottom: 1rem; }
.about-toaster p:last-child { margin-bottom: 0; }

/* ===== TIMELINE: left rail + gutter lanes =====
   All content starts at the same x (--gutter). Threads live inside
   the gutter and never push anything sideways. */
.timeline {
  position: relative;
  max-width: var(--col);
  margin: 0 auto;
  padding: 2rem var(--edge);
}

.lanes {
  position: absolute;
  top: calc(-1 * var(--rail-lead));
  left: var(--edge);   /* same inset as .timeline's padding */
  width: var(--gutter);
  overflow: visible;
  pointer-events: none;
  z-index: 0;
}

/* ---- year block ---- */
.year-block { position: relative; }
/* the heading owns the layout: sticky, the gutter offset, the margins.
   Block-level, so sticky behaves predictably. */
.year-heading {
  position: sticky;
  top: 0;
  z-index: 3;
  margin: 2.2rem 0 1rem;
  padding-left: var(--gutter);
}

/* the button owns only itself, so its hit area is the text + chevron
   and nothing else */
.year-head {
  display: inline-flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.5rem 0;
  background: none;
  border: none;
  cursor: pointer;
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.8rem;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--frame);
  text-align: left;
}
.year-head:hover { color: var(--accent); }
.year-head:hover .year-num { color: var(--accent); }

/* casing rather than a panel: the year head keeps its lightweight feel
   while staying legible over the wave's busiest regions */
.year-head .year-num {
  color: var(--title);
  font-size: 1.2rem;
  -webkit-text-stroke: 2px rgb(var(--ink-rgb));
  paint-order: stroke fill;   /* stroke behind the fill, not centred on it */
  text-shadow: var(--text-casing);
}
.year-head .year-count {
  font-size: 0.68rem;
  letter-spacing: 0.12em;
  /* The count states a fact about the year's contents, which is equally
     true open or closed, so it does not change with collapse state. The
     chevron carries open/closed; this carries what is inside. Amber
     matches the thread that runs down the gutter for the same project,
     which is drawn at full strength in both states.
     Colour, not opacity: opacity would fade the 2px ink stroke along with
     the fill, thinning the casing exactly where it is doing the work. */
  color: var(--accent);
  -webkit-text-stroke: 2px rgb(var(--ink-rgb));
  paint-order: stroke fill;   /* stroke behind the fill, not centred on it */
  text-shadow: var(--text-casing);
}
.year-head .year-chevron {
  display: inline-flex;
  transition: transform 0.2s ease;
  /* soft glow, same role as --text-casing on the year number */
  filter: drop-shadow(0 1px 3px rgba(var(--ink-rgb), 0.95));
}
/* the SVG equivalent of -webkit-text-stroke: same path drawn twice,
   thick ink underneath, normal stroke on top */
.year-head .chev-casing {
  stroke: rgb(var(--ink-rgb));
  stroke-width: 6;
}
.year-head .chev-core {
  stroke: currentColor;
  stroke-width: 2.5;
}
.year-head[aria-expanded="true"] .year-chevron { transform: rotate(180deg); }
/* the count is only written when a year holds live work, so an empty span
   must not claim a flex gap of its own */
.year-head .year-count:empty { display: none; }

.year-block.collapsed .year-body { display: none; }

/* ---- entries: everything aligns at --gutter ---- */
.entry {
  position: relative;
  padding-left: var(--gutter);
  margin-bottom: 1.1rem;
}

/* project = panel card */
.entry.project .card {
  background: var(--panel-bg);
  backdrop-filter: blur(var(--panel-blur));
  border: 1px solid var(--panel-border);
  border-radius: var(--panel-radius);
  overflow: hidden;
}
.entry.project.has-media .card {
  display: grid;
  grid-template-columns: minmax(0, 320px) 1fr;
  /* stretch, so the art always reaches the bottom of the card. With start,
     any card whose body outgrew the 4:3 frame left a visible band of bare
     panel under the image - 21px at iPad width, where the tablet step
     shrinks the frame at the same moment the narrower text column pushes
     the body taller. */
  align-items: stretch;
}

.entry.project.note.compact .card {
  width: fit-content;
  max-width: 100%;   /* safety net so it can't overflow on narrow viewports */
}

.entry.project.note .title {
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.85rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--frame);
  margin-bottom: 0.4rem;
}
.entry.project.note .desc {
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.8rem;
  line-height: 1.6;
  color: var(--text);
}

/* THE FRAME OWNS THE MINIMUM SHAPE.
   .card-media carries the aspect-ratio; the img carries NO sizing of its
   own beyond filling it. Under align-items:stretch the ratio is a FLOOR,
   not a fixed shape: the media is never shorter than 4/3 of its column,
   but a body taller than that pulls it down to match, and `cover` takes
   the difference off the art. That is the trade for never showing bare
   panel under the image.

   ONE ASSET, MANY CROPS. Export 4:3 at 960x720 (3x the 320px frame, so it
   stays sharp on hidpi). At desktop widths `cover` crops nothing. Below
   the 900px step the crop grows with description length, and the stacked
   mobile block re-crops the same file to 3/2. Keep anything load-bearing
   inside the middle ~70% of the composition and every crop works. */
.card-media {
  aspect-ratio: 4 / 3;
  /* width:100% is load-bearing under align-items:stretch. Stretch makes the
     height definite, and aspect-ratio then runs BACKWARDS - it computes the
     width from that height and overflows the 280px track by 25px. Pinning
     the width keeps the ratio as a height floor only. */
  width: 100%;
  overflow: hidden;
}
.card-media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.card-body { padding: 1.3rem 1.5rem 1.4rem; }

.entry .meta {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  color: var(--accent);
  margin-bottom: 0.35rem;
}
.entry .badge {
  font-size: 0.6rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 999px;
  padding: 0.08rem 0.5rem;
}
.entry .title {
  color: var(--title);
  font-size: 1.25rem;
  font-weight: 700;
  margin-bottom: 0.5rem;
}
.entry .desc { font-size: 0.93rem; line-height: 1.55; }

.entry .tags {
  margin-top: 0.9rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}
.entry .tags span {
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.68rem;
  padding: 0.18rem 0.6rem;
  border: 1px solid var(--pill-border);
  border-radius: 999px;
  color: var(--frame);
}

.entry .links {
  margin-top: 0.9rem;
  display: flex;
  gap: 1.1rem;
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.75rem;
}

.entry .links a, .entry .desc a {
  color: var(--accent);
  text-decoration: none;
  border-bottom: 1px solid transparent;
}
.entry .links a:hover, .entry .desc a:hover { border-bottom-color: var(--accent); }
.entry .links a::after { content: " \2197"; opacity: 0.7; }

/* CeDiD copy button - same orange/underline-on-hover treatment as
   .links a and .desc a above, just on a <button> since it copies
   rather than navigates. No ::after arrow, same reasoning as .desc a:
   the arrow means "this leaves the page," which doesn't apply here. */
.cedid-copy {
	display: inline-flex;
	align-items: center;
	gap: 0.35rem;
	background: none;
	border: none;
	padding: 0;
	font: inherit;
	font-family: 'Spline Sans Mono', monospace;
	color: var(--accent);
	cursor: pointer;
	border-bottom: 1px solid transparent;
}
.cedid-copy:hover,
.cedid-copy:focus-visible { border-bottom-color: var(--accent); }
.cedid-copy svg { opacity: 0.7; flex-shrink: 0; }
.cedid-copy.copied,
.cedid-copy.copied svg { color: var(--contrib); }

/* ===== EVENT ENTRIES =====
   display:block, NOT flex - flex blockifies its children, which kills
   box-decoration-break and turns the inline pill into a full-width bar.
   line-height:2 is load-bearing: inline padding does not push lines
   apart, so without extra leading the pills on wrapped lines overlap. */
.entry.event {
  display: block;
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.82rem;
  line-height: 2;
  margin-bottom: 0.5rem;
}
/* the date lives INSIDE .event-title, so it is already on the pill and
   needs no casing - it is a colour and size shift within one inline run */
.entry.event .date {
  color: var(--accent);
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  margin-right: 0.85rem;
}
/* title: a single shrink-to-fit pill. inline-block, not inline +
   box-decoration-break:clone - clone deliberately fragments wrapped
   text into a separate rounded box per line, which is the opposite
   of what this needs: one continuous rectangle, sized to whichever
   wrapped line is widest, both edges rounded only on the outside. */
.event-title {
  display: inline-block;
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.82rem;
  line-height: 1.5;
  background: var(--pill-bg);
  backdrop-filter: blur(var(--panel-blur));
  padding: 0.28rem 1.5rem;
  border-radius: 6px;
  color: var(--title);
  max-width: 100%;
}
a.event-title {
  text-decoration: none;
  border-bottom: none;
}
a.event-title:hover {
  color: var(--accent);
  background: rgba(var(--ink-rgb), 0.78);
}
a.event-title::after { content: " \2197"; opacity: 0.6; }
/* nested inside .entry.project.note's own panel - the pill's own
   opaque background compounds with the card's into a solid black
   box instead of contrasting against the wave. The card panel
   already provides that contrast, so drop it here. */
.entry.project.note .event-title {
  background: none;
  backdrop-filter: none;
}

/* ===== CODA =====
   No panel, no blur, no frosted surface. Just a framed photo with a lot
   of room around it so the wave gets the last word. The border and
   shadow are photo framing, not a card - same treatment the bio photo
   and card art already use. */
.coda {
  min-height: 93vh;    /* fallback */
  min-height: 93svh;   /* wins where supported */
  max-width: var(--col);
  margin: 0 auto;
  padding: 2rem var(--edge);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
}
.coda figure {
  width: 100%;
  max-width: 520px;
}
.coda img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--panel-radius);
  border: 1px solid var(--panel-border);
  box-shadow: var(--panel-shadow);
}
.coda figcaption {
  margin-top: 1.2rem;
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  /* --frame, not --title: at --title this read as another heading rather
     than a caption. This is the same quiet-annotation treatment as
     .dev-left .sub. NOT italic - there is no italic face in fonts/, so
     font-style:italic gets synthesised by shearing the upright, which on a
     monospace looks mechanical. A real italic woff2 is a whole extra font
     file for one line of text. */
  color: var(--frame);
  text-shadow: var(--text-casing);
}
/* a hairline is the caption signal italics would otherwise carry */
.coda figcaption::before {
  content: "";
  display: block;
  width: 44px;
  height: 1px;
  margin: 0 auto 1rem;
  background: var(--pill-border);
}

/* ===== FOOTER ===== */
.site-footer {
  position: relative;
  border-top: 1px solid var(--panel-border);
  background: var(--panel-bg);
  backdrop-filter: blur(var(--panel-blur));
}
.footer-inner {
  max-width: var(--col);
  margin: 0 auto;
  padding: 1.4rem var(--edge);
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  justify-content: space-between;
  align-items: center;
}
.site-footer small {
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.78rem;
  color: var(--frame);
}
.site-footer a { color: var(--frame); text-decoration: none; }
.site-footer a:hover { color: var(--accent); }
.footer-links {
  display: flex;
  align-items: center;
  gap: 1.2rem;
  font-size: 0.85rem;
}
.to-top {
  font-family: 'Spline Sans Mono', monospace;
  font-size: 0.8rem;
  letter-spacing: 0.08em;
}
/* WCAG 2.2 SC 2.5.8 wants >=24x24 for a control that is not inline in a
   sentence. Both of these are standalone nav targets and both measured
   under it (icons 20x20, "top" 45x15). Extend the hit area with a pseudo
   instead of padding, so nothing in the footer moves. */
.footer-links a { position: relative; }
.footer-links a::after {
  content: "";
  position: absolute;
  inset: -8px -6px;
}
.icon-link { display: inline-flex; }
.icon-link::after { inset: -7px; }   /* 20px icon -> 34px target */

/* ===== REDUCED MOTION =====
   Every motion opt-out in the file lives here. script.js checks the same
   query before its one scripted scroll, because an explicit
   behavior:'smooth' in JS overrides scroll-behavior:auto in CSS. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .hero { animation: none; }
  .scroll-cue-inner { animation: none; }
  .year-head .year-chevron { transition: none; }
}

/* ===== TABLET: card art shrinks before it stacks ===== */
@media (max-width: 900px) {
  .entry.project.has-media .card { grid-template-columns: minmax(0, 280px) 1fr; }
}

/* ===== MOBILE: narrow gutter, art stacks above card body ===== */
@media (max-width: 760px) {
  :root {
    --gutter:   40px;
    --lane-gap: 11px;
    --rail-x:   10px;
  }
  .coda { padding: 7rem var(--edge); }
  .about-dev { grid-template-columns: 1fr; gap: 1.4rem; }
  .entry.project.has-media .card { grid-template-columns: 1fr; }
  /* The frame still owns the shape; only the ratio changes, and it is the
     SAME image file - object-fit:cover re-crops it. Stacked, the media
     runs the full card width, and 4:3 there costs 200px of a phone screen
     before the body even starts. 3:2 keeps the card near half the viewport
     and takes 11% off the art (centred) rather than 33%. */
  .card-media { aspect-ratio: 3 / 2; }
  .card-body { padding: 1.1rem 1.2rem 1.2rem; }
  .entry .title { font-size: 1.1rem; }
  .year-head { letter-spacing: 0.2em; }
}

@media (max-width: 760px) and (orientation: landscape) {
  .entry.project.has-media .card { grid-template-columns: minmax(0, 280px) 1fr; }
  .card-media { aspect-ratio: 4 / 3; }
}
