@charset "utf-8";

/* ============================================================
   SITE — navigation, sections, components.
   Composes the tokens in core.css. Defines no new colours.
   ============================================================ */

/* ══════════════════════════════════════════════════════════
   The field

   One fixed canvas under the entire document. Because it is fixed,
   it holds still while the page travels over it — which is what
   makes it read as a system being observed rather than a texture
   being dragged along.

   z-index -1 puts it above the body background and below every
   piece of content, so no section needs to know it exists.
   ══════════════════════════════════════════════════════════ */

.field {
  position: fixed;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* Feathered at the edges so the estate has no frame, and eased
     back through the middle where the reading column sits. */
  /* Weighted away from the middle, not toward it. The reading
     column runs down the centre of every section, so the estate is
     held back there and allowed to come forward at the edges —
     it frames the content instead of lying underneath it. */
  -webkit-mask-image: radial-gradient(70% 66% at 50% 50%, rgb(0 0 0 / 0.4) 0%, rgb(0 0 0 / 0.7) 42%, #000 74%, rgb(0 0 0 / 0.4) 100%);
          mask-image: radial-gradient(70% 66% at 50% 50%, rgb(0 0 0 / 0.4) 0%, rgb(0 0 0 / 0.7) 42%, #000 74%, rgb(0 0 0 / 0.4) 100%);
  /* Fallback strength where the ramp below is unavailable. */
  opacity: 0.82;
}

/* The estate used to be held to 40% on a phone, which on a small
   screen at half brightness meant it was not there at all. The
   ground is darker now, so it can afford to be seen: the visual is
   brought most of the way up and the mask is opened through the
   middle, since a phone's reading column is narrow enough that
   holding the centre back was costing the picture its subject. */
@media (max-width: 899px) {
  .field {
    /* Pulled back on a phone so it can never compete with the copy
       for contrast: dimmer overall, and the reading column down the
       centre is held back hardest, with the estate allowed to come
       forward at the top and edges where no text sits. Readability
       first — the picture is atmosphere here, not the subject. */
    opacity: 0.5;
    -webkit-mask-image: radial-gradient(105% 60% at 50% 40%, rgb(0 0 0 / 0.34) 0%, rgb(0 0 0 / 0.7) 46%, #000 70%, rgb(0 0 0 / 0.5) 100%);
            mask-image: radial-gradient(105% 60% at 50% 40%, rgb(0 0 0 / 0.34) 0%, rgb(0 0 0 / 0.7) 46%, #000 70%, rgb(0 0 0 / 0.5) 100%);
  }
}

/* The hero is a stage and the rest of the page is a document, and
   they want different amounts of this. So the field settles: full
   presence while the opening is on screen, pulled back to
   atmosphere by the time body copy starts. One scroll-driven
   opacity ramp, off the main thread, no listener. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .field {
      animation-name: field-settle;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: scroll(root block);
      animation-range: 0 130vh;
    }
  }
  @media (prefers-reduced-motion: no-preference) and (max-width: 899px) {
    .field { animation-name: field-settle-sm; }
  }
}

@keyframes field-settle    { from { opacity: 1;    } to { opacity: 0.66; } }
/* Phone: the estate is present in the hero but never at a level
   that fights the headline, and it settles further back once body
   copy begins. Readability wins on a small screen in daylight. */
@keyframes field-settle-sm { from { opacity: 0.72; } to { opacity: 0.44; } }

/* Sections no longer paint a solid ground, so the field stays
   continuous the whole way down. The alternating band rhythm comes
   back as a translucent tint instead — the same read, without
   masking the system out every other section. */
.section--elev { background: rgb(255 255 255 / 0.028); }
[data-theme="light"] .section--elev { background: rgb(0 0 0 / 0.028); }
/* A stronger band on a phone, so the alternating rhythm is legible
   there instead of dissolving into the ground. */
@media (max-width: 899px) {
  .section--elev { background: rgb(255 255 255 / 0.05); }
  [data-theme="light"] .section--elev { background: rgb(0 0 0 / 0.04); }
}

/* ══════════════════════════════════════════════════════════
   Progress

   The scrollbar is not painted (see core.css), so two very quiet
   things say where you are instead. Neither is a control and
   neither is a dashboard: one is a hairline along the top edge,
   the other is the treatment of the right edge itself.
   ══════════════════════════════════════════════════════════ */

.runline {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 140;
  height: 2px;
  pointer-events: none;
  background: var(--hair-soft);
}
.runline i {
  display: block;
  height: 100%;
  transform-origin: 0 50%;
  /* Written by scenes.js on every scroll frame. Nothing transitions
     it: a progress indicator that eases is a progress indicator
     that lies. */
  transform: scaleX(var(--doc-p, 0));
  background: linear-gradient(to right, var(--fg-5), var(--accent));
}

/* ── the frame rail ────────────────────────────────────────────
   Nine marks down the right edge, one per scene. At rest they are
   hairlines and read as an edge treatment rather than as a
   component; the mark you are inside lengthens, brightens and puts
   its number up, and its name appears on hover or focus.

   They are anchors, so this is also a complete keyboard route
   through the film — which is what earns it the space.          */

.frames {
  position: fixed;
  top: 50%;
  right: max(0.9rem, env(safe-area-inset-right));
  z-index: 120;
  display: none;
  flex-direction: column;
  gap: 0.85rem;
  transform: translateY(-50%);
}
@media (min-width: 1180px) { .frames { display: flex; } }

.frames__tick {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.6rem;
  height: 18px;
  padding-right: 2px;
  color: var(--fg-4);
}
/* The mark itself. Drawn as a pseudo-element so the link box can
   stay at a comfortable 18px target while the mark stays 1px. */
.frames__tick::after {
  content: "";
  width: 10px;
  height: 1px;
  flex: none;
  background: currentColor;
  opacity: 0.5;
  transition:
    width var(--dur-panel) var(--ease-out),
    opacity var(--dur-ui) var(--ease-out),
    background-color var(--dur-ui) var(--ease-out);
}
.frames__tick.is-active { color: var(--fg); }
.frames__tick.is-active::after { width: 22px; opacity: 1; background: var(--accent); }

.frames__n {
  font-size: 0.625rem;
  letter-spacing: 0.1em;
  opacity: 0;
  transform: translateX(4px);
  transition: opacity var(--dur-ui) var(--ease-out), transform var(--dur-panel) var(--ease-out);
}
.frames__tick.is-active .frames__n,
.frames:hover .frames__n,
.frames__tick:focus-visible .frames__n { opacity: 1; transform: none; }

/* The name is the hover affordance only — it is never the thing
   that has to be read to know where you are. */
.frames__name {
  position: absolute;
  right: calc(100% + 0.5rem);
  white-space: nowrap;
  font-size: 0.75rem;
  letter-spacing: -0.008em;
  color: var(--fg-3);
  opacity: 0;
  transform: translateX(6px);
  pointer-events: none;
  transition: opacity var(--dur-ui) var(--ease-out), transform var(--dur-panel) var(--ease-out);
}
.frames__tick:hover .frames__name,
.frames__tick:focus-visible .frames__name { opacity: 1; transform: none; }

/* ══════════════════════════════════════════════════════════
   Atoms
   ══════════════════════════════════════════════════════════ */

/* ── buttons ───────────────────────────────────────────────
   Scale feedback on press is driven by a spring in JS, which
   writes `transform`. Nothing here may transition transform, or
   it would fight the spring every frame.                      */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.55em;
  min-height: 48px;              /* touch target floor */
  padding: 0 1.5rem;
  border-radius: 999px;
  font-size: 0.9375rem;
  font-weight: 550;
  letter-spacing: -0.008em;
  line-height: 1;
  white-space: nowrap;
  transition: background-color var(--dur-ui) var(--ease-out),
              color var(--dur-ui) var(--ease-out),
              border-color var(--dur-ui) var(--ease-out);
}

/* The hint is only worth paying for when motion is actually
   imminent. A permanent will-change on every button on the page
   is a compositor layer each, held for the whole session. */
.btn:hover, .btn:focus-visible { will-change: transform; }

.btn--sm { min-height: 36px; padding: 0 1.05rem; font-size: 0.8438rem; }

.btn--primary { background: var(--fg); color: var(--bg); }
.btn--quiet {
  color: var(--fg);
  box-shadow: inset 0 0 0 1px var(--hair);
}

@media (hover: hover) and (pointer: fine) {
  .btn--primary:hover { background: color-mix(in oklab, var(--fg) 88%, var(--accent)); }
  .btn--quiet:hover { box-shadow: inset 0 0 0 1px var(--fg-4); background: var(--hair-soft); }
}

/* Reduced motion gets its press feedback as a state change,
   because the spring never runs. */
@media (prefers-reduced-motion: reduce) {
  .btn:active { background-color: var(--hair); }
  .btn--primary:active { background: var(--fg-2); }
}

.btn__arrow {
  display: inline-block;
  transition: transform var(--dur-ui) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .btn:hover .btn__arrow { transform: translateX(3px); }
}

/* ── icon button ───────────────────────────────────────────── */

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: 999px;
  color: var(--fg-2);
  transition: color var(--dur-ui) var(--ease-out), background-color var(--dur-ui) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .icon-btn:hover { color: var(--fg); background: var(--hair-soft); }
}
.icon-btn .icon { width: 19px; height: 19px; }

.theme-toggle .icon--moon { display: none; }
[data-theme="light"] .theme-toggle .icon--sun { display: none; }
[data-theme="light"] .theme-toggle .icon--moon { display: block; }

/* ── tags ──────────────────────────────────────────────────── */

.tags { display: flex; flex-wrap: wrap; gap: 0.4rem; }

.tag {
  display: inline-block;
  padding: 0.3rem 0.65rem;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--hair);
  font-size: 0.8125rem;
  letter-spacing: -0.004em;
  line-height: 1.3;
  color: var(--fg-3);
  transition: color var(--dur-ui) var(--ease-out), box-shadow var(--dur-ui) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .tag:hover { color: var(--fg-2); box-shadow: inset 0 0 0 1px var(--fg-4); }
}

/* ── live dot ──────────────────────────────────────────────── */

.pulse-dot {
  position: relative;
  flex: none;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
}
.pulse-dot::after {
  content: "";
  position: absolute;
  inset: -4px;
  border-radius: 50%;
  border: 1px solid var(--accent);
  opacity: 0;
  animation: pulse-ring 2.4s var(--ease-out) infinite;
}
@keyframes pulse-ring {
  0%   { transform: scale(0.6); opacity: 0.7; }
  70%  { transform: scale(1.25); opacity: 0; }
  100% { transform: scale(1.25); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .pulse-dot::after { animation: none; opacity: 0.45; transform: none; }
}

/* ── section heads ─────────────────────────────────────────── */

.section-head {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: clamp(1rem, 2vw, 1.5rem);
  margin-bottom: clamp(2.75rem, 7vh, 5rem);
}
.section-head__title { max-width: 20ch; }

.br-lg { display: none; }
@media (min-width: 900px) { .br-lg { display: inline; } }

/* ══════════════════════════════════════════════════════════
   The camera

   The hero was not supposed to be the only thing that moves with
   the page. Every section head recedes as it leaves — back and
   down in Z, not just up and off — so scrolling reads as travelling
   through a space rather than sliding a long picture past a window.

   Scroll-driven, so the browser runs it off the main thread: no
   listener, no rAF, no cost per section. Anchored to the left rail
   so a receding head keeps the alignment everything else holds.
   ══════════════════════════════════════════════════════════ */

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .section-head {
      animation-name: head-recede;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: exit 20% exit 100%;
      transform-origin: 0% 50%;
    }

    /* Capability rows come forward as they reach the middle of the
       viewport and settle back as they leave — the reading position
       is lit and the rest of the list stays present but quiet.
       One rule for six rows; the row you are on is always full
       strength, so nothing you are actually reading is dimmed. */
    .capability {
      animation-name: row-focus;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: cover 5% cover 95%;
    }
  }

  /* Dimmed text is the one thing a contrast preference must veto. */
  @media (prefers-contrast: more) {
    .capability { animation-name: none; }
  }
}

/* ── the cut ───────────────────────────────────────────────────
   The section head receding was the whole of the old transition,
   which meant the body of a scene simply slid away underneath it.
   Now the scene itself goes with it: as a scene's last quarter
   leaves the viewport it settles back in Z and dims, so the scene
   arriving underneath reads as coming forward into the frame
   rather than as the next thing on a long strip.

   Deliberately weak — 0.55 opacity and 1.5% of scale. This is
   depth cueing, not a transition the reader has to wait out, and
   it only engages once the content is most of the way gone.

   Free: a scroll-driven animation runs off the main thread, so
   nine scenes cost nothing per frame.                           */

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-scene] {
      animation-name: scene-exit;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: exit 25% exit 100%;
      transform-origin: 50% 0%;
    }
    /* The hero has a stronger recede of its own. */
    .hero[data-scene] { animation-name: none; }
  }
}

@keyframes scene-exit {
  to { opacity: 0.55; transform: translate3d(0, -1.5%, 0) scale(0.985); }
}

@keyframes head-recede {
  to { opacity: 0.3; transform: translate3d(0, -6%, 0) scale(0.97); }
}

@keyframes row-focus {
  0%   { opacity: 0.45; transform: translate3d(0, 10px, 0); }
  38%, 62% { opacity: 1; transform: none; }
  100% { opacity: 0.45; transform: translate3d(0, -10px, 0); }
}

/* ══════════════════════════════════════════════════════════
   Navigation
   ══════════════════════════════════════════════════════════ */

.site-nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Above the sheet, deliberately: the bar carries the control that
     closes it. A sheet painted over its own dismiss button traps
     the user. The scrim stops at the bar for the same reason. */
  z-index: 130;
  height: calc(var(--nav-h) + env(safe-area-inset-top));
  padding-top: env(safe-area-inset-top);
  transition: background-color var(--dur-ui) var(--ease-out);
}

/* Translucent chrome with the page scrolling beneath it, rather
   than an opaque bar that permanently eats a strip of viewport. */
.site-nav.is-scrolled {
  background: var(--glass);
  backdrop-filter: blur(22px) saturate(180%);
  -webkit-backdrop-filter: blur(22px) saturate(180%);
}

/* A scroll edge, not a divider: the boundary fades rather than
   drawing a hard 1px rule across the whole viewport. */
.site-nav::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  height: 24px;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(to bottom, var(--glass), transparent);
  transition: opacity var(--dur-ui) var(--ease-out);
}
.site-nav.is-scrolled::after { opacity: 1; }

.site-nav__inner {
  display: flex;
  align-items: center;
  gap: clamp(0.5rem, 2vw, 1.5rem);
  height: var(--nav-h);
  max-width: calc(var(--content) + var(--gutter) * 2);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.wordmark {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  flex: none;
  font-size: 0.9375rem;
  font-weight: 600;
  letter-spacing: -0.017em;
}
.wordmark__mark {
  width: 9px;
  height: 9px;
  flex: none;
  background: var(--accent);
  /* The diamond from the favicon, carried through as the mark. */
  clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}
.wordmark__text { white-space: nowrap; }

.nav-links {
  display: none;
  margin-inline: auto;
  gap: clamp(1.25rem, 2.4vw, 2.1rem);
}
.nav-link {
  position: relative;
  padding-block: 0.35rem;
  font-size: 0.875rem;
  font-weight: 480;
  letter-spacing: -0.008em;
  color: var(--fg-3);
  transition: color var(--dur-ui) var(--ease-out);
}
.nav-link:hover, .nav-link.is-active { color: var(--fg); }

.nav-link::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -2px;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-panel) var(--ease-in-out);
}
.nav-link.is-active::after { transform: scaleX(1); }

.nav-actions {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin-left: auto;
}

.nav-clock {
  display: none;
  align-items: baseline;
  gap: 0.4rem;
  margin-right: 0.5rem;
  font-size: 0.8125rem;
  color: var(--fg-3);
}
.nav-clock__tz { font-size: 0.625rem; letter-spacing: 0.12em; color: var(--fg-4); }

.nav-cta { display: none; }

/* ── burger → close ────────────────────────────────────────── */

.nav-toggle__bars {
  display: block;
  position: relative;
  width: 18px;
  height: 12px;
}
.nav-toggle__bars i {
  position: absolute;
  left: 0;
  width: 18px;
  height: 1.5px;
  border-radius: 1px;
  background: currentColor;
  transition: transform var(--dur-panel) var(--ease-in-out);
}
.nav-toggle__bars i:first-child { top: 2px; }
.nav-toggle__bars i:last-child { bottom: 2px; }

.nav-toggle[aria-expanded="true"] .nav-toggle__bars i:first-child {
  transform: translateY(4.25px) rotate(45deg);
}
.nav-toggle[aria-expanded="true"] .nav-toggle__bars i:last-child {
  transform: translateY(-4.25px) rotate(-45deg);
}

@media (min-width: 900px) {
  .nav-links, .nav-clock { display: flex; }
  .nav-cta { display: inline-flex; }
  .nav-toggle { display: none; }
  .nav-actions { gap: 0.6rem; }
}

/* ══════════════════════════════════════════════════════════
   Mobile sheet
   ══════════════════════════════════════════════════════════ */

.is-locked, .is-locked body { overflow: hidden; }

.nav-sheet {
  position: fixed;
  inset: 0;
  z-index: 120;
}
.nav-sheet[hidden] { display: none; }

.nav-sheet__scrim {
  position: absolute;
  inset: 0;
  background: var(--scrim);
  opacity: 0;
}

.nav-sheet__panel {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  padding: calc(var(--nav-h) + env(safe-area-inset-top) + 1.25rem) var(--gutter)
           calc(2rem + env(safe-area-inset-bottom));
  border-radius: 0 0 var(--r-lg) var(--r-lg);
  background: var(--glass);
  backdrop-filter: blur(30px) saturate(180%);
  -webkit-backdrop-filter: blur(30px) saturate(180%);
  box-shadow: var(--shadow-lg);
  /* The panel is dragged vertically; the browser must not also
     try to scroll the page from the same gesture. */
  touch-action: none;
  will-change: transform;
  /* Positioned by the spring runtime before first paint. */
  transform: translate3d(0, -100%, 0);
}
/* The panel takes focus on open so the dialog contains it. That is a
   programmatic move, not a keyboard one — it must not draw a ring. */
.nav-sheet__panel:focus { outline: none; }

/* A bright top edge reads as light catching a real material. */
.nav-sheet__panel::before {
  content: "";
  position: absolute;
  inset: auto 0 0;
  height: 1px;
  background: var(--glass-edge);
}

/* The panel arrives from above, so its contents arrive from above
   too — one motion, not a panel with unrelated cargo. Delays live
   only on the open state, so dismissal is immediate: the items
   should not linger after the surface carrying them has gone. */
.nav-sheet__nav { display: flex; flex-direction: column; }

.nav-sheet__nav a,
.nav-sheet__cta,
.nav-sheet__meta {
  opacity: 0;
  transform: translate3d(0, -8px, 0);
  transition:
    opacity var(--dur-panel) var(--ease-out),
    transform var(--dur-panel) var(--ease-out);
}
.nav-sheet.is-open .nav-sheet__nav a,
.nav-sheet.is-open .nav-sheet__cta,
.nav-sheet.is-open .nav-sheet__meta { opacity: 1; transform: none; }

/* 35ms steps: the last item lands ~490ms after the sheet is asked
   for, which keeps the whole opening inside the drawer budget. */
.nav-sheet.is-open .nav-sheet__nav a:nth-child(1) { transition-delay: 35ms; }
.nav-sheet.is-open .nav-sheet__nav a:nth-child(2) { transition-delay: 70ms; }
.nav-sheet.is-open .nav-sheet__nav a:nth-child(3) { transition-delay: 105ms; }
.nav-sheet.is-open .nav-sheet__nav a:nth-child(4) { transition-delay: 140ms; }
.nav-sheet.is-open .nav-sheet__nav a:nth-child(5) { transition-delay: 175ms; }
.nav-sheet.is-open .nav-sheet__cta  { transition-delay: 205ms; }
.nav-sheet.is-open .nav-sheet__meta { transition-delay: 230ms; }

/* Stagger is decoration. It must never be the reason something is
   unreachable, so under reduced motion the contents are simply
   present the moment the sheet is. */
@media (prefers-reduced-motion: reduce) {
  .nav-sheet__nav a,
  .nav-sheet__cta,
  .nav-sheet__meta { opacity: 1; transform: none; transition-delay: 0ms !important; }
}

.nav-sheet__nav a {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  min-height: 56px;
  padding-block: 0.65rem;
  border-bottom: 1px solid var(--hair-soft);
  font-size: 1.625rem;
  font-weight: 600;
  letter-spacing: -0.028em;
}
.nav-sheet__idx { font-size: 0.6875rem; letter-spacing: 0.12em; color: var(--fg-4); }

.nav-sheet__cta { width: 100%; }
.nav-sheet__meta { color: var(--fg-4); }

@media (min-width: 900px) { .nav-sheet { display: none; } }

/* ══════════════════════════════════════════════════════════
   01 · Hero
   ══════════════════════════════════════════════════════════ */

.hero {
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100svh;
  padding-top: calc(var(--nav-h) + env(safe-area-inset-top) + clamp(3rem, 9vh, 6rem));
  padding-bottom: clamp(1.75rem, 5vh, 3.5rem);
  /* clip, not hidden. `overflow: hidden` makes this a scroll
     container, and a view() timeline resolves against the nearest
     scroll container — which would be this element, which never
     scrolls, so the recede below would never progress. `clip`
     clips identically without creating one. */
  overflow: clip;
  isolation: isolate;
}

/* The hero no longer carries its own canvas: the field behind the
   whole document covers it. What remains is the scrim that holds
   the headline over it. */

.hero__veil {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(70% 55% at 22% 42%, var(--bg) 0%, rgb(0 0 0 / 0) 68%),
    linear-gradient(to bottom, rgb(0 0 0 / 0) 62%, var(--bg) 100%);
}
[data-theme="light"] .hero__veil {
  background:
    radial-gradient(70% 55% at 22% 42%, var(--bg) 0%, rgb(255 255 255 / 0) 68%),
    linear-gradient(to bottom, rgb(255 255 255 / 0) 62%, var(--bg) 100%);
}

.hero__inner {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(1.15rem, 2.4vw, 1.75rem);
  align-items: flex-start;
}

/* ── the hero receding ─────────────────────────────────────────
   As the page takes over, the opening recedes rather than simply
   scrolling away — it moves back and dims, so the section arriving
   underneath reads as coming forward. That is the only scroll-
   linked effect on the site.

   It is a CSS scroll-driven animation, which means the browser
   runs it off the main thread: no scroll listener, no rAF, no
   work on the main thread at all. Where the feature is absent the
   hero simply scrolls, which is a perfectly good hero.

   transform and opacity only — a blur here would be sampling a
   full-viewport surface on every frame of every scroll.
   ──────────────────────────────────────────────────────────── */

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    /* Longhands, not the shorthand: `animation: … ` would set
       animation-duration to 0s, and a progress-driven animation
       needs `auto` to span its range. */
    .hero__inner {
      animation-name: hero-recede;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: exit 0% exit 85%;
      /* Anchored to the left rail. Scaling from the centre drifts
         the copy inward as it recedes, and for the length of the
         scroll the hero stops sharing a left edge with everything
         below it — which is the one alignment this page keeps. */
      transform-origin: 0% 50%;
    }
  }
}

@keyframes hero-recede {
  to {
    opacity: 0;
    transform: translate3d(0, -5%, 0) scale(0.94);
  }
}

.hero__eyebrow { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.6rem; }
/* The handle carries the same weight as the status beside it —
   it is an identity, not an annotation. The label class uppercases
   by default; the alias is a brand with its own casing, so it
   opts out and renders exactly as it is written everywhere else. */
.hero__handle {
  font-family: var(--font-mono);
  color: var(--fg-2);
  text-transform: none;
  letter-spacing: 0.04em;
}
.hero__sep { color: var(--fg-5); }
.site-footer__handle { font-size: 0.8125rem; color: var(--fg-4); }

.hero__title { max-width: 15ch; }
.hero__line { display: block; }
/* The second line carries the point, so it carries the emphasis. */
.hero__line:last-child { color: var(--fg-2); }

.hero__lede { max-width: 44ch; }

.hero__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: clamp(0.25rem, 1.5vw, 0.75rem);
}

.hero__ledger { flex: none; }

/* ── the cue ───────────────────────────────────────────────────
   One word and a line that draws itself downward, on a loop with
   a long pause in it. It is the only instruction on the page and
   it retires the moment it has been obeyed.                     */

.hero__cue {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  margin-top: clamp(1.25rem, 3vh, 2rem);
  color: var(--fg-4);
}
.hero__cueLine {
  position: relative;
  width: 46px;
  height: 1px;
  overflow: hidden;
  background: var(--hair);
}
.hero__cueLine::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--fg-3);
  transform-origin: 0 50%;
  animation: cue-draw 3.2s var(--ease-in-out) infinite;
}
@keyframes cue-draw {
  0%, 8%    { transform: scaleX(0); }
  38%, 58%  { transform: scaleX(1); }
  88%, 100% { transform: scaleX(0); transform-origin: 100% 50%; }
}
@media (prefers-reduced-motion: reduce) {
  .hero__cueLine::after { animation: none; transform: scaleX(0.5); }
}

/* Gone by the time the first section is on screen — it has said
   its one thing and there is no reason for it to persist. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .hero__cue {
      animation-name: cue-retire;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: exit 0% exit 40%;
    }
  }
}
@keyframes cue-retire { to { opacity: 0; } }

/* ── ledger ────────────────────────────────────────────────── */

.ledger {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(1.25rem, 3vw, 2rem) clamp(1rem, 3vw, 2.5rem);
  padding-top: clamp(1.75rem, 4vh, 3rem);
  border-top: 1px solid var(--hair-soft);
}
@media (min-width: 700px) {
  .ledger { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.ledger__item {
  display: flex;
  flex-direction: column-reverse;   /* value above its label */
  gap: 0.3rem;
  min-width: 0;
}
.ledger__value {
  margin: 0;
  font-size: clamp(1.625rem, 3.2vw, 2.375rem);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.035em;
  font-variant-numeric: tabular-nums;
}
.ledger__label {
  font-size: 0.8125rem;
  line-height: 1.35;
  letter-spacing: -0.002em;
  color: var(--fg-3);
  text-wrap: pretty;
}

/* ══════════════════════════════════════════════════════════
   02 · Reconnaissance
   ══════════════════════════════════════════════════════════ */

.recon {
  border-radius: var(--r-md);
  box-shadow: inset 0 0 0 1px var(--hair);
  overflow: hidden;
}

.recon__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.95rem clamp(1rem, 2.5vw, 1.5rem);
  border-bottom: 1px solid var(--hair);
  background: var(--hair-soft);
}

.recon__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0;
  padding: 0.4rem clamp(1rem, 2.5vw, 1.5rem) 1rem;
}
@media (min-width: 620px) { .recon__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: clamp(1.5rem, 4vw, 3.5rem); } }
@media (min-width: 1000px) { .recon__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

.recon__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  min-width: 0;
  padding-block: 0.6rem;
  border-bottom: 1px solid var(--hair-soft);
  font-size: 0.8125rem;
}
/* 19 fields never divide evenly into 2 or 3 columns, so the final
   row sits alone. Without this its rule hangs off into space. */
.recon__row:last-child { border-bottom: 0; }

.recon__row dt {
  flex: none;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--fg-4);
}
.recon__row dd {
  min-width: 0;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  text-align: right;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--fg-2);
}

/* ══════════════════════════════════════════════════════════
   03 · Capabilities
   ══════════════════════════════════════════════════════════ */

.capabilities { border-top: 1px solid var(--hair); }

.capability {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0.85rem;
  padding-block: clamp(1.75rem, 4vw, 2.75rem);
  border-bottom: 1px solid var(--hair-soft);
}

@media (min-width: 880px) {
  .capability {
    grid-template-columns: 3rem minmax(0, 1.15fr) minmax(0, 1fr);
    gap: clamp(1.5rem, 4vw, 4rem);
    align-items: start;
  }
}

.capability__index {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.12em;
  color: var(--fg-4);
  padding-top: 0.55em;
}

.capability__title {
  font-size: clamp(1.375rem, 2.6vw, 2rem);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.028em;
  text-wrap: balance;
}

.capability__points {
  display: grid;
  gap: 0.5rem;
  align-content: start;
  padding-top: 0.35rem;
}
.capability__points li {
  position: relative;
  padding-left: 1.1rem;
  font-size: var(--t-small);
  line-height: 1.5;
  color: var(--fg-3);
}
.capability__points li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.68em;
  width: 6px;
  height: 1px;
  background: var(--fg-5);
}

/* ══════════════════════════════════════════════════════════
   04 · Phases

   Four movements of an engagement, stated once and laid out as a
   set. An earlier build pinned this section and cut between the
   phases across four screens of scroll; that was removed on
   request. It is now a plain staggered grid — the four rise into
   place with the rest of the document's reveals and nothing is
   scroll-driven.
   ══════════════════════════════════════════════════════════ */

.phases {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: clamp(1.75rem, 3vw, 2.5rem);
}
@media (min-width: 680px)  { .phases { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 1080px) { .phases { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

.phase {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  padding-top: 1.25rem;
  border-top: 1px solid var(--hair);
}

.phase__index { display: flex; align-items: baseline; gap: 0.5rem; }
.phase__num {
  font-size: clamp(2rem, 4vw, 2.75rem);
  font-weight: 500;
  line-height: 0.9;
  letter-spacing: -0.05em;
  color: var(--accent-text);
  font-variant-numeric: tabular-nums;
}
.phase__of {
  font-size: 0.6875rem;
  letter-spacing: 0.14em;
  color: var(--fg-4);
}

.phase__title {
  font-size: clamp(1.125rem, 1.7vw, 1.375rem);
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.02em;
}
.phase__body { font-size: var(--t-small); line-height: 1.55; color: var(--fg-3); }

/* ══════════════════════════════════════════════════════════
   05 · Selected work
   ══════════════════════════════════════════════════════════ */

/* ── the reel ──────────────────────────────────────────────────
   Three cards side by side is a list of three things. Held frame
   on the left, copy passing on the right, cutting as each piece
   reaches the reading line is one sequence — and it is also the
   honest shape for this content, because these are three write-ups
   read one at a time, not three options being compared.

   Below 900px the frame is dropped entirely rather than shrunk: a
   sticky pane and a reading column cannot share the short side of
   a phone, and pretending otherwise gives you both at half size.
   Each piece becomes its own full-width plate with its picture
   inline, and the cutting is done by the scroll itself.         */

.reel { display: block; }

.reel__stage { display: none; }

.work {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 2.5rem);
}

@media (min-width: 900px) {
  .reel {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: clamp(2rem, 5vw, 4.5rem);
    /* Emphatically NOT `align-items: start`. A sticky element can
       only travel inside its own containing block, so the stage
       column has to stretch to the full height of the copy beside
       it. Shrink-wrapped to its content, the frame is exactly its
       own height, has nowhere to stick, and scrolls away after the
       first piece — leaving the remaining write-ups with no
       picture at all. */
    align-items: stretch;
  }

  .reel__stage { display: block; position: relative; }

  .reel__pin {
    position: sticky;
    top: calc(var(--nav-h) + env(safe-area-inset-top) + clamp(2rem, 8vh, 4.5rem));
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
  }

  .reel__frame {
    position: relative;
    aspect-ratio: 4 / 3;
    border-radius: var(--r-lg);
    /* clip, not hidden: `hidden` would make this a scroll container
       and any view() timeline inside it would resolve against a
       box that never scrolls. */
    overflow: clip;
    background:
      radial-gradient(90% 80% at 30% 22%, var(--hair-soft), transparent 68%),
      var(--bg-raise);
    box-shadow: inset 0 0 0 1px var(--hair);
  }

  /* Each shot fills the frame. The one leaving goes up and back,
     the one arriving comes from below — the same spatial grammar
     as the phases, so the two pinned scenes feel like one device
     used twice rather than two different tricks. */
  .reel__shot {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    opacity: 0;
    transform: translate3d(0, 7%, 0) scale(0.94);
    transition:
      opacity var(--dur-reveal) var(--ease-out),
      transform var(--dur-settle) var(--ease-out);
  }
  .reel__shot.is-on { opacity: 1; transform: none; }
  .reel__shot.is-out { opacity: 0; transform: translate3d(0, -7%, 0) scale(1.04); }

  .reel__art { width: min(66%, 340px); }
  .reel__art svg { width: 100%; height: auto; color: var(--fg); opacity: 0.62; }

  .reel__meta {
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
    font-size: 0.8125rem;
    color: var(--fg-4);
  }
  .reel__slash { color: var(--fg-5); }
  .reel__tag { margin-left: auto; color: var(--accent-text); }

  /* The copy column. Each piece gets most of a screen so that the
     cut and the title arrive together; the last one does not, or
     the section would end on a screen of nothing. */
  .reel__copy { gap: 0; }
  .reel__copy .work__item { min-height: 74svh; justify-content: center; }
  .reel__copy .work__item:last-child { min-height: 0; padding-bottom: 2rem; }

  /* The picture is in the held frame at this width, so the inline
     copy of it is redundant. */
  .reel__copy .work__visual { display: none; }
}

/* The lift is a spring in JS (see the `data-lift` handling in
   motion.js), so the card's own `transform` is owned there and is
   deliberately not transitioned here.

   The raised shadow rides a pseudo-element whose opacity fades
   instead: box-shadow is a paint property, and transitioning it
   repaints the card every frame. Opacity composites. */
/* On a phone each piece is a plate: its own surface, its own
   picture, full width. On the desktop reel the surface is dropped
   entirely — the held frame is carrying the picture, and a card
   around the copy beside it would be a box inside a box. */

.work__item {
  position: relative;
  display: flex;
  flex-direction: column;
  border-radius: var(--r-lg);
  background: var(--bg-raise);
  box-shadow: inset 0 0 0 1px var(--hair-soft);
  /* Deliberately not `overflow: hidden`. The raised shadow lives on
     the pseudo-element below and reaches outside the border box;
     clipping the card would erase it. The visual rounds its own
     corners instead — see .work__visual. */
}
.work__item::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--hair), var(--shadow-lg);
  opacity: 0;
  transition: opacity var(--dur-panel) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .work__item:hover::after,
  .work__item:focus-visible::after { opacity: 1; }
  .work__item:hover .work__visual svg { transform: scale(1.05); }
  .work__item:hover .work__cue { color: var(--accent-text); }
  .work__item:hover .work__cue span { transform: translateX(4px); }
}

@media (min-width: 900px) {
  .work__item {
    background: none;
    border-radius: 0;
    box-shadow: none;
  }
  .work__item::after { content: none; }

  /* The piece the frame is currently showing is at full strength;
     the others stay legible but recede. It is the same idea as the
     capability rows — the reading position is lit — and it is what
     ties the copy on the right to the picture on the left. */
  .work__item { transition: opacity var(--dur-panel) var(--ease-out); }
  .work__item:not(.is-live) { opacity: 0.42; }
  .work__item:focus-visible, .work__item:hover { opacity: 1; }
}

/* Dimming text is the one thing a contrast preference must veto. */
@media (min-width: 900px) and (prefers-contrast: more) {
  .work__item:not(.is-live) { opacity: 1; }
}

.work__visual {
  position: relative;
  aspect-ratio: 16 / 10;
  display: grid;
  place-items: center;
  /* clip, not hidden: `hidden` would make this a scroll container
     and the parallax timeline below would resolve against it. */
  overflow: clip;
  /* Rounds the corners it shares with the card, now that the card
     itself no longer clips. */
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  background:
    radial-gradient(85% 80% at 30% 25%, var(--hair-soft), transparent 70%),
    var(--bg-high);
  border-bottom: 1px solid var(--hair-soft);
}
.work__visual svg {
  width: min(78%, 320px);
  height: auto;
  color: var(--fg);
  /* Lifted from 0.5: the ground beneath it is darker now, and this
     is the only picture the phone gets for each piece. */
  opacity: 0.62;
  transition: transform var(--dur-panel) var(--ease-out);
}


/* ── the diagram drawing itself ──────────────────────────────
   Each motif is a real diagram of what the piece is about, so it
   arrives the way a diagram is made: the lines are traced, then
   the nodes land on them. Once, on entry — the paths carry their
   own length in --len, measured after layout.

   Without JS --len is never set, the two declarations below are
   invalid at computed-value time, and the diagram simply renders
   complete. That is the correct fallback. */
.work__visual path {
  stroke-dasharray: var(--len);
  stroke-dashoffset: var(--len);
  transition: stroke-dashoffset 1000ms var(--ease-in-out) 200ms;
}
.work__visual.is-revealed path { stroke-dashoffset: 0; }

.work__visual circle {
  opacity: 0;
  transition: opacity 420ms var(--ease-out) 760ms;
}
.work__visual.is-revealed circle { opacity: 1; }

/* Each plate is presented rather than listed: the picture opens
   out to full size as it arrives, the way a product shot settles
   into frame. Its own layer, because the crop, the drift and the
   hover scale each already own a transform and the three must
   compose rather than overwrite each other.

   Phone only — at the reel width there is no inline picture to
   open. */
.work__frame { will-change: transform; }

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) and (max-width: 899px) {
    .work__frame {
      animation-name: work-expand;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: entry 8% cover 38%;
    }
  }
}

@keyframes work-expand {
  from { transform: scale(0.9); }
  to   { transform: scale(1); }
}

/* Parallax inside the frame: the diagram drifts against its own
   crop as the plate crosses the viewport, which is what gives a
   flat panel depth. */
.work__parallax { will-change: transform; }

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .work__parallax {
      animation-name: work-parallax;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
    }
  }
}

@keyframes work-parallax {
  from { transform: translate3d(0, -6%, 0); }
  to   { transform: translate3d(0, 6%, 0); }
}

.work__body {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  padding: clamp(1.35rem, 3vw, 2.5rem);
}
/* No surface at the reel width, so no inset to sit inside. */
@media (min-width: 900px) { .work__body { padding: 0; gap: 1rem; } }

.work__top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
}
.work__tag { color: var(--accent-text); }
.work__date { color: var(--fg-4); }

.work__title {
  font-size: clamp(1.375rem, 3.4vw, 2.375rem);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

.work__excerpt {
  font-size: var(--t-lead);
  line-height: 1.52;
  letter-spacing: -0.012em;
  color: var(--fg-2);
  max-width: 48ch;
  text-wrap: pretty;
}

.work__cue {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  margin-top: auto;
  padding-top: 0.65rem;
  font-size: 0.875rem;
  font-weight: 520;
  color: var(--fg-2);
  transition: color var(--dur-ui) var(--ease-out);
}
.work__cue span { display: inline-block; transition: transform var(--dur-ui) var(--ease-out); }

.work__more { display: flex; justify-content: center; margin-top: clamp(2rem, 5vw, 3.5rem); }

/* ══════════════════════════════════════════════════════════
   06 · Experience
   ══════════════════════════════════════════════════════════ */

.roles { border-top: 1px solid var(--hair); }

.role {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: clamp(1rem, 2.5vw, 1.5rem);
  padding-block: clamp(2.25rem, 5vw, 3.5rem);
  border-bottom: 1px solid var(--hair-soft);
}
@media (min-width: 900px) {
  .role {
    grid-template-columns: minmax(11rem, 15rem) minmax(0, 1fr);
    gap: clamp(2rem, 5vw, 5rem);
  }
}

/* The dates hold while their bullets pass. A timeline where the
   date scrolls away from the work it describes is a timeline you
   have to keep scrolling back up in — and pinning it turns the
   section into the same held-frame device the two big scenes use,
   at a scale that suits a list of three roles. */
.role__rail { display: flex; flex-direction: column; gap: 0.5rem; align-content: start; }
@media (min-width: 900px) {
  .role__rail {
    position: sticky;
    top: calc(var(--nav-h) + env(safe-area-inset-top) + 2.5rem);
    align-self: start;
  }
}
.role__when { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; }
.role__now {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent-text);
}
.role__meta { color: var(--fg-3); line-height: 1.6; }

.role__main { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }

.role__org {
  font-size: clamp(1.375rem, 2.6vw, 2rem);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.03em;
}
.role__title { font-size: var(--t-lead); font-weight: 500; color: var(--fg-2); letter-spacing: -0.014em; }
.role__summary { color: var(--fg-2); max-width: 58ch; }

.role__points { display: grid; gap: 0.6rem; max-width: 62ch; }
.role__points li {
  position: relative;
  padding-left: 1.35rem;
  font-size: var(--t-small);
  line-height: 1.6;
  color: var(--fg-3);
}
.role__points li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.72em;
  width: 8px;
  height: 1px;
  background: var(--fg-5);
}

/* ══════════════════════════════════════════════════════════
   07 · Credentials
   ══════════════════════════════════════════════════════════ */

.crest {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(0.75rem, 1.6vw, 1.25rem);
  margin-bottom: clamp(3rem, 8vh, 5.5rem);
}
@media (min-width: 900px) { .crest { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

/* ── the credentials tipping up ────────────────────────────────
   The four arrive as objects rather than as boxes — the row tips
   up out of the picture plane and settles as it enters.

   The rotation is on the group, not on each card, and that is not
   an aesthetic choice: every card's own `transform` already
   belongs to the press-and-lift spring in motion.js, and a CSS
   animation outranks the inline style a spring writes. Animating
   the cards here would silently kill the press feedback on all
   four. Rotating the group leaves each card's transform free, and
   tipping them together is the more correct reading anyway —
   they are one set of credentials, not four separate arrivals. */

.crest__wrap { perspective: 1600px; perspective-origin: 50% 0%; }

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .crest {
      animation-name: crest-settle;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: entry 5% cover 32%;
      transform-origin: 50% 100%;
    }
  }
}

@keyframes crest-settle {
  from { transform: translate3d(0, 24px, 0) rotateX(7deg); opacity: 0.6; }
  to   { transform: none; opacity: 1; }
}

/* Same arrangement as .work__item: transform belongs to the
   spring, the raised shadow fades on a composited pseudo-element. */
.crest__item {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-height: clamp(9.5rem, 18vw, 13rem);
  padding: clamp(1.1rem, 2.4vw, 1.75rem);
  border-radius: var(--r-md);
  background: var(--bg-raise);
  box-shadow: inset 0 0 0 1px var(--hair-soft);
}
.crest__item::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--hair), var(--shadow-md);
  opacity: 0;
  transition: opacity var(--dur-panel) var(--ease-out);
}
/* Only the credentials that link somewhere respond to a pointer.
   A hover state on a card that does nothing is a promise the
   interface cannot keep. */
@media (hover: hover) and (pointer: fine) {
  a.crest__item:hover::after,
  a.crest__item:focus-visible::after { opacity: 1; }
}

.crest__code {
  margin-top: auto;
  font-size: clamp(1.75rem, 4vw, 2.75rem);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.045em;
}
.crest__name {
  font-size: 0.875rem;
  line-height: 1.4;
  letter-spacing: -0.008em;
  color: var(--fg-2);
  text-wrap: pretty;
}
.crest__issuer { color: var(--fg-4); }

/* ── the full path ─────────────────────────────────────────── */

.path { position: relative; }

/* The year pins while its credentials pass under it, then the
   next year pushes it out — the reader always knows which year
   they are inside without a label repeating on every row. */
.path__year {
  position: sticky;
  top: calc(var(--nav-h) + env(safe-area-inset-top));
  z-index: 2;
  padding: clamp(1.5rem, 3vw, 2.25rem) 0 0.6rem 0;
  background: linear-gradient(to bottom, var(--bg) 72%, transparent);
}
.path__year:first-child { padding-top: 0.6rem; }
.path__year-num {
  font-size: clamp(2.5rem, 7vw, 4.5rem);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.04em;
  color: var(--fg-4);
}

.cert {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0.5rem clamp(1.5rem, 4vw, 3rem);
  padding-block: clamp(1.25rem, 2.6vw, 1.75rem);
  border-top: 1px solid var(--hair-soft);
}
@media (min-width: 780px) {
  .cert { grid-template-columns: 8.5rem minmax(0, 1fr); align-items: start; }
}

.cert__when { display: flex; align-items: center; gap: 0.5rem; padding-top: 0.3em; }
.cert__dot {
  flex: none;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--fg-5);
}
.cert.is-latest .cert__dot { background: var(--accent); }

.cert__main { display: flex; flex-direction: column; gap: 0.45rem; min-width: 0; }

.cert__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem 0.85rem; }
.cert__name {
  font-size: clamp(1.0625rem, 1.6vw, 1.25rem);
  font-weight: 550;
  /* Also holds the link box above the 24px target minimum at the
     smallest step of the clamp. */
  line-height: 1.45;
  letter-spacing: -0.016em;
  text-wrap: pretty;
}
.cert__name a { transition: color var(--dur-ui) var(--ease-out); }
.cert__name a:hover { color: var(--accent-text); }
/* The external-link cue only appears where there is a link. */
.cert__name a::after {
  content: " ↗";
  font-size: 0.75em;
  color: var(--fg-4);
  white-space: nowrap;
}
.cert__latest { color: var(--accent-text); }
.cert__cat { color: var(--fg-4); }

.cert__issuer { font-size: var(--t-small); color: var(--fg-3); }
.cert__note { font-size: var(--t-small); line-height: 1.55; color: var(--fg-3); max-width: 56ch; }

.cert__children {
  display: grid;
  gap: 0.35rem;
  margin-top: 0.35rem;
  padding-left: 1rem;
  border-left: 1px solid var(--hair);
}
.cert__children li { font-size: var(--t-small); color: var(--fg-3); }
.cert__children a { transition: color var(--dur-ui) var(--ease-out); }
.cert__children a:hover { color: var(--accent-text); }

.cert__skills { margin-top: 0.4rem; }

/* ══════════════════════════════════════════════════════════
   08 · About
   ══════════════════════════════════════════════════════════ */

.about { display: grid; grid-template-columns: minmax(0, 1fr); gap: clamp(2rem, 5vw, 3rem); }
@media (min-width: 940px) {
  .about {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr);
    gap: clamp(3rem, 7vw, 6rem);
  }
  .about__lead { position: sticky; top: calc(var(--nav-h) + 3rem); align-self: start; }
}

.about__lead { display: flex; flex-direction: column; align-items: flex-start; gap: clamp(1rem, 2vw, 1.5rem); }
.about__body { display: flex; flex-direction: column; gap: clamp(2rem, 4vw, 2.75rem); }
.about__bio { display: grid; gap: 1.25rem; }
.about__bio p { font-size: var(--t-lead); line-height: 1.55; letter-spacing: -0.012em; color: var(--fg-2); text-wrap: pretty; }
.about__bio p:first-child { color: var(--fg); }

.about__meta {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1.5rem clamp(1.5rem, 4vw, 3rem);
  padding-top: clamp(1.5rem, 3vw, 2rem);
  border-top: 1px solid var(--hair);
}
.about__meta > div { display: flex; flex-direction: column; gap: 0.45rem; min-width: 0; }
.about__meta dd { font-size: var(--t-small); line-height: 1.5; color: var(--fg-2); }
.about__meta dd a { border-bottom: 1px solid var(--fg-4); transition: color var(--dur-ui) var(--ease-out), border-color var(--dur-ui) var(--ease-out); }
.about__meta dd a:hover { color: var(--accent-text); border-color: var(--accent); }
/* line-height keeps each link box at or above the 24px target floor. */
.about__links { display: flex; flex-direction: column; gap: 0.3rem; align-items: flex-start; line-height: 1.7; }

/* ══════════════════════════════════════════════════════════
   09 · Contact
   ══════════════════════════════════════════════════════════ */

/* The closing frame. It gets a whole screen and is centred in it,
   because the last thing a presentation does is hold on one card —
   and because the field behind the document is at the end of its
   own sequence here, with the route marked and cooling. */
.contact {
  text-align: center;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 88svh;
  padding-block: clamp(5rem, 14vh, 10rem);
}
.contact .shell { display: flex; flex-direction: column; align-items: center; gap: clamp(1.25rem, 3vw, 2rem); }
.contact__title { max-width: 16ch; }
.contact__lede { color: var(--fg-2); }
.contact__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.75rem;
  margin-top: clamp(0.5rem, 2vw, 1rem);
}

/* ══════════════════════════════════════════════════════════
   Footer
   ══════════════════════════════════════════════════════════ */

.site-footer {
  border-top: 1px solid var(--hair-soft);
  padding-block: clamp(2rem, 5vw, 3rem);
  padding-bottom: calc(clamp(2rem, 5vw, 3rem) + env(safe-area-inset-bottom));
}
.site-footer__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem clamp(1.5rem, 4vw, 3rem);
}
.site-footer__id {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  font-size: 0.9375rem;
  font-weight: 600;
  letter-spacing: -0.017em;
}
.site-footer__note { color: var(--fg-3); }
.site-footer__links { display: flex; flex-wrap: wrap; gap: 1.25rem; margin-left: auto; }
.site-footer__links a {
  font-size: var(--t-small);
  color: var(--fg-3);
  transition: color var(--dur-ui) var(--ease-out);
}
.site-footer__links a:hover { color: var(--fg); }
.site-footer__copy { width: 100%; color: var(--fg-4); }

@media (max-width: 599px) {
  .site-footer__links { margin-left: 0; width: 100%; }
}
