/* ══════════════════════════════════════════════════════════════
   Keln — film grain

   The star field renders in perfectly clean vector space, and
   perfectly clean is the one thing real space photography never
   is. Every image we have of space arrived through an optic and
   onto a sensor or an emulsion, and both leave grain. Its absence
   is why a synthetic star field reads as a screensaver even when
   the stars themselves are right.

   So: one fixed layer over the whole page, carrying two things —
   moving monochrome noise, and a very weak vignette.

   ══ WHY IT IS AN SVG FILTER AND NOT A PNG ═══════════════════
   A noise tile large enough not to visibly repeat is a heavy
   image; a small one repeats and the repeat is legible against a
   dark ground, which is worse than no grain at all. feTurbulence
   with stitchTiles generates the tile in the browser, seamlessly,
   at zero transfer cost, and desaturates in the same filter so no
   colour enters the palette.

   ══ WHY IT MOVES ═══════════════════════════════════════════
   Static grain reads as a texture laid ON the picture. Moving
   grain reads as film — it is the movement, not the noise, that
   the eye recognises. Six discrete steps, because film grain
   jumps frame to frame rather than sliding.

   The movement is a transform on an oversized layer, never a
   background-position: transform stays on the compositor, while
   animating background-position repaints the whole viewport six
   times a second. Same picture, completely different cost.

   ══ WHY IT SITS ABOVE THE TEXT ══════════════════════════════
   Grain that stops at the edge of the content reads as a
   background effect and gives away that the page is a stack of
   layers. At this opacity it is far below the threshold where it
   costs any legibility, and carrying it across everything is what
   makes the whole surface feel like one photographed thing.

   ══ STRUCTURE ══════════════════════════════════════════════
   Three elements rather than one, because the noise and the
   vignette need DIFFERENT opacities and different geometry:

     .grain          the viewport, exactly. No opacity of its own.
     .grain::before  the noise. Oversized, so the jitter transform
                     can never drag an edge into frame, and held
                     at 0.055.
     .grain::after   the vignette. Viewport-aligned, because a
                     vignette that is offset from the frame it is
                     vignetting is just a smudge.

   Putting all three on one element was the first attempt and it
   does not work: opacity is clamped to 1, so there is no way for a
   child to be more opaque than its parent. The vignette rendered
   at the noise's 0.055 and was invisible.
   ══════════════════════════════════════════════════════════════ */

.grain {
  position: fixed;
  inset: 0;

  /* Above every surface on the page, and below the two things that
     must stay clean: modals and error overlays sit at 9000, the
     custom cursor at 9998. Grain across a dialog would be
     harmless but graining the thing a person is reading in order
     to make a decision is the one place the texture stops paying
     for itself. */
  z-index: 8900;

  /* Grain belongs to the surface, not to any element on it, so it
     must never intercept a click. */
  pointer-events: none;

  /* The noise layer is deliberately larger than this box; clipping
     it here keeps it from ever affecting document scroll. */
  overflow: hidden;
}

.grain::before {
  content: "";
  position: absolute;

  /* Oversized on every side so the animated transform can never
     drag an edge into frame. The step distance is around a dozen
     pixels; this is several times the room that needs. */
  inset: -60px;

  opacity: 0.055;

  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-repeat: repeat;

  /* The tile is generated at 220px and drawn at 220px. Scaling it
     would resample the noise into something smoother and blotchier
     — grain has to be rendered at its own resolution or it stops
     being grain. */
  background-size: 220px 220px;

  will-change: transform;
  animation: grain-jitter 0.9s steps(1) infinite;
}

/* Six positions, each held for one step. The offsets are
   deliberately not multiples of the 220px tile — landing on the
   tile pitch would return the identical picture and the grain
   would appear to stand still. */
@keyframes grain-jitter {
  0%   { transform: translate3d(0,      0,     0); }
  16%  { transform: translate3d(-7px,   4px,   0); }
  33%  { transform: translate3d(5px,   -9px,   0); }
  50%  { transform: translate3d(-11px, -3px,   0); }
  66%  { transform: translate3d(8px,    7px,   0); }
  83%  { transform: translate3d(-4px,  -11px,  0); }
  100% { transform: translate3d(0,      0,     0); }
}

/* Reduced motion refuses the movement, not the texture. The grain
   is part of how the page looks; only its jitter is motion. */
@media (prefers-reduced-motion: reduce) {
  .grain::before { animation: none; }
}

/* Every real optic falls off toward the corners, and without that
   a full-bleed star field reads as wallpaper that happens to end
   where the screen does, rather than as a photograph of somewhere.

   Deliberately weak. A vignette you can point at is too strong;
   this one should only be obvious when you toggle it off. */
.grain::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    ellipse 78% 74% at 50% 46%,
    rgba(0, 0, 0, 0) 52%,
    rgba(0, 0, 0, 0.30) 100%
  );
}
