/* =============================================================================
   NOVESTRA MOTION  ·  v1.0.0
   A dependency-free motion layer for ProSteel Innovation web programs.

   Companion to Style_Lab.html (the 36 style skins). Style Lab decides how a
   surface LOOKS; this decides how it MOVES. The two are orthogonal on purpose:
   nothing in here sets colour, background or radius unless the colour IS the
   effect, so a motion class can be dropped onto any skin without a fight.

   Usage (script WITHOUT defer, so .nv-js lands before first paint):
     <script src="motion/novestra-motion.js"></script>
     <link rel="stylesheet" href="motion/novestra-motion.css">
   Then markup-only:
     <div data-nv-reveal="up">...</div>

   Everything animates transform / opacity / filter only, so it composites on
   the GPU and holds 60fps on the shop-floor tablets. Every effect collapses
   under prefers-reduced-motion (section 7, last in the file so it wins).

   Prefix: nv-  ·  Attributes: data-nv-*  ·  No build step. No dependencies.
   ============================================================================= */

/* ====================== 0 · TOKENS ====================== */
/* Retune a whole program by overriding these on :root — never by forking. */

@property --nv-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

:root {
  /* Duration ladder. Pick by intent, not by taste:
     1 press/hover · 2 toggle/tooltip · 3 reveal/modal · 4 section · 5 showpiece */
  --nv-dur-1: 120ms;
  --nv-dur-2: 200ms;
  --nv-dur-3: 320ms;
  --nv-dur-4: 520ms;
  --nv-dur-5: 900ms;

  /* Easing. `out` is the workhorse: fast start, soft landing — reads as
     responsive because the pixel moves before the eye can question it. */
  --nv-ease-out: cubic-bezier(.22, 1, .36, 1);
  --nv-ease-in: cubic-bezier(.64, 0, .78, 0);
  --nv-ease-inout: cubic-bezier(.65, 0, .35, 1);
  --nv-ease-snap: cubic-bezier(.34, 1.56, .64, 1); /* overshoots — use sparingly */
  --nv-ease-linear: linear;

  /* Travel distance for entrances. Small is credible; large reads as a slide show. */
  --nv-dist: 14px;
  --nv-dist-lg: 34px;

  /* Stagger step between siblings. 40-70ms is the readable band. */
  --nv-stagger: 55ms;

  /* Accent — the only colour token. Defaults to a neutral so an un-themed
     page still looks deliberate. Override per program. */
  --nv-accent: #ff5e2b;
  --nv-ok: #16a34a;
  --nv-warn: #d97706;
  --nv-err: #dc2626;

  /* Ambient surfaces */
  --nv-ink: #15161a;
  --nv-veil: rgba(255, 255, 255, .06);
}

/* Utility: opt a subtree out of all motion without touching prefers-reduced-motion */
[data-nv-static],
[data-nv-static] * {
  animation: none !important;
  transition: none !important;
}


/* ====================== 1 · ENTRANCE & REVEAL ====================== */
/* Driven by novestra-motion.js via IntersectionObserver: the element starts in
   its "out" state and gets .nv-in when it crosses into view (once, by default).
   No JS, a blocked script, or an init that throws? Then `.nv-js` is absent,
   or every reveal target gets released anyway. A failed script must never
   leave the page blank. */

[data-nv-reveal] {
  will-change: transform, opacity, filter;
}

/* The out-states. Each is the inverse of where the element lands.
   Scoped to .nv-js — which novestra-motion.js writes onto <html> on its very
   first line — so a blocked, failed or absent script leaves every element
   plainly visible instead of an invisible page. A broken enhancement must
   never be able to hide content. */
.nv-js [data-nv-reveal] {
  opacity: 0;
  transition:
    opacity var(--nv-dur-4) var(--nv-ease-out) var(--nv-delay, 0ms),
    transform var(--nv-dur-4) var(--nv-ease-out) var(--nv-delay, 0ms),
    filter var(--nv-dur-4) var(--nv-ease-out) var(--nv-delay, 0ms),
    clip-path var(--nv-dur-4) var(--nv-ease-out) var(--nv-delay, 0ms);
}

.nv-js [data-nv-reveal="up"]      { transform: translate3d(0, var(--nv-dist), 0); }
.nv-js [data-nv-reveal="down"]    { transform: translate3d(0, calc(var(--nv-dist) * -1), 0); }
.nv-js [data-nv-reveal="left"]    { transform: translate3d(var(--nv-dist), 0, 0); }
.nv-js [data-nv-reveal="right"]   { transform: translate3d(calc(var(--nv-dist) * -1), 0, 0); }
.nv-js [data-nv-reveal="scale"]   { transform: scale(.92); }
.nv-js [data-nv-reveal="pop"]     { transform: scale(.86); transition-timing-function: var(--nv-ease-snap); }
.nv-js [data-nv-reveal="blur"]    { filter: blur(12px); transform: translate3d(0, 8px, 0); }
.nv-js [data-nv-reveal="wipe"]    { clip-path: inset(0 0 100% 0); opacity: 1; }
.nv-js [data-nv-reveal="wipe-x"]  { clip-path: inset(0 100% 0 0); opacity: 1; }
.nv-js [data-nv-reveal="flip"]    { transform: perspective(900px) rotateX(-14deg) translate3d(0, 10px, 0); transform-origin: 50% 100%; }
.nv-js [data-nv-reveal="rise"]    { transform: translate3d(0, var(--nv-dist-lg), 0) scale(.97); }

/* The landed state — one rule retires every out-state above. */
[data-nv-reveal].nv-in {
  opacity: 1;
  transform: none;
  filter: none;
  clip-path: inset(0 0 0 0);
}

/* Stagger. JS writes --nv-i on each child; the delay is index x step, so adding
   a row to a table needs no re-wiring. */
[data-nv-stagger] > * {
  --nv-delay: calc(var(--nv-i, 0) * var(--nv-stagger));
}

/* Text split reveals (JS wraps words / chars in <span class="nv-unit">) */
.nv-split {
  display: inline;
}
.nv-split .nv-unit {
  display: inline-block;
  opacity: 0;
  transform: translate3d(0, .5em, 0);
  transition:
    opacity var(--nv-dur-3) var(--nv-ease-out) var(--nv-delay, 0ms),
    transform var(--nv-dur-3) var(--nv-ease-out) var(--nv-delay, 0ms),
    filter var(--nv-dur-3) var(--nv-ease-out) var(--nv-delay, 0ms);
  white-space: pre;
}
.nv-split[data-nv-split-style="blur"] .nv-unit { filter: blur(6px); transform: none; }
.nv-split[data-nv-split-style="drop"] .nv-unit { transform: translate3d(0, -.45em, 0); }
.nv-split.nv-in .nv-unit {
  opacity: 1;
  transform: none;
  filter: none;
}

/* Typewriter. The caret is a pseudo-element so the typed text stays selectable. */
.nv-type {
  display: inline-block;
}
.nv-type::after {
  content: "";
  display: inline-block;
  width: .08em;
  min-width: 1px;
  height: 1.05em;
  margin-left: .06em;
  background: currentColor;
  vertical-align: text-bottom;
  animation: nv-caret 1.06s steps(1, end) infinite;
}
.nv-type[data-nv-done]::after { animation: none; opacity: 0; }
@keyframes nv-caret { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }

/* Split-flap board. Each cell holds two faces; JS flips the top face down.
   Reads as mechanical because the two halves are lit differently. */
.nv-flap {
  display: inline-flex;
  gap: .12em;
  font-variant-numeric: tabular-nums;
  perspective: 420px;
}
.nv-flap-cell {
  position: relative;
  display: inline-block;
  min-width: .72em;
  text-align: center;
  background: #16181d;
  color: #f2f3f5;
  border-radius: .1em;
  padding: .1em .06em;
  overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07);
}
.nv-flap-cell::after { /* the hinge line */
  content: "";
  position: absolute;
  inset: 50% 0 auto 0;
  height: 1px;
  background: rgba(0, 0, 0, .55);
  z-index: 3;
}
.nv-flap-face {
  display: block;
  backface-visibility: hidden;
}
.nv-flap-cell.nv-flip .nv-flap-face {
  animation: nv-flap-turn 190ms var(--nv-ease-inout);
  transform-origin: 50% 50%;
}
@keyframes nv-flap-turn {
  0%   { transform: rotateX(0deg);   filter: brightness(1) }
  49%  { transform: rotateX(-88deg); filter: brightness(.45) }
  51%  { transform: rotateX(88deg);  filter: brightness(.45) }
  100% { transform: rotateX(0deg);   filter: brightness(1) }
}

/* Count-up needs no CSS beyond tabular figures — JS writes the text. Without
   this the number jitters horizontally as digit widths change. */
[data-nv-count] {
  font-variant-numeric: tabular-nums;
}


/* ====================== 2 · STATE & FEEDBACK ====================== */
/* The tier that actually makes internal software feel alive: the app telling
   you it heard you. Short, literal, non-decorative. */

/* Attention pulse — an expanding ring, drawn outside the box so it never
   reflows the layout it is pointing at. */
.nv-pulse {
  position: relative;
}
.nv-pulse::after {
  content: "";
  position: absolute;
  inset: -2px;
  border-radius: inherit;
  border: 2px solid var(--nv-accent);
  animation: nv-pulse-ring 1.5s var(--nv-ease-out) infinite;
  pointer-events: none;
}
@keyframes nv-pulse-ring {
  0%   { transform: scale(1);    opacity: .75 }
  70%  { transform: scale(1.14); opacity: 0 }
  100% { transform: scale(1.14); opacity: 0 }
}

/* Save / fail flash. Fires once on a row, cell or field. This is the single
   highest-value animation in a data-entry app: it answers "did that land?"
   without a toast, without a reload, without the user asking. */
.nv-flash-ok,
.nv-flash-err,
.nv-flash-warn {
  animation: nv-flash var(--nv-dur-5) var(--nv-ease-out) 1;
}
.nv-flash-ok   { --nv-flash-tint: var(--nv-ok) }
.nv-flash-err  { --nv-flash-tint: var(--nv-err) }
.nv-flash-warn { --nv-flash-tint: var(--nv-warn) }
@keyframes nv-flash {
  0%   { background-color: color-mix(in srgb, var(--nv-flash-tint) 34%, transparent) }
  100% { background-color: transparent }
}

/* Invalid input. Horizontal, decaying, ~400ms — long enough to read as "no",
   short enough not to feel punitive. */
.nv-shake {
  animation: nv-shake 420ms var(--nv-ease-inout) 1;
}
@keyframes nv-shake {
  0%, 100% { transform: translateX(0) }
  15%      { transform: translateX(-7px) }
  30%      { transform: translateX(6px) }
  45%      { transform: translateX(-4px) }
  60%      { transform: translateX(3px) }
  80%      { transform: translateX(-1.5px) }
}

/* Stateful button: idle -> loading -> success. JS swaps data-nv-state; the
   button keeps its width throughout so the layout never jumps. */
[data-nv-btn] {
  position: relative;
  overflow: hidden;
  transition: transform var(--nv-dur-1) var(--nv-ease-out),
              filter var(--nv-dur-2) var(--nv-ease-out);
}
[data-nv-btn]:active { transform: scale(.975) }
[data-nv-btn] .nv-btn-label {
  display: inline-flex;
  align-items: center;
  gap: .5em;
  transition: opacity var(--nv-dur-2) var(--nv-ease-out),
              transform var(--nv-dur-2) var(--nv-ease-out);
}
[data-nv-btn][data-nv-state="loading"] .nv-btn-label,
[data-nv-btn][data-nv-state="success"] .nv-btn-label {
  opacity: 0;
  transform: translateY(-120%);
}
[data-nv-btn] .nv-btn-swap {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  opacity: 0;
  transform: translateY(120%);
  transition: opacity var(--nv-dur-2) var(--nv-ease-out),
              transform var(--nv-dur-2) var(--nv-ease-out);
  pointer-events: none;
}
[data-nv-btn][data-nv-state="loading"] .nv-btn-swap[data-nv-for="loading"],
[data-nv-btn][data-nv-state="success"] .nv-btn-swap[data-nv-for="success"] {
  opacity: 1;
  transform: none;
}

/* Hold-to-confirm. A liquid fill rises while pressed and snaps back on an
   early release — so a destructive action needs sustained intent, not a
   lucky click. Pair it with anything that deletes inventory. */
[data-nv-hold] {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  touch-action: none;
}
[data-nv-hold]::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--nv-accent);
  transform: translateY(100%);
  transition: transform var(--nv-hold-ms, 1200ms) var(--nv-ease-linear);
}
[data-nv-hold][data-nv-holding]::before { transform: translateY(0) }
[data-nv-hold]:not([data-nv-holding])::before {
  transition: transform var(--nv-dur-3) var(--nv-ease-out); /* fast snap-back */
}
[data-nv-hold][data-nv-state="confirmed"] {
  animation: nv-hold-pop var(--nv-dur-3) var(--nv-ease-snap) 1;
}
@keyframes nv-hold-pop {
  0% { transform: scale(1) } 45% { transform: scale(1.05) } 100% { transform: scale(1) }
}

/* Toast stack. Slides in from the trailing edge, collapses its own height on
   the way out so the stack closes smoothly instead of snapping. */
.nv-toasts {
  position: fixed;
  z-index: 9999;
  inset-block-end: 20px;
  inset-inline-end: 20px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  pointer-events: none;
}
.nv-toast {
  pointer-events: auto;
  animation: nv-toast-in var(--nv-dur-3) var(--nv-ease-snap) 1;
}
.nv-toast[data-nv-leaving] {
  animation: nv-toast-out var(--nv-dur-3) var(--nv-ease-in) 1 forwards;
}
@keyframes nv-toast-in {
  from { opacity: 0; transform: translate3d(24px, 0, 0) scale(.96) }
  to   { opacity: 1; transform: none }
}
@keyframes nv-toast-out {
  from { opacity: 1; transform: none; margin-block-start: 0 }
  to   { opacity: 0; transform: translate3d(24px, 0, 0) scale(.96) }
}

/* Determinate progress with a travelling sheen. The sheen is what says
   "still working" when the percentage sits still for a few seconds. */
.nv-progress {
  position: relative;
  overflow: hidden;
  border-radius: 999px;
}
.nv-progress > span {
  display: block;
  height: 100%;
  width: var(--nv-value, 0%);
  background: var(--nv-accent);
  border-radius: inherit;
  transition: width var(--nv-dur-4) var(--nv-ease-out);
}
.nv-progress > span::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .42), transparent);
  animation: nv-sheen 1.5s var(--nv-ease-inout) infinite;
}
@keyframes nv-sheen {
  from { transform: translateX(-100%) }
  to   { transform: translateX(100%) }
}

/* Indeterminate bar — for work with no known total. */
.nv-progress[data-nv-indeterminate] > span {
  width: 38%;
  animation: nv-indet 1.35s var(--nv-ease-inout) infinite;
}
@keyframes nv-indet {
  0%   { transform: translateX(-110%) }
  100% { transform: translateX(300%) }
}

/* Skeleton shimmer. Uses a moving gradient rather than a pulsing opacity,
   because a sweep implies "arriving" and a pulse implies "broken". */
.nv-skeleton {
  position: relative;
  overflow: hidden;
  background: color-mix(in srgb, currentColor 11%, transparent);
  border-radius: 6px;
  color: inherit;
}
.nv-skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, currentColor 13%, transparent), transparent);
  animation: nv-sheen 1.4s var(--nv-ease-inout) infinite;
}
.nv-skeleton[data-nv-lines] { height: .78em; margin-block: .38em }
.nv-skeleton[data-nv-lines]:nth-child(3n) { width: 72% }
.nv-skeleton[data-nv-lines]:nth-child(3n+2) { width: 91% }

/* Orbit spinner — one element, two counter-rotating arcs. */
.nv-spin {
  width: 1.15em;
  height: 1.15em;
  border-radius: 50%;
  border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
  border-top-color: currentColor;
  animation: nv-rotate 720ms var(--nv-ease-linear) infinite;
  display: inline-block;
}
@keyframes nv-rotate { to { transform: rotate(360deg) } }

/* Bouncing dots — reads friendlier than a spinner for short waits. */
.nv-dots {
  display: inline-flex;
  gap: .28em;
  align-items: center;
}
.nv-dots i {
  width: .34em;
  height: .34em;
  border-radius: 50%;
  background: currentColor;
  animation: nv-dot 1.05s var(--nv-ease-inout) infinite;
}
.nv-dots i:nth-child(2) { animation-delay: .13s }
.nv-dots i:nth-child(3) { animation-delay: .26s }
@keyframes nv-dot {
  0%, 60%, 100% { transform: translateY(0); opacity: .45 }
  30%           { transform: translateY(-.38em); opacity: 1 }
}

/* Lattice — a phase-offset grid beside a live verb. Built for agent status
   rows (BrainDrop, Rivet, the hive): it shows "thinking" without a fake
   percentage, which is the honest way to report unknown remaining work. */
.nv-lattice {
  display: inline-grid;
  grid-template-columns: repeat(var(--nv-lattice-n, 3), 1fr);
  gap: 2px;
  vertical-align: middle;
}
.nv-lattice i {
  width: 4px;
  height: 4px;
  border-radius: 1px;
  background: currentColor;
  opacity: .2;
  animation: nv-lattice-wave 1.6s var(--nv-ease-inout) infinite;
  animation-delay: calc(var(--nv-i, 0) * 90ms);
}
@keyframes nv-lattice-wave {
  0%, 70%, 100% { opacity: .18; transform: scale(.85) }
  35%           { opacity: 1;   transform: scale(1) }
}

/* Slosh gauge. A tank whose liquid chases the value with mass: it overshoots,
   tilts with its own velocity, and the surface wave keeps moving after the
   number settles. Literally a warehouse fill level. JS drives --nv-fill,
   --nv-tilt and --nv-wobble. */
.nv-gauge {
  position: relative;
  overflow: hidden;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 8%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 16%, transparent);
  isolation: isolate;
}
.nv-gauge-liquid {
  position: absolute;
  inset-inline: -18%;
  inset-block-end: 0;
  height: calc(var(--nv-fill, 0) * 1%);
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--nv-accent) 88%, white 12%),
    var(--nv-accent));
  transform: rotate(var(--nv-tilt, 0deg));
  transform-origin: 50% 100%;
  transition: height 90ms var(--nv-ease-linear), transform 140ms var(--nv-ease-out);
}
/* Two surface crests at different periods, so there is no visible loop.
   Each is a SHORT strip carrying a horizontally tiled dome, translated by
   exactly one tile width — so the loop is seamless and the strip can never
   be taller than the crest it draws.

   Two mechanisms were tried and rejected, and the reason is worth keeping:
   rotating a thin ellipse sweeps a hard diagonal across the tank like a
   blade, and rotating a large square floods the whole tank above the surface
   with its own body. A tiled translate has neither failure mode because its
   height is bounded and it never rotates. */
.nv-gauge-liquid::before,
.nv-gauge-liquid::after {
  content: "";
  position: absolute;
  inset-inline: -12%;
  inset-block-start: -8px;
  height: 16px;
  --nv-tile: 34px;
  background-repeat: repeat-x;
  background-position: 0 100%;
  background-size: var(--nv-tile) 100%;
  background-image: radial-gradient(
    ellipse calc(var(--nv-tile) / 2) 100% at 50% 100%,
    color-mix(in srgb, var(--nv-accent) 88%, white 12%) 0 99%, transparent 100%);
  animation: nv-slosh 2.9s var(--nv-ease-linear) infinite;
}
.nv-gauge-liquid::after {
  --nv-tile: 53px;
  inset-block-start: -5px;
  height: 11px;
  animation-duration: 4.7s;
  animation-direction: reverse;
  opacity: .55;
}
@keyframes nv-slosh {
  from { transform: translateX(0) }
  to   { transform: translateX(var(--nv-tile)) }
}

/* Multi-step loader — for long jobs with named phases (a BOM import, an
   estimate rebuild). Naming the step is what stops a user reloading. */
.nv-steps { display: flex; flex-direction: column; gap: .55em }
.nv-step {
  display: flex;
  align-items: center;
  gap: .6em;
  opacity: .38;
  transform: translateX(-4px);
  transition: opacity var(--nv-dur-3) var(--nv-ease-out),
              transform var(--nv-dur-3) var(--nv-ease-out);
}
.nv-step[data-nv-state="active"] { opacity: 1; transform: none }
.nv-step[data-nv-state="done"]   { opacity: .7; transform: none }
.nv-step .nv-step-dot {
  width: .6em;
  height: .6em;
  border-radius: 50%;
  background: currentColor;
  flex: none;
  transition: background var(--nv-dur-2) var(--nv-ease-out),
              transform var(--nv-dur-2) var(--nv-ease-snap);
}
.nv-step[data-nv-state="active"] .nv-step-dot {
  background: var(--nv-accent);
  transform: scale(1.35);
  animation: nv-pulse-dot 1.2s var(--nv-ease-inout) infinite;
}
.nv-step[data-nv-state="done"] .nv-step-dot { background: var(--nv-ok) }
@keyframes nv-pulse-dot {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--nv-accent) 55%, transparent) }
  60%      { box-shadow: 0 0 0 .42em transparent }
}


/* ====================== 3 · HOVER & POINTER ====================== */

/* Lift — the baseline card hover. Translate plus a shadow bloom; never scale
   a card that contains text, because the glyphs resample and go blurry. */
.nv-lift {
  transition: transform var(--nv-dur-2) var(--nv-ease-out),
              box-shadow var(--nv-dur-2) var(--nv-ease-out);
}
.nv-lift:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 28px -10px rgba(0, 0, 0, .35);
}

/* Press — for anything tappable on the floor tablets. */
.nv-press { transition: transform var(--nv-dur-1) var(--nv-ease-out) }
.nv-press:active { transform: scale(.96) }

/* Glare — a specular band crossing the surface on hover. */
.nv-glare {
  position: relative;
  overflow: hidden;
  isolation: isolate;
}
.nv-glare::after {
  content: "";
  position: absolute;
  inset: -40% -120%;
  background: linear-gradient(105deg,
    transparent 38%,
    rgba(255, 255, 255, .42) 48%,
    rgba(255, 255, 255, .55) 50%,
    rgba(255, 255, 255, .42) 52%,
    transparent 62%);
  transform: translateX(-70%);
  transition: transform var(--nv-dur-5) var(--nv-ease-inout);
  pointer-events: none;
}
.nv-glare:hover::after { transform: translateX(70%) }

/* Spotlight — a radial wash that tracks the cursor. JS writes --nv-mx/--nv-my.
   The wash is masked to the element, so it lights content without washing it out. */
.nv-spotlight {
  position: relative;
  isolation: isolate;
}
.nv-spotlight::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(
    circle var(--nv-spot-r, 210px) at var(--nv-mx, 50%) var(--nv-my, 50%),
    color-mix(in srgb, var(--nv-accent) 26%, transparent),
    transparent 72%);
  opacity: 0;
  transition: opacity var(--nv-dur-3) var(--nv-ease-out);
  pointer-events: none;
}
.nv-spotlight:hover::before { opacity: 1 }

/* Border glow — a mesh-gradient rim that brightens on the edge the cursor is
   nearest. This is the React Bits BorderGlow idea rebuilt without React:
   one masked pseudo-element, no canvas. */
.nv-border-glow {
  position: relative;
  isolation: isolate;
}
.nv-border-glow::before {
  content: "";
  position: absolute;
  inset: calc(var(--nv-rim, 1.5px) * -1);
  border-radius: inherit;
  padding: var(--nv-rim, 1.5px);
  background:
    radial-gradient(circle 160px at var(--nv-mx, 50%) var(--nv-my, 50%),
      color-mix(in srgb, var(--nv-accent) 95%, white 5%), transparent 65%),
    conic-gradient(from var(--nv-angle),
      color-mix(in srgb, var(--nv-accent) 45%, transparent),
      transparent 25%, transparent 75%,
      color-mix(in srgb, var(--nv-accent) 45%, transparent));
  /* the mask is what turns a filled rect into a 1.5px rim */
  -webkit-mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  opacity: var(--nv-glow-o, .35);
  transition: opacity var(--nv-dur-3) var(--nv-ease-out);
  pointer-events: none;
}
.nv-border-glow:hover::before { --nv-glow-o: 1 }

/* Running border — a conic highlight orbiting the rim. Good for "this is the
   live one" on a dashboard tile. Needs @property (section 0) to animate. */
.nv-border-run::before {
  animation: nv-spin-angle 4s var(--nv-ease-linear) infinite;
}
@keyframes nv-spin-angle { to { --nv-angle: 360deg } }

/* Electric border — jittering arcs. Deliberately restless; use on one thing. */
.nv-border-electric {
  position: relative;
  isolation: isolate;
}
.nv-border-electric::before,
.nv-border-electric::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  border: 1.5px solid var(--nv-accent);
  filter: blur(.4px);
  opacity: .9;
  animation: nv-arc 1.7s var(--nv-ease-inout) infinite;
  pointer-events: none;
}
.nv-border-electric::after {
  animation-delay: -.62s;
  filter: blur(3px);
  opacity: .5;
}
@keyframes nv-arc {
  0%   { clip-path: inset(0 0 68% 0) }
  25%  { clip-path: inset(0 0 0 70%) }
  50%  { clip-path: inset(70% 0 0 0) }
  75%  { clip-path: inset(0 68% 0 0) }
  100% { clip-path: inset(0 0 68% 0) }
}

/* Star border — twinkling sparks orbiting the edge. */
.nv-border-star {
  position: relative;
  isolation: isolate;
  overflow: hidden;
}
.nv-border-star::before,
.nv-border-star::after {
  content: "";
  position: absolute;
  width: 55%;
  height: 2px;
  border-radius: 999px;
  background: radial-gradient(circle, var(--nv-accent), transparent 70%);
  pointer-events: none;
}
.nv-border-star::before { inset-block-start: -1px; animation: nv-star-x 3.2s var(--nv-ease-linear) infinite }
.nv-border-star::after  { inset-block-end: -1px;  animation: nv-star-x 3.2s var(--nv-ease-linear) infinite reverse }
@keyframes nv-star-x {
  from { transform: translateX(-100%) } to { transform: translateX(220%) }
}

/* 3D tilt — perspective follow. JS writes --nv-rx/--nv-ry from pointer offset.
   Children with data-nv-depth float at their own Z for real parallax. */
.nv-tilt {
  transform-style: preserve-3d;
  transition: transform var(--nv-dur-3) var(--nv-ease-out);
  will-change: transform;
}
.nv-tilt-scene { perspective: var(--nv-persp, 900px) }
.nv-tilt.nv-tracking {
  transition: transform 90ms var(--nv-ease-linear); /* tighter while tracking */
  transform: rotateX(var(--nv-rx, 0deg)) rotateY(var(--nv-ry, 0deg));
}
.nv-tilt [data-nv-depth] {
  transform: translateZ(calc(var(--nv-depth, 20) * 1px));
  transform-style: preserve-3d;
}

/* Magnet — drifts toward the cursor then springs home on leave. */
.nv-magnet {
  transition: transform var(--nv-dur-4) var(--nv-ease-snap);
  will-change: transform;
}
.nv-magnet.nv-tracking {
  transition: transform 110ms var(--nv-ease-out);
  transform: translate3d(var(--nv-tx, 0px), var(--nv-ty, 0px), 0);
}

/* Underline sweep — the cheapest link upgrade there is. */
.nv-underline {
  position: relative;
  text-decoration: none;
}
.nv-underline::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-end: -2px;
  height: 1.5px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: var(--nv-origin, left);
  transition: transform var(--nv-dur-3) var(--nv-ease-out);
}
.nv-underline:hover::after,
.nv-underline:focus-visible::after { transform: scaleX(1) }

/* Phosphor icon swap. Because Phosphor ships as a font, the regular and fill
   weights are two glyphs in the same box — so crossfading them is a free
   micro-interaction with no SVG swap and no layout shift.
   Markup: <span class="nv-icon"><i class="ph ph-package"></i><i class="ph-fill ph-package"></i></span> */
.nv-icon {
  position: relative;
  display: inline-grid;
  place-items: center;
  line-height: 1;
}
.nv-icon > i {
  grid-area: 1 / 1;
  transition: opacity var(--nv-dur-2) var(--nv-ease-out),
              transform var(--nv-dur-2) var(--nv-ease-snap);
}
.nv-icon > i:last-child { opacity: 0; transform: scale(.82) }
.nv-icon:hover > i:first-child,
:hover > .nv-icon > i:first-child { opacity: 0; transform: scale(1.14) }
.nv-icon:hover > i:last-child,
:hover > .nv-icon > i:last-child { opacity: 1; transform: none }

/* Icon spin / nudge — for refresh and directional affordances. */
.nv-icon-spin:hover > i,
:hover > .nv-icon-spin > i { animation: nv-rotate 620ms var(--nv-ease-inout) 1 }
.nv-icon-nudge { transition: transform var(--nv-dur-2) var(--nv-ease-out) }
:hover > .nv-icon-nudge,
.nv-icon-nudge:hover { transform: translateX(3px) }

/* Ripple — JS plants a span at the click point. */
.nv-ripple { position: relative; overflow: hidden }
.nv-ripple > .nv-ripple-ink {
  position: absolute;
  border-radius: 50%;
  background: currentColor;
  opacity: .28;
  transform: scale(0);
  animation: nv-ripple var(--nv-dur-5) var(--nv-ease-out) 1 forwards;
  pointer-events: none;
}
@keyframes nv-ripple {
  to { transform: scale(2.6); opacity: 0 }
}

/* Click spark — radial particles at the pointer. JS injects the spans. */
.nv-spark-layer { position: fixed; inset: 0; pointer-events: none; z-index: 9998 }
.nv-spark {
  position: absolute;
  width: 2px;
  height: 9px;
  border-radius: 1px;
  background: var(--nv-accent);
  transform-origin: 50% 100%;
  animation: nv-spark var(--nv-dur-4) var(--nv-ease-out) 1 forwards;
}
@keyframes nv-spark {
  from { transform: rotate(var(--nv-a)) translateY(0) scaleY(1); opacity: 1 }
  to   { transform: rotate(var(--nv-a)) translateY(calc(var(--nv-r) * -1px)) scaleY(.3); opacity: 0 }
}


/* ====================== 4 · LAYOUT & TRANSITION ====================== */

/* Sliding tab indicator. JS measures the active tab and writes the offset, so
   the pill travels between tabs instead of blinking out and back in. */
.nv-tabs {
  position: relative;
  display: inline-flex;
  isolation: isolate;
}
.nv-tabs::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset-block: 3px;
  inset-inline-start: 0;
  width: var(--nv-tab-w, 0px);
  transform: translateX(var(--nv-tab-x, 0px));
  background: color-mix(in srgb, currentColor 10%, transparent);
  border-radius: 8px;
  transition: transform var(--nv-dur-3) var(--nv-ease-snap),
              width var(--nv-dur-3) var(--nv-ease-snap);
}
.nv-tabs[data-nv-ready="0"]::before { transition: none }

/* Accordion that animates to its real content height with no JS measuring —
   0fr to 1fr on a grid row. The old max-height hack always either clipped
   tall content or eased wrong; this is exact. */
.nv-acc-body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--nv-dur-3) var(--nv-ease-out);
}
.nv-acc-body > * { overflow: hidden; min-height: 0 }
[aria-expanded="true"] + .nv-acc-body,
.nv-acc-body[data-nv-open] { grid-template-rows: 1fr }
.nv-acc-chevron { transition: transform var(--nv-dur-3) var(--nv-ease-out) }
[aria-expanded="true"] .nv-acc-chevron { transform: rotate(180deg) }

/* Modal / dialog. @starting-style gives a real enter transition on a
   <dialog> without a JS class dance; the keyframe path covers older engines. */
.nv-modal {
  opacity: 0;
  transform: scale(.94) translateY(8px);
  transition: opacity var(--nv-dur-3) var(--nv-ease-out),
              transform var(--nv-dur-3) var(--nv-ease-snap),
              overlay var(--nv-dur-3) allow-discrete,
              display var(--nv-dur-3) allow-discrete;
}
.nv-modal[open],
.nv-modal.nv-in { opacity: 1; transform: none }
@starting-style {
  .nv-modal[open] { opacity: 0; transform: scale(.94) translateY(8px) }
}
.nv-modal::backdrop {
  background: rgba(8, 9, 12, .45);
  backdrop-filter: blur(3px);
  opacity: 0;
  transition: opacity var(--nv-dur-3) var(--nv-ease-out),
              overlay var(--nv-dur-3) allow-discrete,
              display var(--nv-dur-3) allow-discrete;
}
.nv-modal[open]::backdrop { opacity: 1 }
@starting-style {
  .nv-modal[open]::backdrop { opacity: 0 }
}

/* Drawer — side panel. */
.nv-drawer {
  transform: translateX(100%);
  transition: transform var(--nv-dur-4) var(--nv-ease-out);
}
.nv-drawer[data-nv-side="left"] { transform: translateX(-100%) }
.nv-drawer[data-nv-open] { transform: none }

/* FLIP reorder. JS captures the first rect, applies the inverse transform,
   then removes it — so sorting a 400-row table animates in one paint instead
   of 400 layout thrashes. The workhorse for ERP grids. */
.nv-flip-move { transition: transform var(--nv-dur-4) var(--nv-ease-out) }
.nv-flip-move[data-nv-flipping] { transition: none }

/* Seamless marquee. JS wraps the track in a belt and clones it inside, so the
   belt holds exactly two copies and -50% of the BELT is exactly one copy - the
   seam therefore lands on an identical frame.
   The animation belongs to the belt, never to the tracks: two separately
   animated copies each travel -50% of their own width, which is half a repeat
   period and jumps, and they drift out of phase because a clone's animation
   starts when it is inserted rather than when the original's did. */
.nv-marquee {
  overflow: hidden;
  display: flex;
  -webkit-mask: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
          mask: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.nv-marquee-belt {
  display: flex;
  flex: none;
  animation: nv-marquee var(--nv-speed, 26s) var(--nv-ease-linear) infinite;
}
.nv-marquee-track {
  display: flex;
  flex: none;
  gap: var(--nv-gap, 2.5rem);
  padding-inline-end: var(--nv-gap, 2.5rem);
}
.nv-marquee[data-nv-reverse] .nv-marquee-belt { animation-direction: reverse }
.nv-marquee:hover .nv-marquee-belt { animation-play-state: paused }
@keyframes nv-marquee { to { transform: translateX(-50%) } }

/* Scroll-snap carousel — momentum and snapping with zero JS. */
.nv-snap {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
  overscroll-behavior-x: contain;
}
.nv-snap::-webkit-scrollbar { display: none }
.nv-snap > * { scroll-snap-align: center; flex: none }

/* Focus ring that arrives rather than appears — keyboard users get the same
   quality of feedback as pointer users. */
.nv-focus:focus-visible {
  outline: 2px solid var(--nv-accent);
  outline-offset: 2px;
  animation: nv-focus-in var(--nv-dur-2) var(--nv-ease-snap) 1;
}
@keyframes nv-focus-in {
  from { outline-offset: 7px; outline-color: transparent }
  to   { outline-offset: 2px }
}


/* ====================== 5 · AMBIENT BACKGROUND ====================== */
/* Showpiece layer — landing pages and hero panels (InnoHub), not data screens.
   All of these are position:absolute fills meant to sit behind content. */

.nv-bg {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 0;
}

/* Aurora — three drifting radial blooms. Cheap: no canvas, no blur filter on
   a huge surface, just transforms on three gradients. */
.nv-bg-aurora::before,
.nv-bg-aurora::after,
.nv-bg-aurora > i {
  content: "";
  position: absolute;
  width: 62vmax;
  height: 62vmax;
  border-radius: 50%;
  filter: blur(60px);
  opacity: .42;
  mix-blend-mode: screen;
}
.nv-bg-aurora::before {
  background: radial-gradient(circle, var(--nv-accent), transparent 68%);
  inset-block-start: -22%;
  inset-inline-start: -14%;
  animation: nv-drift-a 19s var(--nv-ease-inout) infinite alternate;
}
.nv-bg-aurora::after {
  background: radial-gradient(circle, var(--nv-aurora-2, #7c5cff), transparent 68%);
  inset-block-end: -28%;
  inset-inline-end: -16%;
  animation: nv-drift-b 23s var(--nv-ease-inout) infinite alternate;
}
.nv-bg-aurora > i {
  display: block;
  background: radial-gradient(circle, var(--nv-aurora-3, #0ea5e9), transparent 68%);
  inset-block-start: 22%;
  inset-inline-start: 38%;
  animation: nv-drift-c 27s var(--nv-ease-inout) infinite alternate;
}
@keyframes nv-drift-a {
  from { transform: translate3d(0, 0, 0) scale(1) }
  to   { transform: translate3d(14%, 10%, 0) scale(1.18) }
}
@keyframes nv-drift-b {
  from { transform: translate3d(0, 0, 0) scale(1.1) }
  to   { transform: translate3d(-16%, -12%, 0) scale(.92) }
}
@keyframes nv-drift-c {
  from { transform: translate3d(0, 0, 0) scale(.9) }
  to   { transform: translate3d(-12%, 16%, 0) scale(1.2) }
}

/* Grid / dot field with a travelling pulse. Two gradients build the grid; a
   masked radial sweeps across it. Sits well under Style Lab's blueprint,
   terminal and wireframe skins. */
.nv-bg-grid::before {
  content: "";
  position: absolute;
  inset: -1px;
  background-image:
    linear-gradient(to right, color-mix(in srgb, currentColor 12%, transparent) 1px, transparent 1px),
    linear-gradient(to bottom, color-mix(in srgb, currentColor 12%, transparent) 1px, transparent 1px);
  background-size: var(--nv-cell, 34px) var(--nv-cell, 34px);
}
.nv-bg-grid::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(circle 30vmax at 50% 50%,
    color-mix(in srgb, var(--nv-accent) 22%, transparent), transparent 70%);
  animation: nv-grid-pulse 7.5s var(--nv-ease-inout) infinite;
}
@keyframes nv-grid-pulse {
  0%   { transform: translate3d(-30%, -20%, 0); opacity: .35 }
  50%  { transform: translate3d(30%, 20%, 0);   opacity: .85 }
  100% { transform: translate3d(-30%, -20%, 0); opacity: .35 }
}

.nv-bg-dots::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 1px, transparent 1px);
  background-size: var(--nv-cell, 22px) var(--nv-cell, 22px);
  animation: nv-dots-shift 11s var(--nv-ease-linear) infinite;
}
@keyframes nv-dots-shift {
  to { background-position: var(--nv-cell, 22px) var(--nv-cell, 22px) }
}

/* Shape grid — the hover-lit tile field (React Bits ShapeGrid, the one saved
   for the InnoHub landing page). JS lights cells near the pointer. */
.nv-shapegrid {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--nv-cell, 30px), 1fr));
  grid-auto-rows: var(--nv-cell, 30px);
  pointer-events: auto;
}
.nv-shapegrid > i {
  /* 10% of currentColor disappears entirely on a near-black hero — measured,
     not guessed. 22% is still quiet and actually survives the background. */
  border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
  background: transparent;
  transition: background var(--nv-dur-4) var(--nv-ease-out),
              transform var(--nv-dur-3) var(--nv-ease-out);
}
.nv-shapegrid > i.nv-hot {
  background: color-mix(in srgb, var(--nv-accent) 45%, transparent);
  transition-duration: var(--nv-dur-1);
}
.nv-shapegrid[data-nv-shape="hexagon"] > i { clip-path: polygon(25% 5%, 75% 5%, 100% 50%, 75% 95%, 25% 95%, 0 50%) }
.nv-shapegrid[data-nv-shape="circle"]  > i { border-radius: 50% }
.nv-shapegrid[data-nv-shape="triangle"] > i { clip-path: polygon(50% 8%, 96% 92%, 4% 92%) }

/* Beams — light shafts sweeping a dark panel. */
.nv-bg-beams > i {
  position: absolute;
  inset-block: -30%;
  width: 1px;
  background: linear-gradient(180deg, transparent,
    color-mix(in srgb, var(--nv-accent) 80%, transparent), transparent);
  filter: blur(.5px);
  animation: nv-beam 6s var(--nv-ease-inout) infinite;
  animation-delay: calc(var(--nv-i, 0) * -1.1s);
  opacity: .6;
}
@keyframes nv-beam {
  0%, 100% { transform: translateY(-18%) scaleY(.6); opacity: 0 }
  50%      { transform: translateY(18%) scaleY(1);   opacity: .75 }
}

/* Waves — stacked line waves. SVG-free: repeating radial edges on three bars. */
.nv-bg-waves > i {
  position: absolute;
  inset-inline: -25%;
  height: 40%;
  border-radius: 46%;
  border: 1px solid color-mix(in srgb, var(--nv-accent) 34%, transparent);
  animation: nv-wave 13s var(--nv-ease-linear) infinite;
  animation-delay: calc(var(--nv-i, 0) * -2.6s);
  inset-block-end: calc(var(--nv-i, 0) * 7% - 14%);
  opacity: .5;
}
@keyframes nv-wave {
  from { transform: rotate(0deg) translateY(0) }
  to   { transform: rotate(360deg) translateY(0) }
}

/* Film grain. An SVG turbulence data-URI, animated by stepping its position —
   far cheaper than re-rendering the filter each frame. */
.nv-bg-noise::before {
  content: "";
  position: absolute;
  inset: -100%;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/></filter><rect width='180' height='180' filter='url(%23n)' opacity='.42'/></svg>");
  opacity: var(--nv-grain, .16);
  animation: nv-grain 620ms steps(4, end) infinite;
  mix-blend-mode: overlay;
}
@keyframes nv-grain {
  0%   { transform: translate3d(0, 0, 0) }
  25%  { transform: translate3d(-3%, 2%, 0) }
  50%  { transform: translate3d(2%, -3%, 0) }
  75%  { transform: translate3d(-2%, -2%, 0) }
  100% { transform: translate3d(0, 0, 0) }
}

/* CRT scanline — pairs with Style Lab's terminal, pixel and cyberpunk skins. */
.nv-bg-scan::before {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(180deg,
    rgba(0, 0, 0, .16) 0 1px, transparent 1px 3px);
}
.nv-bg-scan::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  height: 22%;
  background: linear-gradient(180deg, transparent,
    color-mix(in srgb, var(--nv-accent) 12%, transparent), transparent);
  animation: nv-scan 5.2s var(--nv-ease-linear) infinite;
}
@keyframes nv-scan {
  from { transform: translateY(-120%) } to { transform: translateY(520%) }
}


/* ====================== 6 · TEXT SHOWPIECE ====================== */

/* Shimmer text — a gradient sweep clipped to the glyphs. */
.nv-text-shimmer {
  background: linear-gradient(90deg,
    color-mix(in srgb, currentColor 40%, transparent) 20%,
    currentColor 42%, currentColor 58%,
    color-mix(in srgb, currentColor 40%, transparent) 80%);
  background-size: 220% 100%;
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
  animation: nv-shimmer 2.6s var(--nv-ease-inout) infinite;
}
@keyframes nv-shimmer {
  from { background-position: 120% 0 } to { background-position: -120% 0 }
}

/* Gradient text with a slow hue drift. */
.nv-text-gradient {
  background: linear-gradient(92deg, var(--nv-accent), var(--nv-aurora-2, #7c5cff), var(--nv-aurora-3, #0ea5e9), var(--nv-accent));
  background-size: 300% 100%;
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
  animation: nv-text-flow 9s var(--nv-ease-linear) infinite;
}
@keyframes nv-text-flow {
  to { background-position: 300% 0 }
}

/* Glitch — RGB split. Uses two pseudo-copies from a data-text attribute so the
   real text node stays single, selectable and screen-reader clean. */
.nv-text-glitch {
  position: relative;
  display: inline-block;
}
.nv-text-glitch::before,
.nv-text-glitch::after {
  content: attr(data-nv-text);
  position: absolute;
  inset: 0;
  background: inherit;
  pointer-events: none;
}
.nv-text-glitch::before {
  color: #0ff;
  transform: translateX(-2px);
  animation: nv-glitch-a 2.6s steps(2, end) infinite;
  clip-path: inset(0 0 55% 0);
}
.nv-text-glitch::after {
  color: #f0f;
  transform: translateX(2px);
  animation: nv-glitch-b 2.1s steps(2, end) infinite;
  clip-path: inset(52% 0 0 0);
}
@keyframes nv-glitch-a {
  0%, 88%, 100% { transform: translateX(-2px); opacity: .85 }
  90%  { transform: translateX(-7px) }
  94%  { transform: translateX(4px) }
}
@keyframes nv-glitch-b {
  0%, 86%, 100% { transform: translateX(2px); opacity: .85 }
  89%  { transform: translateX(8px) }
  96%  { transform: translateX(-5px) }
}

/* Fuzzy / vibrating text — restless, for a "live" or "recording" label. */
.nv-text-fuzz {
  animation: nv-fuzz 110ms steps(2, end) infinite;
}
@keyframes nv-fuzz {
  0%   { transform: translate(0, 0) }
  33%  { transform: translate(.4px, -.4px) }
  66%  { transform: translate(-.4px, .4px) }
  100% { transform: translate(0, 0) }
}

/* Letter scramble — JS cycles glyphs before settling on the real character. */
.nv-text-scramble { font-variant-numeric: tabular-nums }
.nv-text-scramble .nv-scrambling { opacity: .62 }

/* Pointer-reactive weight. Real variable-font warp needs a variable face; this
   degrades to a scale nudge on a static font, which still reads as alive. */
.nv-text-pressure .nv-unit {
  display: inline-block;
  transition: transform 140ms var(--nv-ease-out),
              font-variation-settings 140ms var(--nv-ease-out);
  transform: scale(var(--nv-p, 1));
  font-variation-settings: "wght" calc(400 + (var(--nv-p, 1) - 1) * 900);
}


/* ====================== 7 · REDUCED MOTION ====================== */
/* Last in the file so it wins every cascade above. This is not a courtesy:
   vestibular disorders are real, Windows ships the toggle in Settings >
   Accessibility > Visual effects, and every one of the inspiration sites this
   library was drawn from ignores it.

   The rule is "no motion", not "no feedback" — reveals land visible, state
   colour still flashes, and anything that communicated by moving instead
   communicates by appearing. A silent UI is worse than a still one. */

@media (prefers-reduced-motion: reduce) {

  /* Entrances resolve instantly to their landed state. */
  [data-nv-reveal],
  .nv-split .nv-unit {
    opacity: 1 !important;
    transform: none !important;
    filter: none !important;
    clip-path: none !important;
    transition: none !important;
  }

  /* Kill every looping ambient effect outright. */
  .nv-bg-aurora::before,
  .nv-bg-aurora::after,
  .nv-bg-aurora > i,
  .nv-bg-grid::after,
  .nv-bg-dots::before,
  .nv-bg-beams > i,
  .nv-bg-waves > i,
  .nv-bg-noise::before,
  .nv-bg-scan::after,
  .nv-border-run::before,
  .nv-border-electric::before,
  .nv-border-electric::after,
  .nv-border-star::before,
  .nv-border-star::after,
  .nv-marquee-belt,
  .nv-marquee-track,
  .nv-text-shimmer,
  .nv-text-gradient,
  .nv-text-glitch::before,
  .nv-text-glitch::after,
  .nv-text-fuzz,
  .nv-gauge-liquid::before,
  .nv-gauge-liquid::after,
  .nv-progress > span::after,
  .nv-skeleton::after,
  .nv-pulse::after,
  .nv-lattice i,
  .nv-step .nv-step-dot,
  .nv-type::after {
    animation: none !important;
  }

  /* Keep the shimmer surfaces legible once their sweep is gone. */
  .nv-text-shimmer,
  .nv-text-gradient {
    background: none;
    color: inherit;
    -webkit-text-fill-color: currentColor;
  }
  .nv-skeleton { background: color-mix(in srgb, currentColor 14%, transparent) }
  .nv-lattice i { opacity: .55 }
  .nv-type::after { opacity: 1 }

  /* Pointer effects stop tracking but keep their static affordance. */
  .nv-tilt,
  .nv-tilt.nv-tracking,
  .nv-magnet,
  .nv-magnet.nv-tracking {
    transform: none !important;
    transition: none !important;
  }
  .nv-glare::after { display: none }
  .nv-lift:hover { transform: none; box-shadow: 0 8px 20px -12px rgba(0, 0, 0, .4) }
  .nv-press:active { transform: none; filter: brightness(.94) }

  /* Feedback that used motion now uses an instant, still signal. */
  .nv-shake {
    animation: none !important;
    outline: 2px solid var(--nv-err) !important;
    outline-offset: 2px;
  }
  /* !important is load-bearing here, not laziness. The animated flash wins its
     cascade because keyframes outrank normal declarations; a plain
     background-color fallback does not, so ANY app rule that paints the row
     (`.row { background:#fff }`) silently beat it — and a reduced-motion user
     got no save confirmation at all. The fallback has to be at least as strong
     as the effect it replaces. */
  .nv-flash-ok, .nv-flash-err, .nv-flash-warn {
    animation: none !important;
    background-color: color-mix(in srgb, var(--nv-flash-tint) 30%, transparent) !important;
  }
  .nv-ripple > .nv-ripple-ink,
  .nv-spark { display: none }

  /* Structural transitions become instant rather than absent. */
  .nv-acc-body,
  .nv-tabs::before,
  .nv-drawer,
  .nv-modal,
  .nv-modal::backdrop,
  .nv-flip-move,
  .nv-progress > span,
  .nv-gauge-liquid,
  .nv-toast,
  .nv-toast[data-nv-leaving] {
    transition-duration: 1ms !important;
    animation-duration: 1ms !important;
  }
  .nv-snap { scroll-behavior: auto }
  .nv-focus:focus-visible { animation: none }
}

/* Honour the data-saver hint too — ambient loops are pure decoration. */
@media (prefers-reduced-data: reduce) {
  .nv-bg-noise::before,
  .nv-bg-aurora::before,
  .nv-bg-aurora::after,
  .nv-bg-aurora > i { animation: none }
}
