/* ==========================================================================
   Motion & chrome — everything that lives above the page content.

   All of this is [derived]: the Figma file specifies no preloader, cursor,
   menu overlay, page transition or reveal choreography. It is designed to
   sit on top of the supplied layout without moving any of it.

   Contents
     1. Film grain
     2. Scroll progress
     3. Custom cursor
     4. Preloader
     5. Page-transition curtain
     6. Menu overlay
     7. Reveal primitives (split text, masks, clips)
     8. Magnetic / interactive affordances
     9. Reduced-motion + no-JS fallbacks
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Film grain
   A single fixed layer over the whole document. feTurbulence is rendered
   once into a data URI — no network request, no canvas loop, no repaint
   cost beyond compositing.
   -------------------------------------------------------------------------- */

.grain {
  position: fixed;
  inset: 0;
  z-index: var(--z-grain);
  pointer-events: none;
  opacity: 0.038;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  will-change: background-position;
  animation: grain-shift 6s steps(6) infinite;
}

:root[data-theme="light"] .grain {
  opacity: 0.05;
  mix-blend-mode: multiply;
}

@keyframes grain-shift {
  0% { background-position: 0 0; }
  16% { background-position: -37px 23px; }
  33% { background-position: 19px -31px; }
  50% { background-position: -13px 41px; }
  66% { background-position: 43px 7px; }
  83% { background-position: -29px -17px; }
  100% { background-position: 0 0; }
}

/* --------------------------------------------------------------------------
   2. Scroll progress — a hairline under the fixed header.
   -------------------------------------------------------------------------- */

.progress {
  position: fixed;
  top: 0;
  left: 0;
  z-index: calc(var(--z-header) + 1);
  width: 100%;
  height: 2px;
  pointer-events: none;
}

.progress__bar {
  width: 100%;
  height: 100%;
  background: linear-gradient(
    90deg,
    var(--color-yellow),
    var(--color-cream) 60%,
    var(--color-yellow)
  );
  transform: scaleX(var(--scroll-progress, 0));
  transform-origin: 0 50%;
}

/* --------------------------------------------------------------------------
   3. Custom cursor
   Two lerped elements. `difference` blending means it stays legible over
   navy, over cream and over photography without any state tracking.
   Only mounted by JS on fine pointers.
   -------------------------------------------------------------------------- */

.cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--z-cursor);
  pointer-events: none;
  mix-blend-mode: difference;
  opacity: 0;
  transition: opacity var(--dur-fast) linear;
}

.cursor.is-active {
  opacity: 1;
}

.cursor.is-suppressed {
  opacity: 0;
}

.cursor__dot,
.cursor__ring {
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 50%;
  will-change: transform;
}

.cursor__dot {
  width: 6px;
  height: 6px;
  margin: -3px 0 0 -3px;
  background: var(--color-white);
}

.cursor__ring {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin: -22px 0 0 -22px;
  border: 1px solid var(--color-white);
  transition: width var(--dur-fast) var(--ease-out),
    height var(--dur-fast) var(--ease-out),
    margin var(--dur-fast) var(--ease-out),
    background-color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out);
}

/* [derived] The label is plain text to the side of the dot in media mode —
   no enclosing disc, so it reads as a word, not a giant button. */
.cursor__label {
  position: absolute;
  left: calc(100% + 12px);
  top: 50%;
  transform: translateY(-50%) translateX(-6px);
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 11px;
  line-height: 1;
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--color-white);
  white-space: nowrap;
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-spring);
}

/* Link hover — ring inflates, dot collapses into it. */
.cursor.is-link .cursor__ring {
  width: 68px;
  height: 68px;
  margin: -34px 0 0 -34px;
  background: rgb(255 255 255 / 0.12);
}

.cursor.is-link .cursor__dot {
  transform: scale(0);
}

/* Media hover — the ring shrinks back to a small marker and the label
   appears beside the dot. Minimal by request; the difference blend keeps
   both legible over any photograph. */
.cursor.is-media .cursor__ring {
  width: 12px;
  height: 12px;
  margin: -6px 0 0 -6px;
  border-color: var(--color-white);
  background: transparent;
}

.cursor.is-media .cursor__label {
  opacity: 1;
  transform: translateY(-50%) translateX(0);
}

.cursor.is-media .cursor__dot {
  transform: scale(1.4);
}

/* Hide the native cursor only when ours is running. */
html.has-cursor,
html.has-cursor * {
  cursor: none !important;
}

html.has-cursor [data-cursor="none"],
html.has-cursor [data-cursor="none"] * {
  cursor: pointer !important;
}

/* --------------------------------------------------------------------------
   4. Preloader
   Counts 00 → 100 while fonts and hero imagery settle, then lifts as a
   curtain. Plays once per browsing session.
   -------------------------------------------------------------------------- */

.preloader {
  position: fixed;
  inset: 0;
  z-index: var(--z-preloader);
  display: grid;
  grid-template-rows: 1fr auto;
  background: var(--color-navy-900);
  clip-path: inset(0 0 0 0);
  will-change: clip-path;
}

.preloader[hidden] {
  display: none;
}

.preloader__mark {
  display: grid;
  place-items: center;
  align-content: center;
  gap: var(--sp-24);
  text-align: center;
}

.preloader__name {
  overflow: hidden;
  font-family: var(--font-display);
  font-weight: 200;
  font-size: clamp(28px, calc(3.2 * var(--vw)), 62px);
  line-height: 1.2;
  letter-spacing: var(--ls-display-caps);
  text-transform: uppercase;
  color: var(--color-cream);
}

.preloader__name span {
  display: inline-block;
}

.preloader__role {
  font-weight: 700;
  font-size: var(--fs-caps);
  line-height: var(--lh-caps);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--color-text);
  opacity: 0.55;
}

.preloader__foot {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--sp-24);
  padding: var(--gutter);
}

.preloader__count {
  font-family: var(--font-display);
  font-weight: 200;
  font-size: clamp(64px, calc(9 * var(--vw)), 172px);
  line-height: 0.85;
  color: var(--color-text);
  font-variant-numeric: tabular-nums;
}

.preloader__bar {
  flex: 1;
  height: 1px;
  margin-bottom: 0.6em;
  background: var(--color-rule);
  transform-origin: 0 50%;
}

.preloader__bar span {
  display: block;
  height: 100%;
  background: var(--color-yellow);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

/* --------------------------------------------------------------------------
   5. Page-transition curtain
   Grows from the bottom on leave, retracts upward on arrival. The rounded
   leading edge is what stops it reading as a plain fade.
   -------------------------------------------------------------------------- */

.curtain {
  position: fixed;
  /* Overscan the bottom so the rounded leading edge never gaps during the
     departure wipe, and the retract on arrival exits fully off-screen. */
  inset: -2% 0;
  z-index: var(--z-curtain);
  pointer-events: none;
  background: var(--color-navy-900);
  transform: translate3d(0, 102%, 0);
  will-change: transform, border-radius;
}

/* The incoming route's <main> stays invisible while it is swapped in,
   measured and scrolled into position under the curtain. It is revealed
   again at the *start* of the retract, so the curtain wipes away from a
   finished page rather than from an empty viewport. */
#main.is-settling {
  visibility: hidden;
}

/* While settling, every reveal primitive jumps straight to its end state
   instead of transitioning. Above-the-fold elements are already in view the
   instant the route is swapped, so without this their 0.7–1.4s entrances
   would still be mid-flight when the curtain lifts off them — which reads
   as content stuttering in behind the wipe. Below-fold elements are
   untouched and still animate on scroll. */
#main.is-settling [data-reveal],
#main.is-settling [data-clip] img,
#main.is-settling .split__inner,
#main.is-settling .split-chars__char,
#main.is-settling .scrub-word {
  transition: none !important;
}

/* [derived] Route loader — the same navy ground and hairline-with-yellow-fill
   language as the first-load preloader, so a route change reads as a shorter
   version of the same loading moment instead of a different effect. Sits on
   the curtain's own gutter line. */
.curtain__bar {
  position: absolute;
  inset: auto var(--gutter) calc(12vh - 2.6em);
  height: 1px;
  background: var(--color-rule);
  opacity: 0;
  transition: opacity 260ms linear;
}

.curtain.is-loading .curtain__bar {
  opacity: 1;
}

.curtain__bar span {
  display: block;
  width: 40%;
  height: 100%;
  background: var(--color-yellow);
  transform: translate3d(-100%, 0, 0);
}

.curtain.is-loading .curtain__bar span {
  animation: curtain-bar 1150ms var(--ease-inout) infinite;
}

@keyframes curtain-bar {
  0% {
    transform: translate3d(-100%, 0, 0);
  }
  100% {
    transform: translate3d(250%, 0, 0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .curtain__bar span {
    animation: none;
    width: 100%;
    transform: none;
  }
}

.curtain__label {
  position: absolute;
  inset: auto 0 12vh;
  text-align: center;
  font-family: var(--font-display);
  font-weight: 200;
  font-size: clamp(28px, calc(3 * var(--vw)), 58px);
  color: var(--color-cream);
  opacity: 0;
  /* Rises slightly as it fades in, so the label feels placed rather than
     simply switched on. Driven entirely by the JS opacity writes. */
  transform: translate3d(0, 14px, 0);
  will-change: opacity, transform;
}

/* --------------------------------------------------------------------------
   6. Menu overlay
   Replaces the old translate-down mobile panel. Big type, staggered in,
   with a hover preview of each destination on pointer devices.
   -------------------------------------------------------------------------- */

.menu {
  position: fixed;
  inset: 0;
  z-index: var(--z-menu);
  display: grid;
  grid-template-rows: 1fr auto;
  background: var(--color-navy-800);
  clip-path: inset(0 0 100% 0);
  visibility: hidden;
  transition: clip-path var(--dur-slow) var(--ease-mask),
    visibility 0s linear var(--dur-slow);
}

.menu.is-open {
  clip-path: inset(0 0 0 0);
  visibility: visible;
  transition: clip-path var(--dur-slow) var(--ease-mask), visibility 0s;
}

.menu__inner {
  display: flex;
  align-items: center;
  padding: calc(var(--header-h) + var(--sp-40)) var(--gutter) var(--sp-40);
}

.menu__list {
  width: 100%;
}

.menu__item {
  overflow: hidden;
  border-bottom: 1px solid var(--color-rule-soft);
}

.menu__item:first-child {
  border-top: 1px solid var(--color-rule-soft);
}

.menu__link {
  position: relative;
  display: flex;
  align-items: baseline;
  gap: var(--sp-24);
  padding-block: clamp(12px, calc(1.2 * var(--vw)), 26px);
  font-family: var(--font-display);
  font-weight: 200;
  font-size: clamp(44px, calc(6 * var(--vw)), 116px);
  line-height: 1.1;
  letter-spacing: var(--ls-display);
  color: var(--color-text);
  transform: translate3d(0, 110%, 0);
  transition: color var(--dur-fast) var(--ease-out);
}

.menu.is-open .menu__link {
  transform: translate3d(0, 0, 0);
  transition: transform var(--dur-slow) var(--ease-out)
    calc(var(--i, 0) * 70ms + 120ms);
}

.menu__index {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: var(--fs-caps);
  letter-spacing: var(--ls-caps);
  color: var(--color-yellow);
  opacity: 0.7;
}

.menu__link:hover,
.menu__link:focus-visible {
  color: var(--color-cream);
}

/* Left-to-right wipe under the word on hover. */
.menu__link::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0.28em;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--dur-base) var(--ease-out);
}

.menu__link:hover::after,
.menu__link:focus-visible::after {
  transform: scaleX(1);
}

/* [derived] The full-screen menu is tablet/mobile-only (the inline nav
   covers desktop). There the wipe reads better sitting right on the row's
   own rule instead of floating above it near the text baseline. */
@media (max-width: 900px) {
  .menu__link::after {
    bottom: 0;
  }
}

.menu__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-16);
  padding: var(--sp-32) var(--gutter);
  border-top: 1px solid var(--color-rule-soft);
  font-weight: 700;
  font-size: var(--fs-caps);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--color-text-muted);
}

.menu__foot a {
  color: var(--color-text);
  border-bottom: 1px solid transparent;
  transition: border-color var(--dur-fast) var(--ease-out);
}

.menu__foot a:hover,
.menu__foot a:focus-visible {
  border-color: currentColor;
}

/* --------------------------------------------------------------------------
   7. Reveal primitives
   JS adds `is-in` when the element crosses the viewport. Everything degrades
   to "just visible" if JS never runs — see §9.
   -------------------------------------------------------------------------- */

/* Split text: a line mask with the words riding up out of it. */
.split__line {
  display: block;
  overflow: hidden;
}

.split__inner {
  display: block;
  transform: translate3d(0, 110%, 0) rotate(3deg);
  transform-origin: 0 100%;
  will-change: transform;
}

.is-in .split__inner,
.split.is-in .split__inner {
  transform: translate3d(0, 0, 0) rotate(0deg);
  transition: transform var(--dur-slow) var(--ease-out)
    calc(var(--i, 0) * 90ms);
}

/* Per-character variant, used by the hero name. */
.split-chars {
  display: block;
}

.split-chars__char {
  display: inline-block;
  transform: translate3d(0, 0.9em, 0);
  opacity: 0;
  will-change: transform, opacity;
}

.is-in .split-chars__char {
  transform: translate3d(0, 0, 0);
  opacity: 1;
  transition: transform 1s var(--ease-out) calc(var(--i, 0) * 34ms),
    opacity 0.7s linear calc(var(--i, 0) * 34ms);
}

/* Generic fade-up. */
[data-reveal] {
  opacity: 0;
  transform: translate3d(0, 32px, 0);
  will-change: transform, opacity;
}

[data-reveal].is-in {
  opacity: 1;
  transform: none;
  transition: opacity var(--dur-slow) var(--ease-out) var(--reveal-delay, 0ms),
    transform var(--dur-slow) var(--ease-out) var(--reveal-delay, 0ms);
}

/* Clip reveal for media — the image scales back as its mask opens.

   The clip lives on the <img>, never on the observed wrapper. A fully
   clipped element reports an IntersectionObserver ratio of 0, so putting
   `clip-path: inset(0 0 100%)` on the target itself means it can never
   trigger its own reveal. This bit once; keep it on the child. */
[data-clip] {
  /* The child starts at scale(1.18). Contain that transformed geometry so
     below-fold project images cannot widen the document before revealing. */
  overflow: hidden;
}

[data-clip] img {
  clip-path: inset(0 0 100% 0);
  transform: scale(1.18);
  will-change: clip-path, transform;
}

[data-clip].is-in img {
  clip-path: inset(0 0 0% 0);
  transform: scale(1);
  transition: clip-path 1.2s var(--ease-mask) var(--reveal-delay, 0ms),
    transform 1.4s var(--ease-out) var(--reveal-delay, 0ms);
}

/* Case-study media uses a quieter variation: a short fade/settle with only
   a trace of masking and scale instead of the full-height wipe above. */
.page-project [data-clip] img {
  opacity: 0;
  clip-path: inset(0 0 6% 0);
  transform: translate3d(0, 14px, 0) scale(1.035);
  will-change: opacity, clip-path, transform;
}

.page-project [data-clip].is-in img {
  opacity: 1;
  clip-path: inset(0);
  transform: none;
  transition: opacity 700ms var(--ease-out) var(--reveal-delay, 0ms),
    clip-path 900ms var(--ease-mask) var(--reveal-delay, 0ms),
    transform 1s var(--ease-out) var(--reveal-delay, 0ms);
}

/* Scrubbed word opacity — set per word by JS from scroll progress. */
.scrub-word {
  display: inline-block;
  opacity: var(--o, 0.16);
  transition: opacity 120ms linear;
}

/* --------------------------------------------------------------------------
   8. Interactive affordances
   -------------------------------------------------------------------------- */

/* Magnetic elements get their translate written by JS; keep transitions off
   the transform so the lerp stays smooth. */
[data-magnetic] {
  will-change: transform;
}

[data-magnetic] > * {
  will-change: transform;
}

/* Theme toggle — a 44px disc that swaps its glyph. */
.theme-toggle {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: 1px solid var(--color-rule);
  color: var(--color-text);
  overflow: hidden;
  transition: border-color var(--dur-fast) var(--ease-out),
    background-color var(--dur-fast) var(--ease-out);
}

.theme-toggle:hover,
.theme-toggle:focus-visible {
  border-color: var(--color-yellow);
  background: rgb(255 255 255 / 0.06);
}

.theme-toggle svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  transition: transform var(--dur-base) var(--ease-spring),
    opacity var(--dur-fast) var(--ease-out);
}

.theme-toggle__moon {
  position: absolute;
  transform: rotate(-60deg) scale(0.4);
  opacity: 0;
}

:root[data-theme="light"] .theme-toggle__sun {
  transform: rotate(60deg) scale(0.4);
  opacity: 0;
}

:root[data-theme="light"] .theme-toggle__moon {
  transform: rotate(0deg) scale(1);
  opacity: 1;
}

/* --------------------------------------------------------------------------
   Touch/mobile motion
   Native touch momentum and hover emulation make desktop choreography feel
   unstable on phones. Keep only short opacity transitions and make the menu
   immediately legible; keyboard focus styles remain available.
   -------------------------------------------------------------------------- */

/* Matches the site-header__nav / .site-header__toggle breakpoint in
   pages.css: this is the same 900px threshold where the header hands off
   to the hamburger + full-screen menu, so the simplified/touch menu styling
   below applies for the whole range the overlay menu is reachable from —
   not just genuinely touch/coarse-pointer devices. */
@media (hover: none), (pointer: coarse), (max-width: 900px) {
  .grain {
    animation: none;
  }

  .menu {
    height: 100dvh;
    min-height: 100svh;
    grid-template-rows: minmax(0, 1fr) auto;
    overflow-y: auto;
    overscroll-behavior: contain;
    clip-path: none;
    opacity: 0;
    transition: opacity 180ms linear, visibility 0s linear 180ms;
  }

  .menu.is-open {
    clip-path: none;
    opacity: 1;
    transition: opacity 180ms linear, visibility 0s;
  }

  .menu__inner {
    min-height: 0;
    align-items: center;
    padding: calc(var(--header-h) + var(--sp-16)) var(--gutter) var(--sp-16);
  }

  .menu__link,
  .menu.is-open .menu__link {
    /* The display font's line-box reserves more space below the baseline
       (descender allowance + half-leading) than above the cap-height, so
       equal padding-block reads as extra space under the text. Weighting
       padding toward the top optically centers each row between its
       rules. */
    padding-top: 24px;
    padding-bottom: 10px;
    font-size: clamp(38px, calc(13 * var(--vw)), 54px);
    line-height: 1.05;
    color: var(--color-text);
    transform: none;
    transition: none;
  }

  .menu__link::after {
    display: none;
  }

  .menu__foot a:hover {
    border-color: transparent;
  }

  .menu__foot {
    align-items: flex-start;
    flex-direction: column;
    gap: 10px;
    padding: var(--sp-16) var(--gutter)
      max(var(--sp-16), env(safe-area-inset-bottom));
    font-size: 10px;
  }

  .menu__foot a {
    max-width: 100%;
    overflow-wrap: anywhere;
    letter-spacing: 0.08em;
    text-transform: lowercase;
  }

  .marquee {
    transform: none;
  }

  .theme-toggle:hover {
    border-color: var(--color-rule);
    background: transparent;
  }

  [data-reveal] {
    transform: none;
  }

  [data-reveal].is-in {
    transition: opacity 420ms var(--ease-out) var(--reveal-delay, 0ms);
  }

  [data-clip] img,
  .page-project [data-clip] img {
    clip-path: none;
    transform: none;
  }

  [data-clip].is-in img,
  .page-project [data-clip].is-in img {
    clip-path: none;
    transform: none;
    transition: opacity 480ms var(--ease-out) var(--reveal-delay, 0ms);
  }

  .split__inner,
  .split-chars__char {
    transform: none;
    opacity: 1;
    transition: none;
  }
}

/* --------------------------------------------------------------------------
   9. Fallbacks
   -------------------------------------------------------------------------- */

/* Without JS nothing gets `is-in`, so opt every hidden primitive back in. */
html.no-js [data-reveal],
html.no-js [data-clip] img,
html.no-js .split__inner,
html.no-js .split-chars__char {
  opacity: 1;
  transform: none;
  clip-path: none;
}

html.no-js .preloader {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .grain {
    animation: none;
  }

  /* The header arrives rather than travelling. */
  html.js:not(.is-intro) .site-header {
    transform: none;
  }

  /* Reveals still happen — they just arrive rather than travel. */
  [data-reveal],
  [data-clip] img,
  .split__inner,
  .split-chars__char {
    opacity: 1;
    transform: none;
    clip-path: none;
  }

  /* The taxonomy/sitemap diagrams keep the same top-to-bottom transition
     properties as everything else here — reset to their resting transform
     (still centred) rather than `none`, since these use translate(-50%,
     -50%) for layout, not decoration. */
  .tdiagram__node,
  .tdiagram__pill,
  .tdiagram__line,
  .tdiagram__arrow {
    opacity: 1;
    transition: none;
  }

  .tdiagram__node,
  .tdiagram__pill,
  .tdiagram__arrow {
    transform: translate(-50%, -50%);
  }

  .scrub-word {
    opacity: 1;
  }

  .menu {
    transition: opacity var(--dur-fast) linear, visibility 0s linear var(--dur-fast);
    clip-path: none;
    opacity: 0;
  }

  .menu.is-open {
    opacity: 1;
    transition: opacity var(--dur-fast) linear, visibility 0s;
  }

  .menu__link {
    transform: none;
  }
}
