/* ============================================================== duel.css ==

   The head-to-head screen, and the sabotage that lands on it.

   Everything here is built on the tokens and the components in styles.css —
   `.stage`, `.arena`, `.gate`, `.deck`, `.controls` and `.btn--gate` are the
   game's own and are reused unchanged, so a duel card behaves exactly like a
   single-player one. What is new is the furniture around it: two scores instead
   of one, a charge bar, an arsenal, and a set of effects that can be thrown at
   the player.

   The effects are the reason this file exists. All of them are CSS: nothing
   here loads an asset, and every one of them ends on a duration the SERVER
   supplies through `--fx-ms`, so the blur clears exactly when the server says
   the fog is over rather than when a stylesheet guessed it would.

   Law 1 of docs/duel-mode.md is a rule about this file as much as about the
   backend: an effect may make the card hard to READ. It may never make it say
   something untrue. Nothing below touches the text of a card.
============================================================================ */

/* ------------------------------------------------------- the menu entry -- */

/* Above the four solo modes rather than beside them, because it is the only
   one with another person in it. Wide, so it does not read as a fifth of a
   set of five. */
.duelBlock { margin-bottom: var(--s3); }

.duelCta {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--s3);
  width: 100%;
  padding: 14px 16px;
  /* Sits in the lobby, but already lit like the room it opens: the opponent's
     magenta from one corner, the player's cyan from the other. */
  border: 1px solid color-mix(in srgb, var(--c-duel-foe) 38%, transparent);
  border-radius: var(--r-panel);
  background:
    radial-gradient(120% 160% at 100% 0%, color-mix(in srgb, var(--c-duel-foe) 22%, transparent), transparent 60%),
    radial-gradient(120% 160% at 0% 100%, color-mix(in srgb, var(--c-duel-ally) 16%, transparent), transparent 60%),
    var(--felt-lift);
  color: var(--text-1);
  text-align: left;
  cursor: pointer;
  transition: transform .12s var(--ease-out), border-color .2s ease;
}

.duelCta:active { transform: scale(.985); }
.duelCta:disabled { opacity: .45; cursor: default; }

.duelCta__mark {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  border-radius: 12px;
  background: linear-gradient(145deg, var(--c-duel-ally), var(--c-duel-foe));
  color: #07080f;
}

.duelCta__mark svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.duelCta__text { display: grid; gap: 2px; min-width: 0; }

.duelCta__name {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 19px;
  letter-spacing: .01em;
}

.duelCta__desc {
  font-size: var(--t-meta);
  color: var(--text-2);
  line-height: 1.35;
}

.duelCta__meta {
  font-size: var(--t-label);
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--accent-lit);
  white-space: nowrap;
}

/* Deliberately quieter than the button above it, and deliberately not disabled
   with it. It is the door that stays open when the five entries are gone —
   dressed as a footnote to the duel rather than as a sixth mode, because it is
   the same mode with nothing riding on it. */
.duelPractice {
  display: flex;
  align-items: baseline;
  gap: var(--s2);
  width: 100%;
  margin-top: 6px;
  padding: 9px 16px;
  border: 1px solid var(--line);
  border-top: 0;
  border-radius: 0 0 var(--r-panel) var(--r-panel);
  background: var(--felt-lift);
  color: var(--text-2);
  text-align: left;
  cursor: pointer;
  transition: border-color .2s ease, color .2s ease;
}

.duelPractice:active { transform: scale(.985); }
.duelPractice:disabled { opacity: .45; cursor: default; }

@media (hover: hover) {
  .duelPractice:not(:disabled):hover {
    border-color: var(--line-strong);
    color: var(--text-1);
  }
}

.duelPractice__name {
  font-size: var(--t-label);
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--text-1);
  white-space: nowrap;
}

.duelPractice__desc {
  font-size: var(--t-meta);
  line-height: 1.3;
  min-width: 0;
}

/* ------------------------------------------------------ choosing, waiting -- */

/* Two answers, not a good one and a fallback. Laid out as two equal cards so
   neither reads as the consolation prize — the bot is a real opponent tuned to
   the player, and dressing it as second best would be its own small dishonesty. */
.pick { display: grid; gap: var(--s2); }

.pick__opt {
  display: grid;
  gap: 3px;
  padding: 12px 14px;
  border: 1px solid rgba(111, 108, 247, .38);
  border-radius: var(--r-card);
  background: var(--felt-lift);
  color: var(--text-1);
  text-align: left;
  cursor: pointer;
  transition: border-color .2s ease, transform .12s var(--ease-out);
}

.pick__opt:active { transform: scale(.985); }
.pick__opt:disabled { opacity: .45; cursor: default; }

@media (hover: hover) {
  .pick__opt:not(:disabled):hover { border-color: var(--accent-lit); }
}

.pick__name {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 17px;
}

.pick__desc {
  font-size: var(--t-meta);
  color: var(--text-2);
  line-height: 1.35;
}

/* The search runs for a known number of seconds, so it gets a bar rather than a
   spinner: a spinner says "wait" forever, and the one promise this screen makes
   is that it will stop. */
.searchBar {
  height: 4px;
  margin: var(--s2) 0;
  border-radius: var(--r-pill);
  background: var(--felt-deep);
  overflow: hidden;
}

.searchBar__fill {
  display: block;
  width: 0;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--accent), var(--accent-lit));
  transition: width .2s linear;
}

/* Why the queue has people in it and still has nobody for you: a different
   language, a difficulty that cannot be shared, the same account on both
   devices. Quieter than the count above it, because it is the sentence that
   only appears when something is actually in the way. */
.modal__hint {
  margin-top: var(--s2);
  padding: var(--s2);
  border-radius: var(--r-control);
  background: var(--felt-deep);
  border-inline-start: 3px solid var(--accent);
  font-size: var(--t-meta);
  line-height: 1.5;
  color: var(--text-2);
  text-align: start;
}

/* ------------------------------------------------------------ the screen -- */

.screen--duel {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}

.duelBar {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

.duelClock {
  flex: 1 1 auto;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: var(--s3);
}

.duelClock__track {
  height: 6px;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, .08);
  overflow: hidden;
}

.duelClock__fill {
  display: block;
  height: 100%;
  width: 100%;
  border-radius: var(--r-pill);
  background: linear-gradient(90deg, var(--ally-deep), var(--ally));
  /* No transition. The fill is redrawn every frame from the interpolated
     clock, and a transition on top of that lags behind the number beside it. */
}

.duelClock__value {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 20px;
  font-variant-numeric: tabular-nums;
  color: var(--text-1);
  min-width: 3.4ch;
  text-align: right;
}

.duelClock.is-low .duelClock__value { color: var(--no); }
.duelClock.is-low .duelClock__fill { background: linear-gradient(90deg, var(--no-deep), var(--no)); }

/* Momentum says so. A hand on the scales nobody is told about reads as a bug. */
.duelBadge {
  padding: 4px 10px;
  border: 1px solid color-mix(in srgb, var(--ally) 40%, transparent);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--ally) 12%, transparent);
  color: var(--ally);
  font-size: var(--t-label);
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  white-space: nowrap;
}

/* Practice wears the neutral colour rather than the cyan one: it is a statement
   about the match, not a bonus being handed out, and a grey badge does not
   compete with the scoreboard for the eye. */
.duelBadge--practice {
  border-color: var(--line-strong);
  background: transparent;
  color: var(--text-3);
}

/* ------------------------------------------------------------- the score -- */

/* Both bars are drawn against whoever is leading, so the picture is the GAP.
   Two independent bars would both sit near full and say nothing. */
.versus {
  display: grid;
  gap: var(--s2);
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-control);
  background: var(--felt);
}

.versus__side {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-areas: 'name score' 'track track';
  align-items: baseline;
  gap: 4px var(--s2);
  /* The players' own pair, not mint and rose: those mean right and wrong, and
     a scoreboard that paints the opponent "wrong" says something untrue. */
  --side: var(--ally);
}

.versus__side--them { --side: var(--foe); }

.versus__name {
  grid-area: name;
  font-size: 13px;
  font-weight: 700;
  color: var(--text-1);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.versus__kind {
  grid-area: name;
  justify-self: end;
  align-self: center;
  font-size: var(--t-micro);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-3);
  padding-inline-end: var(--s2);
}

.versus__score {
  grid-area: score;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 22px;
  font-variant-numeric: tabular-nums;
  color: var(--side);
  min-width: 3ch;
  text-align: right;
}

.versus__track {
  grid-area: track;
  height: 8px;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, .07);
  overflow: hidden;
}

.versus__fill {
  display: block;
  height: 100%;
  width: 0;
  border-radius: var(--r-pill);
  transition: width .28s var(--ease-out);
}

.versus__fill--me { background: linear-gradient(90deg, var(--ally-deep), var(--ally)); }
.versus__fill--opp { background: linear-gradient(90deg, var(--foe-deep), var(--foe)); }

/* ------------------------------------------------------------ the charge -- */

.duelMeta {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
  min-height: 26px;
}

.charge { display: flex; gap: 4px; }

.charge span {
  width: 22px;
  height: 9px;
  border-radius: 3px;
  background: rgba(255, 255, 255, .1);
  transition: background .18s ease, box-shadow .18s ease;
}

.charge span.is-on {
  background: linear-gradient(180deg, var(--ally), var(--ally-deep));
  box-shadow: 0 0 9px color-mix(in srgb, var(--ally) 50%, transparent);
}

/* The shield is drawn on the charge bar rather than somewhere else on screen,
   because it is bought with the same currency and spending on one is choosing
   not to spend on the other. */
.charge.is-shielded {
  padding: 2px 5px;
  margin: -2px -5px;
  border-radius: var(--r-pill);
  box-shadow: inset 0 0 0 1.5px var(--ally), 0 0 12px color-mix(in srgb, var(--ally) 35%, transparent);
}

.duelMeta__streak {
  font-size: var(--t-meta);
  color: var(--text-3);
}

.duelMeta__streak b {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 15px;
  color: var(--text-1);
}

/* Incoming. Loud, small, and gone when the effect is. */
.incoming {
  margin-inline-start: auto;
  padding: 3px 10px;
  border: 1px solid color-mix(in srgb, var(--foe) 45%, transparent);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--foe) 12%, transparent);
  color: var(--foe);
  font-size: var(--t-meta);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  animation: incoming-in .18s var(--ease-out) both;
}

@keyframes incoming-in {
  from { opacity: 0; transform: translateX(6px); }
}

/* ---------------------------------------------------------- the arsenal -- */

.atkRow {
  /* One row of four equal columns rather than a wrapping line of pills.
  
     What was here was 80x34 buttons floating between the card and the answer
     gates: under the 44px a finger actually needs, easy to miss, and reading as
     decoration beside the two big buttons rather than as part of the game. They
     are a quarter of the width each now, tall enough to hit without looking,
     and they sit on a rail of their own so the eye finds them as one thing. */
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: var(--s2);
  align-items: stretch;
  margin: 0 var(--s3) var(--s2);
  padding: var(--s2);
  border-radius: calc(var(--r-card) * .8);
  background: var(--panel);
  border: 1px solid var(--line);
  /* Never squashed to nothing. Everything above this row can give up height;
     the attacks cannot, because a half-height button reads as a broken one. */
  flex: none;
}

/* ------------------------------------------------------- fitting the card --

   `--card-h` is a fraction of the VIEWPORT, which is the right measure on the
   single-player screens and the wrong one here. This screen carries three rows
   the others do not — the scoreboard, the charge and streak line, and the
   attack row — and a card sized against the whole window ignores all three. On
   a 667px-tall phone that came to a 333px card standing in 271px of stage,
   overflowing 40px upwards over the streak, the charge meter and the incoming
   attack, and 22px down into the attack buttons themselves.
   The card covered the two things a duel is played by looking at.

   So on this screen the card is sized against the space that is actually left
   rather than against the window. `--card-h` stays the ceiling — the card is
   never bigger than it is elsewhere — and the stage's own height becomes the
   floor it cannot cross. */

.screen--duel .arena {
  /* Stretched rather than centred, so its height IS the row's height and the
     percentage below has something definite to resolve against. */
  align-self: stretch;
  height: 100%;
  min-height: 0;
  /* And one explicit row, capped. Without this the arena's single implicit row
     is sized by its tallest child — which is the gate rail, itself measured in
     viewport height — so the card's `100%` resolved against the very overflow
     it was supposed to prevent, and the whole rule quietly did nothing. */
  grid-template-rows: minmax(0, 1fr);
}

.screen--duel .deck { height: min(var(--card-h), 100%); }

/* The rails are measured in viewport height too, and a door taller than the
   room it opens onto is the same bug in a thinner shape. */
.screen--duel .gate { max-height: 100%; }

.atk {
  /* The name over the price, not beside it: two short lines fit a quarter of a
     phone's width at a size that can be read mid-match, where one line had to
     shrink to fit and then could not be. */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  /* 52 plus the row's padding clears the 44px target with room for a thumb
     that lands short — and this row sits directly above the answer gates,
     where a thumb that misses hits something that ends a card. */
  min-height: 52px;
  padding: 6px 4px;
  border: 1px solid color-mix(in srgb, var(--foe) 35%, transparent);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--foe) 10%, transparent);
  color: var(--text-1);
  font-family: var(--font-ui);
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
  /* Cards are thrown across this row's neighbourhood; a tap here must never be
     interpreted as the start of a drag. */
  touch-action: manipulation;
  transition: transform .1s var(--ease-out), opacity .18s ease;
}

.atk--def {
  border-color: color-mix(in srgb, var(--ally) 40%, transparent);
  background: color-mix(in srgb, var(--ally) 10%, transparent);
}

.atk:active { transform: scale(.94); }

/* Ready to fire. The disabled state was the only state that said anything, so
   an attack that had just become affordable announced itself by very quietly
   ceasing to be dim. This is the other half of that sentence. */
.atk.is-ready {
  border-color: color-mix(in srgb, var(--foe) 70%, transparent);
  background: color-mix(in srgb, var(--foe) 18%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--foe) 25%, transparent),
              0 4px 14px -6px color-mix(in srgb, var(--foe) 60%, transparent);
}

.atk--def.is-ready {
  border-color: color-mix(in srgb, var(--ally) 70%, transparent);
  background: color-mix(in srgb, var(--ally) 18%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--ally) 25%, transparent),
              0 4px 14px -6px color-mix(in srgb, var(--ally) 60%, transparent);
}

.atk:disabled {
  opacity: .32;
  cursor: default;
}

.atk__cost {
  display: grid;
  place-items: center;
  min-width: 20px;
  height: 20px;
  padding: 0 4px;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, .12);
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 12px;
  color: var(--ally);
}

/* The last third, where everything costs one less. The discount is visible on
   the buttons themselves — a price that quietly changed is a price nobody
   noticed changing. */
.atk.is-cheap .atk__cost {
  background: rgba(251, 191, 36, .2);
  color: var(--gold-lit);
}

/* -------------------------------------------------------------- effects -- */

/* Fog. Blurs and clears over the effect's own lifetime, so the card is at its
   worst the instant it lands and readable again by the time it ends — the
   player can always answer, they just cannot answer FAST. */
@keyframes fx-fog {
  from { filter: blur(6px) saturate(.85); }
  to   { filter: blur(0) saturate(1); }
}

.fx-fog .deck { animation: fx-fog var(--fx-ms, 3000ms) linear both; }

/* Jitter. Shakes the whole stack; the gesture is unaffected. */
@keyframes fx-jitter {
  0%, 100% { transform: translate3d(0, 0, 0); }
  20% { transform: translate3d(-4px, 1px, 0); }
  40% { transform: translate3d(3px, -2px, 0); }
  60% { transform: translate3d(-3px, -1px, 0); }
  80% { transform: translate3d(4px, 2px, 0); }
}

.fx-jitter .deck { animation: fx-jitter 140ms linear infinite; }

/* Ink. A blot over part of the meaning, fading as it goes. Placed on the arena
   rather than the card so it does not travel with a thrown card. */
.fx-inkblot .arena::after {
  content: '';
  position: absolute;
  inset: 38% 22% 34% 18%;
  border-radius: 46% 54% 38% 62% / 55% 42% 58% 45%;
  background: var(--bg-void);
  opacity: .92;
  animation: fx-ink var(--fx-ms, 4000ms) ease-out both;
  pointer-events: none;
}

.arena { position: relative; }

@keyframes fx-ink {
  from { opacity: .94; }
  to   { opacity: 0; }
}

/* Lock. The card will not move, and says so: without the chain and the pulse
   a frozen card is indistinguishable from a hung game, and the player blames
   the app rather than the opponent. */
.fx-lock .deck {
  pointer-events: none;
  animation: fx-lock var(--fx-ms, 2500ms) steps(1) both;
}

@keyframes fx-lock {
  from { filter: grayscale(.5) brightness(.8); }
  to   { filter: none; }
}

/* Silence is deliberately invisible here: it takes away the sound, the haptics
   and the streak readout, which is exactly what makes it hurt. Marking it on
   screen would hand back the feedback it removes. */

/* Mirror. The one effect that MUST announce itself — a silent control swap is
   indistinguishable from a broken gesture. */
.mirror {
  align-self: center;
  margin-top: var(--s2);
  padding: 4px 14px;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--foe) 18%, transparent);
  color: var(--foe);
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 13px;
  letter-spacing: .12em;
  text-transform: uppercase;
  animation: mirror-pulse 1s ease-in-out infinite;
}

@keyframes mirror-pulse {
  50% { opacity: .55; }
}

/* --------------------------------------------------------------- the end -- */

.duelOver {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: grid;
  place-items: center;
  padding: var(--gutter);
  background: rgba(7, 8, 15, .82);
  backdrop-filter: blur(8px);
  animation: screen-in .26s var(--ease-out) both;
}

.duelOver__panel {
  width: min(100%, 340px);
  display: grid;
  gap: var(--s2);
  justify-items: center;
  text-align: center;
  padding: var(--s5) var(--s4);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-panel);
  background: var(--felt-lift);
}

.duelOver__kicker {
  font-size: var(--t-label);
  font-weight: 700;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--text-3);
}

.duelOver[data-result='win'] .duelOver__kicker { color: var(--yes); }
.duelOver[data-result='loss'] .duelOver__kicker { color: var(--no); }

.duelOver__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 40px;
  font-variant-numeric: tabular-nums;
  color: var(--text-1);
}

.duelOver__side {
  margin: 0;
  font-size: var(--t-meta);
  color: var(--text-2);
}

.duelOver__trophies {
  margin: var(--s1) 0 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 17px;
  color: var(--gold-lit);
}

/* Where the trophies would have been said, in the voice of a footnote rather
   than of a prize. The player is not being denied something; they chose the
   run that does not count. */
.duelOver__trophies.is-practice {
  font-family: inherit;
  font-weight: 600;
  font-size: var(--t-meta);
  color: var(--text-3);
}

.duelOver__entries {
  margin: 0;
  font-size: var(--t-meta);
  color: var(--cyan);
}

.duelOver__actions {
  display: flex;
  gap: var(--s2);
  width: 100%;
  margin-top: var(--s3);
}

.duelOver__actions .btn { flex: 1 1 0; }

/* --------------------------------------------------------- reduced motion -- */

/* The shake is the only effect that is purely motion, and the server already
   swaps it for fog when a client asks for reduced motion. This is the belt to
   that brace: if one ever arrives anyway, it does not shake. */
@media (prefers-reduced-motion: reduce) {
  .fx-jitter .deck { animation: none; }
  .mirror { animation: none; }
  .incoming { animation: none; }
  .versus__fill { transition: none; }
}

/* ------------------------------------------------------- the opening count --

   Three seconds with the table covered, so that a match between two people
   starts when both of them are ready rather than when the one who happened to
   be looking at the screen starts swiping. It sits over the arena and not
   beside it, because the whole point is that the first card stays unread. */

.startCount {
  position: absolute;
  inset: 0;
  z-index: 4;
  display: grid;
  place-content: center;
  gap: var(--s2);
  justify-items: center;
  border-radius: var(--r-card);
  background: color-mix(in srgb, var(--bg-1) 88%, transparent);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}

.startCount__num {
  font-family: var(--font-display);
  font-size: clamp(64px, 22vmin, 132px);
  font-weight: 700;
  line-height: 1;
  color: var(--text-1);
  text-shadow: 0 0 28px color-mix(in srgb, var(--foe) 55%, transparent);
  /* Keyed on the number itself, so each second lands with its own beat rather
     than one animation running across all three. */
  animation: count-beat .9s var(--ease-out);
}

.startCount__label {
  font-family: var(--font-ui);
  font-size: 15px;
  font-weight: 700;
  color: var(--text-2);
  letter-spacing: .02em;
}

/* The last beat is the one that says go, so it is the one that is different. */
.startCount.is-go .startCount__num {
  color: var(--ally);
  text-shadow: 0 0 34px color-mix(in srgb, var(--ally) 65%, transparent);
}

@keyframes count-beat {
  0%   { transform: scale(.72); opacity: 0; }
  30%  { transform: scale(1.06); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .startCount__num { animation: none; }
}

/* An effect that has just landed, as opposed to one that has been sitting
   there. The badge already says what is on the player; this is what makes the
   arrival of it impossible to miss while it is arriving. */
.incoming.is-hit {
  animation: incoming-hit .5s var(--ease-out);
}

@keyframes incoming-hit {
  0%   { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in srgb, var(--foe) 70%, transparent); }
  35%  { transform: scale(1.14); box-shadow: 0 0 0 8px color-mix(in srgb, var(--foe) 0%, transparent); }
  100% { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in srgb, var(--foe) 0%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
  .incoming.is-hit { animation: none; outline: 2px solid var(--foe); }
}
