.Grain__grain {
  position: fixed;
  inset: 0;
  z-index: 9999;
  pointer-events: none;
  overflow: hidden;
}

/**
 * The grain itself. Oversized well past the viewport on every side so the
 * frame-to-frame offset never drags an empty edge into view.
 */
.Grain__grain::before {
  content: '';
  position: absolute;
  inset: -120px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23g)'/%3E%3C/svg%3E");
  background-repeat: repeat;
  opacity: 0.04;

  /**
   * 4 discrete frames over 333ms = 12fps. Real film grain lives around
   * 12-24fps; updating every frame at 60 reads as digital sensor noise, and
   * costs a full-viewport repaint 60 times a second for the privilege.
   *
   * step-end holds each keyframe rather than interpolating, so the offsets
   * below are the only four states that ever render.
   */
  animation: grain-shift 333ms step-end infinite;
  will-change: transform;
}

/**
 * Offsets are deliberately not multiples of the 220px tile — a translation by
 * exactly one tile is invisible, since the pattern repeats into itself.
 */
@keyframes grain-shift {
  0% {
    transform: translate3d(0, 0, 0);
  }
  25% {
    transform: translate3d(-37px, 13px, 0);
  }
  50% {
    transform: translate3d(19px, -29px, 0);
  }
  75% {
    transform: translate3d(-43px, -7px, 0);
  }
}

/**
 * Vignette. Pulls the corners down so the eye settles centre-frame, the way a
 * fast lens falls off wide open.
 */
.Grain__grain::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(
    ellipse at center,
    transparent 45%,
    rgb(0 0 0 / 32%) 100%
  );
}

@media (prefers-reduced-motion: reduce) {
  /* The grain stays — it is texture, not motion — but stops moving. */
  .Grain__grain::before {
    animation: none;
    will-change: auto;
  }
}
