/* ==========================================================================
   CRIT — atmosphere.
   Six layers, always in this order. Only one major phenomenon is ever moving
   at full strength; the rest stay deliberately quiet.
     01 base        near-black physical surface
     02 atmosphere  blurred forms, light fields, monochrome image fragments
     03 depth       planes travelling at different speeds
     04 micro       grain, dust, light fragments, faint scratches
     05 light       environmental response to the cursor
     06 directory   response to whoever is currently being explored
   ========================================================================== */

.atmosphere {
  position: fixed;
  inset: 0;
  z-index: var(--z-void);
  pointer-events: none;
  contain: strict;
}

/* 01 — base surface. Not a flat black rectangle: a deep midnight room with a
   cool light from above, a violet cast to one side and a low ember at the
   floor. The colour is quiet — it reads as atmosphere, never as decoration. */
.atmo-base {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(120% 82% at 50% -12%, rgba(104, 130, 192, 0.30) 0%, rgba(46, 60, 100, 0.11) 38%, transparent 66%),
    radial-gradient(86% 68% at 84% 14%, rgba(132, 96, 158, 0.18) 0%, transparent 58%),
    radial-gradient(112% 92% at 46% 120%, rgba(158, 100, 58, 0.17) 0%, rgba(74, 46, 30, 0.05) 44%, transparent 72%),
    radial-gradient(135% 120% at 50% 44%, #12141f 0%, #0b0c16 44%, #06060c 76%, #030306 100%);
}

.atmo-base::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(140% 110% at 50% 45%, transparent 46%, rgba(0, 0, 0, 0.68) 100%);
  opacity: var(--env-dim, 1);
}

/* 02 — atmosphere: photographic fragments sitting far behind everything. */
.atmo-fragments {
  position: absolute;
  inset: -14%;
  z-index: var(--z-fragments);
  opacity: var(--fragment-opacity, 0.07);
  /* Deliberately unfiltered. A viewport-sized filter here had to be re-rendered
     by the compositor on every parallax frame — even with no images loaded — and
     it was the single largest cost in the whole scene. The softening now lives
     on the images themselves, which are small, few, and often absent. */
  will-change: transform;
}

.atmo-fragment {
  position: absolute;
  object-fit: cover;
  opacity: 0;
  mix-blend-mode: luminosity;
  filter: grayscale(1) contrast(1.08) blur(var(--fragment-blur, 26px));
  -webkit-mask-image: radial-gradient(72% 72% at 50% 50%, #000 34%, transparent 80%);
  mask-image: radial-gradient(72% 72% at 50% 50%, #000 34%, transparent 80%);
  transform: translate3d(0, 0, 0) scale(1.12);
  transition: opacity var(--t-scene) var(--ease-out-expo);
}

.atmo-fragment[data-on='1'] { opacity: 0.9; }

/* Soft light fields — volumetric, never a flashlight. --light-intensity is the
   stored homepage control; it is the master gain on this whole layer. */
.atmo-lights {
  position: absolute;
  inset: -20%;
  z-index: var(--z-light);
  opacity: var(--light-intensity, 1);
  will-change: transform, opacity;
}

.atmo-light {
  position: absolute;
  border-radius: 50%;
  /* No filter: blur(). These fields are already soft radial gradients, so the
     blur only re-rasterised a viewport-sized surface every frame — the single
     largest cost in the compositor. The gradient stops below carry the same
     softness for free, and the drift/opacity animation is untouched. */
  mix-blend-mode: screen;
  will-change: transform, opacity;
}

.atmo-light[data-kind='key'] {
  width: 62vmax;
  height: 62vmax;
  left: -18vmax;
  top: -22vmax;
  background: radial-gradient(circle, rgba(154, 182, 240, 0.22) 0%, rgba(92, 112, 164, 0.1) 30%, rgba(64, 82, 126, 0.035) 52%, transparent 76%);
  animation: lightDriftA 64s var(--ease-in-out-quart) infinite alternate;
}

.atmo-light[data-kind='fill'] {
  width: 48vmax;
  height: 48vmax;
  right: -14vmax;
  bottom: -16vmax;
  background: radial-gradient(circle, rgba(178, 156, 220, 0.17) 0%, rgba(104, 96, 154, 0.08) 34%, rgba(78, 74, 122, 0.03) 56%, transparent 78%);
  animation: lightDriftB 82s var(--ease-in-out-quart) infinite alternate;
}

.atmo-light[data-kind='raking'] {
  width: 120vmax;
  height: 26vmax;
  left: -20vmax;
  top: 34%;
  background: radial-gradient(closest-side, rgba(198, 216, 250, 0.14) 0%, rgba(198, 216, 250, 0.07) 44%, transparent 82%);
  animation: rakingSweep 96s linear infinite;
}

@keyframes lightDriftA {
  from { transform: translate3d(0, 0, 0) scale(1); opacity: 0.85; }
  to   { transform: translate3d(9vmax, 7vmax, 0) scale(1.14); opacity: 1; }
}

@keyframes lightDriftB {
  from { transform: translate3d(0, 0, 0) scale(1.06); opacity: 0.7; }
  to   { transform: translate3d(-11vmax, -5vmax, 0) scale(0.94); opacity: 1; }
}

@keyframes rakingSweep {
  from { transform: translate3d(-16%, 0, 0); opacity: 0.35; }
  50%  { opacity: 0.85; }
  to   { transform: translate3d(18%, 0, 0); opacity: 0.35; }
}

/* 03 — depth: a canvas of dust, light fragments and drifting veils moving at
   different speeds. The single most expensive layer, and the only one that
   runs on requestAnimationFrame. */
.atmo-canvas {
  position: absolute;
  inset: 0;
  z-index: var(--z-depth);
  width: 100%;
  height: 100%;
  opacity: var(--canvas-opacity, 1);
}

/* 04 — micro detail: grain, dust haze, faint scratches. */
.atmo-grain {
  position: fixed;
  inset: -120px;
  z-index: var(--z-micro);
  pointer-events: none;
  opacity: var(--grain-opacity);
  background-repeat: repeat;
  background-size: 180px 180px;
  mix-blend-mode: overlay;
  will-change: transform;
  animation: grainShift 1.1s steps(1, end) infinite;
}

@keyframes grainShift {
  0%   { transform: translate3d(0, 0, 0); }
  14%  { transform: translate3d(-14px, 9px, 0); }
  28%  { transform: translate3d(11px, -16px, 0); }
  42%  { transform: translate3d(-22px, -7px, 0); }
  56%  { transform: translate3d(17px, 13px, 0); }
  70%  { transform: translate3d(-9px, 21px, 0); }
  84%  { transform: translate3d(19px, -11px, 0); }
  100% { transform: translate3d(0, 0, 0); }
}

/* A second, much coarser grain pass reads as emulsion rather than static. */
.atmo-grain--coarse {
  background-size: 420px 420px;
  opacity: calc(var(--grain-opacity) * 0.5);
  mix-blend-mode: soft-light;
  animation-duration: 2.4s;
  animation-direction: reverse;
}

.atmo-scratches {
  position: fixed;
  /* Oversized so the composited translate never exposes an uncovered edge. */
  inset: -160px;
  z-index: var(--z-micro);
  pointer-events: none;
  opacity: var(--scratch-opacity, 0.32);
  mix-blend-mode: screen;
  background-image: var(--scratch-image, none);
  background-size: 900px 900px;
  animation: scratchShift 26s steps(6, end) infinite;
}

/* Position is animated with transform, not background-position: moving the
   texture is a compositor job, while moving background-position repainted the
   whole viewport-sized layer on every step. */
@keyframes scratchShift {
  0%   { transform: translate3d(0, 0, 0); }
  20%  { transform: translate3d(-140px, 60px, 0); }
  40%  { transform: translate3d(90px, -110px, 0); }
  60%  { transform: translate3d(-60px, 140px, 0); }
  80%  { transform: translate3d(130px, 40px, 0); }
  100% { transform: translate3d(0, 0, 0); }
}

/* 05 — directory response: a faint column of light that finds the name being
   explored, plus a global dim when the visitor commits to a person. */
.atmo-focus {
  position: fixed;
  inset: 0;
  z-index: var(--z-light);
  pointer-events: none;
  opacity: var(--focus-opacity, 0);
  transition: opacity var(--t-base) var(--ease-out-expo);
}

.atmo-focus__beam {
  position: absolute;
  top: -10vh;
  bottom: -10vh;
  left: 0;
  width: clamp(160px, 22vw, 340px);
  /* Travel is a compositor transform, never a layout change. Moving `left` here
     shifted a ~300x991 element and was the single largest source of Cumulative
     Layout Shift on the site (one move scored up to 0.27). The calc lands on
     exactly the same pixel: translateX(--focus-x) then pull back by half the
     beam's own width. */
  transform: translate3d(calc(var(--focus-x, 50vw) - 50%), 0, 0);
  background: radial-gradient(
    52% 120% at 50% 50%,
    rgba(228, 232, 242, 0.075) 0%,
    rgba(228, 232, 242, 0.05) 40%,
    rgba(228, 232, 242, 0.02) 66%,
    transparent 82%
  );
  mix-blend-mode: screen;
  opacity: var(--focus-opacity, 0);
  transition: opacity var(--t-base) var(--ease-out-expo);
}

.atmo-vignette {
  position: fixed;
  inset: 0;
  z-index: var(--z-light);
  pointer-events: none;
  opacity: var(--vignette-opacity, 0.66);
  background:
    radial-gradient(128% 108% at 50% 46%, transparent 34%, rgba(0, 0, 0, 0.52) 78%, rgba(0, 0, 0, 0.92) 100%);
}

/* The whole engine can be dimmed without unmounting it — used by the portal
   transition, which compresses the darkness instead of cutting to black. */
.atmosphere[data-dim='1'] .atmo-lights,
.atmosphere[data-dim='1'] .atmo-canvas {
  opacity: calc(var(--canvas-opacity, 1) * 0.25);
}

.atmosphere[data-dim='1'] .atmo-base::after { opacity: 1; }

/* While the world is dimmed — the portal into a profile, and every moment spent
   inside one — the ambient layers stop animating. Their drift is invisible
   behind the transition, and standing them down leaves the whole compositor
   budget to the transition itself, which is what keeps interaction responsive. */
.atmosphere[data-dim='1'] .atmo-light,
.atmosphere[data-dim='1'] .atmo-grain,
.atmosphere[data-dim='1'] .atmo-grain--coarse,
.atmosphere[data-dim='1'] .atmo-scratches {
  animation-play-state: paused;
}

.atmosphere[data-mode='negative-space'] .atmo-lights { opacity: calc(var(--light-intensity, 1) * 0.62); }
.atmosphere[data-mode='archive-scan'] .atmo-scratches { opacity: 0.55; }
.atmosphere[data-mode='photographic-ghosts'] .atmo-fragments { opacity: 0.12; }
.atmosphere[data-mode='liquid-silver'] .atmo-light[data-kind='key'] { opacity: 1.2; }
.atmosphere[data-mode='light-chamber'] .atmo-light { animation-duration: 48s; }

/* Weak machines keep the whole composition — dust, lights, depth, the cursor
   light — but stop paying for the full-screen passes that cost the most: the
   per-frame grain/scratch repaint. */
[data-tier='medium'] .atmo-grain,
[data-tier='medium'] .atmo-grain--coarse,
[data-tier='medium'] .atmo-scratches,
[data-tier='medium'] .atmo-light,
[data-tier='low'] .atmo-grain,
[data-tier='low'] .atmo-grain--coarse,
[data-tier='low'] .atmo-scratches {
  animation: none;
}

[data-tier='low'] .atmo-light {
  animation: none;
}

/* ------------------------------------------------------- compositing economy
   `screen` over this palette is a no-op the compositor still pays for: over a
   near-black backdrop screen(source, backdrop) ≈ source, so switching these
   layers to source-over changes the image by a measured mean of 0.7/255 with
   0.02% of pixels moving at all — but it removes one full-screen backdrop read
   per layer per frame. This is the single largest interaction-latency win in
   the atmosphere. The glows keep their colour, size and drift; only the blend
   accounting changes. */
.atmo-light,
.atmo-scratches,
.atmo-focus__beam {
  mix-blend-mode: normal;
}

/* On a CPU compositor the grain passes are the next-largest cost. They keep
   their texture and density; only the blend operand changes. */
[data-render='software'] .atmo-grain,
[data-render='software'] .atmo-grain--coarse {
  mix-blend-mode: normal;
  opacity: calc(var(--grain-opacity) * 0.6);
}

/* A CPU compositor gains nothing from holding every ambient layer on its own
   GPU layer — the promotion is pure overhead there. */
[data-render='software'] .atmo-fragments,
[data-render='software'] .atmo-lights,
[data-render='software'] .atmo-grain,
[data-render='software'] .atmo-scratches {
  will-change: auto;
}