/* FOREVER:NEON — Shared Signal FX visual presets.
   Classes are toggled briefly by assets/fx/signal-fx.js and removed once
   the effect completes. Restrained by design: equipment reacts physically
   (a lamp warms, a relay pulses, a CRT rolls) rather than announcing
   itself with particle bursts or screen shake. Anomalous presets
   (mandela-drift, anomaly-flicker, registration-slip) stay brief enough
   that a player can reasonably wonder whether they actually saw them. */

.fn-fx-relay-pulse{ animation: fnFxRelayPulse .22s ease-out; }
@keyframes fnFxRelayPulse{
  0%{ filter:brightness(1); box-shadow:none; }
  30%{ filter:brightness(1.3); box-shadow:0 0 0 2px rgba(0,245,212,.3); }
  100%{ filter:brightness(1); box-shadow:none; }
}

.fn-fx-lamp-wake{ animation: fnFxLampWake .38s ease-out; }
@keyframes fnFxLampWake{
  0%{ filter:brightness(.45) saturate(.75); }
  100%{ filter:brightness(1) saturate(1); }
}

.fn-fx-crt-bloom{ animation: fnFxCrtBloom .22s ease-out; }
@keyframes fnFxCrtBloom{
  0%{ filter:brightness(1) contrast(1); }
  35%{ filter:brightness(1.45) contrast(1.08); }
  100%{ filter:brightness(1) contrast(1); }
}

.fn-fx-crt-roll{ animation: fnFxCrtRoll .32s steps(6); }
@keyframes fnFxCrtRoll{
  0%{ transform:translateY(0); }
  20%{ transform:translateY(2px); }
  40%{ transform:translateY(-2px); }
  60%{ transform:translateY(1px); }
  80%{ transform:translateY(-1px); }
  100%{ transform:translateY(0); }
}

.fn-fx-registration-slip{ animation: fnFxRegistrationSlip .14s steps(2); }
@keyframes fnFxRegistrationSlip{
  0%{ text-shadow:none; }
  50%{ text-shadow:1px 0 0 rgba(0,245,212,.5), -1px 0 0 rgba(255,62,165,.4); }
  100%{ text-shadow:none; }
}

.fn-fx-phosphor-persistence{ animation: fnFxPhosphorPersistence .52s ease-out; }
@keyframes fnFxPhosphorPersistence{
  0%{ filter:brightness(1.35); }
  100%{ filter:brightness(1); }
}

.fn-fx-signal-lock{ animation: fnFxSignalLock .22s ease-out; outline-offset:2px; }
@keyframes fnFxSignalLock{
  0%{ outline:2px solid transparent; }
  40%{ outline:2px solid rgba(0,245,212,.8); }
  100%{ outline:2px solid transparent; }
}

.fn-fx-reject-jitter{ animation: fnFxRejectJitter .16s steps(4); }
@keyframes fnFxRejectJitter{
  0%{ transform:translateX(0); }
  25%{ transform:translateX(-2px); }
  50%{ transform:translateX(2px); }
  75%{ transform:translateX(-1px); }
  100%{ transform:translateX(0); }
}

.fn-fx-mandela-drift{ animation: fnFxMandelaDrift .8s ease-in-out; }
@keyframes fnFxMandelaDrift{
  0%{ opacity:1; transform:translateX(0); }
  50%{ opacity:.94; transform:translateX(.5px); }
  100%{ opacity:1; transform:translateX(0); }
}

.fn-fx-archive-stamp{ animation: fnFxArchiveStamp .16s ease-out; }
@keyframes fnFxArchiveStamp{
  0%{ transform:scale(1); filter:none; }
  40%{ transform:scale(.98); filter:contrast(1.1); }
  100%{ transform:scale(1); filter:none; }
}

.fn-fx-anomaly-flicker{ animation: fnFxAnomalyFlicker .17s steps(2); }
@keyframes fnFxAnomalyFlicker{
  0%{ opacity:1; }
  45%{ opacity:.86; }
  70%{ opacity:1; }
  100%{ opacity:1; }
}

/* Reduced-motion variant: JS applies this alongside the effect class and
   caps the visible duration to ~80ms. No keyframe motion — the state that
   the sound cue represents still lands as a brief static highlight, it
   just doesn't move. */
.fn-fx-reduced{
  animation:none !important;
  transition:filter .06s linear, opacity .06s linear, outline-color .06s linear;
  filter:brightness(1.12);
}
.fn-fx-reduced.fn-fx-signal-lock{ filter:none; outline:2px solid rgba(0,245,212,.8); }
.fn-fx-reduced.fn-fx-reject-jitter{ filter:none; transform:none !important; outline:2px solid rgba(255,110,110,.55); }
.fn-fx-reduced.fn-fx-anomaly-flicker{ filter:none; opacity:.92; }
.fn-fx-reduced.fn-fx-crt-roll,
.fn-fx-reduced.fn-fx-mandela-drift{ transform:none !important; }

/* =========================================================================
   Second wave — visual language grammar (SIGNAL/HAM, MACHINE, LOOSH,
   SUBSTRATE/ANOMALY). Same doctrine as the engine comment in signal-fx.js:
   normal technology adds motion, anomalous technology changes the rules
   of the interface. Keep BREACH+ effects (contamination, disagreement,
   substrate, 03:17) reading as "the interface is wrong", not merely
   "louder than a button press". ========================================= */

/* ---- HAM RF-instrument states -------------------------------------- */
.fn-fx-receiver-wake{ animation: fnFxReceiverWake .22s ease-out; }
@keyframes fnFxReceiverWake{
  0%{ filter:brightness(.5); clip-path:inset(0 0 100% 0); }
  60%{ filter:brightness(1.15); clip-path:inset(0 0 0 0); }
  100%{ filter:brightness(1); clip-path:inset(0 0 0 0); }
}

/* Persistent (start/stop, not a fixed duration) — kept well under 3Hz
   and low-contrast so it reads as "still scanning", not a strobe. */
.fn-fx-receiver-search{ animation: fnFxReceiverSearch 1.4s ease-in-out infinite; }
@keyframes fnFxReceiverSearch{
  0%,100%{ box-shadow:0 0 0 0 rgba(0,245,212,0); filter:brightness(1); }
  50%{ box-shadow:0 0 0 2px rgba(0,245,212,.18); filter:brightness(1.03); }
}

/* Persistent Field Kit scan. The caller stops this as soon as its real
   analysis work settles; the animation never manufactures progress. */
.fn-fx-field-kit-scan{ outline:1px solid rgba(168,212,155,.28); }
.fn-fx-field-kit-scan .fk-scan-band{
  display:block;
  animation:fnFxFieldKitScan 1.25s linear infinite;
}
@keyframes fnFxFieldKitScan{
  0%{ top:5%; opacity:.25; }
  50%{ opacity:.75; }
  100%{ top:95%; opacity:.25; }
}
.fn-fx-field-kit-scan.fn-fx-reduced{
  filter:none;
  outline:2px solid rgba(168,212,155,.58);
}
.fn-fx-field-kit-scan.fn-fx-reduced .fk-scan-band,
html.fn-force-reduced-motion .fn-fx-field-kit-scan .fk-scan-band{
  display:none;
  animation:none!important;
}

.fn-fx-signal-loss{ animation: fnFxSignalLoss .16s steps(2); }
@keyframes fnFxSignalLoss{
  0%{ transform:translateX(0); filter:brightness(1) saturate(1); }
  40%{ transform:translateX(-1px); filter:brightness(.82) saturate(.7); }
  100%{ transform:translateX(0); filter:brightness(1) saturate(1); }
}

/* INTERFACE BREACH: the only preset allowed to visibly reach past its
   own box, and only by a very small margin. */
.fn-fx-carrier-hostile{ animation: fnFxCarrierHostile .3s steps(5); }
@keyframes fnFxCarrierHostile{
  0%{ filter:none; box-shadow:none; }
  20%{ filter:hue-rotate(-4deg) contrast(1.12); box-shadow:0 0 0 12px rgba(255,80,80,.05); }
  40%{ filter:hue-rotate(3deg) contrast(1.08); box-shadow:0 0 0 18px rgba(255,80,80,.04); }
  60%{ filter:hue-rotate(-2deg) contrast(1.1); box-shadow:0 0 0 14px rgba(255,80,80,.05); }
  100%{ filter:none; box-shadow:none; }
}

/* The opposite of corruption: this is what signal.lock also applies to
   itself (see STABILIZING_VISUALS in signal-fx.js) — a clean settle,
   never another layer of noise. */
.fn-fx-countertone-stabilize{ animation: fnFxCountertoneStabilize .2s ease-out; }
@keyframes fnFxCountertoneStabilize{
  0%{ filter:contrast(1) saturate(1); outline:2px solid transparent; }
  50%{ filter:contrast(1.03) saturate(1.02); outline:2px solid rgba(125,249,198,.55); }
  100%{ filter:contrast(1) saturate(1); outline:2px solid transparent; }
}

/* ---- unified puzzle feedback grammar --------------------------------
   ui.contact / ui.invalid / ui.partial deliberately form an ascending
   ladder of restraint below signal-lock: invalid input gets almost
   nothing, so random clicking is never rewarded with an interesting
   effect. */
.fn-fx-ui-contact{ animation: fnFxUiContact .12s ease-out; }
@keyframes fnFxUiContact{
  0%{ filter:brightness(1); }
  40%{ filter:brightness(1.25); }
  100%{ filter:brightness(1); }
}

.fn-fx-ui-invalid{ animation: fnFxUiInvalid .09s steps(2); }
@keyframes fnFxUiInvalid{
  0%{ outline:1px solid transparent; }
  50%{ outline:1px solid rgba(255,255,255,.18); }
  100%{ outline:1px solid transparent; }
}

.fn-fx-ui-partial{ animation: fnFxUiPartial .18s ease-out; }
@keyframes fnFxUiPartial{
  0%{ filter:contrast(1) brightness(1); }
  50%{ filter:contrast(1.05) brightness(1.06); }
  100%{ filter:contrast(1) brightness(1); }
}

/* ---- LOOSH: stored electrical/psychic pressure, not a mana bar ------
   Amplitude is driven from JS (loosh.gain's sublinear scale via
   FN.SignalFX.trigger('loosh.gain',{amount})); the CSS keyframe itself
   stays fixed and restrained so even a maxed-out gain never becomes a
   celebration animation. */
.fn-fx-loosh-gain{ animation: fnFxLooshGain .26s ease-out; }
@keyframes fnFxLooshGain{
  0%{ transform:scale(1); filter:brightness(1); }
  40%{ transform:scale(.985); filter:brightness(1.18); }
  100%{ transform:scale(1); filter:brightness(1); }
}

.fn-fx-loosh-spend{ animation: fnFxLooshSpend .22s ease-in; }
@keyframes fnFxLooshSpend{
  0%{ filter:brightness(1); }
  60%{ filter:brightness(.78); }
  100%{ filter:brightness(1); }
}

/* The memorable zero-state: glow dies from perimeter inward, system
   settles cold. No explosion, no shake, no full-screen flash — pair
   with FN.SignalFX.anomaly.persist(target) from the caller for a
   longer-lived afterimage than this one-shot keyframe carries alone. */
.fn-fx-loosh-collapse{ animation: fnFxLooshCollapse .52s ease-in; }
@keyframes fnFxLooshCollapse{
  0%{ filter:brightness(1); box-shadow:0 0 0 0 rgba(0,245,212,0); }
  55%{ filter:brightness(.5); box-shadow:inset 0 0 0 1px rgba(0,245,212,.15); }
  100%{ filter:brightness(1); box-shadow:0 0 0 0 rgba(0,245,212,0); }
}

/* Significant threshold crossing: a brief competing "second reading"
   that resolves back to one, hinting LOOSH is more than a counter
   without exposing the actual threshold. */
.fn-fx-loosh-threshold{ animation: fnFxLooshThreshold .26s steps(3); }
@keyframes fnFxLooshThreshold{
  0%{ text-shadow:none; }
  45%{ text-shadow:1px 0 0 rgba(0,245,212,.4), -1px 0 0 rgba(255,179,71,.35); }
  100%{ text-shadow:none; }
}

/* ---- Orange Star fragment FX ----------------------------------------- */
.fn-fx-fragment-trace{ animation: fnFxFragmentTrace .22s ease-out; }
@keyframes fnFxFragmentTrace{
  0%{ opacity:.35; filter:contrast(1.3) brightness(1.1); }
  100%{ opacity:1; filter:contrast(1) brightness(1); }
}

.fn-fx-fragment-stamp{ animation: fnFxFragmentStamp .2s ease-out; }
@keyframes fnFxFragmentStamp{
  0%{ transform:scale(1); filter:none; }
  35%{ transform:scale(.97); filter:contrast(1.12); }
  100%{ transform:scale(1); filter:none; }
}

/* ---- Substrate / anomaly primitives ----------------------------------
   Reusable building blocks (see FN.SignalFX.anomaly.* in signal-fx.js),
   not one-off animations — every mission-specific anomaly should compose
   from these rather than inventing new keyframes. */
.fn-fx-anomaly-contamination{ animation: fnFxAnomalyContamination .26s ease-in-out; }
@keyframes fnFxAnomalyContamination{
  0%{ filter:none; }
  50%{ filter:hue-rotate(8deg) saturate(1.3) contrast(1.06); }
  100%{ filter:none; }
}
.fn-fx-anomaly-contamination-bleed{ animation: fnFxAnomalyContaminationBleed .18s ease-out; }
@keyframes fnFxAnomalyContaminationBleed{
  0%{ box-shadow:none; }
  50%{ box-shadow:0 0 0 10px rgba(180,60,220,.05); }
  100%{ box-shadow:none; }
}

/* Two versions of the same geometry occupy slightly different
   positions, then one resolves — the ghost is aria-hidden and fades
   under the real element, so the real text is never the one that reads
   unreadable. */
.fn-fx-anomaly-disagreement-ghost{
  opacity:.45;
  transform:translate(2px,-1px);
  filter:hue-rotate(6deg);
  pointer-events:none;
  animation: fnFxAnomalyDisagreementGhost .11s linear forwards;
}
@keyframes fnFxAnomalyDisagreementGhost{
  0%{ opacity:.55; }
  100%{ opacity:0; }
}
.fn-fx-anomaly-disagreement-reduced{ animation: fnFxAnomalyDisagreementReduced .09s steps(1); }
@keyframes fnFxAnomalyDisagreementReduced{
  0%{ outline:1px solid transparent; }
  50%{ outline:1px dashed rgba(255,179,71,.5); }
  100%{ outline:1px solid transparent; }
}

/* The delayed artifact that makes a player notice an anomaly after the
   main event seems finished. Deliberately the longest-lived class in
   the kit — see MAX duration in VISUAL_DURATIONS. */
.fn-fx-anomaly-persistence{ animation: fnFxAnomalyPersistence 2.2s ease-out forwards; }
@keyframes fnFxAnomalyPersistence{
  0%{ box-shadow:inset 0 0 0 1px rgba(0,245,212,.22); }
  85%{ box-shadow:inset 0 0 0 1px rgba(0,245,212,.06); }
  100%{ box-shadow:none; }
}
/* orange-star variant: same timing/shape, recolored to the Orange Star
   signature so a persistence mark can echo that later payoff (e.g. the
   Reader Ch8 glitch bridge) without a one-off animation. */
.fn-fx-anomaly-persistence-orange-star{ animation-name: fnFxAnomalyPersistenceOrangeStar; }
@keyframes fnFxAnomalyPersistenceOrangeStar{
  0%{ box-shadow:inset 0 0 0 1px rgba(255,179,71,.28); }
  85%{ box-shadow:inset 0 0 0 1px rgba(255,179,71,.08); }
  100%{ box-shadow:none; }
}

/* Substrate intrusion goes too clean, not noisier — the contrasting
   register from ordinary signal corruption above. */
.fn-fx-anomaly-substrate{ animation: fnFxAnomalySubstrate .5s ease-in-out; }
@keyframes fnFxAnomalySubstrate{
  0%{ filter:none; letter-spacing:normal; }
  50%{ filter:contrast(1) saturate(.92) brightness(1.02); letter-spacing:.01em; }
  100%{ filter:none; letter-spacing:normal; }
}

/* One consistent, reproducible 03:17 signature — three-step luminance
   falloff, never a flash, never the literal text "03:17". */
.fn-fx-anomaly-0317{ animation: fnFxAnomaly0317 .26s steps(3); }
@keyframes fnFxAnomaly0317{
  0%{ filter:brightness(1); }
  33%{ filter:brightness(.75); }
  66%{ filter:brightness(.55); }
  100%{ filter:brightness(1); }
}

/* ---- reduced-motion variants for every second-wave class above ------
   Same contract as wave one: no keyframe motion, but the state the
   sound/animation represents still lands as a brief static cue. */
.fn-fx-reduced.fn-fx-receiver-wake{ filter:none; }
.fn-fx-reduced.fn-fx-receiver-search{ animation:none !important; box-shadow:0 0 0 2px rgba(0,245,212,.18); }
.fn-fx-reduced.fn-fx-signal-loss{ transform:none !important; filter:brightness(.85); }
.fn-fx-reduced.fn-fx-carrier-hostile{ filter:none; box-shadow:0 0 0 2px rgba(255,110,110,.3); }
.fn-fx-reduced.fn-fx-countertone-stabilize{ filter:none; outline:2px solid rgba(125,249,198,.55); }
.fn-fx-reduced.fn-fx-ui-contact{ filter:none; outline:1px solid rgba(0,245,212,.4); }
.fn-fx-reduced.fn-fx-ui-invalid{ outline:1px solid rgba(255,255,255,.25); }
.fn-fx-reduced.fn-fx-ui-partial{ filter:none; outline:1px solid rgba(0,245,212,.3); }
.fn-fx-reduced.fn-fx-loosh-gain{ transform:none !important; filter:brightness(1.1); }
.fn-fx-reduced.fn-fx-loosh-spend{ filter:brightness(.85); }
.fn-fx-reduced.fn-fx-loosh-collapse{ filter:brightness(.6); }
.fn-fx-reduced.fn-fx-loosh-threshold{ text-shadow:none; outline:1px solid rgba(255,179,71,.4); }
.fn-fx-reduced.fn-fx-fragment-trace{ opacity:1; filter:none; }
.fn-fx-reduced.fn-fx-fragment-stamp{ transform:none !important; filter:none; outline:1px solid rgba(0,245,212,.4); }
.fn-fx-reduced.fn-fx-anomaly-contamination{ filter:none; outline:1px solid rgba(180,60,220,.4); }
.fn-fx-reduced .fn-fx-anomaly-disagreement-ghost{ display:none; }
.fn-fx-reduced.fn-fx-anomaly-persistence{ animation:none !important; box-shadow:inset 0 0 0 1px rgba(0,245,212,.15); }
.fn-fx-reduced.fn-fx-anomaly-persistence-orange-star{ box-shadow:inset 0 0 0 1px rgba(255,179,71,.18); }
.fn-fx-reduced.fn-fx-anomaly-substrate{ filter:none; letter-spacing:.01em; }
.fn-fx-reduced.fn-fx-anomaly-0317{ filter:brightness(.7); }

/* Defense in depth: even if a page loads this stylesheet without the JS
   reduced-motion check running yet, the OS-level query still neutralizes
   every fn-fx-* animation, matching the floor set in
   fn-adaptive-foundation.css for .fn-adaptive-foundation. */
@media (prefers-reduced-motion: reduce){
  [class*="fn-fx-"]{
    animation-duration:.001ms !important;
    animation-iteration-count:1 !important;
    transition-duration:.001ms !important;
  }
}

/* ---- redacted precision marker ---------------------------------- */
.fn-redacted-fx{
  background:rgba(0,245,212,.12);
  color:#00f5d4;
  padding:1px 6px;
  border-radius:3px;
  cursor:pointer;
  letter-spacing:.5px;
  transition:background .15s;
}
.fn-redacted-fx:hover,.fn-redacted-fx:focus-visible{background:rgba(0,245,212,.22)}
.fn-redacted-fx:focus-visible{outline:1px solid #00f5d4;outline-offset:1px}
@media(prefers-reduced-motion:reduce){.fn-redacted-fx{transition:none}}
