/* ============================================================
   motion.css — the vibrance layer.

   styles.css owns the lab-report structure (navy ink on white).
   This file owns colour energy and movement, layered on top so the
   report stays readable with this file removed. Everything here is
   transform/opacity/filter only — no layout-affecting animation —
   and the whole file self-disables under prefers-reduced-motion.
   ============================================================ */

:root {
  /* Glow washes for the aurora. The background is brand blue now, so
     these are three lightness steps off the SAME hue (hsl 242, the hue
     of #0e08ae) rather than three different hues — the wash shifts in
     depth as the blobs drift across each other instead of shifting in
     colour. Alphas stay low: this should read as tinted light, not as a
     colour block.

     The mint / violet / pink / sky / coral accents this file used to
     declare are gone with the washes that used them. Nothing else
     referenced them — the rating palette and the score bands carry
     their own colours in styles.css. */
  --glow-blue-deep: rgba(14, 8, 174, 0.20);
  --glow-blue-mid: rgba(66, 60, 246, 0.18);
  --glow-blue-light: rgba(114, 109, 248, 0.22);

  /* Easings. --ease-spring overshoots; use it for anything that should
     feel like it has mass (gauge settle, press release, count-up). */
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-out-soft: cubic-bezier(0.65, 0, 0.35, 1);

  --dur-fast: 0.18s;
  --dur-mid: 0.36s;
  --dur-slow: 0.7s;
  --dur-reveal: 0.62s;

  /* Elevation. Tinted rather than neutral-black so shadows pick up the
     accent wash instead of graying it out. */
  --lift-1: 0 1px 2px rgba(18, 33, 63, 0.05), 0 4px 14px rgba(18, 33, 63, 0.06);
  --lift-2: 0 2px 6px rgba(18, 33, 63, 0.07), 0 14px 38px rgba(18, 33, 63, 0.10);
  --lift-glow: 0 10px 34px rgba(14, 8, 174, 0.22);
}

/* ---------- Aurora field ----------
   Three blurred colour blobs drifting behind the whole page. Fixed, so
   they stay put while content scrolls over them; -1 z-index and
   pointer-events:none so they can never eat a click. */
.aurora {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}
.aurora span {
  position: absolute;
  display: block;
  border-radius: 50%;
  filter: blur(70px);
  opacity: 0.75;
  will-change: transform;
}
.aurora .b1 {
  width: 46vw; height: 46vw; min-width: 320px; min-height: 320px;
  top: -14vw; left: -10vw;
  background: radial-gradient(circle at 50% 50%, var(--glow-blue-deep), transparent 70%);
  animation: aurora-drift-a 26s var(--ease-in-out-soft) infinite alternate;
}
.aurora .b2 {
  width: 40vw; height: 40vw; min-width: 280px; min-height: 280px;
  top: -6vw; right: -12vw;
  background: radial-gradient(circle at 50% 50%, var(--glow-blue-mid), transparent 70%);
  animation: aurora-drift-b 32s var(--ease-in-out-soft) infinite alternate;
}
.aurora .b3 {
  width: 38vw; height: 38vw; min-width: 260px; min-height: 260px;
  top: 34vh; left: 42vw;
  background: radial-gradient(circle at 50% 50%, var(--glow-blue-light), transparent 70%);
  animation: aurora-drift-c 38s var(--ease-in-out-soft) infinite alternate;
}

@keyframes aurora-drift-a {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(6vw, 8vh, 0) scale(1.18); }
}
@keyframes aurora-drift-b {
  from { transform: translate3d(0, 0, 0) scale(1.1); }
  to   { transform: translate3d(-7vw, 10vh, 0) scale(0.92); }
}
@keyframes aurora-drift-c {
  from { transform: translate3d(0, 0, 0) scale(0.95); }
  to   { transform: translate3d(-10vw, -12vh, 0) scale(1.2); }
}

/* Phones need MORE aurora than desktop, not less, for two reasons that
   both work against it:

     - the blobs are sized in vw, so at 390px they bottom out on their
       min-width floor (320px vs 589px on desktop)
     - the content card covers ~92% of the viewport width, against ~88%
       on desktop but with none of the wide side margins the wash reads
       through there

   Net effect before this block: the whole colour field squeezed into an
   8% gutter at 0.6 opacity, i.e. invisible. So on phones the blobs get
   bigger than the viewport and are pushed out past its edges, where the
   part that survives is the saturated middle of the gradient rather
   than its faded tail.

   The animation stays. An earlier version of this rule tried to stop it
   here for GPU cost, but `.aurora span` (0,1,1) never beat `.aurora .b1`
   (0,2,0), so it had no effect and the drift has been running on phones
   all along — measurably free, because blur rasterises once and the
   drift is a compositor-only transform. Keeping it is now the deliberate
   choice; the blur radius still comes down, since that part is real. */
@media (max-width: 760px) {
  .aurora span { filter: blur(46px); opacity: 0.95; }
  .aurora .b1 { width: 105vw; height: 105vw; top: -30vw; left: -38vw; }
  .aurora .b2 { width: 96vw; height: 96vw; top: 4vh; right: -44vw; left: auto; }
  .aurora .b3 { width: 100vw; height: 100vw; top: 48vh; left: -34vw; }
}

/* ---------- Anchor scrolling ----------
   In-page #checker jumps landed the user somewhere new with no sense of having
   travelled — the page simply became a different page. Smooth here also
   picks up plain `scrollIntoView()` calls that pass no behavior, which is
   what we want everywhere except the admin replay (see app.js, which asks
   for "instant" explicitly because it fires mid-load). scroll-padding-top
   in styles.css keeps the target clear of the sticky bar. */
html { scroll-behavior: smooth; }

/* ---------- Masthead rail ----------
   styles.css paints the static teal→indigo hairline under the bar; this
   runs a highlight along it, left to right, on a long loop. Two layers on
   the one 2px pseudo-element: the travelling highlight on top, the brand
   ramp underneath. Only the highlight's background-position animates, and
   it repaints a 2px strip, so the cost is nil — a transform would be
   cheaper still but ::after has no child to move.

   The pause between passes comes from the highlight being sized far wider
   than the bar (400%): most of the cycle it is off-screen, so the rail
   reads as a quiet line that catches the light every few seconds rather
   than a barber pole. */
.masthead::after {
  background-image:
    linear-gradient(
      90deg,
      transparent 0%,
      transparent 9%,
      rgba(255, 255, 255, 0.55) 12%,
      rgba(186, 255, 247, 0.95) 14%,
      rgba(255, 255, 255, 0.55) 16%,
      transparent 19%,
      transparent 100%
    ),
    linear-gradient(90deg, #2dd4bf, var(--blue-500, #423cf6) 55%, #2dd4bf);
  background-size: 400% 100%, 100% 100%;
  background-repeat: no-repeat;
  animation: mast-rail 7s linear infinite;
}
@keyframes mast-rail {
  from { background-position: -100% 0, 0 0; }
  to   { background-position: 300% 0, 0 0; }
}
/* Pinned: the same rail, brighter and quicker, so scrolling into a long
   report gives the bar a little more life exactly when it is the only
   chrome on screen. */
.masthead.stuck::after {
  opacity: 1;
  animation-duration: 4.5s;
}

/* ---------- Animated gradient text ---------- */
.grad-text {
  background: linear-gradient(
    100deg,
    var(--blue-700) 0%, var(--blue-600) 24%, var(--blue-500) 46%,
    var(--blue-400) 64%, var(--blue-600) 84%, var(--blue-700) 100%
  );
  background-size: 260% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: gradient-pan 9s linear infinite;
}
@keyframes gradient-pan {
  from { background-position: 0% 50%; }
  to   { background-position: 260% 50%; }
}

/* ---------- Scroll reveal ----------
   app.js adds .in via IntersectionObserver. The un-revealed state is
   only applied once JS has confirmed it can observe (html.js-reveal),
   so a JS failure leaves content visible rather than invisible. */
.js-reveal .reveal {
  opacity: 0;
  transform: translateY(18px);
}
.js-reveal .reveal.in {
  opacity: 1;
  transform: none;
  transition:
    opacity var(--dur-reveal) var(--ease-out-expo),
    transform var(--dur-reveal) var(--ease-out-expo);
  transition-delay: var(--reveal-delay, 0s);
}

/* ---------- Buttons ----------
   A shimmer sweeps across the primary CTA on hover, and the button
   presses in with a spring return. The sweep lives on ::after so it
   does not disturb the label's own stacking. */
.btn {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  transition:
    transform var(--dur-fast) var(--ease-spring),
    box-shadow var(--dur-mid) var(--ease-out-expo),
    background-color var(--dur-fast) ease;
}
.btn::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(
    100deg,
    transparent 20%,
    rgba(255, 255, 255, 0.28) 42%,
    rgba(255, 255, 255, 0.5) 50%,
    rgba(255, 255, 255, 0.28) 58%,
    transparent 80%
  );
  transform: translateX(-120%);
}
.btn:hover:not([disabled]) {
  transform: translateY(-2px);
  box-shadow: var(--lift-glow);
}
.btn:hover:not([disabled])::after {
  animation: shimmer-sweep 0.9s var(--ease-out-expo);
}
.btn:active:not([disabled]) { transform: translateY(0) scale(0.97); }
.btn.ghost:hover:not([disabled]) { box-shadow: var(--lift-1); }

@keyframes shimmer-sweep {
  from { transform: translateX(-120%); }
  to   { transform: translateX(120%); }
}

/* Touch devices report `hover: none` and never fire :hover, so on a phone
   every effect above was dead — the shimmer, the lift, the glow. Tap is
   the only signal there is, so :active carries all of it. The sweep runs
   slightly faster than on hover: a tap is a shorter gesture than a
   dwell, and the animation should be over by the time the thumb is. */
@media (hover: none) {
  .btn:active:not([disabled]) {
    transform: scale(0.97);
    box-shadow: var(--lift-glow);
  }
  .btn:active:not([disabled])::after {
    animation: shimmer-sweep 0.65s var(--ease-out-expo);
  }
  .btn.ghost:active:not([disabled]) { box-shadow: var(--lift-1); }
  .lift:active { transform: translateY(-3px); box-shadow: var(--lift-2); }
  /* The profile chips and upload buttons are the other things a thumb
     lands on, and they had no press state at all. */
  .choice, .upload {
    transition: transform var(--dur-fast) var(--ease-spring),
                box-shadow var(--dur-fast) var(--ease-out-expo);
  }
  .choice:active, .upload:active {
    transform: scale(0.96);
    box-shadow: var(--lift-1);
  }
}

/* ---------- Border beam ----------
   A light dot orbiting a card's edge, drawn as a conic gradient behind
   a masked inset. Applied to the score card and the signup gate — the
   two moments worth drawing the eye to. */
@property --beam-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.beam { position: relative; }
.beam::before {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  padding: 1px;
  background: conic-gradient(
    from var(--beam-angle, 0deg),
    transparent 0deg,
    var(--blue-600) 24deg,
    var(--blue-300) 44deg,
    transparent 68deg
  );
  /* Mask to the border ring only: fill the padding box, then punch it
     back out so just the 1px frame paints. */
  -webkit-mask:
    linear-gradient(#fff 0 0) content-box,
    linear-gradient(#fff 0 0);
  mask:
    linear-gradient(#fff 0 0) content-box,
    linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
  animation: beam-orbit var(--beam-duration, 6s) linear infinite;
}
@keyframes beam-orbit {
  to { --beam-angle: 360deg; }
}

/* ---------- Score gauge ----------
   The report's hero moment. styles.css draws the ring; this section adds
   the gradient stroke, the glow, the rotating sheen and the settle pulse. app.js drives the sweep and the count-up frame by frame,
   so the CSS here deliberately does NOT transition stroke-dashoffset — a
   transition on top of a rAF loop fights it and lands late. */

/* Per-band gradient ramps. Each runs from the band's own hue to a deeper
   shade of it, so the arc reads as a gradient rather than a flat colour. */
.score-card.band-1 { --band-grad-a: #e2543f; --band-grad-b: #a52e23; --band-glow: rgba(226, 84, 63, 0.45); }
.score-card.band-2 { --band-grad-a: #f08a2c; --band-grad-b: #c2610c; --band-glow: rgba(240, 138, 44, 0.45); }
.score-card.band-3 { --band-grad-a: #e3b62c; --band-grad-b: #a8830a; --band-glow: rgba(227, 182, 44, 0.45); }
.score-card.band-4 { --band-grad-a: #7bc25c; --band-grad-b: #4f8f3b; --band-glow: rgba(123, 194, 92, 0.42); }
.score-card.band-5 { --band-grad-a: #2fbf72; --band-grad-b: #12784a; --band-glow: rgba(47, 191, 114, 0.45); }
.score-card.band-none { --band-grad-a: #9aa2ae; --band-grad-b: #6b6b6b; --band-glow: rgba(107, 107, 107, 0.25); }

.g-fill {
  stroke: url(#gaugeGrad);
  /* app.js owns stroke-dashoffset; see note above. */
  transition: none;
  filter: drop-shadow(0 0 5px var(--band-glow, transparent));
}

/* Conic sheen, masked to a ring just outside the track. Sits under the
   svg so the arc always paints over it. */
.g-sheen {
  position: absolute;
  inset: 2%;
  border-radius: 50%;
  background: conic-gradient(
    from 0deg,
    transparent 0deg,
    var(--band-glow, transparent) 40deg,
    transparent 130deg,
    transparent 360deg
  );
  -webkit-mask: radial-gradient(circle, transparent 60%, #fff 62%, #fff 78%, transparent 80%);
  mask: radial-gradient(circle, transparent 60%, #fff 62%, #fff 78%, transparent 80%);
  opacity: 0;
  pointer-events: none;
  animation: sheen-spin 7s linear infinite;
}
/* app.js adds .armed once a real score has rendered — an empty gauge has
   nothing to celebrate. */
.gauge.armed .g-sheen { opacity: 1; transition: opacity 0.8s ease 0.9s; }
@keyframes sheen-spin {
  to { transform: rotate(360deg); }
}

/* The number pops once as the arc lands. */
.gauge.settled .g-center .num {
  display: inline-block;
  animation: score-pop 0.5s var(--ease-spring);
}
@keyframes score-pop {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.13); }
  100% { transform: scale(1); }
}

/* And the whole card breathes out a single ring of band colour. */
.score-card { position: relative; }
.score-card.settled::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  animation: card-pulse 1.1s var(--ease-out-expo);
}
@keyframes card-pulse {
  from { box-shadow: 0 0 0 0 var(--band-glow, transparent); }
  to   { box-shadow: 0 0 0 18px transparent; }
}

/* ---------- Verdict states ----------
   Moderate / Unclear / Hazardous are the findings people came for, so they get
   the loudest motion in the app. Two rules keep it from turning into a
   fairground: every attention loop is FINITE (it plays a few times and
   stops, so a report left open is still), and nothing here animates
   colour or layout — only opacity and transform, on overlay elements. */

.ingredient-row {
  position: relative;
  /* Rows are re-created by innerHTML on every render, so the entrance
     animation re-runs by itself; app.js only supplies the delay. */
  animation: row-in 0.5s var(--ease-out-expo) both;
  animation-delay: var(--row-delay, 0s);
}
@keyframes row-in {
  from { opacity: 0; transform: translateX(-10px); }
  to   { opacity: 1; transform: none; }
}

/* The rail. styles.css already paints a 3px inset edge on non-safe rows;
   this overlays a glowing copy that pulses and then stops. */
.ingredient-row.r-moderate::before,
.ingredient-row.r-unclear::before,
.ingredient-row.r-hazardous::before {
  content: "";
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 3px;
  pointer-events: none;
  opacity: 0;
  /* Finite: three beats, then the row goes quiet for good. */
  animation: rail-pulse 1.5s var(--ease-in-out-soft) 3 both;
  animation-delay: calc(var(--row-delay, 0s) + 0.35s);
}
.ingredient-row.r-moderate::before { background: var(--moderate); box-shadow: 0 0 10px var(--moderate); }
.ingredient-row.r-unclear::before { background: var(--unclear); box-shadow: 0 0 10px var(--unclear); }
.ingredient-row.r-hazardous::before { background: var(--hazardous); box-shadow: 0 0 12px var(--hazardous); }
/* Hazardous pulses harder and one beat longer than the softer ratings. */
.ingredient-row.r-hazardous::before { animation-duration: 1.2s; animation-iteration-count: 4; }
@keyframes rail-pulse {
  0%, 100% { opacity: 0.15; }
  50%      { opacity: 1; }
}

/* A single light sweep across each hazardous row as it lands. Once only —
   this is a "look here", not a loop. */
.ingredient-row.r-hazardous::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    100deg,
    transparent 35%,
    rgba(179, 64, 47, 0.16) 50%,
    transparent 65%
  );
  transform: translateX(-100%);
  animation: row-sweep 1.1s var(--ease-out-expo) 1 both;
  animation-delay: calc(var(--row-delay, 0s) + 0.3s);
}
@keyframes row-sweep {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}

/* Rating pills: the two ratings that mean "act on this" get a short
   attention beat, the calmer ones stay put. */
.tag.hazardous { animation: tag-beat 1.3s var(--ease-spring) 3 both; animation-delay: calc(var(--row-delay, 0s) + 0.4s); }
.tag.moderate { animation: tag-beat 1.6s var(--ease-spring) 2 both; animation-delay: calc(var(--row-delay, 0s) + 0.4s); }
@keyframes tag-beat {
  0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
  40%      { transform: scale(1.07); box-shadow: 0 0 0 4px rgba(179, 64, 47, 0.14); }
}

/* ---------- Count tiles ----------
   app.js counts the numbers up in step with the gauge. A tile that found
   something also shakes once so a non-zero count is felt, not just read. */
.tile { transition: transform var(--dur-mid) var(--ease-out-expo), box-shadow var(--dur-mid) var(--ease-out-expo); }
.tile.hit { animation: tile-shake 0.55s var(--ease-spring) both; animation-delay: var(--tile-delay, 0s); }
.tile.moderate.hit { box-shadow: 0 6px 20px rgba(176, 122, 16, 0.18); }
.tile.hazardous.hit { box-shadow: 0 6px 22px rgba(179, 64, 47, 0.22); }
@keyframes tile-shake {
  0%   { transform: translateX(0); }
  20%  { transform: translateX(-4px); }
  40%  { transform: translateX(4px); }
  60%  { transform: translateX(-2px); }
  80%  { transform: translateX(2px); }
  100% { transform: translateX(0); }
}

/* The Overall pill lands last, after the gauge has settled. */
.overall { animation: overall-in 0.55s var(--ease-spring) 1.05s both; }
@keyframes overall-in {
  from { opacity: 0; transform: translateY(8px) scale(0.94); }
  to   { opacity: 1; transform: none; }
}

/* ---------- Panel lift ----------
   Opt-in hover elevation for cards that are interactive. */
.lift {
  transition:
    transform var(--dur-mid) var(--ease-out-expo),
    box-shadow var(--dur-mid) var(--ease-out-expo);
}
.lift:hover { transform: translateY(-3px); box-shadow: var(--lift-2); }

/* ---------- Value-card icons: the colour chase ----------
   A lit arc of the chip's own accent travels around its rounded edge on
   hover/focus. Rather than animating a conic gradient's angle (which
   needs @property to interpolate), ::before is an oversized square that
   simply spins, and ::after covers the middle with the chip's own fill
   so only a 2px ring of the spinning gradient shows through. Both are
   transform/opacity work on a 44px box — cheap, and it stops the moment
   the pointer leaves. */
.acard .ico { overflow: hidden; }
.acard .ico::before {
  content: "";
  position: absolute; z-index: 0;
  top: 50%; left: 50%; width: 150%; aspect-ratio: 1; margin: -75% 0 0 -75%;
  background: conic-gradient(
    from 0turn,
    transparent 0 0.60turn,
    color-mix(in srgb, var(--ico-accent) 45%, transparent) 0.80turn,
    var(--ico-accent) 0.88turn,
    #fff 0.91turn,
    var(--ico-accent) 0.94turn,
    transparent 1turn
  );
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-out-expo);
  will-change: transform;
}
.acard .ico::after {
  content: "";
  position: absolute; z-index: 1; inset: 2px;
  border-radius: 10px;
  background: inherit; /* picks up the per-card pastel from .ico */
}
/* Hovering anywhere on the card lights the ring, so the whole card feels
   live. */
.acard:hover .ico::before {
  opacity: 1;
  animation: ico-chase 2.1s linear infinite;
}
@keyframes ico-chase {
  to { transform: rotate(1turn); }
}
/* A small hover swell on the chip itself, so the icon acknowledges the
   pointer before the ring even completes a lap. */
.acard .ico { transition: transform var(--dur-fast) var(--ease-spring); }
.acard:hover .ico { transform: scale(1.06); }

/* ---------- Upload / Camera icon marks ----------
   The two photo buttons sit under the ingredients textarea and were the
   only controls in the form whose icons never acknowledged a pointer.
   Both animations are one-shot and pointer-driven: nothing loops at
   rest, because a permanently moving thing beside the textarea competes
   with the field the user is actually meant to fill in.

   Upload: the three sheets fan apart and the front one lifts. The base
   offsets live in CSS (not on the SVG's transform attribute) because a
   CSS transform would replace the attribute wholesale — carrying the
   offset in the same property is what lets the hover state add rotation
   on top of it. --ease-spring overshoots, which is the "settle". */
.u-stack .u-sheet {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  transition: transform var(--dur-mid) var(--ease-spring);
}
.u-stack .u-sheet-b { transform: translate(-3.6px, -3.2px); }
.u-stack .u-sheet-m { transform: translate(-1.8px, -1.6px); }
.upload:hover .u-sheet-b { transform: translate(-4.4px, -3.6px) rotate(-11deg); }
.upload:hover .u-sheet-m { transform: translate(-2px, -2.4px) rotate(-4.5deg); }
.upload:hover .u-sheet-f { transform: translate(1.6px, 0.8px) rotate(6.5deg); }

/* Camera: the lens closes and reopens once, like a shutter firing, with
   the flash dot catching light on the same beat. The dot is dim at rest
   so the blink has somewhere to travel. */
.u-cam .u-lens {
  transform-box: fill-box;
  transform-origin: 50% 50%;
}
.u-cam .u-flash { opacity: 0.45; }
.upload:hover .u-lens { animation: shutter-blink 0.5s var(--ease-out-expo); }
.upload:hover .u-flash { animation: flash-pop 0.5s var(--ease-out-expo); }
@keyframes shutter-blink {
  0% { transform: scale(1); }
  35% { transform: scale(0.5); }
  65% { transform: scale(1.16); }
  100% { transform: scale(1); }
}
@keyframes flash-pop {
  0% { opacity: 0.45; }
  35% { opacity: 1; }
  100% { opacity: 0.45; }
}

/* Touch: no hover to key off, so the press does the same work. Sits
   inside the existing (hover: none) block's sibling territory — the
   selectors are separate so a stylus/desktop press doesn't double-fire
   what :hover already ran. */
@media (hover: none) {
  .upload:active .u-sheet-b { transform: translate(-4.4px, -3.6px) rotate(-11deg); }
  .upload:active .u-sheet-m { transform: translate(-2px, -2.4px) rotate(-4.5deg); }
  .upload:active .u-sheet-f { transform: translate(1.6px, 0.8px) rotate(6.5deg); }
  .upload:active .u-lens { animation: shutter-blink 0.5s var(--ease-out-expo); }
  .upload:active .u-flash { animation: flash-pop 0.5s var(--ease-out-expo); }
}

/* ---------- Reduced motion ----------
   Kills every animation and transition this file introduces. Elements
   whose visible state depends on an animation (aurora, reveal) are
   pinned to their end state rather than their start. */
@media (prefers-reduced-motion: reduce) {
  .aurora span,
  .grad-text,
  .beam::before,
  .btn:hover:not([disabled])::after,
  .g-sheen,
  .gauge.settled .g-center .num,
  .masthead::after,
  .score-card.settled::after {
    animation: none !important;
  }
  /* Stopping the rail mid-cycle could park the highlight anywhere; pin it
     off the left edge so what remains is the plain brand hairline. */
  .masthead::after { background-position: -100% 0, 0 0; }
  /* Anchor jumps go back to instant — a smooth scroll is exactly the kind
     of large-area motion this query is asking us not to run. */
  html { scroll-behavior: auto; }
  /* Verdict motion off. The rails and sweeps are pure decoration on top
     of the inset edge styles.css already paints, so they simply go; the
     row, pill and tile animations are pinned to their end state because
     the elements start invisible or displaced. */
  .ingredient-row.r-moderate::before,
  .ingredient-row.r-unclear::before,
  .ingredient-row.r-hazardous::before,
  .ingredient-row.r-hazardous::after {
    display: none;
  }
  .ingredient-row, .tag.hazardous, .tag.moderate, .tile.hit, .overall {
    animation: none !important;
    opacity: 1;
    transform: none;
  }
  .tile { transition: none !important; }
  /* app.js skips the sweep entirely under reduced motion and writes the
     final offset directly, so the ring still shows the right score. */
  .js-reveal .reveal { opacity: 1 !important; transform: none !important; }
  .btn, .lift, .choice, .upload { transition: none !important; }
  /* The sheets keep their resting offsets — that stack IS the icon — but
     they stop fanning, and the shutter stops firing. */
  .u-stack .u-sheet { transition: none !important; }
  .upload:hover .u-sheet-b, .upload:active .u-sheet-b { transform: translate(-3.6px, -3.2px) !important; }
  .upload:hover .u-sheet-m, .upload:active .u-sheet-m { transform: translate(-1.8px, -1.6px) !important; }
  .upload:hover .u-sheet-f, .upload:active .u-sheet-f { transform: none !important; }
  .upload:hover .u-lens, .upload:active .u-lens,
  .upload:hover .u-flash, .upload:active .u-flash { animation: none !important; }
  /* The masthead still thickens/densifies when pinned — it just does it
     instantly, and the logo stops tilting on hover. */
  .masthead, .mast-inner, .mast-inner .logo { transition: none !important; }
  .mast-inner .brand:hover .logo { transform: none !important; }
  .btn:hover:not([disabled]),
  .btn:active:not([disabled]),
  .lift:hover, .lift:active,
  .choice:active, .upload:active { transform: none !important; }
  .btn:active:not([disabled])::after { animation: none !important; }
  /* No animation would leave a static gradient, which is fine — but pin
     the position so it lands on the readable part of the ramp. */
  .grad-text { background-position: 12% 50%; }

  /* Value-card icons: the ring still lights up on hover, it just stops
     travelling. The chip swell goes entirely. */
  .acard .ico::before { animation: none !important; }
  .acard .ico { transition: none !important; }
  .acard:hover .ico { transform: none !important; }
}
