/* ==========================================================================
   Resume site
   Order: tokens · base · layout · components · motion · print
   ========================================================================== */

/* --- Tokens --------------------------------------------------------------- */

/* The palette deliberately avoids both extremes. Pure black on pure white
   glares; washed-out greys are unreadable. Everything sits inside a
   compressed range: softened ink, lifted secondary text. */
:root {
  --bg:        #F7F6F3;
  --fg:        #23201C;
  --fg-body:   #3E3A35;
  --fg-muted:  #5F5952;
  --rule:      #DCD8D2;
  --accent:    #A94E1D;
  --accent-rgb: 169 78 29;
  --accent-deep: #A2491B;
  --accent-press: #8E3E15;
  --shimmer-rgb: 169 78 29;
  /* Grain colour for the CTA crumble. Tuned per theme so the falling sand
     reads against the page, not against the button it leaves. */
  --sand:      #A88553;

  --font-sans: "Familjen Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --measure:   680px;
  --gutter:    clamp(1.5rem, 8vw, 2rem);

  --ease:      cubic-bezier(0.22, 0.61, 0.36, 1);

  /* Where a chapter is considered to have arrived: the year pins here, and the
     milestone pops here. Both read this, so they cannot drift apart. Far enough
     down that a new chapter lands in comfortable reading position rather than
     jammed against the top edge. */
  --corner:    200px;

  /* Height the floating header occupies, incl. its top offset. Anything else
     that sticks to the top has to clear this. */
  --nav-space: 4.75rem;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg:       #161512;
    --fg:       #E0DCD5;
    --fg-body:  #BDB6AD;
    --fg-muted: #9A938B;
    --rule:     #2F2C27;
    --accent:   #D8794A;
    --accent-rgb: 216 121 74;
    --accent-deep: #A2491B;
    --accent-press: #8E3E15;
    --shimmer-rgb: 255 176 122;
    --sand:     #C6A878;
  }
}

/* --- Base ----------------------------------------------------------------- */

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

/* The `hidden` attribute is only a user-agent rule, so ANY author `display`
   beats it. .lightbox, .dock and .shots__nav all set display, which meant
   hidden did nothing at all — the lightbox's close button flipped the
   property and the overlay stayed on screen. Restate it as an author rule so
   the attribute actually hides. */
[hidden] { display: none !important; }

html {
  scrollbar-gutter: stable;
  -webkit-text-size-adjust: 100%;
  background: var(--bg);
}

/* No font-smoothing override on purpose — forcing `antialiased` thins the
   strokes, which is exactly what makes light-on-dark text hard to read. */
body {
  margin: 0;
  /* No background here: html already paints the canvas, and a background on
     body would cover the grain layer below. */
  color: var(--fg);
  font-family: var(--font-sans);
  font-size: 1.0625rem;
  line-height: 1.68;
  font-weight: 400;
  text-rendering: optimizeLegibility;
}

/* --- Surface -------------------------------------------------------------

   A concrete slab under everything, so the page reads as a surface rather
   than a flat fill. Four turbulence passes rendered to a data URI —
   deterministic, no request, nothing to ship.

   What makes it concrete rather than noise is that the passes are different
   KINDS of feature, summed: pores, scuffs, and a slow tonal drift. The scuffs
   come from anisotropic turbulence — an uneven baseFrequency like
   '0.014 0.75' stretches the noise into streaks — run once each way so the
   scratching is not all combed in one direction.

   Everything in that pass is BLACK. Grey speckle lightens a dark ground, and
   a lightened dark ground is haze however fine the speckle; keeping the slab
   purely subtractive is what keeps the page dark. The one exception is a
   second, very sparse white layer: a handful of bright flecks where aggregate
   catches the light. Without it a near-black ground has nowhere to fall and
   the texture disappears entirely — with it, at ~2% coverage, the surface
   reads without lifting the overall tone.

   Every amplitude here was set by rasterising the tile and measuring it, not
   by eye. The first pass summed four layers at ~2x amplitude each and
   saturated: alpha p50 215 with p90 and p99 both pegged at 255, which is a
   flat wash with fine noise on top, not a surface. These values hold p10 40
   / p50 68 / p99 151 with nothing clipped, so the slab actually varies. On
   the dark ground that lands roughly 1.5/255 of darkening in the open and
   6/255 at its heaviest; the flecks cover 1.9% and peak at 62/255. Held
   deliberately light — the slab should be something you notice second, after
   the type, not a surface competing with it.

   Sits at z-index -1 in the root stacking context, which paints it after
   html's background colour and before any content, so it can never come out
   over the type. Fixed rather than scrolling: viewport-locked texture has no
   tiling seam travelling up the page. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0.09;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='280' height='280'%3E%3Cfilter id='s' x='0' y='0' width='100%' height='100%'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='1' seed='55' stitchTiles='stitch' result='n'/%3E%3CfeColorMatrix in='n' type='saturate' values='0' result='g'/%3E%3CfeComponentTransfer in='g' result='c'%3E%3CfeFuncA type='gamma' exponent='14' amplitude='8'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix in='c' type='matrix' values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='280' height='280' filter='url(%23s)' opacity='0.6'/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='640'%3E%3Cfilter id='c' x='0' y='0' width='100%' height='100%'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='4' stitchTiles='stitch' result='p'/%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.014 0.75' numOctaves='2' seed='12' stitchTiles='stitch' result='h'/%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75 0.014' numOctaves='2' seed='23' stitchTiles='stitch' result='v'/%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.011' numOctaves='3' seed='31' stitchTiles='stitch' result='m'/%3E%3CfeColorMatrix in='p' type='saturate' values='0' result='pg'/%3E%3CfeColorMatrix in='h' type='saturate' values='0' result='hg'/%3E%3CfeColorMatrix in='v' type='saturate' values='0' result='vg'/%3E%3CfeColorMatrix in='m' type='saturate' values='0' result='mg'/%3E%3CfeComponentTransfer in='pg' result='pc'%3E%3CfeFuncA type='gamma' exponent='2.6' amplitude='0.9'/%3E%3C/feComponentTransfer%3E%3CfeComponentTransfer in='hg' result='hc'%3E%3CfeFuncA type='gamma' exponent='4.5' amplitude='0.55'/%3E%3C/feComponentTransfer%3E%3CfeComponentTransfer in='vg' result='vc'%3E%3CfeFuncA type='gamma' exponent='5.2' amplitude='0.4'/%3E%3C/feComponentTransfer%3E%3CfeComponentTransfer in='mg' result='mc'%3E%3CfeFuncA type='gamma' exponent='1.7' amplitude='0.22'/%3E%3C/feComponentTransfer%3E%3CfeComposite in='pc' in2='hc' operator='arithmetic' k2='1' k3='1' result='s1'/%3E%3CfeComposite in='s1' in2='vc' operator='arithmetic' k2='1' k3='1' result='s2'/%3E%3CfeComposite in='s2' in2='mc' operator='arithmetic' k2='1' k3='1' result='s3'/%3E%3CfeColorMatrix in='s3' type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='640' height='640' filter='url(%23c)'/%3E%3C/svg%3E");
  background-size: 280px 280px, 640px 640px;
}

@media (prefers-color-scheme: dark) {
  /* A 22/255 ground has almost nowhere to fall, so the dark theme needs a far
     heavier layer than the light one to land a comparable delta. */
  body::before { opacity: 0.45; }
}

@media print { body::before { display: none; } }

h1, h2, h3, p, ul, dl, dd, figure { margin: 0; }
ul { padding: 0; list-style: none; }

a {
  color: inherit;
  text-decoration: none;
  box-shadow: inset 0 -1px 0 var(--rule);
  transition: color 0.15s ease, box-shadow 0.15s ease;
}
a:hover {
  color: var(--accent);
  box-shadow: inset 0 -1px 0 var(--accent);
}

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

::selection {
  background: var(--accent);
  color: var(--bg);
}

/* --- Layout --------------------------------------------------------------- */

.page {
  position: relative;
  z-index: 1;
  max-width: var(--measure);
  margin: 0 auto;
  padding: 0 var(--gutter) clamp(4rem, 12vh, 7rem);
}

.hero { padding-top: clamp(1.5rem, 5vh, 3rem); }

/* The name and the canvas occupy the same box. The canvas sits on top but is
   pure wireframe, so the type reads as standing inside the scene rather than
   behind a panel. It never takes pointer events, so the name stays selectable. */
.hero__scene {
  position: relative;
  min-height: clamp(17rem, 43vh, 25rem);
  display: flex;
  align-items: center;
  margin-bottom: clamp(1rem, 2.5vh, 1.75rem);
  /* The figure can be grabbed and spun, so horizontal gestures belong to us.
     pan-y hands vertical ones straight back to the browser: without it the
     drag would swallow page scrolling on the tallest element in the hero. */
  touch-action: pan-y;
}

/* Both set from the same hit test the drag uses, so the cursor only offers a
   grab where one will actually take. */
.hero__scene.is-grabbable { cursor: grab; }
.hero__scene.is-grabbing  { cursor: grabbing; }

/* Stacked and set large: the type has to be the environment for anything to
   read as standing inside it. Beside a caption-sized name the cactus just
   looked like an illustration parked next to some text. */
.hero__scene .hero__name {
  position: relative;
  z-index: 1;
  margin: 0;
  max-width: 100%;
  font-size: clamp(2.4rem, 8.5vw, 5.25rem);
  line-height: 0.92;
  letter-spacing: -0.04em;
}

.hero__scene .hero__name .word { display: block; }

.hero__scene .holo__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* The name is the z=0 plane; the figure is split across it. */
.holo__canvas--back  { z-index: 0; }
.holo__canvas--front { z-index: 2; }


.section { margin-top: clamp(3.5rem, 9vw, 5.5rem); }

/* Touch targets keyed to the POINTER, not the width. A 768px tablet is a
   touch device and was getting the 37px-tall desktop nav; a 768px browser
   window on a laptop is not. Width cannot tell those apart, hover can. */
@media (hover: none) {
  .nav a {
    padding-top: 0.78rem;
    padding-bottom: 0.78rem;
  }
  .nav__me {
    padding-top: 0.78rem;
    padding-bottom: 0.78rem;
  }
  .shots__arrow {
    width: 3rem;
    height: 3rem;
  }
}

/* Smallest phones. The bar measured 268px of content inside 269px at 320px
   wide, which is not a bug yet but has no slack in it either: one more
   character anywhere and the row starts compressing its own targets. */
@media (max-width: 360px) {
  .nav {
    font-size: 0.58rem;
    letter-spacing: 0.06em;
  }
  .nav a { padding: 0.75rem 0.45rem; }
  .nav__me { padding: 0.75rem 0.55rem 0.75rem 0.7rem; }
  .nav__ico { width: 0.9rem; height: 0.9rem; }
}

/* --- Nav ------------------------------------------------------------------ */

/* A floating pill that rides along with the page. Translucent over a blur so
   content reads through it rather than being hidden behind a bar. */
/* One pinned pill at a time. Each bar is sticky inside its own zone, so when
   a zone scrolls out its bar is pushed up and the next zone's bar arrives in
   the same slot — the handoff falls out of the layout rather than needing JS
   to arbitrate a shared position. */
.bar {
  position: sticky;
  top: 1rem;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.25rem;
  min-height: 2.9rem;
  padding: 0.4rem;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: color-mix(in srgb, var(--bg) 93%, transparent);
  -webkit-backdrop-filter: blur(14px) saturate(1.3);
          backdrop-filter: blur(14px) saturate(1.3);
  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.26);
  isolation: isolate;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.nav { margin-top: 1.25rem; }

/* --- pill flare ----------------------------------------------------------

   Light pooling inside the glass, not a lit edge. Two off-centre elliptical
   flares with a hot core, plus a thin sheen along the top inside — so the
   pill reads as frosted glass with something warm behind it rather than a
   capsule with an orange outline. The border stays neutral; tinting it was
   the previous attempt and it just looked like a coloured stroke.

   Backgrounds are clipped by border-radius, so the flares stay inside the
   capsule with no overflow rule and no clipping of the links' focus rings.

   Scoped to the two pinned pills — the nav, and the tally once it closes into
   pill form. The tally's flat band in the hero has no glass to light. */
/* Liquid glass. Four things together make the material read, and none of
   them alone does:

   1. A thinner fill and a heavier backdrop. Glass is mostly what is behind it,
      so the pill lets more through and blurs and saturates it harder — the
      page warms and smears under the bar rather than being hidden by it.
   2. A specular rim that is bright at two opposite corners and dark between
      (the ::after below). A lens catches light where it curves away, not
      evenly around its edge, so a uniform stroke reads as a drawn outline and
      this reads as a surface.
   3. Inset highlights: a hard catch on the top lip, and a soft bounce thrown
      back up off the bottom inside.
   4. Light living inside the glass rather than on it (the ::before flare).

   The border itself drops to almost nothing — the rim ring is the edge now,
   and keeping both gave the pill a doubled outline. */
.nav,
.tally.is-pinned {
  border-color: rgb(0 0 0 / 0.07);
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  -webkit-backdrop-filter: blur(22px) saturate(1.9);
          backdrop-filter: blur(22px) saturate(1.9);
  box-shadow:
    0 12px 30px rgba(0, 0, 0, 0.10),
    0 2px 8px rgba(0, 0, 0, 0.05),
    inset 0 1px 1px rgb(255 255 255 / 0.85),
    inset 0 -10px 26px -18px rgb(0 0 0 / 0.16);
}

/* On a light ground the rim has to be dark — a white specular edge over a
   near-white pill is invisible, which is what the dark-first version looked
   like here. The top lip stays white in both: that is the light catching the
   near edge, and it works either way round. */
@media (prefers-color-scheme: dark) {
  .nav,
  .tally.is-pinned {
    border-color: rgb(255 255 255 / 0.06);
    box-shadow:
      0 12px 34px rgba(0, 0, 0, 0.34),
      0 2px 8px rgba(0, 0, 0, 0.16),
      inset 0 1px 1px rgb(255 255 255 / 0.20),
      inset 0 -10px 26px -18px rgb(255 255 255 / 0.34);
  }
}

/* The rim. Same border-box-minus-content-box mask trick as the CTA's shine
   ring, so the 1px band follows the capsule's radius exactly instead of being
   a rectangle behind it. */
.nav::after,
.tally.is-pinned::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  pointer-events: none;
  background: linear-gradient(145deg,
    rgb(0 0 0 / 0.24) 0%,
    rgb(0 0 0 / 0.05) 26%,
    rgb(0 0 0 / 0) 47%,
    rgb(0 0 0 / 0.05) 70%,
    rgb(0 0 0 / 0.20) 100%);
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
}

.nav::before,
.tally.is-pinned::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background:
    /* glass sheen along the top inside edge */
    linear-gradient(rgb(255 255 255 / 0.055), transparent 45%),

    /* One broad source sitting outside the bottom-right corner, falling off
       smoothly across the bar toward the top-left.

       This was a chain of seven small ellipses, which is what made it
       blotchy: each one carried its own edge, and light has no edges. A
       single huge radius with a long stop ramp is a continuous gradient
       rather than a row of blobs — and it halves the peak alpha while
       still reading brighter, because the light is now joined up. */
    radial-gradient(125% 330% at 104% 152%,
      rgb(var(--accent-rgb) / 0.21) 0%,
      rgb(var(--accent-rgb) / 0.10) 32%,
      rgb(var(--accent-rgb) / 0.03) 60%,
      transparent 82%),

    /* The weaker half that carried on through the glass, set up and left of
       where a straight line would have put it — the bend, without a seam. */
    radial-gradient(75% 250% at 32% -34%,
      rgb(var(--accent-rgb) / 0.095) 0%,
      rgb(var(--accent-rgb) / 0.028) 46%,
      transparent 74%);
}

/* The band has no glass to light, so the flare fades in with the rest of the
   chrome rather than snapping on at the moment it pins. */
@media (prefers-color-scheme: dark) {
  .nav::after,
  .tally.is-pinned::after {
    background: linear-gradient(145deg,
      rgb(255 255 255 / 0.50) 0%,
      rgb(255 255 255 / 0.08) 26%,
      rgb(255 255 255 / 0) 47%,
      rgb(255 255 255 / 0.10) 70%,
      rgb(255 255 255 / 0.42) 100%);
  }
}

.tally::before,
.tally::after {
  opacity: 0;
  transition: opacity 0.11s var(--ease);
}
.tally.is-pinned::before,
.tally.is-pinned::after { opacity: 1; }

.nav a {
  position: relative;
  padding: 0.6rem 1rem;
  border: 0;
  border-radius: 999px;
  box-shadow: none;
  color: var(--fg-muted);
  white-space: nowrap;
  transition: color 0.16s ease;
}

.nav a:hover {
  color: var(--fg);
  box-shadow: none;
}

/* The marker sits under the label, inside the pill. */
.nav a::after {
  content: "";
  position: absolute;
  left: 1rem;
  right: 1rem;
  bottom: 0.3rem;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transition: transform 0.22s var(--ease);
}

.nav a.is-current {
  color: var(--fg);
}

.nav a.is-current::after { transform: scaleX(1); }

/* The profile link. Icon-only and set apart by a hairline, so it reads as a
   destination off the page rather than a fifth section of it — the other four
   are in-page anchors and this one leaves. */
.nav__me {
  position: relative;
  display: inline-flex;
  align-items: center;
  padding: 0.75rem 0.85rem 0.75rem 1rem;
  margin-left: 0.15rem;
  color: var(--fg-muted);
}

.nav__me::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 1px;
  height: 1.05rem;
  background: var(--rule);
}

/* .nav a:hover pulls every link to --fg; this one goes accent instead, since
   the mark is a brand and reads better coloured than merely brightened. */
.nav__me:hover,
.nav__me:focus-visible { color: var(--accent); }

.nav__ico {
  width: 0.95rem;
  height: 0.95rem;
  display: block;
  fill: currentColor;
  stroke: none;
}

@media (max-width: 560px) {
  .nav {
    gap: 0;
    padding: 0.3rem;
    font-size: 0.62rem;
    letter-spacing: 0.08em;
  }
  /* Taller touch targets on a phone. The pill has the headroom: its
     min-height is 2.9rem and the links were only using 34px of it. */
  .nav a { padding: 0.75rem 0.6rem; }
  .nav a::after { left: 0.6rem; right: 0.6rem; bottom: 0.5rem; }
  .nav__me { padding: 0.75rem 0.85rem 0.75rem 1rem; }
}

html { scroll-behavior: smooth; }
.section, .footer { scroll-margin-top: calc(var(--nav-space) + 1rem); }

/* --- Hero ----------------------------------------------------------------- */

.hero__name {
  font-size: clamp(2.1rem, 5.2vw, 3.4rem);
  font-weight: 500;
  line-height: 1.02;
  letter-spacing: -0.03em;
  margin-bottom: 1.25rem;
}

.hero__name .word { display: inline-block; }

.hero__role {
  font-size: 1.0625rem;
  margin-bottom: 1.5rem;
}

.hero__job { white-space: nowrap; }

.hero__place { color: var(--fg-muted); white-space: nowrap; }

.hero__job + .hero__place::before {
  content: "·";
  color: var(--rule);
  padding: 0 0.55rem;
}

/* Two columns, label stacked over its capabilities — half the height of four
   full-width rows, and it reads as a block rather than a list to wade through.
   Separators are dimmed so the skills carry the weight, not the punctuation. */
/* A spec table, not a stack of blocks. Each group used to be a label
   floating above its own row of terms with 30px of nothing between groups,
   which read as four loose things rather than one list. Label left, terms
   right, hairline between: the same two-column-plus-rule idiom the eras and
   items already use, so the section joins the page's system instead of
   inventing a third arrangement. It is also considerably denser.

   The tools live in the same list for the same reason: a label and its terms
   is exactly what they are. */
.specs {
  margin-bottom: 0;
}

.spec {
  display: grid;
  grid-template-columns: 7rem 1fr;
  gap: 0 1.75rem;
  align-items: baseline;
  padding: 0.95rem 0;
  border-top: 1px solid var(--rule);
}

.spec:last-child { border-bottom: 1px solid var(--rule); }

/* The label owns a column now, so it no longer needs to push the terms down. */
.specs dt { margin-bottom: 0; }

@media (max-width: 560px) {
  .spec {
    grid-template-columns: 1fr;
    gap: 0.4rem 0;
    padding: 0.8rem 0;
  }
}

.specs dd {
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  /* The column gap is what separates the terms now, so it has to stay clearly
     wider than a word space — otherwise "landing pages" reads as two. */
  gap: 0.3rem 1.15rem;
  font-size: 0.875rem;
  line-height: 1.45;
  color: var(--fg-body);
}

/* Breakpoint matches where initHolo switches to its narrow figure (canvas
   ~560px ≈ 620px viewport). At 700 it didn't: the scene kept a short height
   while the figure was still drawing at full size, so the floor fell outside
   the canvas and the cactus was cut off at the name.
   `align-items` stays centred — flex-end pinned the name to the bottom and
   left no room for the trunk and floor beneath it. */
/* Both halves are nowrap so neither breaks mid-phrase, which means they
   cannot share a line once the viewport is narrow — at 390px the location ran
   clean off the right edge. Stack them and drop the separator. */
@media (max-width: 620px) {
  .hero__job,
  .hero__place { display: block; }

  .hero__job + .hero__place { margin-top: 0.15rem; }
  .hero__job + .hero__place::before { content: none; }

  /* Shorter scene: the figure's floor is derived from the name, so a tall box
     left a dead band of empty canvas under it. */
  .hero__scene { min-height: clamp(13rem, 34vh, 17rem); }

  /* The base clamp bottomed out around 33px here, which the cactus dwarfed. */
  .hero__scene .hero__name { font-size: clamp(2.6rem, 12vw, 3.4rem); }
}

@media (max-width: 460px) {
  .specs { grid-template-columns: 1fr; gap: 0.85rem; }
}

/* Rounded, flat-faced, with a light ring around the edge and the mark on the
   left. The fill is --accent-deep rather than --accent: a white label needs
   4.5:1 and only clears it on the darker shade, so the button keeps the brand
   hue while staying legible in both themes. */
.cta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.85rem;
  margin-top: 1.9rem;
}

.cta__btn {
  /* The shine pseudo-element is absolutely positioned against this. Without
     it the ring resolved against .page and drew around the whole page. */
  position: relative;
  /* Forces a stacking context so the crack layer's negative z-index lands
     above this button's fill rather than behind the whole button. */
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.7rem;
  min-height: 3.25rem;
  padding: 0 1.6rem;
  border: 1px solid transparent;
  border-radius: 14px;
  box-shadow: none;
  font-family: var(--font-sans);
  font-size: 0.95rem;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  white-space: nowrap;
  color: var(--fg);
  transition:
    transform 0.12s ease,
    box-shadow 0.18s ease,
    background-color 0.18s ease,
    border-color 0.18s ease,
    color 0.18s ease;
}

.cta__ico {
  width: 1.2rem;
  height: 1.2rem;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 0.22s var(--ease);
}

.cta__btn:hover .cta__ico--arrow { transform: translateY(2px); }
.cta__btn:hover .cta__ico--mail { transform: translateX(2px); }

/* --- primary ------------------------------------------------------------ */

/* Registered so the angle can actually be animated — a plain custom property
   is a string to the animation engine and would jump rather than sweep. */
@property --cta-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

.cta__btn--primary {
  color: #fff;
  background-color: var(--accent-deep);
  /* The light hairline ring, sitting just inside the edge. */
  border-color: rgba(255, 255, 255, 0.42);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
}

/* A highlight travelling around the perimeter. The ring is a conic gradient
   clipped to a 1px band by compositing two masks — border-box minus
   content-box — so it rides the rounded corners exactly instead of being a
   rectangle behind them. Where @property is unsupported the gradient simply
   sits still, which still reads as the lit edge in the reference. */
.cta__btn--primary::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  pointer-events: none;
  background: conic-gradient(
    from var(--cta-angle),
    rgba(255, 255, 255, 0) 0deg,
    rgba(255, 255, 255, 0) 215deg,
    rgba(255, 255, 255, 0.85) 290deg,
    rgba(255, 255, 255, 0) 340deg);
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  animation: cta-shine 3.6s linear infinite;
}

@keyframes cta-shine {
  to { --cta-angle: 360deg; }
}

/* Hover DEEPENS the fill rather than brightening it. brightness(1.1) lifted
   the ground under a white label from 5.99:1 to ~5.2:1 and read as washed
   out; --accent-press is 7.4:1, so the label gets easier to read at the exact
   moment the visitor is looking at it. */
/* The generic a:hover sets color: var(--accent), and at (0,1,1) it outranked
   the (0,1,0) .cta__btn--primary that sets the label white — so hovering the
   filled button turned its label and icon orange on orange. Restating white
   here at (0,2,0) is what actually keeps them legible; the darker fill below
   only helps once they are white again. */
.cta__btn--primary:hover,
.cta__btn--primary:focus-visible {
  color: #fff;
}

.cta__btn--primary:hover {
  background-color: var(--accent-press);
  border-color: rgba(255, 255, 255, 0.6);
  box-shadow: 0 5px 16px rgba(0, 0, 0, 0.34);
  transform: translateY(-1px);
}

.cta__btn--primary:active {
  transform: translateY(1px);
  background-color: var(--accent-press);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}

/* --- secondary: same silhouette, unfilled ------------------------------- */

.cta__btn--ghost {
  border-color: var(--rule);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16);
}

/* The label holds full --fg through the hover rather than turning accent —
   dropping the text to a mid-tone at the moment someone is reading it is the
   wrong trade. The pop comes from everything around it instead: an accent
   wash in the fill, an accent rim, the icon going accent, and a faint halo. */
.cta__btn--ghost:hover {
  border-color: rgb(var(--accent-rgb) / 0.8);
  background-color: rgb(var(--accent-rgb) / 0.13);
  color: var(--fg);
  transform: translateY(-1px);
  box-shadow:
    0 4px 14px rgba(0, 0, 0, 0.22),
    0 0 0 3px rgb(var(--accent-rgb) / 0.11);
}

.cta__btn--ghost:hover .cta__ico { stroke: var(--accent); }

.cta__btn--ghost:active {
  transform: translateY(1px);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}

/* --- desert-floor crumble ----------------------------------------------- */

/* On hover the button's face cracks like dry ground. The fissure is generated
   in JS so each break is different and a second hover does not replay the same
   shape; see initCrumble(). Nothing here runs under reduced motion — the
   script creates no crack at all in that case.

   The layer sits at z-index -1 inside the button's own stacking context, which
   puts it above the fill but below the label and icon: within a stacking
   context, negative-z children paint after the background and before inline
   content. So the crack runs across the orange and the white text stays clean
   on top of it. */
.cta__sand {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  border-radius: inherit;
  pointer-events: none;
}

.cta__crack {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: none;
  stroke: var(--crack);
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  transition: opacity 0.32s ease;
}

/* Each plate border is dashed to its own length, so shrinking the offset to
   zero draws that edge opening. Transitions rather than keyframes: the crack
   has to HOLD while the pointer is on the button and close when it leaves,
   and a transition is reversible where a one-shot animation is not. */
.cta__crack path {
  stroke-dasharray: var(--len);
  stroke-dashoffset: var(--len);
  transition: stroke-dashoffset 0.24s ease-out;
}

.cta__btn--primary { --crack: rgba(0, 0, 0, 0.72); }
/* Black would vanish on the unfilled button in dark mode, so this one cracks
   in ink rather than in black. */
.cta__btn--ghost   { --crack: color-mix(in srgb, var(--fg) 55%, transparent); }

.cta__btn:hover .cta__crack,
.cta__btn:focus-visible .cta__crack {
  opacity: 1;
  transition-duration: 0.12s;
}

/* The per-edge delay applies on the way in only. On the way out they close
   together under the fade, which reads as the ground knitting back up rather
   than unzipping in the order it broke. */
.cta__btn:hover .cta__crack path,
.cta__btn:focus-visible .cta__crack path {
  stroke-dashoffset: 0;
  transition-delay: var(--delay);
}

@media (max-width: 460px) {
  .cta { gap: 0.6rem; }
  .cta__btn { flex: 1 1 auto; padding: 0 1.1rem; }
}

/* --- Skills section ------------------------------------------------------- */

/* The capability groups moved out of the hero and sit above the tool list. */
.specs + .stack { margin-top: 1.9rem; }

/* The email is the actual call to action, so it is typeset as one — not
   buried behind a generic envelope glyph in a row of bordered squares. */
.contact {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.5rem 0.9rem;
}

.contact__primary {
  /* The email is the main call to action on the page; give it a target to
     match. Negative margin keeps the footer row at its designed height. */
  display: inline-block;
  padding: 0.6rem 0;
  margin: -0.6rem 0;
  font-size: 1.0625rem;
  color: var(--fg);
  box-shadow: none;
  background-image: linear-gradient(var(--accent), var(--accent));
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 0 1px;
  transition: background-size 0.28s var(--ease);
}

.contact:hover .contact__primary,
.contact__primary:hover,
.contact__primary:focus-visible { background-size: 100% 1px; }

/* Padding out to a real touch target, pulled back with a negative margin so
   the visible row is unchanged. It measured 30x14 on a phone. */
.contact__copy {
  appearance: none;
  border: 0;
  padding: 0.9rem;
  margin: -0.9rem;
  background: none;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--fg-muted);
  transition: color 0.15s ease;
}

.contact__copy:hover { color: var(--accent); }
.contact__copy.is-done { color: var(--accent); }

.contact__rest {
  flex-basis: 100%;
  display: flex;
  gap: 1.1rem;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.05em;
}

/* Same trick as the copy button: the footer links were 20px tall. */
.contact__rest a {
  color: var(--fg-muted);
  box-shadow: none;
  padding: 0.72rem 0;
  margin: -0.72rem 0;
}
.contact__rest a:hover { color: var(--accent); }
.contact__rest span { padding-left: 0.25rem; }

/* The mark is filled rather than stroked like the rest of the page's icons —
   it is a wordmark, and outlining it would be a redrawn logo, not the logo.
   It tracks the link's colour, so it picks up the accent on hover with the
   label. */
.contact__link {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.contact__ico {
  width: 0.95rem;
  height: 0.95rem;
  flex: none;
  fill: currentColor;
  stroke: none;
}


.sep {
  color: var(--rule);
  margin: 0 0.6rem;
}

/* --- Hologram ------------------------------------------------------------- */


/* --- Section label -------------------------------------------------------- */

.section__label {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--fg-muted);
  padding-bottom: 0.8rem;
  border-bottom: 1px solid var(--rule);
  margin-bottom: 2rem;
}

/* --- Tally ---------------------------------------------------------------- */

/* Two states, one element. In the hero it is a flat stat band: no chrome, no
   pill, values spread across the measure at display size — so the first screen
   carries the numbers without a second capsule sitting under the nav. On the
   way up it becomes the pinned pill and takes the slot the nav vacates.

   Every property that differs between the two states is animatable, which is
   what makes the handoff a transition rather than a swap. That constraint is
   why the cells stay stacked in both states and spread via flex-grow: an
   inline re-cut, or a justify-content change, would have snapped.

   .is-pinned is set by a sticky-detection observer in initTally(). */
.tally {
  margin: clamp(2.25rem, 6vw, 3.25rem) 0 0;
  gap: 0;
  padding: 0;

  /* THE LAYOUT BOX NEVER CHANGES. This is the whole trick, and getting it
     wrong is what made the handoff flicker: the bar is position:sticky, so
     its box still occupies flow, and animating its height reflowed the
     document — which moved the very boundary the pin observer watches, which
     re-fired the toggle, which changed the height again. A feedback loop, and
     it thrashed for the length of the transition before settling.

     So the height is fixed at the pill's height in both states, and the flat
     band is sized to fit inside it. Nothing that animates can now affect
     document layout: the remaining transitions are paint-only, plus type
     metrics that reflow just the three cells inside a box of fixed size. */
  height: 2.9rem;
  min-height: 0;

  border-radius: 0;
  border-color: transparent;
  background: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
          backdrop-filter: none;
  transition:
    border-radius 0.26s var(--ease),
    border-color 0.26s var(--ease),
    background-color 0.26s var(--ease),
    box-shadow 0.26s var(--ease);
}

/* Nothing sets .is-pinned without JS, so the bar would pin as bare type over
   the page. It stays in the flow instead. */
html:not(.js) .tally { position: static; }

.tally__asof { display: none; }

.tally__cell {
  /* Exact thirds. With `1 1 auto` each cell took its content width PLUS a
     share of the slack, and the three labels are different lengths, so the
     dividers landed at three different intervals and the gap before each one
     varied — which is what read as uneven. Basis 0 makes the columns
     identical regardless of what is in them; min-width:0 is required or
     min-width:auto quietly floors each cell at its content width and brings
     the problem straight back.

     A basis of 0 was not enough either: that splits the SLACK equally, and
     the first cell has no left padding and no divider, so it started from a
     smaller base and stayed ~10px narrower. A percentage basis is measured
     border-box, so the columns come out exactly equal whatever padding each
     one carries. */
  flex: 0 0 33.3333%;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Centred in its own third rather than ragged left. With exact thirds the
     columns are already regular; centring the contents makes the rhythm read
     that way too, instead of three blocks pushed left with uneven space
     trailing each one before its divider. */
  align-items: center;
  /* Constant, like everything else here — one less thing moving. */
  gap: 0.18rem;
  padding: 0 clamp(0.8rem, 3vw, 1.35rem);
  border-left: 1px solid var(--rule);
  white-space: nowrap;
  transition: padding-left 0.26s var(--ease);
}

/* The divider belongs between columns, not before the first one. Padding is
   left symmetrical now that the contents are centred — an unpadded first cell
   would shift its text off the shared centre line. */
.tally__cell:first-child { border-left: 0; }

.tally__value {
  font-family: var(--font-sans);
  /* Capped so value + gap + label clears the bar's fixed height. */
  font-size: clamp(1.35rem, 7.1vw, 1.9rem);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.02em;
  text-transform: none;
  color: var(--fg);
  font-variant-numeric: tabular-nums;
  transition: font-size 0.18s var(--ease);
}

.tally__label {
  font-size: 0.6rem;
  line-height: 1;
  letter-spacing: 0.1em;
  color: var(--fg-muted);
  transition: font-size 0.18s var(--ease);
}

/* --- pinned: the same band, closed up into the header pill --------------- */

.tally.is-pinned {
  border-radius: 999px;
  /* The chrome arrives fast. It used to fade over the full 0.26s, and for that
     whole window the bar was already sitting over the article with no backdrop
     under it — page text running straight through the numbers. Whatever else
     is still settling, the glass has to be there the moment the bar overlaps
     anything. */
  transition-duration: 0.11s;
  /* border-color, background and box-shadow come from the glass rule above. */
}

.tally.is-pinned .tally__value { font-size: 0.9rem; }
.tally.is-pinned .tally__label { font-size: 0.53rem; }

/* Labels shed their second word before the row can overflow — which is also
   what buys the values room to run larger here than the desktop ratio would
   allow. The cells give up side padding for the same reason. */
@media (max-width: 620px) {
  .tally__more { display: none; }
  /* Fixed thirds leave no borrowing between columns, so the widest cell has
     to fit its own third unaided — hence tighter padding here. */
  .tally__cell { padding: 0 0.45rem; }
  .tally__label { font-size: 0.6rem; letter-spacing: 0.07em; }
  .tally.is-pinned .tally__value { font-size: 0.85rem; }
  .tally.is-pinned .tally__label { font-size: 0.5rem; letter-spacing: 0.06em; }
}


/* Only the pinned pill needs to give ground on the narrowest phones; the flat
   band has the width to stay at display size. (This block used to shrink the
   band itself — a leftover from when there was only the pill, and it was
   quietly overriding the band's clamp down to 12px.) */
@media (max-width: 400px) {
  .tally.is-pinned .tally__value { font-size: 0.8rem; }
  .tally.is-pinned .tally__label { font-size: 0.48rem; letter-spacing: 0.04em; }
}

/* --- Dock ----------------------------------------------------------------- */

/* Two free-standing controls, not a bar containing buttons — nesting a pill
   inside a pill was the thing that looked unresolved. Appears once the hero
   is behind you, which is the same moment the tally takes the header slot. */
.dock {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 1.25rem;
  z-index: 30;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 0.6rem;
  max-width: var(--measure);
  margin: 0 auto;
  padding: 0 var(--gutter);
  pointer-events: none;
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 0.3s ease, transform 0.3s var(--ease);
}

.dock > * { pointer-events: auto; }

.dock.is-up {
  opacity: 1;
  transform: none;
}

.dock svg {
  width: 1.05rem;
  height: 1.05rem;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.dock__contact {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  height: 2.9rem;
  padding: 0 1.4rem;
  border: 1px solid rgba(255, 255, 255, 0.26);
  border-radius: 999px;
  background: var(--accent-deep);
  color: #fff;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
  /* Same type and icon as the hero CTAs — it is the same button, just docked,
     and the mono uppercase it used to carry made it read as a third style. */
  font-family: var(--font-sans);
  font-size: 0.95rem;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  white-space: nowrap;
  transition: filter 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
}

.dock__contact { gap: 0.7rem; }

.dock__contact svg {
  width: 1.2rem;
  height: 1.2rem;
  stroke-width: 1.6;
}

.dock__contact:hover {
  filter: brightness(1.1);
  transform: translateY(-1px);
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.44);
  color: #fff;
}

.dock__contact:active { transform: translateY(1px); }

.dock__up {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.9rem;
  height: 2.9rem;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: color-mix(in srgb, var(--bg) 94%, transparent);
  -webkit-backdrop-filter: blur(12px);
          backdrop-filter: blur(12px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
  /* Nudged up from --fg-muted and a hairline rule: it was reading as an empty
     circle next to the filled Contact button. Still quieter than that one —
     it is the secondary action. */
  color: var(--fg);
  border-color: color-mix(in srgb, var(--rule) 45%, var(--fg));
  transition: color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}

.dock__up svg {
  width: 1.15rem;
  height: 1.15rem;
  stroke-width: 1.9;
}

.dock__up:hover {
  color: var(--accent);
  border-color: var(--accent);
  transform: translateY(-1px);
}

@media print { .dock { display: none; } }

/* --- Eras ----------------------------------------------------------------- */

.era {
  position: relative;
  display: grid;
  grid-template-columns: 3.5rem 1fr;
  gap: 0 2.25rem;
  padding: 2.6rem 0 0.4rem;
  border-top: 1px solid var(--rule);
}

.era:first-child { border-top: 0; padding-top: 1.8rem; }

/* Sits at the top of its chapter, level with the title — not sticky. Travelling
   the year down the chapter was redundant (the tally already names the current
   year) and its containing block made it drift off the corner anyway.
   `relative` is kept so the dot anchors to it. */
.era__when {
  position: relative;
  align-self: start;
}

.era__year {
  font-family: var(--font-mono);
  font-size: 0.95rem;
  font-weight: 500;
  letter-spacing: 0.02em;
}

.era__span {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  color: var(--fg-muted);
  margin-top: 0.15rem;
}

.era__span::before { content: "— "; }

.era__title {
  font-size: 1.35rem;
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.15;
}

.era--current .era__title::after {
  content: "";
  display: inline-block;
  vertical-align: 0.35em;
  margin-left: 0.5rem;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent);
}

.era__role {
  margin-top: 0.25rem;
  font-size: 0.95rem;
  color: var(--fg-body);
}

/* Two titles at one company, listed newest first the way LinkedIn nests
   roles, with no claim about why the title changed.

   Stacked alone they read as a duplicated line rather than a sequence, so
   they get their own miniature spine: a hairline with a node per title,
   echoing the dots on the timeline's main spine. The indent is what says
   these belong to the company above them. */
.era__titles {
  position: relative;
  margin-top: 0.25rem;
  padding-left: 0.95rem;
}

/* No container-spanning rule: insetting it by a guessed em left it short of
   both nodes and it was drawn in --rule, which is #2F2C27 on a #161512
   ground and effectively invisible. Each title instead draws the segment
   down to the next one, anchored to the node geometry rather than estimated,
   and tinted with the accent so the connection actually reads. */

.era__titles .era__role {
  position: relative;
  margin-top: 0;
}

.era__titles .era__role::before {
  content: "";
  position: absolute;
  left: -0.95rem;
  top: 0.44em;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
}

/* The run from this node down to the next one. */
.era__titles .era__role:not(:last-child)::after {
  content: "";
  position: absolute;
  left: calc(-0.95rem + 3px);
  top: calc(0.44em + 7px);
  bottom: calc(-0.3rem - 0.44em);
  width: 1px;
  background: color-mix(in srgb, var(--accent) 60%, transparent);
}

/* The earlier title reads as history: hollow node, quieter text. */
.era__role--past {
  margin-top: 0.3rem;
  color: var(--fg-muted);
}

/* Scoped to match the specificity of the rule that fills the node above,
   which would otherwise keep the accent on this one too. */
.era__titles .era__role--past::before {
  background: var(--bg);
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 55%, transparent);
}

.era__context {
  margin-top: 0.3rem;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.04em;
  color: var(--fg-muted);
}

.era__story {
  margin-top: 1rem;
  max-width: 54ch;
  color: var(--fg-body);
}

/* --- Spine ---------------------------------------------------------------- */

/* Years and spine live in the left margin, so the era text lines up with every
   other section on the page instead of sitting indented behind a gutter.
   Only applied where there is margin to hang into. */
@media (min-width: 1000px) {
  .eras { margin-left: -5.75rem; }
}

/* A hairline down the year column with an accent segment that fills as you
   scroll, so the timeline visibly advances rather than just sitting there. */
.eras {
  position: relative;
}

.eras::before {
  content: "";
  position: absolute;
  left: 4.25rem;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--rule);
}

.eras__progress {
  position: absolute;
  left: 4.25rem;
  top: 0;
  width: 1px;
  height: 100%;
  background: var(--accent);
  transform-origin: top center;
  transform: scaleY(0);
  will-change: transform;
}

/* The fill is a scroll-LINKED effect, so the compositor should own it. Driven
   from JavaScript it was always correct and always a frame behind, because the
   page scrolls on the compositor while the transform was written on the main
   thread: a measured 16px of fill lag with frames otherwise landing at 8ms and
   no long tasks. That trailing is what read as stutter, and it is not a cost
   problem, so no amount of making the frame cheaper closes it.

   The inset collapses the scrollport to a zero-height line at the corner, so
   the timeline starts when .eras' top edge crosses that line and finishes when
   its bottom edge does. That is the same progress initSpine() computes, and
   both read --corner, so the two cannot drift apart.

   initSpine() detects support and leaves the transform alone when this
   applies; where it does not (Firefox today) the JavaScript path still runs. */
@supports (animation-timeline: view()) {
  .eras {
    view-timeline-name: --spine;
    view-timeline-axis: block;
    view-timeline-inset: var(--corner) calc(100% - var(--corner));
  }

  .eras__progress {
    animation-name: spine-fill;
    animation-duration: auto;
    animation-timing-function: linear;
    animation-fill-mode: both;
    animation-timeline: --spine;
  }
}

@keyframes spine-fill {
  from { transform: scaleY(0); }
  to   { transform: scaleY(1); }
}

/* The node on the spine for each era; fills when that chapter is the one
   being read. */
.era__dot {
  position: absolute;
  left: calc(4.25rem - 3.5px);   /* centred on the spine */
  top: 0.45rem;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--bg);
  box-shadow: inset 0 0 0 1px var(--rule);
  transition: box-shadow 0.35s ease, background 0.35s ease, transform 0.35s var(--ease);
}

/* Reaching a chapter should register without demanding attention: the node
   overshoots slightly and settles, and a single ring opens out of it once. */
.era__dot::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: 50%;
  border: 1px solid var(--accent);
  opacity: 0;
  pointer-events: none;
}

.era.is-active .era__dot {
  background: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
  transform: scale(1.35);
  animation: dot-pop 520ms var(--ease);
}

.era.is-active .era__dot::after {
  animation: dot-ring 700ms var(--ease);
}

.era.is-active .era__year {
  animation: year-pop 480ms var(--ease);
}

@keyframes dot-pop {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.75); }
  100% { transform: scale(1.35); }
}

@keyframes dot-ring {
  0%   { transform: scale(0.5); opacity: 0.5; }
  100% { transform: scale(3.2); opacity: 0; }
}

@keyframes year-pop {
  0%   { transform: translateY(0); }
  40%  { transform: translateY(-2px); }
  100% { transform: translateY(0); }
}

/* The shimmer rides the glyphs only, never the page behind them. Each run of
   text carries two stacked backgrounds — a moving band of accent over a solid
   fill of its own colour — clipped to the letterforms. The solid layer means
   the copy still renders in its true colour at rest. */
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .era__title,
  .era__role,
  .era__context,
  .era__story,
  .era__items .item__metric,
  .era__items .item__text {
    /* A solid core, not a single peak — ramping straight through full opacity
       left most of the band half-blended with the colour underneath, which is
       what made the sweep read as faint. */
    background-image:
      linear-gradient(100deg,
        transparent 34%,
        rgb(var(--shimmer-rgb)) 45%,
        rgb(var(--shimmer-rgb)) 55%,
        transparent 66%),
      linear-gradient(currentColor, currentColor);
    background-size: 240% 100%, 100% 100%;
    background-position: 175% 0, 0 0;
    background-repeat: no-repeat;
    -webkit-background-clip: text;
            background-clip: text;
    -webkit-text-fill-color: transparent;
  }

  /* Nested runs have no background of their own, so they need their colour
     painted back or they would inherit the transparent fill and vanish. */
  .item__unit { -webkit-text-fill-color: var(--fg-muted); }

  .era.is-active .era__title,
  .era.is-active .era__role,
  .era.is-active .era__context,
  .era.is-active .era__story,
  .era.is-active .era__items .item__metric,
  .era.is-active .era__items .item__text {
    animation: shimmer-text 1500ms linear;
  }
}

@keyframes shimmer-text {
  from { background-position: 165% 0, 0 0; }
  to   { background-position: -65% 0, 0 0; }
}

/* The totals mark the moment too — a brief accent flash as the number lands. */
.tally__value.is-bumped {
  animation: tally-bump 620ms ease;
}

@keyframes tally-bump {
  0%   { color: var(--accent); }
  55%  { color: var(--accent); }
  100% { color: var(--fg); }
}

.era__when { text-align: right; }

.era__year { transition: color 0.35s ease; }
.era.is-active .era__year { color: var(--accent); }

@media (max-width: 700px) {
  .eras { margin-left: 0; }
  .eras::before, .eras__progress { left: 3px; }
  .era { padding-left: 1.6rem; }
  .era__dot { left: -1.6rem; top: 0.3rem; }
}

/* --- Items ---------------------------------------------------------------- */

.era__items {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 0;
  margin-top: 1.75rem;
}

.item {
  padding: 1.1rem 1.4rem 1.3rem 0;
  border-top: 1px solid var(--rule);
}

.item__metric {
  font-size: 1.45rem;
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
}

.item__unit {
  font-size: 0.85rem;
  font-weight: 400;
  letter-spacing: 0;
  color: var(--fg-muted);
}

.item__text {
  margin-top: 0.45rem;
  font-size: 0.85rem;
  line-height: 1.55;
  color: var(--fg-body);
}

@media (max-width: 700px) {
  .era {
    grid-template-columns: 1fr;
    gap: 0;
  }

  /* Year and range sit side by side above the chapter on narrow screens. */
  .era__when {
    display: flex;
    align-items: baseline;
    gap: 0.4rem;
    margin-bottom: 0.7rem;
  }

  .era__span { margin-top: 0; }

}

/* --- Roles ---------------------------------------------------------------- */

.role + .role { margin-top: 2rem; }
.role--compact + .role--compact { margin-top: 1.5rem; }

.role__head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1.5rem;
}

.role__title {
  font-size: 1.125rem;
  font-weight: 500;
  letter-spacing: -0.011em;
}

.role__meta {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  flex: none;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--fg-muted);
  white-space: nowrap;
}

.role--current .role__meta::before {
  content: "";
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent);
}

.role__org {
  color: var(--fg-body);
  font-size: 0.95rem;
  margin-top: 0.15rem;
}

/* A full sentence, not the usual one-line institution/place. The measure is
   the page's, ~90ch at this size, which is far too wide to read comfortably. */
.role__org--note {
  max-width: 54ch;
  margin-top: 0.35rem;
}

.role__context {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.04em;
  color: var(--fg-muted);
  margin-top: 0.35rem;
}

@media (max-width: 560px) {
  .role__head {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.35rem;
  }
}

/* --- Stack ---------------------------------------------------------------- */

.stack {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  line-height: 2;
  letter-spacing: 0.04em;
  color: var(--fg-body);
}

/* --- Era tools -----------------------------------------------------------

   What each chapter was actually built with, at the foot of its card.

   A GRID, not a wrapping row. The row version was dividered flex, and with
   twelve tools it broke over four ragged lines each beginning with a stray
   divider — border-left only knows about source order, not about where the
   line happens to wrap. Columns fix both at once: nothing is ragged, and the
   alignment does the separating so the dividers are not needed at all.

   Marks are flattened the same way as the skills list below — see .tool img
   for why. A tool with no logo file renders as its label alone, so the grid
   stays complete and a mark can drop in later with no markup change. */
.era__tools {
  display: grid;
  /* Sized so three columns fit .era__body, which is narrower than the page
     measure by the year column beside it. At 8.5rem it fell to two columns
     and ran six rows deep. */
  /* max-content, not 1fr. With 1fr the tracks always divide the full width,
     so a chapter with two tools stranded them at opposite ends of the row.
     Tracks now size to their contents, which keeps the columns aligned when
     there are many and lets them sit together when there are few. */
  grid-template-columns: repeat(auto-fill, minmax(6.6rem, max-content));
  justify-content: start;
  gap: 0.8rem 1.6rem;
  margin-top: 1.7rem;
  padding-top: 1.2rem;
  border-top: 1px solid var(--rule);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.03em;
  color: var(--fg-muted);
}

.era__tool {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  min-width: 0;
}

/* A LANDSCAPE box, not a square. object-fit can only fit the canvas it is
   handed, so a wide mark squeezed into a square box shrinks until its width
   fits and ends up optically tiny next to a round one — VWO was the worst of
   it. The marks are cropped to their own ink (see tools/logotrim.py), so a
   box wider than it is tall lets a wide mark use the width while a square one
   is still bounded by the height, and both read at the same weight.

   object-position keeps every mark flush left so the column starts on one
   line; the fixed box width keeps every LABEL on one line too. */
.era__tool img {
  width: 1.25rem;
  height: 0.85rem;
  flex: none;
  object-fit: contain;
  object-position: left center;
  filter: brightness(0);
  opacity: 0.55;
}

/* Keeps the labels lined up when a row mixes tools that have a mark with
   tools that do not. */
.era__tool:not(:has(img))::before {
  content: "";
  width: 1.25rem;
  flex: none;
}

@media (prefers-color-scheme: dark) {
  .era__tool img { opacity: 0.6; filter: brightness(0) invert(1); }
}

/* --- Tools ---------------------------------------------------------------

   Every mark is flattened to a flat silhouette rather than shown in its brand
   colours: brightness(0) drives RGB to zero while leaving alpha alone, and
   invert(1) takes that black to white. Two consequences worth having —
   fourteen brand palettes would have wrecked a page built on black, white and
   one orange; and because the colour comes from CSS rather than the file, the
   same assets work on both themes, where a set of white PNGs would have gone
   invisible on the light one.

   It also means a logo that arrives in colour needs no editing. Clarity's blue
   prism flattens to its own silhouette like the rest. */
.tools {
  display: flex;
  flex-wrap: wrap;
  gap: 0.85rem 1.5rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.04em;
  color: var(--fg-body);
}

.tool {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  white-space: nowrap;
}

.tool img {
  width: 1.05rem;
  height: 1.05rem;
  flex: none;
  object-fit: contain;
  filter: brightness(0);
  opacity: 0.72;
}

@media (prefers-color-scheme: dark) {
  .tool img {
    filter: brightness(0) invert(1);
    opacity: 0.8;
  }
}

/* --- Carousel ------------------------------------------------------------- */

/* Bleeds into the page gutter so it reads as a band, not a boxed widget.
   Native overflow scrolling means touch and trackpad work with no JS. */
.carousel {
  margin-inline: calc(var(--gutter) * -1);
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  -ms-overflow-style: none;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--gutter), #000 calc(100% - var(--gutter)), transparent);
          mask-image: linear-gradient(to right, transparent, #000 var(--gutter), #000 calc(100% - var(--gutter)), transparent);
}

.carousel::-webkit-scrollbar { display: none; }

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

.carousel__track {
  display: flex;
  gap: 0.875rem;
  width: max-content;
  padding-inline: var(--gutter);
}

/* Landscape frames: the work is vehicle and interior photography, nearly all
   of it shot wide. A 4:5 portrait crop cut the vans in half. */
.slide {
  flex: none;
  width: clamp(260px, 68vw, 380px);
  aspect-ratio: 3 / 2;
  background: var(--rule);
  overflow: hidden;
}

/* The whole frame is the control, so expanding works from the keyboard as
   well as the pointer. Clones carry aria-hidden and are taken out of the tab
   order in initCarousel — otherwise the band would be 24 stops deep. */
.slide__open {
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: zoom-in;
}

.slide img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.4s var(--ease), opacity 0.2s ease;
}

.slide__open:hover img,
.slide__open:focus-visible img { transform: scale(1.035); }

/* --- shots: arrows ------------------------------------------------------- */

.shots__nav {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
  margin-top: 1rem;
}

.shots__arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: none;
  color: var(--fg-muted);
  cursor: pointer;
  transition: color 0.16s ease, border-color 0.16s ease, background-color 0.16s ease;
}

.shots__arrow svg {
  width: 1.05rem;
  height: 1.05rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.shots__arrow:hover {
  color: var(--fg);
  border-color: rgb(var(--accent-rgb) / 0.8);
  background-color: rgb(var(--accent-rgb) / 0.12);
}

.shots__arrow:active { transform: translateY(1px); }

/* --- lightbox ------------------------------------------------------------ */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(1rem, 5vw, 3rem);
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  -webkit-backdrop-filter: blur(18px) saturate(1.2);
          backdrop-filter: blur(18px) saturate(1.2);
  cursor: zoom-out;
}

.lightbox__frame {
  max-width: min(100%, 1400px);
  max-height: 100%;
  cursor: default;
}

.lightbox__frame img {
  display: block;
  max-width: 100%;
  max-height: calc(100vh - 2 * clamp(1rem, 5vw, 3rem));
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 2px;
  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
}

.lightbox__close {
  position: absolute;
  top: clamp(0.75rem, 3vw, 1.5rem);
  right: clamp(0.75rem, 3vw, 1.5rem);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 2.6rem;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: color-mix(in srgb, var(--bg) 70%, transparent);
  color: var(--fg);
  cursor: pointer;
  transition: border-color 0.16s ease, background-color 0.16s ease;
}

.lightbox__close svg {
  width: 1.1rem;
  height: 1.1rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}

.lightbox__close:hover {
  border-color: rgb(var(--accent-rgb) / 0.8);
  background-color: rgb(var(--accent-rgb) / 0.14);
}

html.motion .lightbox { animation: lb-in 0.18s ease both; }
html.motion .lightbox__frame { animation: lb-rise 0.26s var(--ease) both; }

@keyframes lb-in { from { opacity: 0; } }
@keyframes lb-rise {
  from { opacity: 0; transform: scale(0.97); }
}

@media print { .lightbox, .shots__nav { display: none; } }

/* --- Footer --------------------------------------------------------------- */

.footer .contact { flex-basis: 100%; }

.footer {
  margin-top: clamp(4rem, 11vw, 6.5rem);
  padding-top: 1.75rem;
  border-top: 1px solid var(--rule);
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1.5rem;
  flex-wrap: wrap;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.04em;
}

.footer__note { color: var(--fg-muted); }

/* ==========================================================================
   Motion
   Gated on html.motion, added by an inline script only when the visitor has
   not asked to reduce motion. With no JavaScript the page renders complete
   and static — nothing here hides content by default.
   ========================================================================== */

html.motion:not(.intro-done) { overflow: hidden; }

html.motion .hero__name {
  transform: translateY(var(--intro-y, 0px));
}
/* The transition lives on the end state only. Arming the start pose sets
   --intro-y, and that first jump must not animate — otherwise the name
   drifts down into the centre before it rises out of it. */
html.intro-settle .hero__name {
  transform: none;
  transition: transform 0.5s var(--ease);
}

html.motion .hero__name .word {
  opacity: 0;
  filter: blur(6px);
  transform: translateY(14px);
  transition:
    opacity   0.5s ease,
    filter    0.5s ease,
    transform 0.5s var(--ease);
  transition-delay: calc(var(--i) * 60ms);
}
html.intro-in .hero__name .word {
  opacity: 1;
  filter: blur(0);
  transform: none;
}

/* The stat band lands last in the intro, one cell at a time, reusing the
   name's rise-and-unblur so it reads as the end of one entrance rather than a
   separate effect. The transition lives on the end state, and carries the pin
   morph's properties with it — a transition declared on html.motion would
   have outlived the intro and clobbered the morph. */
html.motion .tally__cell {
  opacity: 0;
  transform: translateY(10px);
  filter: blur(5px);
}

html.intro-in .tally__cell {
  opacity: 1;
  transform: none;
  filter: blur(0);
  transition:
    opacity   0.45s ease      calc(360ms + var(--i) * 90ms),
    filter    0.45s ease      calc(360ms + var(--i) * 90ms),
    transform 0.45s var(--ease) calc(360ms + var(--i) * 90ms),
    padding-left 0.26s var(--ease);
}

html.motion .hero__reveal {
  opacity: 0;
  transform: translateY(12px);
  transition:
    opacity   0.5s ease,
    transform 0.5s var(--ease);
  transition-delay: calc(var(--i) * 70ms);
}
html.intro-rest .hero__reveal {
  opacity: 1;
  transform: none;
}

/* Each chapter arrives as you reach it — the header first, then its numbers
   stepping in behind it, so the timeline reads as advancing rather than
   appearing all at once. */
html.motion .era__body > * {
  opacity: 0;
  transform: translateY(18px);
  transition:
    opacity   0.55s ease,
    transform 0.55s var(--ease);
}
html.motion .era.is-in .era__body > * {
  opacity: 1;
  transform: none;
}
html.motion .era__body > *:nth-child(2) { transition-delay: 60ms; }
html.motion .era__body > *:nth-child(3) { transition-delay: 110ms; }
html.motion .era__body > *:nth-child(4) { transition-delay: 155ms; }
html.motion .era__body > *:nth-child(5) { transition-delay: 195ms; }
html.motion .era__body > *:nth-child(6) { transition-delay: 230ms; }
html.motion .era__body > *:nth-child(7) { transition-delay: 260ms; }
html.motion .era__body > *:nth-child(8) { transition-delay: 285ms; }

html.motion .era__when {
  opacity: 0;
  transform: translateY(10px);
  transition:
    opacity   0.5s ease,
    transform 0.5s var(--ease);
}
html.motion .era.is-in .era__when {
  opacity: 1;
  transform: none;
}

html.motion .era__items .item {
  opacity: 0;
  transform: translateY(14px);
  transition:
    opacity   0.5s ease,
    transform 0.5s var(--ease);
  transition-delay: calc(220ms + var(--i, 0) * 55ms);
}
html.motion .era.is-in .era__items .item {
  opacity: 1;
  transform: none;
}

html.motion .reveal {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity   0.5s ease,
    transform 0.5s var(--ease);
}
/* The timeline is 5200px tall. Fading AND translating a box that size on the
   first scroll cost 15 frames over 24ms against a steady-state 8.5ms, because
   the compositor has to raster a 10,000px layer while the reader is moving.
   It still hides until the first scroll, it just arrives in one paint instead
   of animating: the chapters inside stagger in on their own, so the motion is
   not lost, only the expensive part of it.

   Moving .reveal onto the section label instead was tried first and was
   worse: the label stopped revealing within 900px of scrolling at all. */
html.motion #story.reveal {
  transform: none;
  transition: none;
}

html.motion .reveal.is-visible {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* --- Print ---------------------------------------------------------------- */

@media print {
  :root {
    --bg: #FFFFFF;
    --fg: #000000;
    --fg-body: #1A1A1A;
    --fg-muted: #444444;
    --rule: #CCCCCC;
    --accent: #000000;
  }

  html.motion:not(.intro-done) { overflow: visible; }

  html .hero__name,
  html .hero__name .word,
  html .hero__reveal,
  html .reveal {
    opacity: 1 !important;
    filter: none !important;
    transform: none !important;
    transition: none !important;
  }

  .hero { padding-top: 0; }
  .page { max-width: none; padding: 0; }

  /* A horizontal scroller cannot print meaningfully. */
  .section:has(.carousel) { display: none; }

  a { box-shadow: none; }
  .nav, .contact__copy { display: none; }

  .footer__links a::after {
    content: " (" attr(href) ")";
    font-size: 0.7em;
    color: #555;
  }
  .footer__links a[href^="mailto:"]::after,
  .footer__links a[href^="tel:"]::after { content: ""; }

  /* Scroll entrances are a screen affordance; print everything at full
     strength regardless of how far the visitor scrolled. */
  .eras__progress,
  .era__dot { display: none; }

  html .era,
  html .era .item,
  html .era__body > *,
  html .era__when {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
  }

  .tally { position: static; border-bottom: 1px solid #CCC; }
  .eras::before { display: none; }

  .section, .footer, .role, .item, .era { break-inside: avoid; }
}
