/* ============================================================
   Public site components
   ============================================================ */

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

.nav {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-4) var(--gutter);
  /* Opaque, and no backdrop-filter.
     This used to be an 88%-opaque bar with blur(12px) behind it.
     A fixed, full-width element that blurs its backdrop has to
     re-sample and re-blur the strip behind it on every frame the
     page scrolls, because what is behind it changes every frame.
     It is the most reliable way to make a page that scrolls at
     60fps still feel heavy, and it is worst in Safari.
     Opaque costs one paint and never repeats. */
  background: var(--bg);
  border-bottom: 1px solid transparent;
  transition: border-color var(--t-mid) var(--ease);
}

.nav[data-scrolled="true"] {
  border-bottom-color: var(--rule);
}

.nav__brand {
  font-family: var(--font-display);
  font-size: var(--step--1);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink);
  text-decoration: none;
}

/* On the home page the nav's top-left starts empty: the hero's
   name travels into this slot, then hands it over to the wordmark.
   --m is that hand-over, 0 to 1, written by hero.js.

   Hidden with opacity rather than display, because hero.js
   measures this element's box to know where the name should land
   -- a display: none element has no box to measure. The class is
   added by hero.js, so with no JS or reduced motion the wordmark
   simply stays put.

   It rises the last few pixels as it appears, against the name
   collapsing into the same corner, so the two read as one mark
   changing rather than a straight cross-fade. */
.has-name-dock .nav__brand {
  opacity: var(--m, 0);
  transform: translateY(calc((1 - var(--m, 0)) * 4px));
  pointer-events: none;
}

/* The wordmark is a link. It should only be clickable once it is
   actually the thing sitting in the corner -- an invisible link in
   the corner of the page is nobody's idea of a good time. */
html[data-mark="logo"] .nav__brand {
  pointer-events: auto;
}

.nav__links {
  display: none;
  align-items: center;
  gap: var(--s-5);
}

@media (min-width: 768px) {
  .nav__links {
    display: flex;
  }
}

.nav__link {
  font-size: var(--step--1);
  color: var(--ink-2);
  text-decoration: none;
  transition: color var(--t-fast) var(--ease);
}

.nav__link:hover {
  color: var(--ink);
}

/* Sanctioned accent use 2 of 5: the active nav item. */
.nav__link.is-active {
  color: var(--accent);
}

.nav__toggle {
  display: grid;
  place-items: center;
  padding: var(--s-2);
  margin-inline-end: calc(var(--s-2) * -1);
  color: var(--ink);
  border-radius: var(--r-sm);
}

@media (min-width: 768px) {
  .nav__toggle {
    display: none;
  }
}

.nav__menu {
  position: fixed;
  inset: 0;
  z-index: 49;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-6);
  background: var(--bg);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-mid) var(--ease);
}

.nav__menu[data-open="true"] {
  opacity: 1;
  pointer-events: auto;
}

.nav__menu a {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  color: var(--ink);
  text-decoration: none;
}

/* ---- Hero --------------------------------------------------
   Copy on the left, one photograph on the right.

   --p is the scroll progress of the hero through the viewport,
   0 to 1, written once per frame by assets/js/hero.js. It drives
   a parallax on the photograph and nothing else: the picture
   drifts and grows slightly while the copy holds still.

   It defaults to 0, so with JavaScript off or reduced motion on
   the hero is simply the resting layout with nothing moving.
   There is no pin: an earlier version held the section for 250vh
   while a reel of photographs travelled past, which is a lot of
   scrolling to ask for and a lot of machinery to maintain for
   one image.                                                 */

.hero {
  --p: 0;
  position: relative;
}

.hero__stage {
  display: grid;
  gap: var(--s-7);
  align-items: center;
  width: 100%;
  max-width: var(--wrap);
  margin-inline: auto;
  min-height: 100vh;
  padding-block: clamp(6rem, 14vh, 8rem) clamp(3rem, 8vh, 5rem);
  padding-inline: var(--gutter);
}

@media (min-width: 900px) {
  .hero__stage {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
    gap: clamp(var(--s-6), 5vw, var(--s-9));
  }
}

/* ---- The copy ---------------------------------------------- */

.hero__place,
.hero__role {
  font-size: var(--step--1);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-2);
}

.hero__place {
  margin-bottom: var(--s-5);
}

/* The name is what docks into the nav. hero.js writes the whole
   transform as --name-tf; the default here is what reduced motion
   and a JS-less page get.

   z-index has to clear the nav's 50, because the docked name sits
   on top of an opaque bar. .hero and .hero__stage set no z-index,
   so this competes in the root stacking context and wins.

   No will-change: this scales text, and a promoted layer would be
   rasterised once and then stretched, which is exactly how you get
   a blurry wordmark. Re-rastering crisply is worth the cost on a
   two-line heading -- unlike the photograph, which is why that one
   is translate-only. */
.hero__name {
  position: relative;
  z-index: 60;
  transform-origin: left top;
  transform: var(--name-tf, none);
}

/* Fades out as the wordmark takes over. pointer-events: none
   because it ends up lying on top of the bar at z-index 60, and
   an invisible heading must not swallow clicks meant for the
   wordmark underneath it. */
.has-name-dock .hero__name {
  opacity: calc(1 - var(--m, 0));
  pointer-events: none;
}

.hero__name span {
  display: block;
  color: var(--ink-2);
}

.hero__role {
  margin-top: var(--s-4);
}

.hero__lede {
  max-width: 34ch;
  margin-top: var(--s-5);
  color: var(--ink-2);
}

.hero__cta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  margin-top: var(--s-6);
}

/* ---- The photograph ----------------------------------------
   A fixed-ratio window with the image oversized inside it, so
   the parallax has somewhere to travel without dragging its own
   edge into view.

   The oversize is done with position and size, NOT with scale()
   in the transform. Animating scale changes the rasterization
   the compositor needs, so the layer is re-rastered as the value
   moves -- and here the layer is a 1440px photograph inside a
   border-radius + overflow: hidden frame, which makes it a
   rounded clip of a re-rastering layer on every frame. Animating
   translate alone is a pure compositor move: raster once, shift
   the texture.                                                */

.hero__shot {
  position: relative;
  overflow: hidden;
  border-radius: var(--r-lg);
  background: var(--surface);
  aspect-ratio: 4 / 5;
  max-height: 78vh;
}

@media (min-width: 900px) {
  .hero__shot {
    aspect-ratio: 5 / 6;
  }
}

.hero__shot picture {
  display: contents;
}

/* Overhang and travel are deliberately not close together. The
   translate percentage resolves against the image's own height
   (116% of the frame), so a 4% drift moves it 4.64% of the frame
   against an 8% overhang -- 3.4% of slack. At 6% overhang and 5%
   travel the slack was 0.4%, and sub-pixel rounding pulled the
   frame's own background into view at every size tested. */
.hero__shot img {
  position: absolute;
  inset: -8%;
  /* base.css sets max-width: 100% on every image, which clamped
     this back to the frame's width -- height was free, width was
     not, so the overhang collapsed on the left and right only and
     a 40px strip of the frame's own background showed down the
     right edge. The previous version escaped it by oversizing
     with scale(), since transforms ignore max-width. Oversizing
     with real dimensions does not. */
  max-width: none;
  max-height: none;
  width: 116%;
  height: 116%;
  object-fit: cover;
  transform: translate3d(0, calc(var(--p) * -4%), 0);
  will-change: transform;
}

@media (max-width: 899px) {
  .hero__stage {
    min-height: 0;
    padding-block: clamp(5rem, 13vh, 7rem) var(--s-8);
  }

  .hero__place {
    margin-bottom: var(--s-3);
  }

  .hero__lede {
    max-width: none;
    margin-top: var(--s-3);
  }

  .hero__cta {
    margin-top: var(--s-4);
  }

  .hero__shot {
    aspect-ratio: 4 / 3;
    max-height: none;
  }
}

/* ---- Buttons ----------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-5);
  border-radius: var(--r-full);
  font-size: var(--step--1);
  font-weight: 600;
  text-decoration: none;
  transition: transform var(--t-fast) var(--ease),
              background-color var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease),
              color var(--t-fast) var(--ease);
}

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

/* Sanctioned accent use 3 of 5: the primary button. Chartreuse
   is a fill here in both themes, so the label must come from
   --on-accent-fill, never --ink. */
.btn--primary {
  background: var(--accent-fill);
  color: var(--on-accent-fill);
}

.btn--primary:hover {
  background: color-mix(in srgb, var(--accent-fill) 84%, var(--ink));
}

.btn--ghost {
  border: 1px solid var(--rule);
  color: var(--ink);
}

.btn--ghost:hover {
  border-color: var(--ink-2);
}

/* ---- Section furniture -------------------------------------
   Eyebrows are deliberately NOT accent-coloured. Putting the
   accent on every section label is most of what made the old
   page read as generated. */

.eyebrow {
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-2);
}

.section__head {
  margin-bottom: var(--s-7);
}

.section__head h2 {
  margin-top: var(--s-3);
}

.section__lede {
  max-width: var(--measure);
  margin-top: var(--s-4);
  color: var(--ink-2);
}

/* ---- Cards -------------------------------------------------- */

.card {
  padding: var(--s-5);
  background: var(--surface-hi);
  border: 1px solid var(--rule);
  border-radius: var(--r-lg);
}

.section--tint .card {
  background: var(--bg);
}

.grid {
  display: grid;
  gap: var(--s-4);
}

/* 28rem, not 20rem: at the 1140px wrap a 20rem minimum fits three
   tracks, so "grid--2" was quietly rendering three columns and
   orphaning the fourth card. */
.grid--2 {
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr));
}

.grid--3 {
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}

/* ---- Work cards --------------------------------------------- */

.work-card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  padding: 0;
  transition: transform var(--t-mid) var(--ease),
              box-shadow var(--t-mid) var(--ease),
              border-color var(--t-mid) var(--ease);
}

.work-card:hover {
  transform: translateY(-3px);
  border-color: var(--ink-2);
  box-shadow: var(--shadow-2);
}

/* Screenshot slot. Collapses to a labelled placeholder when the
   image is absent, so the card still reads as finished. */
.work-card__shot {
  position: relative;
  aspect-ratio: 16 / 10;
  background: var(--surface);
  border-bottom: 1px solid var(--rule);
  overflow: hidden;
}

.work-card__shot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top center;
}

.work-card__shot::after {
  content: attr(data-placeholder);
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: var(--step--1);
  color: var(--ink-2);
  opacity: 0.55;
}

.work-card__shot:has(img)::after {
  content: none;
}

.work-card__body {
  display: flex;
  flex: 1;
  flex-direction: column;
  padding: var(--s-5);
}

.work-card__title {
  display: flex;
  align-items: center;
  gap: var(--s-2);
}

.work-card__title h3 {
  font-size: var(--step-1);
}

.work-card__role {
  margin-top: var(--s-1);
  font-size: var(--step--1);
  color: var(--ink-2);
}

.work-card__desc {
  flex: 1;
  margin-top: var(--s-3);
  font-size: var(--step--1);
  color: var(--ink-2);
}

.work-card__link {
  display: inline-grid;
  place-items: center;
  width: 1.6rem;
  height: 1.6rem;
  color: var(--ink-2);
  border-radius: var(--r-sm);
  transition: color var(--t-fast) var(--ease);
}

.work-card__link:hover {
  color: var(--accent);
}

/* ---- Tags ---------------------------------------------------
   Neutral by design. These used to be accent-coloured, which is
   what put the accent over budget on every single screen. */

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-top: var(--s-4);
}

.tag {
  padding: 0.15rem var(--s-3);
  border: 1px solid var(--rule);
  border-radius: var(--r-full);
  font-size: var(--step--1);
  color: var(--ink-2);
  white-space: nowrap;
}

/* ---- Entry lists (awards, roles) ---------------------------- */

.entries {
  display: flex;
  flex-direction: column;
}

.entry {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--s-4);
  padding-block: var(--s-4);
  border-bottom: 1px solid var(--rule);
}

.entry:last-child {
  border-bottom: 0;
  padding-bottom: 0;
}

.entry:first-child {
  padding-top: 0;
}

.entry__title {
  font-size: var(--step-0);
  font-weight: 600;
}

.entry__meta {
  margin-top: var(--s-1);
  font-size: var(--step--1);
  color: var(--ink-2);
}

.entry__logo {
  flex-shrink: 0;
  width: auto;
  height: 2.25rem;
  object-fit: contain;
  opacity: 0.85;
}

/* ---- Skills / levels ---------------------------------------- */

.level {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);
  padding-block: var(--s-3);
  border-bottom: 1px solid var(--rule);
}

.level:last-child {
  border-bottom: 0;
}

.level__name {
  font-size: var(--step--1);
}

.level__value {
  flex-shrink: 0;
  font-size: var(--step--1);
  color: var(--ink-2);
}

/* ---- Teaser (music) ----------------------------------------- */

.teaser {
  display: grid;
  gap: 0;
  overflow: hidden;
  padding: 0;
  text-decoration: none;
  color: inherit;
  transition: transform var(--t-mid) var(--ease),
              box-shadow var(--t-mid) var(--ease),
              border-color var(--t-mid) var(--ease);
}

@media (min-width: 720px) {
  .teaser {
    grid-template-columns: 1.1fr 1fr;
    align-items: stretch;
  }
}

.teaser:hover {
  transform: translateY(-3px);
  border-color: var(--ink-2);
  box-shadow: var(--shadow-2);
}

.teaser__body {
  padding: var(--s-6);
}

.teaser__body h3 {
  margin-top: var(--s-3);
  font-size: var(--step-2);
}

.teaser__body p {
  max-width: 42ch;
  margin-top: var(--s-3);
  color: var(--ink-2);
}

.teaser__go {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-5);
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--accent);
}

.teaser__media {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2px;
  min-height: 14rem;
}

.teaser__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ---- Contact ------------------------------------------------ */

.contact-card {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  text-decoration: none;
  color: inherit;
  transition: transform var(--t-mid) var(--ease),
              border-color var(--t-mid) var(--ease),
              box-shadow var(--t-mid) var(--ease);
}

.contact-card:hover {
  transform: translateY(-2px);
  border-color: var(--ink-2);
  box-shadow: var(--shadow-1);
}

.contact-card svg {
  flex-shrink: 0;
  color: var(--ink-2);
}

.contact-card__label {
  font-size: var(--step--1);
  color: var(--ink-2);
}

.contact-card__value {
  font-size: var(--step--1);
  font-weight: 600;
  overflow-wrap: anywhere;
}

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

.footer {
  padding-block: var(--s-7);
  border-top: 1px solid var(--rule);
}

.footer__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  font-size: var(--step--1);
  color: var(--ink-2);
}

.footer__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-4);
}

/* ---- Reveal on scroll ---------------------------------------- */

.reveal {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity var(--t-slow) var(--ease),
              transform var(--t-slow) var(--ease);
}

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

@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
  }
}
