/* ===================================================================
   NOVIXX — Ambient Light Effects (additive layer, Motion & Light Plan R9)
   Purely presentational. css/main.css and every .html file are NOT
   modified by this file — it is loaded in addition to main.css.

   Master gate: <html> only carries .fx-on when JS ran AND the user has
   not requested reduced motion. Every rule below that shows a new visual
   (not just its animation) is scoped under html.fx-on, so with JS off,
   or with prefers-reduced-motion, the page renders as the plain design
   that shipped before this round — nothing new to re-verify in that
   state. This is stricter than the hard constraints require (they only
   demand animation stop under reduced motion, not that the visuals
   disappear), but it collapses "JS-off" and "reduced-motion" into one
   already-safe fallback instead of two separate states to reason about.
   =================================================================== */

/* === 0. REDUCED MOTION / PRINT SAFETY NET ===
   main.css already forces `*{animation:none!important;transition:none!important}`
   under reduced motion, which alone stops every @keyframes below — this
   is a second, file-local guarantee so effects.css keeps working even if
   read in isolation. Belt and braces, not a substitute for the fx-on gate. */
@media (prefers-reduced-motion: reduce) {
  .fx,
  .fx__orb,
  .fx__grid,
  .btn--primary::before,
  .btn--secondary::before,
  .cta-band::before,
  .section--navy::before,
  .card::before,
  .panel::before,
  .hero::before {
    animation: none !important;
    transition: none !important;
  }
}

@media print {
  .fx,
  .btn--primary::before,
  .btn--secondary::before,
  .cta-band::before,
  .section--navy::before,
  .card::before,
  .panel::before,
  .hero::before {
    display: none !important;
  }

  html.fx-on .section--alt {
    background: var(--color-surface-alt) !important;
  }
}

/* === 1. TOKENS ===
   Raw "r,g,b" triplets (not full color values) so every glow can share
   one rgba(var(--fx-c-x), alpha) call at whatever alpha the surface
   needs — orbs and rings reuse the same three hues at very different
   strengths. --fx-angle is a registered custom property so the button
   ring's rotation can be smoothly animated (plain custom properties
   can't be interpolated by the animation engine without this). */
:root {
  --fx-c-primary: 20, 87, 217;   /* == --color-primary, #1457D9 */
  --fx-c-cyan: 34, 211, 238;     /* accent — cool counterpoint to the brand blue */
  --fx-c-violet: 109, 95, 224;   /* accent — kept desaturated on purpose, see §2 */
}

@property --fx-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

/* No @supports guard needed for the fallback: browsers that don't know
   @property simply drop this whole at-rule and treat --fx-angle as an
   ordinary custom property. The conic-gradients below all read it via
   var(--fx-angle, 0deg), so they still paint a full gradient — just one
   that can't be smoothly tweened, i.e. a static ring, never a black or
   missing button. */

/* === 2. GLOBAL AMBIENT LAYER (.fx) ===
   Fixed behind everything (z-index:-1), so it only becomes visible
   through the ~78 transparent .section elements and the translucent
   .section--alt in §4 — .cta-band / .section--navy are opaque and get
   their own inner glow in §3 instead. overflow:hidden is what actually
   guarantees no horizontal scroll here, not the drift amplitude below. */
/* display:none, not opacity:0, when the gate is off. opacity < 1 creates a
   stacking context and a compositing layer even at zero, and measured by
   full-page pixel diff that was enough to flip text from subpixel to grayscale
   antialiasing across the whole site — the "effects off" page was NOT identical
   to the pre-round design. With display:none it now is, byte for byte. */
.fx {
  display: none;
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

html.fx-on .fx {
  display: block;
}

/* No filter: blur() here, deliberately. Measured on a 1440x900 viewport, a
   blur(70px) on these orbs tripled median frame time (6.9ms baseline -> 27ms)
   because the blurred layer is re-rasterised as the element animates. The
   multi-stop gradients below reproduce the same haze for free: at these alphas
   a smooth falloff is visually indistinguishable from a blur, and it rasterises
   once. Size matters for the same reason — fill rate scales with area, so these
   are deliberately ~35vmax rather than the 60vmax that first looked right.
   Measured result of both changes together: back to ~7ms, i.e. baseline. */
.fx__orb {
  position: absolute;
  border-radius: 50%;
}

.fx__orb--a {
  width: 38vmax;
  height: 38vmax;
  left: -8vmax;
  top: -6vmax;
  background: radial-gradient(circle,
    rgba(var(--fx-c-primary), .17) 0%,
    rgba(var(--fx-c-primary), .10) 30%,
    rgba(var(--fx-c-primary), .035) 55%,
    rgba(var(--fx-c-primary), 0) 75%);
}

.fx__orb--b {
  width: 42vmax;
  height: 42vmax;
  right: -12vmax;
  top: 24vmax;
  background: radial-gradient(circle,
    rgba(var(--fx-c-cyan), .14) 0%,
    rgba(var(--fx-c-cyan), .08) 30%,
    rgba(var(--fx-c-cyan), .03) 55%,
    rgba(var(--fx-c-cyan), 0) 75%);
}

.fx__orb--c {
  width: 34vmax;
  height: 34vmax;
  left: 20vmax;
  bottom: -14vmax;
  /* Violet is the one hue on this site with no existing token (brief
     forbids purple in UI chrome) — kept to the lowest alpha of the
     three orbs so it reads as a cool shadow tint, never a purple cast. */
  background: radial-gradient(circle,
    rgba(var(--fx-c-violet), .12) 0%,
    rgba(var(--fx-c-violet), .07) 30%,
    rgba(var(--fx-c-violet), .025) 55%,
    rgba(var(--fx-c-violet), 0) 75%);
}

/* will-change is NOT redundant here, despite a running compositor animation
   normally being enough to promote an element. Measured best-of-3 across
   index/solutions/quick-check: 37 long frames with it, 57 without. These are
   animations on pseudo-elements, which is the case where implicit promotion is
   least reliable. Kept deliberately; re-measure before removing. */
html.fx-on .fx__orb--a {
  will-change: transform;
  animation: fx-drift-a 32s ease-in-out infinite;
}

html.fx-on .fx__orb--b {
  will-change: transform;
  animation: fx-drift-b 38s ease-in-out infinite;
  animation-delay: -9s; /* independent phase so the three orbs never move in lockstep */
}

html.fx-on .fx__orb--c {
  will-change: transform;
  animation: fx-drift-c 26s ease-in-out infinite;
  animation-delay: -17s;
}

/* Every drift below is translate-only. Scaling a large composited layer forces
   a re-raster each frame — measured across three pages, scale took long frames
   from 79 to 99 — and a 5-8% size pulse over half a minute is not perceptible
   anyway. The drift is what the eye reads. */
@keyframes fx-drift-a {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(5%, -4%); }
}

@keyframes fx-drift-b {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(-6%, 5%); }
}

@keyframes fx-drift-c {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(4%, 6%); }
}

/* Faint 1px grid, radially masked so it reads as a texture fading into
   the page rather than a hard-edged panel. Static — the brief only
   asks the orbs to drift, and a moving grid would compete with them. */
.fx__grid {
  position: absolute;
  inset: 0;
  background-image:
    repeating-linear-gradient(to right, rgba(11, 31, 58, 1) 0 1px, transparent 1px 40px),
    repeating-linear-gradient(to bottom, rgba(11, 31, 58, 1) 0 1px, transparent 1px 40px);
  mask-image: radial-gradient(circle at center, #000 0%, transparent 70%);
  -webkit-mask-image: radial-gradient(circle at center, #000 0%, transparent 70%);
  opacity: 0;
}

html.fx-on .fx__grid {
  opacity: .05;
}

/* === 3. DARK-SECTION GLOW (.cta-band, .section--navy) ===
   Highest-value part of the round: glow reads premium on a dark
   surface. Both hosts need position+numeric z-index (not just
   position:relative) — without a real z-index, `.cta-band` never
   becomes its own stacking context, so its ::before (z-index:-1) would
   be compared against the WHOLE document's stacking order instead of
   just this section, and could end up rendered behind the section's
   own background, i.e. invisible. isolation:isolate is the same fix
   again for browsers that don't respect z-index:0 on a non-positioned
   context the same way. With that in place, ::before (z-index:-1)
   still sits in front of the section's own background but behind every
   plain in-flow child (the title, text, journey stages, etc. are all
   non-positioned, so they paint after negative-z-index siblings by
   spec) — content stays legible without needing extra z-index rules
   on .container. */
html.fx-on .cta-band,
html.fx-on .section--navy {
  position: relative;
  z-index: 0;
  isolation: isolate;
  overflow: hidden; /* contains the oversized, drifting ::before — never the page */
}

html.fx-on .cta-band::before,
html.fx-on .section--navy::before {
  will-change: transform;
  content: '';
  position: absolute;
  inset: -15%;
  z-index: -1;
  pointer-events: none;
  opacity: 0;
}

/* .cta-band's own background is already a bright, medium-blue gradient
   (#1457D9 → #0B46B6), so white text there starts at only ~6.2:1 —
   comfortable, but far less headroom than .section--navy's near-black
   #0B1F3A (~16.6:1). Alphas here are kept deliberately low and the
   anchors pushed toward the outer edges, away from the centred
   .cta-band__grid text column, so even the gradient's hottest point
   never lands under the headline. Computed worst case (peak cyan
   alpha grazing the text column edge) stays close to 6:1 — re-check
   in the browser contrast sweep regardless, this is the thinner of
   the two margins in this file. */
html.fx-on .cta-band::before {
  opacity: 1;
  background:
    radial-gradient(40% 50% at 8% 20%, rgba(var(--fx-c-cyan), .06), transparent 70%),
    radial-gradient(46% 56% at 92% 80%, rgba(var(--fx-c-primary), .18), transparent 70%);
  animation: fx-glow-drift 28s ease-in-out infinite;
}

/* .section--navy has a large contrast margin (#0B1F3A base, white and
   #C7D3E4 text), so this can afford to be the bolder of the two —
   the brief specifically asks for this to be "genuinely striking". */
html.fx-on .section--navy::before {
  opacity: 1;
  background:
    radial-gradient(45% 55% at 12% 30%, rgba(var(--fx-c-cyan), .14), transparent 70%),
    radial-gradient(50% 60% at 82% 68%, rgba(var(--fx-c-primary), .32), transparent 70%);
  animation: fx-glow-drift 34s ease-in-out infinite;
}

@keyframes fx-glow-drift {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(-3%, 4%); }
}

/* === 4. LIGHT-SECTION LIFT (.section--alt) ===
   The single riskiest rule in this file: --color-text-muted (#5B6A80)
   is tuned to exactly 4.5:1 against a SOLID #F1F5F9 (main.css §2).
   Any translucency here has to be small enough that the worst-case
   pixel — .section--alt directly over the brightest point of an .fx
   orb — still can't move the effective background far from #F1F5F9.
   Worked worst case: orb peak alpha .07 of #1457D9 over the page
   background #FAFBFC composites to ~#EAF0FA; blending THAT under
   .section--alt at 97% opacity (i.e. this rule) lands within ~1
   channel value of #F1F5F9 itself — the two source colours are close
   enough that the blend barely moves. That leaves the existing 5.02:1
   (measured, main.css §2) essentially untouched, nowhere near the
   4.5:1 floor. Still: re-measure this pair after implementation as
   the plan's verification gate requires — the math above assumes the
   orb alphas in §2 aren't increased later without re-checking this. */
html.fx-on .section--alt {
  background: rgba(241, 245, 249, .97); /* == --color-surface-alt at 97% */
}

/* === 5. BUTTON LIGHT RING (.btn--primary, .btn--secondary) ===
   .btn is display:inline-flex, border-radius:var(--radius-md), with an
   existing `border:1px solid transparent` — exactly the hook this kind
   of gradient-ring technique expects. Same stacking-context issue as
   §3: .btn has no position/z-index of its own, so without the rule
   below its ::before (z-index:-1) would escape to the document root
   instead of sitting locally behind the label — on .btn--primary this
   would flicker in and out of view depending on whatever else happens
   to share that root-level stacking position on a given page.
   position:relative + z-index:0 pins it to the button itself. */
html.fx-on .btn--primary,
html.fx-on .btn--secondary {
  position: relative;
  z-index: 0;
}

html.fx-on .btn--secondary::before {
  content: '';
  position: absolute;
  inset: -2px;
  z-index: -1;
  /* +2px so the ring reads as a halo concentric with the button, not a
     tighter or looser radius than the box it wraps. */
  border-radius: calc(var(--radius-md) + 2px);
  padding: 2px; /* ring thickness */
  background: conic-gradient(
    from var(--fx-angle, 0deg),
    transparent 0deg,
    rgba(var(--fx-c-cyan), .95) 25deg,
    rgba(var(--fx-c-primary), 1) 95deg,
    transparent 150deg,
    transparent 210deg,
    rgba(var(--fx-c-primary), 1) 265deg,
    rgba(var(--fx-c-cyan), .95) 335deg,
    transparent 360deg
  );
  /* Double-layer mask + xor/exclude cuts the centre out of the ring
     regardless of the button's own background — this is what keeps
     .btn--secondary (transparent background) from showing the full
     gradient square behind its label; only the 2px band survives.
     Both the unprefixed and -webkit- forms are required: Firefox has
     no -webkit-mask at all (it would silently drop a webkit-only
     rule and paint the unmasked square), Safari still needs the
     prefixed form. */
  /* Prefixed FIRST, standard LAST — order is load-bearing, not cosmetic.
     Firefox aliases -webkit-mask to the mask shorthand, so declaring the
     shorthand after mask-composite resets compositing to its initial `add`;
     -webkit-mask-composite is then unknown to Firefox and dropped, leaving
     `add` — a union that masks nothing. Verified in Firefox 151 before the
     fix: computed mask-composite was `add, add` and the full conic square
     painted behind the label, navy text on bright cyan. With this order the
     standard longhand lands last and wins. */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
  opacity: 0;
}

/* Primary is the main call to action, so its ring runs continuously.
   It does NOT use the --fx-angle technique above: animating a registered
   custom property that feeds a conic-gradient repaints the whole gradient
   every frame. Measured on index.html at 1440x900 — 20.3ms median frame
   time with it running vs 7.0ms with the same animation paused, making it
   the single most expensive rule in this file. Spinning a *static* gradient
   with transform is compositor-only and costs nothing, so primary instead
   rotates an oversized square of gradient that the button clips to its own
   rounded rect, with ::after re-filling the centre so only a thin band of
   moving light survives at the edge. */
html.fx-on .btn--primary {
  overflow: hidden;
}

html.fx-on .btn--primary::before {
  will-change: transform;
  content: '';
  position: absolute;
  z-index: -2;
  top: 50%;
  left: 50%;
  /* Square, wide enough that its inscribed circle still covers the button's
     corners at any aspect ratio. Margins are -half the width: percentage
     margins resolve against the containing block's width on every side, so
     -120% is exactly half of 240% vertically too. */
  width: 240%;
  aspect-ratio: 1;
  margin: -120% 0 0 -120%;
  opacity: 1;
  background: conic-gradient(
    transparent 0deg,
    rgba(var(--fx-c-cyan), .9) 30deg,
    #FFFFFF 70deg,
    rgba(var(--fx-c-cyan), .9) 110deg,
    transparent 160deg,
    transparent 360deg
  );
  animation: fx-spin 6s linear infinite;
  pointer-events: none;
}

/* Re-fills the middle so the gradient only shows as a 2px edge band. Must
   track the button's own background, hover included, or the fill would
   visibly disagree with the button under the cursor. */
html.fx-on .btn--primary::after {
  content: '';
  position: absolute;
  z-index: -1;
  inset: 2px;
  /* padding-box radius is 10px - 1px border = 9px; inset 2px inside that
     makes the geometrically correct inner radius 7px. Using 9px made the
     light band visibly wider at the corners than along the edges. */
  border-radius: calc(var(--radius-md) - 3px);
  background: var(--color-primary);
  transition: background var(--dur-fast) var(--ease-out);
  pointer-events: none;
}

html.fx-on .btn--primary:hover::after {
  background: var(--color-primary-hover);
}

/* A disabled button must not look alive. The quick-check "Next" starts disabled
   and stays that way until an option is picked, so without this a ring of light
   keeps sweeping around a control that does nothing — and keeps a compositor
   animation running on every visit to that page. */
html.fx-on .btn:disabled::before,
html.fx-on .btn[disabled]::before {
  animation: none;
  opacity: 0;
}

@keyframes fx-spin {
  to { transform: rotate(1turn); }
}

/* Secondary buttons are far more numerous per page, so the ring — and
   its animation — only exists on hover/focus, per the brief's GPU-cost
   bound. */
html.fx-on .btn--secondary:not(:disabled):hover::before,
html.fx-on .btn--secondary:not(:disabled):focus-visible::before {
  opacity: 1;
  animation: fx-rotate 8s linear infinite;
}

@keyframes fx-rotate {
  to { --fx-angle: 360deg; }
}


/* === 7. PAUSE WHEN NOT VISIBLE ===
   The plan required animations to stop off-screen and in a hidden tab; without
   this a CTA ring kept spinning 4000px below the fold for the whole visit.
   js/effects.js marks hosts with .fx-outside via IntersectionObserver and sets
   .fx-hidden on <html> from visibilitychange. animation-play-state is a
   compositor-level pause — no re-layout, no re-raster, and the animation
   resumes at the same phase rather than jumping. */
html.fx-hidden *,
.fx-outside::before,
.fx-outside::after {
  animation-play-state: paused !important;
}

/* === 8. DISABLED BUTTONS ===
   Specificity note: the secondary hover rule is (0,3,2) and so was the earlier
   form of this guard, so source order alone decided it — hovering a disabled
   secondary button would have restarted the ring. :not(:disabled) on the hover
   rules removes the tie entirely rather than relying on ordering. */

/* === 6. CARD / PANEL SPOTLIGHT ===
   Same stacking-context fix as §3/§5: neither .card nor .panel has its
   own position/z-index in main.css, so both need it here before their
   ::before (z-index:-1) can be trusted to sit behind the label/text
   and in front of the card's own background rather than escaping to
   the page root. overflow:hidden clips the spotlight to the card's
   own border-radius; neither class has content that relies on
   overflowing its box. Pointer-only: touch has no hover, so there is
   nothing to drive --fx-x/--fx-y with on those devices. */
@media (hover: hover) and (pointer: fine) {
  html.fx-on .card,
  html.fx-on .panel {
    position: relative;
    z-index: 0;
    overflow: hidden;
  }

  html.fx-on .card::before,
  html.fx-on .panel::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background: radial-gradient(
      360px circle at var(--fx-x, 50%) var(--fx-y, 50%),
      rgba(var(--fx-c-primary), .08),
      transparent 68%
    );
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--dur) var(--ease-out);
  }

  html.fx-on .card:hover::before,
  html.fx-on .panel:hover::before {
    opacity: 1;
  }
}

/* === 7. HERO BEAM ===
   Subtle by design: the h1 is the LCP element and must stay crisp, so
   this only ever touches the space BEHIND the copy column, never the
   text itself, and .hero already carries overflow:hidden (main.css
   §10) so the oversized beam box can never contribute to page overflow.
   .hero has position:relative already but no z-index — same fix as
   above, needed so ::before (z-index:-1) stays scoped to .hero instead
   of the document root. */
html.fx-on .hero {
  z-index: 0;
}

html.fx-on .hero::before {
  will-change: transform;
  content: '';
  position: absolute;
  left: -10%;
  top: -20%;
  width: 60%;
  height: 140%;
  z-index: -1;
  pointer-events: none;
  /* No filter: blur() — same finding as the orbs in §2. A blurred layer is
     re-rasterised as it animates; measured, this one rule accounted for 21 of
     the 34 long frames on index.html. The multi-stop falloff below is the blur,
     baked into the gradient, and costs nothing to move. */
  background: radial-gradient(closest-side,
    rgba(var(--fx-c-primary), .10) 0%,
    rgba(var(--fx-c-primary), .06) 32%,
    rgba(var(--fx-c-primary), .02) 56%,
    transparent 78%);
  opacity: 0;
}

html.fx-on .hero::before {
  opacity: 1;
  animation: fx-beam-drift 24s ease-in-out infinite;
}

@keyframes fx-beam-drift {
  0%, 100% { transform: translate(0, 0); }
  50%      { transform: translate(2%, -2%); }
}
