/* ==========================================================================
   NightLANP animation
   --------------------------------------------------------------------------
   EVERYTHING TUNABLE LIVES IN THE :root BLOCK BELOW. Nothing below that block
   contains a magic number - the keyframes and rules only ever read variables.

   Timing was measured off logo.mov frame by frame (tools/measure_timing.py),
   not estimated. The measured values are noted beside each phase.

   How the timeline works
   ----------------------
   Every animated element declares its own duration and its own start time:

       animation-delay: calc(var(--np-reveal-start));

   so all phase timing lives in the variables above, never in keyframe
   percentages. Nothing coordinates the elements at runtime - they simply share
   one clock, started together when .np-play is added.

   Production playback is therefore pure CSS: an IntersectionObserver adds
   .np-play when the card scrolls into view and that is the whole engine.

   Debug mode layers the Web Animations API on top - getAnimations() plus
   currentTime gives frame-exact scrubbing for comparing against logo.mov.
   WAAPI is used ONLY there, so the production path never depends on it.

   The wordmark is NOT animated. It is a separate static SVG
   (nightlanp-logo.svg) whose only theme-dependent value is "Night".
   ========================================================================== */

:root {
  /* ---- master ------------------------------------------------------------ */
  --np-cycle: 3120ms;         /* total duration; MOV is 3105ms              */
  --np-play: paused;          /* flipped to `running` by the .np-play class */
  --np-ambient-play: paused;  /* flipped by .np-visible while on screen     */

  /* ---- palette (IBM colourblind-safe, read from the PPTX) ---------------- */
  --np-purple:  #785EF0;
  --np-green:   #7DDEB2;
  --np-magenta: #DC267F;
  --np-orange:  #FE6100;
  --np-yellow:  #FFB000;
  --np-brown:   #5F3331;

  /* Transparent so the artwork sits on whatever the page background is.
     Set a colour here if you ever want an opaque backdrop. */
  --np-bg: transparent;

  /* ---- wordmark colours (deck uses slightly different values to the plot) */
  --np-wm-night: #000000;     /* the one value that flips with the theme    */
  --np-wm-l:     #7CDEB2;
  --np-wm-a:     #DC257E;
  --np-wm-n:     #FE6000;
  --np-wm-p:     #FFD200;

  /* ---- lamp -------------------------------------------------------------- */
  --np-lamp-shade: #058B8C;   /* teal shade interior                        */
  --np-bulb-off:   #A2A2A2;   /* bulb, unlit                                */
  --np-bulb-on:    #FFE491;   /* bulb, lit                                  */
  --np-lamp-dx:    0px;       /* nudge the whole lamp                       */
  --np-lamp-dy:    0px;
  --np-lamp-scale: 1;         /* about the lamp's own base                  */

  /* ---- plot -------------------------------------------------------------- */
  --np-band-opacity: 0.10;    /* matches the deck exactly                   */
  --np-curve-width:  4;

  /* ---- layout ------------------------------------------------------------ */
  /* Each SVG's proportions. These matter because the markup is now mounted by
     script into an empty placeholder: without them the card would be zero-high
     until the bundle ran, then jolt into place. Regenerated by build_svg.py
     from the actual viewBoxes, so they cannot drift from the artwork. */
  /* >>> generated by tools/build_svg.py - do not edit by hand */
  --np-ratio:      2.0052;
  --np-logo-ratio: 5.0853;
  /* <<< end generated */

  /* Wordmark width relative to the animation, preserving the proportion it
     had in the original composition (measured: 51.6%). */
  --np-logo-width: 52%;
  --np-logo-gap:   0.6em;

  /* ---- phase: bulb lights   (measured: lit by 480ms) --------------------- */
  --np-bulb-start: 440ms;
  --np-bulb-dur:    80ms;

  /* ---- phase: rays fan out  (measured: 625ms start, 800ms full) ---------- */
  --np-ray-start:   620ms;
  --np-ray-dur:     100ms;
  --np-ray-stagger:  20ms;

  /* ---- phase: plot reveal   (measured: linear, 0.506 units/ms) ----------- */
  --np-reveal-start: 620ms;
  --np-reveal-dur:  2050ms;
  --np-reveal-from: 781.4px;  /* left edge of the matplotlib axes           */
  --np-reveal-to:  1819.1px;  /* right edge + feather, so the wipe clears   */

  /* ---- ambient idle (after the sequence; the card stays quietly alive) --- */
  --np-ambient-start:        2700ms;
  --np-ray-flicker-dur:      2600ms;
  --np-ray-flicker-stagger:   180ms;
  --np-ray-flicker-min:       0.72;
  --np-band-breathe-dur:     4200ms;
  --np-band-breathe-stagger:  260ms;
  --np-band-breathe-peak:     0.145;

  /* ---- hover scrubbing ---------------------------------------------------- */
  /* Once the one-shot has played, pointer x across the figure drives the
     timeline. Only applies where a real hover exists (not touch). */
  --np-scrub-smooth: 0.2;     /* 0..1 per frame; lower = laggier, smoother  */
  --np-scrub-return: 450ms;   /* glide back to the end frame on pointer-out */

  /* ---- easing ------------------------------------------------------------ */
  --np-ease-ray:    cubic-bezier(0.2, 0.75, 0.3, 1);
  --np-ease-reveal: linear;       /* the MOV's wipe is genuinely linear     */
}

/* ==========================================================================
   Dark theme
   --------------------------------------------------------------------------
   Two selectors carry the same values, and they are NOT redundant:

     .np-theme-dark   set by animation.js from the page's EFFECTIVE theme.
                      sidchaini.github.io applies dark mode by writing
                      --background-color on <html>, and its moon-icon toggle
                      never touches prefers-color-scheme - so a media query
                      alone would leave a black wordmark on a dark page.

     @media …dark     the no-JS / standalone fallback.

   .np-theme-light is emitted by the script too, so a manual switch to LIGHT
   on a dark system still wins over the media query.

   Bands are lightened rather than left at 10%: over a dark background a
   low-alpha saturated fill darkens into a muddy olive smear instead of reading
   as a soft halo. Mixing toward white makes them add light, which is what an
   uncertainty band should do.
   ========================================================================== */

:root.np-theme-dark {
  --np-wm-night: #FFFFFF;
  --np-band-opacity: 0.20;
  --np-band-lighten: 50%;
  --np-band-breathe-peak: 0.26;
}

@media (prefers-color-scheme: dark) {
  :root:not(.np-theme-light) {
    --np-wm-night: #FFFFFF;
    --np-band-opacity: 0.20;
    --np-band-lighten: 50%;
    --np-band-breathe-peak: 0.26;
  }
}

/* --np-band-lighten is defined ONLY in the dark blocks above. On a light theme
   it is missing, the 100% fallback applies, and color-mix returns the plain
   curve colour - so these six lines are inert in light mode. */
:root.np-theme-dark,
:root:not(.np-theme-light) {
  --np-band-purple:  color-mix(in srgb, var(--np-purple)  var(--np-band-lighten, 100%), #fff);
  --np-band-green:   color-mix(in srgb, var(--np-green)   var(--np-band-lighten, 100%), #fff);
  --np-band-magenta: color-mix(in srgb, var(--np-magenta) var(--np-band-lighten, 100%), #fff);
  --np-band-orange:  color-mix(in srgb, var(--np-orange)  var(--np-band-lighten, 100%), #fff);
  --np-band-yellow:  color-mix(in srgb, var(--np-yellow)  var(--np-band-lighten, 100%), #fff);
  --np-band-brown:   color-mix(in srgb, var(--np-brown)   var(--np-band-lighten, 100%), #fff);
}

/* ==========================================================================
   Host elements
   ========================================================================== */

/* Wrapper holding the animation with the wordmark beneath it. */
.np-figure {
  display: block;
  width: 100%;
}

.np-stage {
  display: block;
  width: 100%;
  aspect-ratio: var(--np-ratio);
  background: var(--np-bg);
}

.np-stage > svg {
  display: block;
  width: 100%;
  height: auto;
}

.np-logo {
  display: block;
  width: var(--np-logo-width);
  aspect-ratio: var(--np-logo-ratio);
  margin: var(--np-logo-gap) auto 0;
}

.np-logo > svg {
  display: block;
  width: 100%;
  height: auto;
}

/* Running state is inherited as a custom property, because
   animation-play-state itself does not inherit. */
.np-stage.np-play { --np-play: running; }

/* The ambient loop runs only while the card is actually on screen. It animates
   fill-opacity, a paint-level property that repaints every frame - no reason
   to spend that on a card the reader has scrolled past. The observer toggles
   this class in both directions. */
.np-stage.np-visible { --np-ambient-play: running; }

/* ==========================================================================
   Layer 1 - lamp
   ========================================================================== */

.np-lamp {
  transform: translate(var(--np-lamp-dx), var(--np-lamp-dy))
             scale(var(--np-lamp-scale));
  transform-box: fill-box;
  transform-origin: 50% 100%;    /* scale about the lamp's base             */
}

.np-bulb {
  animation: np-bulb-on var(--np-bulb-dur) steps(1, end) both;
  animation-delay: calc(var(--np-bulb-start));
  animation-play-state: var(--np-play);
}

@keyframes np-bulb-on {
  from { fill: var(--np-bulb-off); }
  to   { fill: var(--np-bulb-on); }
}

/* ==========================================================================
   Layer 2 - light rays

   Each bar grows out of its lamp-side edge. transform-box:fill-box makes
   `left center` mean the bar's own inner end, whatever its rotation.
   ========================================================================== */

.np-ray-bar {
  transform-box: fill-box;
  transform-origin: left center;
  animation:
    np-ray-grow var(--np-ray-dur) var(--np-ease-ray) both,
    np-ray-flicker var(--np-ray-flicker-dur) ease-in-out infinite;
  animation-delay:
    calc(var(--np-ray-start) + var(--i) * var(--np-ray-stagger)),
    calc(var(--np-ambient-start) + var(--i) * var(--np-ray-flicker-stagger));
  animation-play-state: var(--np-play), var(--np-ambient-play);
}

@keyframes np-ray-grow {
  from { transform: scaleX(0); opacity: 0; }
  to   { transform: scaleX(1); opacity: 1; }
}

/* Ambient flicker uses fill-opacity, NOT opacity, so it cannot collide with
   the grow animation's opacity track. */
@keyframes np-ray-flicker {
  0%, 100% { fill-opacity: 1; }
  50%      { fill-opacity: var(--np-ray-flicker-min); }
}

/* ==========================================================================
   Layer 3 - observations

   Static for the whole cycle: the MOV shows all 39 points from frame 0.
   ========================================================================== */

/* (intentionally unanimated - see note above) */

/* ==========================================================================
   Layer 4 - plot reveal

   A gradient-edged mask, not a clipPath: the MOV's wipe fades over ~180 units
   rather than cutting hard. The mask rect carries an objectBoundingBox
   gradient, so translating the rect drags the soft edge along with it.
   ========================================================================== */

.np-reveal-rect {
  animation: np-reveal var(--np-reveal-dur) var(--np-ease-reveal) both;
  animation-delay: calc(var(--np-reveal-start));
  animation-play-state: var(--np-play);
}

@keyframes np-reveal {
  from { transform: translateX(var(--np-reveal-from)); }
  to   { transform: translateX(var(--np-reveal-to)); }
}

.np-band {
  animation: np-band-breathe var(--np-band-breathe-dur) ease-in-out infinite;
  animation-delay: calc(var(--np-ambient-start)
                        + var(--i) * var(--np-band-breathe-stagger));
  animation-play-state: var(--np-ambient-play);
}

@keyframes np-band-breathe {
  0%, 100% { fill-opacity: var(--np-band-opacity); }
  50%      { fill-opacity: var(--np-band-breathe-peak); }
}

/* ==========================================================================
   Accessibility - reduced motion / static

   Show the finished composition with nothing moving. `.np-static` is applied
   by the script when it decides not to animate; the media query does the same
   for users who ask the OS for reduced motion.
   ========================================================================== */

.np-stage.np-static :is(.np-bulb, .np-ray-bar, .np-reveal-rect, .np-band) {
  animation: none !important;
}

.np-stage.np-static .np-bulb    { fill: var(--np-bulb-on); }
.np-stage.np-static .np-ray-bar { opacity: 1; transform: none; }

/* Park the wipe past the right edge so the plot reads as complete. */
.np-stage.np-static .np-reveal-rect {
  transform: translateX(var(--np-reveal-to));
}

@media (prefers-reduced-motion: reduce) {
  .np-stage :is(.np-bulb, .np-ray-bar, .np-reveal-rect, .np-band) {
    animation: none !important;
  }

  .np-stage .np-bulb        { fill: var(--np-bulb-on); }
  .np-stage .np-ray-bar     { opacity: 1; transform: none; }
  .np-stage .np-reveal-rect { transform: translateX(var(--np-reveal-to)); }
}
