/* ===========================================================================
   MetaTech — navbar and hero
   Tailwind (CDN) provides layout utilities. This file owns the depth stack,
   the hero background, text-reveal masks, language-aware typography and the
   reduced-motion contract.

   Palette: navy / white / black. Everything chromatic resolves to a navy
   family, so white type always has a clean, high-contrast ground.
   =========================================================================== */

:root {
  --black: #04060a;
  --navy-deep: #071223;
  --navy: #0e2247;
  --white: #ffffff;
  --ink: #ffffff;
  --muted: rgba(226, 233, 245, 0.62);
  --line: rgba(255, 255, 255, 0.12);
  --accent: #ffffff;
  --accent-2: #7fa9de;
  --nav-h: 76px;
}

/* --- base ---------------------------------------------------------------- */

/* No `scroll-behavior: smooth` here, deliberately. It makes the browser animate
   every scroll position this page writes, and the snap tween writes one every
   frame — two animations owning scrollTop at once, which on touch reads as the
   page stuttering or sliding past where it was meant to stop. The two places
   that genuinely want a smooth scroll (anchor navigation, the carousel) ask for
   it per call, where it does not collide with anything. */
html {
  -webkit-text-size-adjust: 100%;
}

body {
  background: var(--black);
  color: var(--ink);
  font-family:
    "Inter",
    "Noto Sans Thai",
    system-ui,
    -apple-system,
    sans-serif;
  -webkit-font-smoothing: antialiased;
  /* `clip`, not `hidden`: overflow:hidden turns body into a scroll container,
     which silently breaks the sticky hero. `clip` contains the overflow
     without creating one. */
  overflow-x: clip;
}

/* Thai needs more leading and no tight tracking — latin display settings crush
   the vowel and tone marks that stack above and below the baseline. */
html[data-lang="th"] body {
  font-family: "Noto Sans Thai", "Inter", system-ui, sans-serif;
}
html[data-lang="th"] .hero-title,
html[data-lang="th"] .about-title,
html[data-lang="th"] .panel-title {
  letter-spacing: 0;
  line-height: 1.34;
}
/* Thai stacks vowels and tone marks well above and below the baseline, so the
   word mask needs a deeper safe area than latin descenders require. */
html[data-lang="th"] .word {
  padding-top: 0.4em;
  margin-top: -0.4em;
  padding-bottom: 0.34em;
  margin-bottom: -0.34em;
}
html[data-lang="th"] .label {
  letter-spacing: 0.06em;
  text-transform: none;
}

::selection {
  background: rgba(127, 169, 222, 0.35);
  color: #fff;
}

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

/* Keyboard-only escape hatch past the nav. */
.skip-link {
  position: fixed;
  top: 0;
  left: 50%;
  translate: -50% -120%;
  z-index: 100;
  padding: 0.7rem 1.2rem;
  background: var(--white);
  color: var(--black);
  font-size: 0.85rem;
  font-weight: 500;
  border-radius: 0 0 10px 10px;
  transition: translate 0.3s ease;
}
.skip-link:focus-visible {
  translate: -50% 0;
}

.label {
  font-family: "JetBrains Mono", "Noto Sans Thai", ui-monospace, monospace;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

/* --- loading screen ------------------------------------------------------
   Covers the page until fonts and the hero image are decoded, then lifts as a
   curtain. js/main.js removes it from the DOM and always has a hard timeout,
   so a stalled asset can never trap the visitor behind it.                  */

body.is-loading {
  overflow: hidden;
}

.preloader {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--black);
  clip-path: inset(0 0 0 0);
}

.preloader-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.6rem;
  width: min(280px, 62vw);
}

.preloader-mark {
  display: flex;
  align-items: center;
  gap: 0.65rem;
}
.preloader-mark span {
  font-family: "JetBrains Mono", ui-monospace, monospace;
  font-size: 0.95rem;
  letter-spacing: 0.24em;
  text-transform: uppercase;
}

.preloader-bar {
  width: 100%;
  height: 2px;
  background: rgba(255, 255, 255, 0.14);
  overflow: hidden;
}
.preloader-bar span {
  display: block;
  height: 100%;
  width: 100%;
  background: var(--white);
  transform: scaleX(0);
  transform-origin: left center;
}

.preloader-pct {
  font-family: "JetBrains Mono", ui-monospace, monospace;
  font-size: 0.7rem;
  letter-spacing: 0.18em;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* --- depth stack ---------------------------------------------------------
   0 background · 1 scrims · 2 decorations · 4 text · 5 foreground FX         */

.layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.depth-0 {
  z-index: 0;
}
.depth-1 {
  z-index: 1;
}
.depth-2 {
  z-index: 2;
}
.depth-3 {
  z-index: 3;
}
.depth-4 {
  z-index: 4;
}
.depth-5 {
  z-index: 5;
}
.depth-4 > * {
  pointer-events: auto;
}

/* --- hero background (depth 0) -------------------------------------------
   js/hero-bg.js chooses the presentation and writes it to <html data-bg>:
     webgl → canvas shows the graded, parallaxed WebGL render
     image → the plain <img>, parallaxed in CSS (no WebGL, reduced motion, or
             a tainted source such as opening the page over file://)
   The <img> stays laid out in both modes — it is the GL texture source.     */

.bg-stage {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  background: var(--black);
}

.bg-canvas,
.bg-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 1s ease;
}

@keyframes bg-settle {
  from {
    transform: scale(1.26);
  }
  to {
    transform: scale(1);
  }
}

/* Oversized so the CSS parallax never exposes an edge. max-width:none for the
   same reason as .panel-media — preflight would otherwise clamp it to 100% and
   leave a strip uncovered on the right. */
.bg-image {
  inset: -8% -4%;
  width: 108%;
  max-width: none;
  height: 116%;
  /* Match the shader grade: strip the source colour back to the navy family. */
  filter: saturate(0.8) brightness(0.82) contrast(1.04);
  will-change: transform;
  /* Mirrors the shader's uIntro so both presentations settle identically. */
  animation: bg-settle 2.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}

html[data-bg="webgl"] .bg-canvas {
  opacity: 1;
}
html[data-bg="image"] .bg-image {
  opacity: 1;
}

/* The <img> path has no shader, so the grade and scrim come from CSS. */
html[data-bg="image"] .bg-stage::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    radial-gradient(
      ellipse 62% 52% at 50% 46%,
      rgba(4, 6, 10, 0.52),
      rgba(4, 6, 10, 0.24) 55%,
      transparent 78%
    ),
    linear-gradient(180deg, rgba(14, 34, 71, 0.22), rgba(4, 6, 10, 0.34));
}

/* Curved, blurred dark base. Sits above the background but below the copy, so
   the hero dissolves into the next section along an arc instead of a hard edge.
   Wider than the viewport so the arc's ends never show. */
.hero-curve {
  position: absolute;
  left: -25%;
  right: -25%;
  bottom: -14vh;
  height: 34vh;
  z-index: 3;
  background: var(--black);
  border-radius: 100% 100% 0 0 / 100% 100% 0 0;
  filter: blur(34px);
  pointer-events: none;
}

/* --- scrims (depth 1) ---------------------------------------------------- */

.scrim {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to bottom, rgba(4, 6, 10, 0.62) 0%, transparent 26%),
    linear-gradient(
      to top,
      rgba(4, 6, 10, 0.55) 0%,
      rgba(4, 6, 10, 0.12) 26%,
      transparent 48%
    );
}

.glow {
  position: absolute;
  border-radius: 50%;
  filter: blur(110px);
  opacity: 0.34;
  will-change: transform;
}
.glow-warm {
  width: 44vw;
  height: 44vw;
  left: 50%;
  top: 56%;
  translate: -50% -50%;
  background: radial-gradient(circle, rgba(23, 53, 99, 0.75), transparent 68%);
}
.glow-cool {
  width: 38vw;
  height: 38vw;
  left: 14%;
  top: 16%;
  background: radial-gradient(
    circle,
    rgba(127, 169, 222, 0.3),
    transparent 70%
  );
}

/* --- orbit ring decorations (depth 2) ------------------------------------
   Two nested elements on purpose: .orbit-wrap is driven by the pointer,
   .orbit-rings by ScrollTrigger. One transform owner each. */

.orbit-wrap {
  position: absolute;
  inset: 0;
  will-change: transform;
}
.orbit-rings {
  position: absolute;
  left: 50%;
  top: 50%;
  width: min(140vh, 140vw);
  aspect-ratio: 1;
  translate: -50% -50%;
  opacity: 0.4;
}
.orbit-rings circle {
  fill: none;
  stroke: rgba(190, 215, 255, 0.13);
  stroke-width: 0.3;
}
.orbit-rings .ring-dashed {
  stroke-dasharray: 1 3.6;
  stroke: rgba(127, 169, 222, 0.24);
}

/* --- navbar -------------------------------------------------------------- */

.nav {
  transition:
    background-color 0.45s ease,
    backdrop-filter 0.45s ease,
    border-color 0.45s ease;
  border-bottom: 1px solid transparent;
}
.nav.is-stuck {
  background: rgba(4, 6, 10, 0.74);
  backdrop-filter: blur(16px) saturate(140%);
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  border-bottom-color: var(--line);
}

.nav-link {
  position: relative;
  color: var(--muted);
  /* The hit area was the text box alone, which on a 14px label is a ~19px
     tall target — hover flickered whenever the pointer drifted a couple of
     pixels. Padding it out gives the link a real target without moving it,
     since the nav row centres its children either way. */
  padding: 0.4rem 0.1rem;
  transition: color 0.3s ease;
}
.nav-link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0.15rem;
  width: 100%;
  height: 1.5px;
  border-radius: 2px;
  background: var(--white);
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.4s cubic-bezier(0.65, 0, 0.35, 1);
}
.nav-link:hover,
.nav-link:focus-visible {
  color: var(--ink);
}
.nav-link:hover::after,
.nav-link:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left;
}

.lang-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.42rem 0.85rem;
  color: var(--muted);
  transition:
    color 0.3s ease,
    border-color 0.3s ease,
    background-color 0.3s ease;
}
.lang-toggle:hover,
.lang-toggle:focus-visible {
  color: var(--ink);
  border-color: rgba(255, 255, 255, 0.45);
  background: rgba(255, 255, 255, 0.1);
}

/* Filled CTA. Hover inverts it to navy: the previous state was a 2px lift
   plus a translucent sweep over a white pill, which on a photographic hero
   was very nearly invisible — the button read as not responding at all. */
.btn-primary {
  position: relative;
  overflow: hidden;
  background: var(--white);
  color: var(--black);
  border-radius: 999px;
  transition:
    transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1),
    background-color 0.35s ease,
    color 0.35s ease,
    box-shadow 0.35s ease;
}
.btn-primary::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    110deg,
    transparent 20%,
    rgba(255, 255, 255, 0.45) 50%,
    transparent 80%
  );
  transform: translateX(-110%);
  /* Deliberately NOT transitioned at rest. The sweep is a one-way gesture,
     and a transition declared here applies to the return trip too — leaving
     the pointer replayed the highlight backwards across the button every
     time. Declaring it only in :hover means the reset is instant. */
}
.btn-primary:hover,
.btn-primary:focus-visible {
  background: var(--navy);
  color: var(--white);
  transform: translateY(-2px);
  box-shadow: 0 14px 34px -12px rgba(14, 34, 71, 0.85);
}
.btn-primary:hover::before,
.btn-primary:focus-visible::before {
  transform: translateX(110%);
  transition: transform 0.75s cubic-bezier(0.65, 0, 0.35, 1);
}
.btn-primary > span {
  position: relative;
  z-index: 1;
}

.btn-ghost {
  border: 1px solid rgba(255, 255, 255, 0.22);
  border-radius: 999px;
  color: var(--ink);
  background: rgba(255, 255, 255, 0.05);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  transition:
    border-color 0.35s ease,
    background-color 0.35s ease,
    transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.btn-ghost:hover,
.btn-ghost:focus-visible {
  border-color: rgba(255, 255, 255, 0.72);
  background: rgba(255, 255, 255, 0.16);
  transform: translateY(-2px);
}
/* The arrow leans into the direction of travel. */
.btn-ghost svg {
  transition: transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.btn-ghost:hover svg,
.btn-ghost:focus-visible svg {
  transform: translateX(4px);
}

/* --- mobile menu --------------------------------------------------------- */

.mobile-menu {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(4, 6, 10, 0.97);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  clip-path: inset(0 0 100% 0);
  visibility: hidden;
  transition:
    clip-path 0.7s cubic-bezier(0.76, 0, 0.24, 1),
    visibility 0s linear 0.7s;
}
.mobile-menu.is-open {
  clip-path: inset(0 0 0 0);
  visibility: visible;
  transition:
    clip-path 0.7s cubic-bezier(0.76, 0, 0.24, 1),
    visibility 0s;
}
.mobile-menu a {
  opacity: 0;
  transform: translateY(24px);
  transition:
    opacity 0.5s ease,
    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.mobile-menu.is-open a {
  opacity: 1;
  transform: none;
}
.mobile-menu.is-open a:nth-child(1) {
  transition-delay: 0.18s;
}
.mobile-menu.is-open a:nth-child(2) {
  transition-delay: 0.25s;
}
.mobile-menu.is-open a:nth-child(3) {
  transition-delay: 0.32s;
}
.mobile-menu.is-open a:nth-child(4) {
  transition-delay: 0.39s;
}
/* These links had no interaction state at all. Scoped to .is-open so it can
   override the closed state's own transform rather than fighting it.
   transition-delay is reset because the staggered entry delays above apply
   to every transition on the element, hover included — without this the
   link lags up to 0.4s behind the pointer. */
.mobile-menu.is-open a:hover,
.mobile-menu.is-open a:focus-visible {
  transform: translateX(10px);
  color: var(--accent-2);
  transition-delay: 0s;
}

.burger span {
  display: block;
  height: 1.5px;
  background: var(--ink);
  transition:
    transform 0.4s cubic-bezier(0.76, 0, 0.24, 1),
    opacity 0.25s ease;
}
.burger.is-open span:nth-child(1) {
  transform: translateY(5.5px) rotate(45deg);
}
.burger.is-open span:nth-child(2) {
  opacity: 0;
}
.burger.is-open span:nth-child(3) {
  transform: translateY(-5.5px) rotate(-45deg);
}

/* --- hero ---------------------------------------------------------------- */

/* Sticky, so that a section added after the hero is dragged up over it while
   the hero "freezes". Sticky rather than a ScrollTrigger pin on purpose:
   pinSpacing:false would take the hero out of flow, letting the next section
   jump up and cover it at scroll 0. */
#hero {
  position: sticky;
  top: 0;
  height: 100svh;
  z-index: 1;
}

.hero-title {
  font-size: clamp(2.35rem, 5.4vw, 4.9rem);
  line-height: 1.04;
  letter-spacing: -0.033em;
  font-weight: 600;
  text-wrap: balance;
  text-shadow:
    0 2px 30px rgba(4, 6, 10, 0.85),
    0 1px 4px rgba(4, 6, 10, 0.5);
}

/* Accent phrase inside the headline. Solid colour is the base state, so it is
   never invisible. The gradient is layered on only where text-clipping is
   supported, and is applied to .word-inner — the element that actually holds
   the glyphs. Chrome clips nothing when background-clip:text sits on an inline
   <em> whose children are inline-block word wrappers. */
.hero-title em,
.about-title em {
  font-style: normal;
  color: var(--accent-2);
}

@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .hero-title em,
  .hero-title em .word-inner,
  .about-title em,
  .about-title em .word-inner {
    background-image: linear-gradient(
      105deg,
      #ffffff 0%,
      #cfe0f5 40%,
      var(--accent-2) 100%
    );
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
  }
}

/* Word-mask reveal. The vertical padding leaves room for Thai marks and latin
   descenders, then is pulled back with a negative margin so metrics hold. */
.word {
  display: inline-block;
  overflow: hidden;
  padding-top: 0.22em;
  margin-top: -0.22em;
  padding-bottom: 0.16em;
  margin-bottom: -0.16em;
  vertical-align: bottom;
}
.word-inner {
  display: inline-block;
  will-change: transform;
}

.hero-sub {
  color: rgba(240, 244, 252, 0.86);
  font-size: clamp(1rem, 1.35vw, 1.18rem);
  line-height: 1.7;
  text-wrap: pretty;
  text-shadow:
    0 1px 18px rgba(4, 6, 10, 0.95),
    0 1px 3px rgba(4, 6, 10, 0.7);
}

/* --- scroll cue ---------------------------------------------------------- */

.scroll-cue .rail {
  width: 1px;
  height: 54px;
  background: linear-gradient(
    to bottom,
    transparent,
    rgba(255, 255, 255, 0.45),
    transparent
  );
  position: relative;
  overflow: hidden;
}
.scroll-cue .rail::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: -50%;
  height: 50%;
  background: linear-gradient(to bottom, transparent, var(--white));
  animation: rail-run 2.4s cubic-bezier(0.76, 0, 0.24, 1) infinite;
}
@keyframes rail-run {
  0% {
    transform: translateY(0);
    opacity: 0;
  }
  25% {
    opacity: 1;
  }
  100% {
    transform: translateY(300%);
    opacity: 0;
  }
}

/* --- pre-animation state -------------------------------------------------
   Set by JS (html.js-ready) so visitors without JS still see everything.    */

/* The hero intro's pre-animation state. Every element the intro timeline
   touches must be listed here, and the hero headline must already be split into
   .word-inner spans before the loading screen lifts — otherwise this selector
   matches nothing at reveal time and the copy flashes in unstyled before the
   animation yanks it back to hidden.

   Scoped to .hero-title on purpose: unscoped it would also hold the #about
   headline hidden until the hero timeline finished, though that section owns
   its own entry. */
.js-ready .reveal-up,
.js-ready .nav-inner,
.js-ready .hero-title .word-inner {
  opacity: 0;
}

/* Touch devices: cheaper blurs, no backdrop filters, no per-element layers.
   Everything below is a compositing cost rather than a download cost — the
   sort of work that stays invisible on a desktop GPU and dominates the frame
   budget on a phone. The visual difference in each case is small; the cost is
   not. */
@media (pointer: coarse) {
  .glow {
    filter: blur(70px);
    opacity: 0.28;
  }

  /* These four are the hero's pointer-parallax layers, and touch has no
     pointer parallax any more (the tilt-driven version was removed — see
     initParallax). `will-change: transform` on a layer that never moves is
     pure cost: it reserves a compositor layer for the lifetime of the page,
     and on .glow that layer is a blurred full-bleed circle. Promoting it buys
     nothing when there is no transform coming. */
  .glow,
  .orbit-wrap,
  .hero-inner {
    will-change: auto;
  }

  .btn-ghost {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /* A backdrop-filter on a FIXED bar re-blurs whatever is behind it on every
     scrolled frame, for the whole length of the page. The bar is already 74%
     opaque, so going fully opaque costs almost nothing to look at. */
  .nav.is-stuck {
    background: rgba(4, 6, 10, 0.94);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .mobile-menu {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

/* --- about: centred statement -------------------------------------------
   Opaque and above the sticky hero, so scrolling drags it up over the frozen
   hero — this is the section the hero's freeze exists for.                 */

/* Sticky for the same reason the hero is: it freezes while the first panel
   is revealed over the top of it. */
#about {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--black);
  height: 100svh;
  display: flex;
  align-items: center;
  border-top: 1px solid var(--line);
}

.about-title {
  font-size: clamp(1.9rem, 4.4vw, 4rem);
  line-height: 1.14;
  letter-spacing: -0.03em;
  font-weight: 600;
  text-wrap: balance;
}

.about-body {
  color: var(--muted);
  font-size: clamp(1rem, 1.35vw, 1.15rem);
  line-height: 1.75;
  text-wrap: pretty;
}

/* Position markers for snapping. GSAP ScrollTrigger reads their offsets; there
   is deliberately no CSS scroll-snap-type, because mandatory snapping refuses to
   rest mid-wipe and proximity fights the scrub. One marker per section, and the
   hero has none, so snapping begins at the second section. */
.snap-stop {
  position: absolute;
  inset-inline: 0;
  height: 1px;
  pointer-events: none;
  /* Mid-dwell: after the wipe and copy have landed, before the panel releases. */
  top: 130svh;
}
.snap-stop-lead {
  top: 0;
}

/* --- panels: wipe reveal ----------------------------------------------
   The track provides the scroll room; the panel is sticky inside it, so it
   holds still while GSAP scrubs its clip-path open from left to right.

   The closed clip-path is set in JS, never here: baked into CSS it would leave
   the panel permanently invisible if the animation library failed to load. */

/* The negative margin overlaps #about's own flow region, so the panel is
   already stuck at top:0 the moment #about freezes. Without it the panel has to
   travel a full viewport before it can stick, and that approach is dead scroll:
   #about frozen, panel still clipped shut, nothing happening. */
.panel-track {
  position: relative;
  z-index: 3;
  height: 340svh;
  /* First track only: overlap #about's flow so the panel is already stuck at
     top:0 the moment #about freezes. Without it the panel must travel a full
     viewport first, and that approach is dead scroll. */
  margin-top: -100svh;
}

/* Subsequent tracks overlap far harder, so the panel underneath stays frozen
   until the incoming one has fully wiped over it. At -100svh each panel
   released exactly as the next began, so the outgoing panel slid away
   mid-wipe and exposed the section beneath — three layers moving at once.

   Geometry, in viewport units: a panel is stuck for (height - 100) and the next
   track starts at (height - margin). Holding the lower panel until the incoming
   wipe finishes needs margin >= 205; leaving it a readable beat first needs
   height >= margin + 135. */
.panel-track + .panel-track {
  margin-top: -215svh;
}

.panel-scene {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
  display: flex;
  align-items: center;
  background: var(--black);
}

/* Oversized so the counter-drift during the wipe never exposes an edge.
   max-width:none is required, not cosmetic: Tailwind's preflight sets
   `img { max-width: 100% }`, which silently clamps this back to the panel width.
   The drift then has no overhang to move into and leaves a bare strip down one
   side. Overhang is 12% each side to cover a 9%-of-own-width drift. */
.panel-media {
  position: absolute;
  inset: 0 -12%;
  width: 124%;
  max-width: none;
  height: 100%;
  object-fit: cover;
  will-change: transform;
}

.panel-overlay {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(
      100deg,
      rgba(4, 6, 10, 0.9) 0%,
      rgba(7, 18, 35, 0.62) 46%,
      rgba(14, 34, 71, 0.2) 100%
    ),
    linear-gradient(to top, rgba(4, 6, 10, 0.85), transparent 46%);
}

/* Grain sits on the overlay so it survives swapping the photograph. */
.panel-overlay::after {
  content: "";
  position: absolute;
  inset: 0;
  opacity: 0.14;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

.panel-ghost {
  position: absolute;
  right: -1vw;
  bottom: -4vh;
  font-size: clamp(11rem, 28vw, 28rem);
  font-weight: 700;
  line-height: 0.8;
  letter-spacing: -0.06em;
  color: rgba(255, 255, 255, 0.05);
  pointer-events: none;
  user-select: none;
  z-index: 1;
}

.panel-content {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: 1180px;
  margin-inline: auto;
  padding-inline: clamp(1.5rem, 6vw, 7rem);
}

.panel-title {
  font-size: clamp(2.6rem, 7vw, 6.4rem);
  line-height: 1;
  letter-spacing: -0.035em;
  font-weight: 600;
}

.panel-desc {
  color: var(--muted);
  font-size: clamp(1rem, 1.4vw, 1.2rem);
  line-height: 1.7;
  max-width: 46ch;
}

/* --- projects: horizontal drag carousel -----------------------------------
   Deliberately NOT part of the vertical panel/snap system above — this
   section scrolls normally with the page. Its own track scrolls horizontally
   on native overflow (so touch users get real momentum scrolling for free);
   js/main.js layers a pointer-drag affordance on top for mouse users only.

   The background crossfades to match the centred card via the
   [data-active-project] attribute js/main.js keeps on the section — pure CSS
   attribute-selector crossfade, no per-frame animation work.               */

.projects-section {
  /* Sticky, not relative: this section freezes at the top of the viewport
     while the CTA track that follows it is dragged up over it — the same
     handoff the hero uses for #about. It releases on its own once <main>
     ends, which hands the scroll to the footer. */
  position: sticky;
  top: 0;
  z-index: 2;
  height: 100svh;
  display: flex;
  flex-direction: column;
  background: var(--black);
  overflow: hidden;
  /* Fixed top gutter (not flex-centred) so the head can never end up under the
     fixed nav regardless of head height across languages; bottom is a smaller
     fixed breathing gap. The track fills and vertically centres in whatever
     is left, via flex below. */
  padding-top: clamp(5.5rem, 12vh, 7.5rem);
  padding-bottom: clamp(1.25rem, 4vh, 2.5rem);
}

.projects-bg {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
}

.projects-bg-layer {
  position: absolute;
  /* Oversized past the section edge, not flush inset:0 — blur() samples
     outside its own box, and the JS-driven pan shifts this element up to
     22px besides. Flush edges would expose a soft gap of bare black at
     whichever side the blur and pan combine against; the same problem this
     codebase already solved the same way for .panel-media and .bg-image. */
  inset: -40px;
  opacity: 0;
  background-size: cover;
  background-position: center;
  /* Soft-focus on purpose: this is meant to read as atmosphere behind the
     sharp foreground card, not as a second place to read the screenshot. */
  filter: blur(8px) saturate(0.85);
  transition: opacity 1.1s cubic-bezier(0.16, 1, 0.3, 1);
  /* No transform or transform-transition here on purpose: js/main.js drives
     scale + translateX on whichever layer is active, continuously, every
     scroll tick, so the background visibly pans and breathes in sync with the
     card drag rather than only crossfading at the moment focus changes. A CSS
     transition on transform would fight that per-frame write and turn into
     stutter — see initProjectsCarousel's updateActive. */
}

/* One rule per card index — attribute selector on the ancestor section drives
   which layer is visible, so the crossfade needs no JS beyond flipping the
   data attribute. */
.projects-section[data-active-project="0"]
  .projects-bg-layer[data-bg-index="0"],
.projects-section[data-active-project="1"]
  .projects-bg-layer[data-bg-index="1"],
.projects-section[data-active-project="2"]
  .projects-bg-layer[data-bg-index="2"],
.projects-section[data-active-project="3"]
  .projects-bg-layer[data-bg-index="3"],
.projects-section[data-active-project="4"]
  .projects-bg-layer[data-bg-index="4"],
.projects-section[data-active-project="5"]
  .projects-bg-layer[data-bg-index="5"],
.projects-section[data-active-project="6"]
  .projects-bg-layer[data-bg-index="6"] {
  /* Kept modest on purpose: some source screenshots are mostly white
     (a calendar UI, a light dashboard), and at higher opacity those wash out
     against the dark theme far more than the darker photos do — the scrim
     below adds a fixed darkening on top so brightness stays roughly even
     across every project regardless of what colour its screenshot happens
     to be. */
  opacity: 0.4;
}

.projects-bg-scrim {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(
      to bottom,
      var(--black) 0%,
      transparent 16%,
      transparent 78%,
      var(--black) 100%
    ),
    rgba(4, 6, 10, 0.5);
}
.projects-bg-scrim::after {
  content: "";
  position: absolute;
  inset: 0;
  opacity: 0.12;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* Five on-brand gradient placeholders, standing in for real project
   photography. Card thumbnail and expanded background reuse the same
   gradient per index, so the background reads as the card "opening up"
   rather than an unrelated swap. */
.proj-01 {
  background: radial-gradient(
    120% 130% at 20% 15%,
    #1c3a6e 0%,
    #0b1830 55%,
    #04060a 100%
  );
}
.proj-02 {
  background: radial-gradient(
    120% 130% at 80% 20%,
    #14524f 0%,
    #0a2223 55%,
    #04060a 100%
  );
}
.proj-03 {
  background: radial-gradient(
    120% 130% at 25% 85%,
    #6b4a1e 0%,
    #2a1d0e 55%,
    #04060a 100%
  );
}
.proj-04 {
  background: radial-gradient(
    120% 130% at 75% 80%,
    #3a2a63 0%,
    #1a1330 55%,
    #04060a 100%
  );
}
.proj-05 {
  background: radial-gradient(
    120% 130% at 50% 50%,
    #33506e 0%,
    #16273a 55%,
    #04060a 100%
  );
}
.proj-06 {
  background: radial-gradient(
    120% 130% at 70% 25%,
    #5e4a12 0%,
    #26200c 55%,
    #04060a 100%
  );
}
.proj-07 {
  background: radial-gradient(
    120% 130% at 30% 75%,
    #1e5a52 0%,
    #0c2622 55%,
    #04060a 100%
  );
}

.projects-head {
  position: relative;
  z-index: 2;
  flex-shrink: 0;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 2rem;
  max-width: 1400px;
  margin-inline: auto;
  width: 100%;
  padding-inline: clamp(1.5rem, 5vw, 2.5rem);
  margin-bottom: clamp(1.5rem, 4vh, 2.75rem);
}

.projects-title {
  font-size: clamp(1.7rem, 3.6vw, 2.75rem);
  line-height: 1.05;
  letter-spacing: -0.03em;
  font-weight: 600;
}

.projects-sub {
  color: var(--muted);
  font-size: clamp(0.95rem, 1.2vw, 1.05rem);
  line-height: 1.65;
  max-width: 40ch;
}

.projects-nav-btns {
  display: flex;
  gap: 0.75rem;
  flex-shrink: 0;
}

.projects-arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--muted);
  transition:
    color 0.3s ease,
    border-color 0.3s ease,
    background-color 0.3s ease,
    transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.projects-arrow:hover {
  color: var(--ink);
  border-color: rgba(255, 255, 255, 0.4);
  background: rgba(255, 255, 255, 0.07);
  transform: translateY(-2px);
}
.projects-arrow:active {
  transform: translateY(0) scale(0.94);
}
.projects-arrow:disabled {
  opacity: 0.3;
  pointer-events: none;
}

.projects-track-wrap {
  position: relative;
  z-index: 2;
  /* Fills whatever height is left below the head and centres the (shorter)
     track within it, so cards sit in the vertical middle of the section
     rather than pinned to its top. */
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: center;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  -ms-overflow-style: none;
  /* Both axes, deliberately. This was `pan-y`, which permits ONLY vertical
     panning — so the browser refused to scroll this element horizontally on
     touch. That would be right if JS owned the horizontal gesture, but
     initProjectsCarousel excludes touch from its pointer-drag on purpose and
     leaves horizontal scrolling to the browser, so `pan-y` left the carousel
     completely unswipeable on a phone: the arrows were the only way to move
     it. `pan-x pan-y` gives the element the horizontal gesture and still
     lets a vertical swipe scroll the page through it. */
  touch-action: pan-x pan-y;
  outline-offset: 6px;
}
.projects-track-wrap::-webkit-scrollbar {
  display: none;
}
/* Disabled mid-drag: fighting a proximity snap while a pointer is actively
   setting scrollLeft makes the drag feel like it is catching on rails. */
.projects-track-wrap.is-dragging {
  scroll-snap-type: none;
}
@media (pointer: fine) {
  /* The native cursor is replaced entirely by .drag-cursor below. */
  .projects-track-wrap {
    cursor: none;
  }
}

/* Custom cursor: a small circle that follows the pointer with an eased lerp
   (same follow technique as the hero's pointer parallax) while hovering the
   carousel — js/main.js toggles .is-visible on pointerenter/leave and writes
   left/top every frame. Left/top are never CSS-transitioned (that would lag
   behind the JS easing, which is already doing the smoothing); only the
   reveal/dismiss and the drag-state scale are.

   Touch has no persistent hover/pointer to follow, so this stays hidden there
   — .projects-track-wrap keeps its native touch-scroll cursor affordance,
   there is nothing this circle could usefully show. */
.drag-cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 90;
  width: 84px;
  height: 84px;
  margin: -42px 0 0 -42px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.94);
  color: var(--black);
  display: none;
  align-items: center;
  justify-content: center;
  font-family: "JetBrains Mono", ui-monospace, monospace;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  pointer-events: none;
  opacity: 0;
  transform: scale(0.4);
  transition:
    opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1),
    transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.drag-cursor.is-visible {
  opacity: 1;
  transform: scale(1);
}
.drag-cursor.is-active {
  transform: scale(0.82);
}
@media (pointer: fine) {
  .drag-cursor {
    display: flex;
  }
}

.projects-track {
  display: flex;
  align-items: center;
  gap: clamp(1rem, 2.5vw, 2rem);
  width: max-content;
  /* Inline padding is set by JS to (wrap width / 2 − card width / 2), so the
     first and last cards can still reach dead centre like every card between
     them — a fixed padding could not do this across breakpoints. */
}

.project-card {
  position: relative;
  flex: 0 0 auto;
  /* Height-driven, not width-driven: the section is a fixed 100svh, so cards
     must size to whatever vertical room is actually left rather than to
     viewport width, or they would overflow the section on short/wide
     viewports (landscape phones, short laptop screens). Landscape 16:9 to
     match the source screenshots — width follows from the height cap. */
  height: min(42vh, 460px);
  width: auto;
  aspect-ratio: 16 / 9;
  /* Backstop on the height-driven width: a tall, narrow viewport just above
     the mobile breakpoint (tablet portrait) resolves 42vh at 16:9 to within
     a few px of the full viewport width — no peek of the next card, and one
     step taller tips it into overflowing. Never binds on desktop, where the
     derived width is far under this. */
  max-width: 88vw;
  border-radius: 1.25rem;
  overflow: hidden;
  scroll-snap-align: center;
  opacity: 0.5;
  transform: scale(0.92);
  transform-origin: 50% 50%;
  filter: saturate(0.65) brightness(0.7);
  transition:
    opacity 0.55s cubic-bezier(0.16, 1, 0.3, 1),
    transform 0.55s cubic-bezier(0.16, 1, 0.3, 1),
    filter 0.55s cubic-bezier(0.16, 1, 0.3, 1);
  will-change: transform;
}
.project-card.is-focus {
  opacity: 1;
  transform: scale(1);
  filter: saturate(1) brightness(1);
}

/* media/overlay/view/body are direct children of .project-card (already
   position:relative), not wrapped in a card-wide link — only .project-card-view
   below is an <a>. This is deliberate: dragging anywhere else on the card (the
   photo, the title, the empty overlay) must be pure drag surface, never
   competing with a click target, matching/undoing the wipe-suppression dance
   a card-wide link would otherwise need. */

/* .project-card-media is a real <img> — object-fit is what actually crops and
   fills it; background-size/position are kept alongside as a harmless no-op
   so this class still works if a card is ever swapped back to a plain div.
   draggable="false" (in the HTML) stops the browser's native "drag this
   image" gesture — the default behaviour for any <img> — from competing with
   the carousel's own pointer-drag for the same mousedown+move; user-drag/
   user-select back it up for engines that still act on the CSS properties. */
/* The <picture> exists only to pick a format/size — it must not become a box.
   Without this it would sit between .project-card and .project-card-media and
   take the img's place in layout: on mobile the card is a flex column and the
   media is its flex item, so the picture would have become the item and the
   img would size to an inline wrapper instead of the card. display:contents
   drops the wrapper entirely, leaving every existing rule pointing at the img
   still correct in both layouts. */
.project-card-picture {
  display: contents;
}

.project-card-media {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  background-size: cover;
  background-position: center;
  -webkit-user-drag: none;
  user-select: none;
  -webkit-user-select: none;
}

.project-card-overlay {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to top,
    rgba(4, 6, 10, 0.94) 0%,
    rgba(4, 6, 10, 0.18) 55%,
    transparent 75%
  );
}

/* The ONLY clickable element in a card — everywhere else is drag surface.
   Revealed on hover/focus for mouse/keyboard, AND whenever the carousel's own
   drag-tracking marks this card as the centred one — the latter is what makes
   it discoverable on touch, where :hover doesn't meaningfully exist.
   cursor:pointer overrides the wrap's cursor:none (the custom .drag-cursor
   circle) specifically here — js/main.js also hides that circle on
   pointerenter/leave of this element, since "Drag" makes no sense over the
   one thing on the card you are meant to click, not drag. */
.project-card-view {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 3;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.8rem 1.4rem;
  border-radius: 999px;
  background: rgba(4, 6, 10, 0.5);
  border: 1px solid rgba(255, 255, 255, 0.4);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  color: var(--white);
  text-decoration: none;
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  white-space: nowrap;
  cursor: pointer;
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.85);
  transition:
    opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
    transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
    background-color 0.3s ease,
    border-color 0.3s ease;
}
.project-card-view:focus-visible,
.project-card.is-focus .project-card-view {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
}
/* :hover reaches the button even on a card that is not the centred one —
   dragging is unaffected since this is a distinct element from the drag
   surface, not a state of the card itself. */
.project-card-view:hover {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1.06);
  background: rgba(255, 255, 255, 0.96);
  border-color: rgba(255, 255, 255, 0.96);
  color: var(--black);
}

.project-card-body {
  position: absolute;
  z-index: 2;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 1.5rem 1.6rem;
}

.project-card-index {
  display: block;
  font-size: 10px;
  color: rgba(127, 169, 222, 0.85);
  margin-bottom: 0.6rem;
}

.project-card-title {
  font-size: clamp(1.2rem, 2.2vw, 1.55rem);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.2;
}

.project-card-tag {
  color: var(--muted);
  font-size: 0.85rem;
  margin-top: 0.35rem;
}

/* --- projects: narrow viewports -------------------------------------------
   .project-card above is height-driven on purpose — the section is a fixed
   100svh, so a width-driven card would overflow it on short/wide viewports.
   On a tall narrow phone that same rule inverts into the opposite failure:
   42vh of height at 16:9 resolves to a ~630px card on a 390px screen, so the
   card was cropped at BOTH edges at once and .project-card-body — which sits
   at the card's bottom-left — was pushed off-frame entirely. The project's
   name, index and tag were simply not on screen.

   Below the breakpoint the card is width-driven instead, sized to leave a
   sliver of the next one visible as the swipe affordance.                  */
@media (max-width: 767px) {
  .projects-section {
    padding-top: clamp(4.75rem, 10vh, 6rem);
  }

  /* Headline and copy take the full width; the arrows drop to their own row
     rather than squeezing the title into the ~210px left beside them. */
  .projects-head {
    flex-direction: column;
    align-items: stretch;
    gap: 1rem;
    margin-bottom: 1.25rem;
  }
  .projects-nav-btns {
    align-self: flex-end;
  }
  .projects-sub {
    max-width: none;
  }

  /* Width-driven, and the height follows from the content rather than being
     pinned to the section. Sizing the card to fill the available height
     instead made it near-square, and object-fit:cover then cropped these
     16:9 screenshots to roughly the middle half of their width — which on
     the lighter projects is page margin, so the card showed an almost empty
     rectangle with no sign of the work in it. */
  .project-card {
    /* 84 rather than 88vw: the leftover margin is what leaves an edge of the
       next card showing, and that sliver is the only cue that the row swipes
       at all — there are no hover affordances on touch. */
    width: min(84vw, 460px);
    height: auto;
    aspect-ratio: auto;
    display: flex;
    flex-direction: column;
    border-radius: 1rem;
    background: rgba(255, 255, 255, 0.045);
    border: 1px solid var(--line);
  }

  /* Back into flow at its own 16:9, so the screenshot is shown whole. */
  .project-card-media {
    position: relative;
    inset: auto;
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 9;
  }

  /* The copy has its own ground below the image now, so there is nothing
     left for the gradient to separate it from. */
  .project-card-overlay {
    display: none;
  }

  .project-card-body {
    position: relative;
    inset: auto;
    padding: 0.9rem 1rem 1rem;
  }
  .project-card-title {
    font-size: 1.1rem;
  }
  .project-card-tag {
    font-size: 0.8rem;
  }
  .project-card-index {
    margin-bottom: 0.4rem;
  }

  /* Centred on the image rather than on the whole card, which now includes
     the copy block below it — at 50% the button straddled the two. */
  .project-card-view {
    top: 28%;
  }
}

/* NOTE: `scroll-snap-type: x mandatory` was tried here for touch, so a swipe
   always settles on a card rather than being free to rest between two. It is
   deliberately NOT shipped: mandatory is the strictest mode about
   programmatic scrollLeft writes, and initProjectsCarousel's loop recentring
   writes scrollLeft mid-scroll (updateActive, `wrap.scrollLeft += w`) on
   every touch scroll. Whether the two fight cannot be established in this
   project's headless test setup, which does not drive touch scrolling at all
   — snap none/proximity/mandatory all produce an identical, immobile
   scrollLeft. Proximity stays until it can be checked on a real device;
   with cards at 84vw a flick lands near a snap point almost every time
   anyway, so the practical difference is small.                            */

/* --- services / packages: the price list -----------------------------------
   The only section in <main> that is neither sticky nor pinned — a plain,
   content-height block that scrolls normally. What makes it read as part of
   the same choreography is the stacking: z-index 3 puts it above the frozen
   carousel (z-index 2), so it is dragged up over it and later pulled off it,
   the same gesture #about performs on the hero. Nothing about that needs JS,
   which is why the whole section survives with GSAP absent.

   Its own background is opaque on purpose: at any moment during that drag,
   the carousel is still painting directly behind it.                        */

.services-section {
  position: relative;
  z-index: 3;
  background: var(--black);
  /* The glow below is oversized and bleeds past the section edge. */
  overflow: hidden;
  /* Top clears the fixed navbar with room to spare, since an anchor jump to
     #services lands on the section's own top edge. */
  padding-block: clamp(5.5rem, 12vh, 9rem);
}

/* A single navy wash behind the middle of the grid, so the plans sit on a
   ground rather than on flat black — the same trick .glow plays in the hero,
   at a fraction of the size. */
.services-glow {
  position: absolute;
  left: 50%;
  top: 22%;
  width: min(1100px, 120vw);
  aspect-ratio: 1;
  translate: -50% 0;
  background: radial-gradient(
    circle,
    rgba(14, 34, 71, 0.85) 0%,
    rgba(7, 18, 35, 0.45) 45%,
    transparent 70%
  );
  pointer-events: none;
}

.services-inner {
  position: relative;
  z-index: 1;
  max-width: 1400px;
  margin-inline: auto;
  padding-inline: clamp(1.5rem, 5vw, 2.5rem);
}

.services-head {
  max-width: 46rem;
  margin-inline: auto;
  text-align: center;
  margin-bottom: clamp(2.5rem, 6vh, 4rem);
}

.services-title {
  font-size: clamp(1.8rem, 4vw, 3rem);
  line-height: 1.08;
  letter-spacing: -0.03em;
  font-weight: 600;
}
.services-title em {
  font-style: normal;
  background: linear-gradient(100deg, var(--accent-2), var(--white));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.services-sub {
  color: var(--muted);
  font-size: clamp(0.95rem, 1.2vw, 1.05rem);
  line-height: 1.7;
  margin-top: 1.1rem;
}

/* --- the three plans -------------------------------------------------------
   Equal columns, and the cards stretch to the tallest of the three: the
   feature lists differ in length across languages, and a ragged bottom edge
   would put the three prices and the three buttons on different lines.     */

.plans-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(1rem, 1.6vw, 1.5rem);
  align-items: stretch;
}

.plan-card {
  position: relative;
  display: flex;
  flex-direction: column;
  padding: clamp(1.6rem, 2.2vw, 2.2rem);
  border: 1px solid var(--line);
  border-radius: 1.25rem;
  background: rgba(255, 255, 255, 0.04);
  transition:
    border-color 0.4s ease,
    background-color 0.4s ease,
    transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.plan-card:hover {
  border-color: color-mix(in srgb, var(--plan-accent) 45%, transparent);
  background: rgba(255, 255, 255, 0.06);
  transform: translateY(-4px);
}

/* The recommended plan: lifted, framed in its own accent, and given a tinted
   ground so the choice is legible before any of the copy is read. */
.plan-card.is-featured {
  border-color: color-mix(in srgb, var(--plan-accent) 55%, transparent);
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--plan-accent) 12%, transparent) 0%,
    rgba(255, 255, 255, 0.04) 45%
  );
  box-shadow: 0 24px 60px -32px color-mix(in srgb, var(--plan-accent) 70%, transparent);
}

.plan-badge {
  position: absolute;
  top: 0;
  left: clamp(1.6rem, 2.2vw, 2.2rem);
  translate: 0 -50%;
  padding: 0.35rem 0.7rem;
  border-radius: 999px;
  font-size: 9px;
  color: var(--black);
  background: var(--plan-accent);
  white-space: nowrap;
}

.plan-name {
  font-size: clamp(1.05rem, 1.5vw, 1.25rem);
  font-weight: 600;
  letter-spacing: -0.01em;
}

.plan-tag {
  color: var(--muted);
  font-size: 0.875rem;
  line-height: 1.55;
  margin-top: 0.35rem;
}

.plan-price-block {
  margin-top: 1.5rem;
  padding-bottom: 1.4rem;
  border-bottom: 1px solid var(--line);
}

.plan-from {
  display: block;
  font-size: 10px;
  color: var(--muted);
  margin-bottom: 0.35rem;
}

.plan-price {
  font-size: clamp(2rem, 3.4vw, 2.6rem);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1;
  color: var(--plan-accent);
}
.plan-currency {
  font-size: 0.55em;
  font-weight: 500;
  margin-right: 0.12em;
  vertical-align: 0.22em;
  opacity: 0.75;
}

/* Ticks are a masked SVG rather than 27 inline <svg> nodes: the mark is
   identical everywhere and only its colour changes, which a mask does for
   free from --plan-accent. */
.plan-features,
.extras-list-ticked {
  margin-top: 1.4rem;
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

.plan-features li,
.extras-list-ticked li {
  position: relative;
  padding-left: 1.7rem;
  font-size: 0.9rem;
  line-height: 1.55;
  color: rgba(226, 233, 245, 0.86);
}

.plan-features li::before,
.extras-list-ticked li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.18em;
  width: 1.05rem;
  height: 1.05rem;
  background: var(--plan-accent, var(--accent-2));
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
    center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
    center / contain no-repeat;
}

/* Pushed to the bottom of the card by the auto margin, so the delivery window
   and the button line up across all three regardless of list length. */
.plan-days {
  margin-top: auto;
  padding-top: 1.5rem;
  font-size: 0.85rem;
  color: var(--muted);
}
.plan-days strong {
  color: var(--ink);
  font-weight: 500;
}

.plan-cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  margin-top: 1.1rem;
  padding: 0.8rem 1.2rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  font-size: 0.875rem;
  font-weight: 500;
  transition:
    background-color 0.35s ease,
    border-color 0.35s ease,
    color 0.35s ease;
}
.plan-cta svg {
  transition: transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.plan-cta:hover,
.plan-cta:focus-visible {
  background: var(--plan-accent);
  border-color: var(--plan-accent);
  color: var(--black);
}
.plan-cta:hover svg,
.plan-cta:focus-visible svg {
  transform: translate(2px, -2px);
}
/* The recommended plan's button is filled from the start — it is the default
   action, not one of three equal ones. */
.is-featured .plan-cta {
  background: var(--plan-accent);
  border-color: var(--plan-accent);
  color: var(--black);
}
.is-featured .plan-cta:hover,
.is-featured .plan-cta:focus-visible {
  filter: brightness(1.08);
}

/* --- add-ons and notes ---------------------------------------------------- */

.services-extras {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(1rem, 1.6vw, 1.5rem);
  margin-top: clamp(1rem, 1.6vw, 1.5rem);
}

.extras-card {
  padding: clamp(1.4rem, 2vw, 1.9rem);
  border: 1px solid var(--line);
  border-radius: 1.25rem;
  background: rgba(255, 255, 255, 0.025);
}

.extras-title {
  font-size: 10px;
  color: var(--accent-2);
  margin-bottom: 1.1rem;
}

.extras-list {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  font-size: 0.9rem;
  line-height: 1.6;
  color: var(--muted);
}
/* The add-ons column is ticked and two-up; the notes column is prose and
   stays one-up, so a caveat is never split into a narrow column. */
.extras-list-ticked {
  margin-top: 0;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.7rem 1.5rem;
}

@media (max-width: 1023px) {
  .plans-grid {
    grid-template-columns: minmax(0, 1fr);
    max-width: 34rem;
    margin-inline: auto;
  }
  .services-extras {
    grid-template-columns: minmax(0, 1fr);
    max-width: 34rem;
    margin-inline: auto;
  }
  /* A lifted middle card is meaningless once the three are stacked, and the
     shadow only muddies the card below it. */
  .plan-card.is-featured {
    box-shadow: none;
  }
}

@media (max-width: 639px) {
  .extras-list-ticked {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* --- what you get: the one light section -----------------------------------
   Like #services above it is stacked over the frozen carousel (z-index 3 vs
   2) and dragged up across it — but unlike #services it does not carry on
   past. It is the last thing seen before the contact panel, so it is what the
   contact panel has to open over.

   The freeze itself is NOT here — it is a sticky `top` inset applied from
   js/main.js (initSheetFreeze), because the value depends on the section's
   rendered height and so is only knowable at runtime. What this file
   contributes is the fallback: position:relative, a plain scrolling section,
   which is what a visitor without JS gets and is exactly how the section
   behaved before the freeze existed.

   Two dead ends worth recording, since both look right in a stylesheet:

     position: sticky; top: 0 — freezes the section's HEAD against the top of
     the viewport. On anything narrower than a desktop this section is around
     twice the viewport's height, so its lower half becomes unreachable: the
     page stops scrolling with the quote and the aftercare card still below
     the fold.

     position: sticky; bottom: 0 — reads like "freeze the bottom", and does
     something else entirely. A bottom inset lifts a box UP toward the
     viewport while it is still below the fold, as far as its containing
     block allows, and stops applying once the box has scrolled past. With
     <main> as the containing block it dragged this section up over the hero
     and painted it there from the first frame.

   min-height covers the opposite case, on a viewport tall enough to fit the
   whole section: without it the frozen frame would be shorter than the
   screen and the carousel would show in the strip above it.

   Every global token on this page (--ink, --muted, --line) is built for white
   type on black, so none of them can be reused here. This section declares
   its own --sheet-* set instead and every rule below reads from those, which
   keeps the inversion to one block: nothing outside .sheet-section is
   touched, and there is no risk of a light value leaking into the dark
   sections through a shared name.                                           */

/* The containing block for the freeze, and the reason it ends where it does:
   a stuck element releases at its containing block's bottom edge. Bounding
   the pair here means the section holds for exactly as long as the contact
   panel needs it and not a pixel longer, however <main> grows later.
   z-index here — not on the two sections inside — is what lifts the pair
   above the frozen carousel; within the stack, plain document order puts the
   CTA over the sheet. */
.reveal-stack {
  position: relative;
  z-index: 3;
}

.sheet-section {
  --sheet-ink: #071223;
  --sheet-muted: rgba(7, 18, 35, 0.66);
  --sheet-line: rgba(7, 18, 35, 0.12);
  --sheet-accent: #0e2247;

  position: relative;
  min-height: 100svh;
  /* Centres the content in the leftover room on a viewport taller than the
     section, so the frozen frame is composed rather than top-heavy. Has no
     effect at the sizes where the content is already the taller of the two. */
  display: flex;
  align-items: center;
  background: var(--white);
  color: var(--sheet-ink);
  padding-block: clamp(5rem, 11vh, 8rem);
}

/* The one place on the page where text is selected against a light ground,
   where the global rule's translucent blue all but disappears. Written flat
   rather than nested: nothing else in this file nests, and ::selection does
   not inherit — it has to be matched on the descendants themselves. */
.sheet-section ::selection {
  background: rgba(14, 34, 71, 0.16);
  color: var(--sheet-ink);
}

/* width, because the section is a flex container: a flex item shrinks to fit
   its content by default, which would strand the max-width and the auto
   margins doing nothing. */
.sheet-inner {
  width: 100%;
  max-width: 1400px;
  margin-inline: auto;
  padding-inline: clamp(1.5rem, 5vw, 2.5rem);
}

.sheet-head {
  max-width: 48rem;
  margin-bottom: clamp(2.5rem, 6vh, 3.75rem);
}

.sheet-label {
  font-size: 11px;
  color: var(--sheet-accent);
  opacity: 0.75;
  margin-bottom: 1rem;
}

.sheet-title {
  font-size: clamp(1.8rem, 4vw, 3rem);
  line-height: 1.1;
  letter-spacing: -0.03em;
  font-weight: 600;
}
.sheet-title em {
  font-style: normal;
  /* Navy on white is the mirror of the gradient the dark sections use — same
     job (mark the phrase the sentence turns on), legible on this ground. */
  color: var(--sheet-accent);
}

.sheet-sub {
  color: var(--sheet-muted);
  font-size: clamp(0.95rem, 1.2vw, 1.05rem);
  line-height: 1.7;
  margin-top: 1.1rem;
  max-width: 44rem;
}

/* --- the four outcomes ----------------------------------------------------
   Rules rather than boxes: on a white ground a bordered card at this size
   reads as a form field. A hairline between columns gives the same grouping
   for far less ink, and collapses cleanly when the grid rewraps.           */

.gains-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(1.75rem, 3vw, 3rem);
}

.gain {
  position: relative;
  padding-left: clamp(1.25rem, 2vw, 2rem);
  border-left: 1px solid var(--sheet-line);
}
.gains-grid > .gain:first-child {
  padding-left: 0;
  border-left: 0;
}

.gain-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 46px;
  height: 46px;
  margin-bottom: 1.25rem;
  border-radius: 14px;
  color: var(--white);
  background: var(--sheet-accent);
}

.gain-title {
  font-size: 1.05rem;
  font-weight: 600;
  line-height: 1.35;
  letter-spacing: -0.01em;
}

.gain-desc {
  margin-top: 0.6rem;
  font-size: 0.9rem;
  line-height: 1.65;
  color: var(--sheet-muted);
}

/* --- who it is for -------------------------------------------------------- */

.fits {
  margin-top: clamp(2.5rem, 5vh, 3.5rem);
  padding: clamp(1.4rem, 2.4vw, 2rem);
  border-radius: 1.25rem;
  background: rgba(7, 18, 35, 0.04);
}

.fits-title {
  font-size: 10px;
  color: var(--sheet-accent);
  opacity: 0.7;
  margin-bottom: 1.25rem;
}

.fits-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem 0.7rem;
}

.fit {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0.55rem 0.95rem;
  border: 1px solid var(--sheet-line);
  border-radius: 999px;
  background: var(--white);
  font-size: 0.875rem;
  line-height: 1.2;
  white-space: nowrap;
}
.fit svg {
  color: var(--sheet-accent);
  flex-shrink: 0;
}

/* --- closing band --------------------------------------------------------- */

.sheet-band {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
  gap: clamp(1rem, 1.6vw, 1.5rem);
  margin-top: clamp(1rem, 1.6vw, 1.5rem);
}

.sheet-quote {
  display: flex;
  align-items: center;
  padding: clamp(1.75rem, 3vw, 2.75rem);
  border-radius: 1.25rem;
  background: rgba(7, 18, 35, 0.04);
  font-size: clamp(1.15rem, 2.1vw, 1.6rem);
  line-height: 1.45;
  letter-spacing: -0.02em;
  font-weight: 500;
}
.sheet-quote em {
  font-style: normal;
  color: var(--sheet-accent);
  font-weight: 600;
}
/* An oversized quote mark, set in the ground colour so it sits behind the
   words rather than competing with them. */
.sheet-quote::before {
  content: "“";
  font-size: 4.5rem;
  line-height: 0.8;
  margin-right: 0.4rem;
  align-self: flex-start;
  color: var(--sheet-accent);
  opacity: 0.22;
}

.sheet-support {
  padding: clamp(1.5rem, 2.4vw, 2rem);
  border-radius: 1.25rem;
  background: var(--sheet-accent);
  color: var(--white);
}

.sheet-support-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 42px;
  height: 42px;
  margin-bottom: 1rem;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.12);
}

.sheet-support-title {
  font-size: 1.05rem;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.sheet-support-body {
  margin-top: 0.5rem;
  font-size: 0.9rem;
  line-height: 1.6;
  color: rgba(255, 255, 255, 0.74);
}

@media (max-width: 1023px) {
  /* Two up. The dividing rule has to be re-assigned rather than dropped:
     :first-child was the only cell without one, which on a 2-column grid
     leaves the third card carrying a rule with nothing to its left. */
  .gains-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .gains-grid > .gain:nth-child(odd) {
    padding-left: 0;
    border-left: 0;
  }

  .sheet-band {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (max-width: 639px) {
  .gains-grid {
    grid-template-columns: minmax(0, 1fr);
    gap: 1.75rem;
  }
  .gains-grid > .gain {
    padding-left: 0;
    border-left: 0;
  }
  .gain-icon {
    width: 40px;
    height: 40px;
    margin-bottom: 0.9rem;
  }
  /* The mark costs more room than it earns at this width. */
  .sheet-quote::before {
    display: none;
  }
}

/* --- call to action: circle grows into the contact section ----------------
   #deliverables above freezes on its closing frame (see initSheetFreeze) and
   this track is dragged up over it. A small circle rises with the scroll, and
   on reaching the centre of the viewport it grows into the full-page panel.

   Everything below is the FINISHED, open state. The closed state — small
   circle, parked below the fold, content hidden — is applied from JS and
   scrubbed open, exactly like the panel wipes' clip-path. Baked into CSS it
   would leave the whole contact section permanently invisible if GSAP failed
   to load, which is the one part of the page that must never disappear.    */

.cta-track {
  position: relative;
  z-index: 3;
  /* Sticky travel is (height - 100svh) = 200svh: ~170 for the two scrubbed
     phases, the rest a beat of dwell on the finished panel before the footer
     starts pulling it away. */
  height: 300svh;
}

.cta-scene {
  --cta-pad: clamp(12px, 1.6vw, 22px);
  position: sticky;
  top: 0;
  height: 100svh;
  /* Clips the expand's back.out overshoot, which deliberately travels past
     full bleed before settling into the resting padding. */
  overflow: hidden;
  /* No background: the frozen section beneath is meant to show through around
     the rising circle, dimmed by .cta-scrim rather than hidden. */
}

.cta-scrim {
  position: absolute;
  inset: 0;
  background: rgba(4, 6, 10, 0.78);
}

.cta-panel {
  position: absolute;
  left: 50%;
  /* Centred in the room BELOW the fixed navbar, not in the raw viewport, so
     the panel's top edge and its copy both clear the nav instead of sliding
     under it. Shifting the centre by half the nav height and taking the
     whole nav height out of the height keeps the gap top and bottom equal to
     --cta-pad. Costs the rising circle ~half a nav height of centring, which
     is not perceptible at this scale. */
  top: calc(50% + var(--nav-h) / 2);
  /* Centred with the standalone `translate` property, never `transform`:
     GSAP owns transform here (it animates `y` for the rise), and the two
     compose instead of overwriting each other. */
  translate: -50% -50%;
  width: calc(100% - var(--cta-pad) * 2);
  height: calc(100% - var(--nav-h) - var(--cta-pad) * 2);
  border-radius: 28px;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Light surface on purpose: against a page this dark it makes the rising
     circle legible from the moment it clears the fold, and gives the contact
     section the feel of an arrival rather than one more dark panel. */
  background: #f6f7f9;
  color: var(--black);
}

/* The mark inside the circle while it is still small — the timeline fades it
   out as the growth starts, so it is never seen over the open panel. */
.cta-seed {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--navy);
  opacity: 0;
}

.cta-content {
  width: min(100%, 760px);
  padding: clamp(1.5rem, 5vw, 3rem);
  text-align: center;
}

.cta-kicker {
  font-size: 11px;
  color: rgba(14, 34, 71, 0.55);
  margin-bottom: 1.25rem;
}

.cta-title {
  font-size: clamp(2rem, 5.2vw, 3.6rem);
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1.1;
  text-wrap: balance;
}

.cta-title em {
  font-style: italic;
  color: var(--navy);
}

.cta-sub {
  margin: 1.1rem auto 0;
  max-width: 46ch;
  color: rgba(4, 6, 10, 0.62);
  font-size: clamp(0.95rem, 1.3vw, 1.08rem);
  line-height: 1.7;
  text-wrap: pretty;
}

.cta-channels {
  display: grid;
  gap: 0.75rem;
  margin-top: clamp(1.75rem, 4vh, 2.75rem);
  text-align: left;
}

@media (min-width: 720px) {
  .cta-channels {
    grid-template-columns: 1fr 1fr;
  }
}

.cta-channel {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  padding: 1rem 1.15rem;
  border: 1px solid rgba(14, 34, 71, 0.14);
  border-radius: 14px;
  background: #ffffff;
  color: var(--black);
  text-decoration: none;
  transition:
    border-color 0.35s cubic-bezier(0.16, 1, 0.3, 1),
    transform 0.35s cubic-bezier(0.16, 1, 0.3, 1),
    box-shadow 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}

.cta-channel:hover,
.cta-channel:focus-visible {
  border-color: rgba(14, 34, 71, 0.4);
  transform: translateY(-2px);
  box-shadow: 0 14px 30px -18px rgba(14, 34, 71, 0.55);
}

.cta-channel-icon {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 999px;
  background: rgba(14, 34, 71, 0.07);
  color: var(--navy);
}

.cta-channel-text {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
}

.cta-channel-label {
  font-family: "JetBrains Mono", ui-monospace, monospace;
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(14, 34, 71, 0.55);
}

.cta-channel-value {
  font-size: 0.95rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  /* The email is long enough to overflow the card on narrow viewports. */
  overflow-wrap: anywhere;
}

.cta-channel-arrow {
  flex: 0 0 auto;
  margin-left: auto;
  color: rgba(14, 34, 71, 0.4);
}

.cta-note {
  margin-top: clamp(1.5rem, 3.5vh, 2.25rem);
  font-size: 0.8rem;
  color: rgba(4, 6, 10, 0.45);
}

/* --- footer --------------------------------------------------------------
   Opaque and above the sticky hero, so scrolling drags it up over the frozen
   hero — the same handoff the hero was built for.                          */

.site-footer {
  position: relative;
  z-index: 4;
  background: var(--black);
  border-top: 1px solid var(--line);
  overflow: hidden;
}

/* Oversized wordmark bleeding off the bottom edge. */
.footer-watermark {
  position: absolute;
  left: 50%;
  bottom: -3.5vw;
  translate: -50% 0;
  width: max-content;
  font-size: clamp(5rem, 17vw, 17rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 0.78;
  color: rgba(255, 255, 255, 0.032);
  pointer-events: none;
  user-select: none;
  white-space: nowrap;
}

.footer-kicker {
  color: var(--muted);
}

/* The one oversized link in the footer — the primary exit to contact. */
.footer-cta {
  display: inline-flex;
  align-items: baseline;
  gap: 0.5em;
  font-size: clamp(2rem, 5.2vw, 4.2rem);
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1.05;
  color: var(--white);
}
.footer-cta .footer-cta-underline {
  display: block;
  height: 1px;
  margin-top: 0.18em;
  background: rgba(255, 255, 255, 0.28);
  transform: scaleX(0);
  transform-origin: left center;
  transition:
    transform 0.55s cubic-bezier(0.65, 0, 0.35, 1),
    background-color 0.4s ease;
}
.footer-cta:hover .footer-cta-underline {
  transform: scaleX(1);
  background: var(--accent-2);
}
.footer-cta svg {
  transition: transform 0.45s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.footer-cta:hover svg {
  transform: translate(0.25em, -0.25em);
}

.footer-col-title {
  color: rgba(255, 255, 255, 0.45);
}

.footer-link {
  position: relative;
  display: inline-block;
  color: var(--muted);
  transition: color 0.3s ease;
}
.footer-link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -2px;
  width: 100%;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.4s cubic-bezier(0.65, 0, 0.35, 1);
}
.footer-link:hover {
  color: var(--ink);
}
.footer-link:hover::after {
  transform: scaleX(1);
  transform-origin: left;
}

/* Social buttons */
.footer-social {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--muted);
  transition:
    color 0.3s ease,
    border-color 0.3s ease,
    background-color 0.3s ease,
    transform 0.35s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.footer-social:hover,
.footer-social:focus-visible {
  color: var(--ink);
  border-color: rgba(255, 255, 255, 0.4);
  background: rgba(255, 255, 255, 0.07);
  transform: translateY(-2px);
}

/* The handle beside the single LINE button — with only one icon left there is
   room to name the account rather than make people tap to find out. */
.footer-social-id {
  color: var(--muted);
  letter-spacing: 0.08em;
}

.back-to-top {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  color: var(--muted);
  transition: color 0.3s ease;
}
.back-to-top:hover {
  color: var(--ink);
}
.back-to-top svg {
  transition: transform 0.4s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.back-to-top:hover svg {
  transform: translateY(-3px);
}

/* --- accessibility ---------------------------------------------------------
   Deliberately last in the file, not just topically grouped: an override here
   must always win the cascade against every component rule above it, at equal
   selector specificity, source order is what decides — and this file has
   repeatedly grown new component blocks (panels, projects, footer) after this
   point. .panel-scene { position: relative } was silently defeated exactly
   this way for a full turn (the base position:sticky rule was declared later
   in the file), which under reduced motion left panels sticky-pinned instead
   of static. Keep this block last so that class of bug cannot recur.        */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .js-ready .reveal-up,
  .js-ready .nav-inner,
  .js-ready .word-inner {
    opacity: 1 !important;
    transform: none !important;
  }

  .scroll-cue .rail::after {
    animation: none;
  }
  /* No wipe and no pinning: the panel is a plain full-height section. */
  .panel-track,
  .panel-track + .panel-track {
    height: auto;
    margin-top: 0;
  }
  .panel-scene {
    position: relative;
  }

  /* No rise and no growth: the contact panel is simply already open, and the
     track collapses to it so there is no empty scroll room left behind. The
     carousel stops freezing for the same reason — nothing is being dragged
     over it any more. (The light section's freeze needs no undoing here:
     initSheetFreeze declines to apply it under reduced motion, so the section
     keeps the plain position:relative this stylesheet gives it.) */
  .projects-section {
    position: relative;
  }
  /* Nothing is holding a full screen open below it, so the section only needs
     the height its own content asks for. */
  .sheet-section {
    min-height: 0;
  }
  .cta-track {
    height: auto;
  }
  .cta-scene {
    position: relative;
    height: 100svh;
  }
}

/* --- touch: compositing cost ---------------------------------------------
   Placed at the END of the stylesheet on purpose. A media query adds no
   specificity, so these only win by source order — an earlier @media block
   (there is one up by .glow) silently lost to the base rules further down,
   and the blur and the layer promotion below both stayed live on phones.

   Everything here is GPU cost rather than download cost: invisible on a
   desktop card, dominant in a phone's frame budget. The visual difference in
   each case is small; the cost is not.                                     */
@media (pointer: coarse) {
  /* NOTE: the blur on .projects-bg-layer stays. Dropping it here was tried and
     was plainly wrong — a 960px source covering a 390px viewport is
     DOWNSCALED, not upscaled, so it stays crisp, and the backdrop's own
     headline came up sharp enough to read straight through the section copy.
     What actually made it expensive was blur re-rasterising because JS panned
     the layer's transform every scroll tick; with that write skipped on touch
     (see updateActive) the blurred layer is composited once and cached, so it
     costs nothing per frame.

     Full-screen blend-mode passes. Both are grain at ~0.12 opacity, close to
     invisible on a phone, but each makes the compositor keep and re-blend a
     separate full-viewport layer. */
  .projects-bg-scrim::after,
  .panel-overlay::after {
    display: none;
  }

  /* The carousel runs 21 cards (7 real plus two clone sets). Promoting every
     one to its own compositor layer up front is a large, permanent GPU memory
     reservation on a device with little to spare, and the scale transition
     composites fine without the hint. */
  .project-card {
    will-change: auto;
  }

  .back-to-top {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

/* --- mobile: the flattened page -------------------------------------------
   The structural half of the mobile motion budget. js/main.js declines to
   build the scroll timelines below 768px (see MOBILE_MOTION_MQ — this query
   MUST match it); this block removes the scroll room those timelines existed
   to fill. Without it a phone would scroll through three 340svh tracks and a
   300svh CTA track with nothing happening in any of them, which is worse than
   the animations were.

   Rules are the reduced-motion block's structural half, verbatim and for the
   same reasons — the difference is only in what motion survives, and that is
   decided in JS, not here. Both must stay at the end of the file: a media
   query adds no specificity, so these only beat the component rules above by
   source order.

   #about and #hero deliberately keep their stickiness. The one animation
   mobile keeps is #about's entry, which is scrubbed to that section climbing
   over the frozen hero — unstick either and the page loses the only motion it
   has left. Sticky positioning itself costs nothing per frame; it is the
   scrubbed timelines that were expensive.                                  */

@media (max-width: 767px) {
  /* No wipe and no pinning: each panel is a plain full-height section, and the
     panel scenes still cover the frozen #about as they scroll past. */
  .panel-track,
  .panel-track + .panel-track {
    height: auto;
    margin-top: 0;
  }
  .panel-scene {
    position: relative;
  }

  /* Nothing is dragged over the carousel any more, so it stops freezing... */
  .projects-section {
    position: relative;
  }
  /* ...and nothing holds a screen open below the light section, so it only
     needs the height its own content asks for. */
  .sheet-section {
    min-height: 0;
  }
  /* The contact panel is simply already open — the rise and the growth are
     gone — so the track collapses to it rather than leaving 200svh of empty
     scroll behind. */
  .cta-track {
    height: auto;
  }
  .cta-scene {
    position: relative;
    height: 100svh;
  }

  /* The two looping/entrance keyframe animations. Transitions are left alone
     on purpose: tap and focus feedback is interface, not decoration. */
  .bg-image {
    animation: none;
  }
  .scroll-cue .rail::after {
    animation: none;
  }

  /* Both promises are now to animations that no longer exist — the settle on
     the hero image and the wipe's counter-drift on the panel media. A
     will-change on an element that never moves reserves a compositor layer for
     the life of the page and buys nothing, and these two are full-bleed
     images: the largest layers on the page, on the device with the least
     memory to give them. */
  .bg-image,
  .panel-media {
    will-change: auto;
  }
}
