/* ════════════════════════════════════════════════════════════════════
   CANVASS PRO · fx.css  →  the FX / celebration + motion engine
   TOKEN-ONLY. Not one raw hex lives here. Every celebration color (the
   disposition burst, the flame, the rank tier hue, the confetti shard,
   the combo gold) arrives from JS as an inline custom property on the
   element (--c, --c2, the disposition --c pattern), exactly like the
   pins radial and the hail color. This file owns only geometry, motion,
   and the token-derived chrome.

   THE LAW (restated so this file never drifts):
     - base surfaces get ZERO decoration here; the FX layer is a separate
       fixed overlay (#fxLayer) that paints OVER everything and eats no
       pointer events. The reward energy lives here; the instrument stays
       matte.
     - glow / gradient / elevation appear ONLY inside a reward moment and
       only for that moment's life (the burst ring, the level-up emblem,
       the flame core, the goal sweep). Never a persistent halo.
     - prefers-reduced-motion degrades every effect to instant: the reward
       still REGISTERS (the number flashes, the emblem appears, the ribbon
       shows) but nothing travels and no particles spawn.
   ════════════════════════════════════════════════════════════════════ */

/* ── the overlay the engine injects once. Sits above the radial (1290)
      and below the toasts (1300) so a celebration never buries a toast
      and never blocks a tap. ──────────────────────────────────────── */
#fxLayer {
  position: fixed; inset: 0; z-index: 1295;
  pointer-events: none; overflow: hidden;
  contain: layout style;
}
#fxLayer * { pointer-events: none; }

/* a celebration that DOES take a tap (the level-up card dismisses on tap)
   opts back in explicitly. */
#fxLayer .fx-tappable { pointer-events: auto; }

/* ════════════════════════════════════════════════════════════════
   1 · THE KNOCK BURST · the dopamine floor (a milled ring, not a
   firework). One expanding ring in the disposition color (--c, inline).
   ════════════════════════════════════════════════════════════════ */
.fx-burst {
  position: absolute; left: 0; top: 0;
  width: 0; height: 0;
  transform: translate(var(--x), var(--y));
}
.fx-ring {
  position: absolute; left: 50%; top: 50%;
  width: var(--r0, 8px); height: var(--r0, 8px);
  margin-left: calc(var(--r0, 8px) / -2);
  margin-top: calc(var(--r0, 8px) / -2);
  border-radius: 50%;
  border: 2px solid var(--c, var(--plat-dim));
  opacity: 0;
  will-change: transform, opacity;
}
.fx-ring.go { animation: fxRing var(--dur, 360ms) var(--ease-exit) forwards; }
@keyframes fxRing {
  0%   { transform: scale(.35); opacity: .9; }
  100% { transform: scale(var(--scale, 7)); opacity: 0; }
}
/* the faint second ring on interested+ (a half-beat behind, softer) */
.fx-ring.fx-ring-2 { border-width: 1px; opacity: 0; }
.fx-ring.fx-ring-2.go { animation: fxRing2 var(--dur, 360ms) var(--ease-exit) 60ms forwards; }
@keyframes fxRing2 {
  0%   { transform: scale(.5); opacity: .5; }
  100% { transform: scale(calc(var(--scale, 7) * 1.25)); opacity: 0; }
}

/* the money ring (appt/sold/contract): a thicker double ring that carries
   a transient ember glow for its life only, then is gone. The glow color
   is the inline --c so it reads as field language (ember for money). */
.fx-ring.fx-ring-money { border-width: 2.5px; }
.fx-ring.fx-ring-money.go {
  box-shadow: 0 0 18px 1px color-mix(in srgb, var(--c, var(--ember)) 55%, transparent);
  animation: fxRingMoney var(--dur, 560ms) var(--ease-exit) forwards;
}
@keyframes fxRingMoney {
  0%   { transform: scale(.3);  opacity: .95; }
  70%  { opacity: .55; }
  100% { transform: scale(var(--scale, 8)); opacity: 0; }
}

/* ════════════════════════════════════════════════════════════════
   2 · THE SCORE FLYUP · "+12" / "SET" / "SOLD" rises and fades.
   Color via inline --c; quiet dispositions use --plat-dim (honest:
   small reward for a small outcome).
   ════════════════════════════════════════════════════════════════ */
.fx-flyup {
  position: absolute; left: 0; top: 0;
  transform: translate(var(--x), var(--y)) translate(-50%, -50%);
  font: 800 var(--fs, 17px) / 1 var(--body);
  font-variant-numeric: tabular-nums; font-feature-settings: "tnum";
  letter-spacing: -.01em; white-space: nowrap;
  color: var(--c, var(--platinum));
  opacity: 0; will-change: transform, opacity;
  text-shadow: 0 1px 6px color-mix(in srgb, var(--obsidian) 70%, transparent);
}
/* a money flyup gets a hairline ember edge so the win reads as money */
.fx-flyup.fx-money {
  -webkit-text-stroke: 0.5px color-mix(in srgb, var(--ember) 70%, transparent);
}
.fx-flyup.fx-word { font-size: var(--fs, 26px); letter-spacing: .06em; text-transform: uppercase; }
.fx-flyup.go { animation: fxFly var(--dur, 620ms) var(--ease-exit) forwards; }
@keyframes fxFly {
  0%   { transform: translate(var(--x), var(--y)) translate(-50%, -50%) scale(.7); opacity: 0; }
  22%  { transform: translate(var(--x), calc(var(--y) - 6px)) translate(-50%, -50%) scale(1.08); opacity: 1; }
  60%  { opacity: 1; }
  100% { transform: translate(var(--x), calc(var(--y) - 30px)) translate(-50%, -50%) scale(1); opacity: 0; }
}

/* ════════════════════════════════════════════════════════════════
   3 · PARTICLES · confetti shards + sparks. Each shard is a tiny rhombus
   (echoing the appt diamond), colored by an inline --c per shard. Gravity
   + drift handled by per-shard custom properties set in JS.
   ════════════════════════════════════════════════════════════════ */
.fx-particles { position: absolute; left: 0; top: 0; width: 0; height: 0; }
.fx-shard {
  position: absolute; left: 0; top: 0;
  width: var(--sw, 7px); height: var(--sh, 7px);
  background: var(--c, var(--ember));
  border-radius: 1px;
  transform: translate(var(--x), var(--y)) rotate(0deg);
  opacity: 0;
}
/* a rhombus shard (the diamond echo) */
.fx-shard.fx-rhombus { transform: translate(var(--x), var(--y)) rotate(45deg); border-radius: 0; }
.fx-shard.go { animation: fxShard var(--dur, 1100ms) var(--ease-exit) forwards; }
@keyframes fxShard {
  0%   { transform: translate(var(--x), var(--y)) rotate(var(--rot0, 0deg)) scale(.6); opacity: 1; }
  12%  { opacity: 1; }
  100% { transform: translate(calc(var(--x) + var(--dx)), calc(var(--y) + var(--dy))) rotate(var(--rot1, 200deg)) scale(1); opacity: 0; }
}

/* a spark: a thin line that springs out and fades (the door warmed up) */
.fx-spark {
  position: absolute; left: 0; top: 0;
  width: var(--len, 10px); height: 2px;
  background: linear-gradient(90deg, var(--c, var(--ember-hi)), transparent);
  transform-origin: left center;
  transform: translate(var(--x), var(--y)) rotate(var(--ang, 0deg)) scaleX(.2);
  opacity: 0; border-radius: 2px;
}
.fx-spark.go { animation: fxSpark var(--dur, 520ms) var(--ease-exit) forwards; }
@keyframes fxSpark {
  0%   { transform: translate(var(--x), var(--y)) rotate(var(--ang, 0deg)) scaleX(.2); opacity: 1; }
  100% { transform: translate(calc(var(--x) + var(--ex)), calc(var(--y) + var(--ey))) rotate(var(--ang, 0deg)) scaleX(1); opacity: 0; }
}

/* ════════════════════════════════════════════════════════════════
   4 · THE RIBBON · a celebratory chip that slides from the top of the
   map (NOT a toast). Token chrome; the only color is a hairline accent
   in the inline --c (ember for money, tier hue for streak/badge).
   ════════════════════════════════════════════════════════════════ */
.fx-ribbon-wrap {
  position: absolute; left: 0; right: 0; top: 0;
  display: flex; justify-content: center;
  padding-top: max(14px, env(safe-area-inset-top));
}
.fx-ribbon {
  display: inline-flex; align-items: center; gap: 10px;
  max-width: calc(100vw - 36px);
  padding: 9px 16px 9px 13px;
  background: var(--header-bg);
  -webkit-backdrop-filter: saturate(140%) blur(14px);
  backdrop-filter: saturate(140%) blur(14px);
  border: 1px solid var(--line-hi);
  border-top: 2px solid var(--c, var(--ember));
  border-radius: var(--r-card);
  box-shadow: var(--e3);
  color: var(--platinum);
  font: 650 13.5px var(--body); letter-spacing: .005em;
  opacity: 0; transform: translateY(-18px);
  will-change: transform, opacity;
}
.fx-ribbon .fx-rib-glyph { display: inline-flex; flex: 0 0 auto; color: var(--c, var(--ember)); }
.fx-ribbon .fx-rib-k {
  font: 700 9.5px var(--mono); letter-spacing: .14em; text-transform: uppercase;
  color: var(--c, var(--ember)); margin-right: 2px;
}
.fx-ribbon .fx-rib-strong { font-weight: 750; }
.fx-ribbon.go { animation: fxRibbon var(--dur, 1600ms) var(--ease-spring) forwards; }
@keyframes fxRibbon {
  0%   { opacity: 0; transform: translateY(-18px) scale(.96); }
  9%   { opacity: 1; transform: translateY(0) scale(1); }
  86%  { opacity: 1; transform: translateY(0) scale(1); }
  100% { opacity: 0; transform: translateY(-14px) scale(.99); }
}

/* ════════════════════════════════════════════════════════════════
   5 · THE LEVEL-UP MOMENT · the one indulgent sequence. The map dims
   for the beat (a token scrim), a bespoke rank emblem scales in with
   overshoot, a ring sweeps it, the rank name + level read below.
   Tier hue arrives inline as --c; the bloom is transient.
   ════════════════════════════════════════════════════════════════ */
.fx-stage {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 16px; padding: 24px;
  background: color-mix(in srgb, var(--obsidian) 30%, transparent);
  opacity: 0; will-change: opacity;
}
.fx-stage.go { animation: fxStageIn 160ms var(--ease-exit) forwards; }
.fx-stage.out { animation: fxStageOut 220ms var(--ease) forwards; }
@keyframes fxStageIn  { from { opacity: 0; } to { opacity: 1; } }
@keyframes fxStageOut { from { opacity: 1; } to { opacity: 0; } }

.fx-emblem-wrap {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  width: 168px; height: 168px;
}
/* the sweeping ring of light around the emblem (transient gradient, on the
   reward only). Color = tier hue inline. */
.fx-emblem-sweep {
  position: absolute; inset: -6px; border-radius: 50%;
  background: conic-gradient(from 0deg,
    transparent 0deg,
    color-mix(in srgb, var(--c, var(--ember)) 70%, transparent) 40deg,
    transparent 90deg);
  opacity: 0; mix-blend-mode: screen;
  will-change: transform, opacity;
}
.fx-stage.go .fx-emblem-sweep { animation: fxSweep 1100ms var(--ease-exit) 120ms; }
@keyframes fxSweep {
  0%   { opacity: 0; transform: rotate(-30deg); }
  18%  { opacity: .9; }
  100% { opacity: 0; transform: rotate(330deg); }
}
.fx-emblem {
  position: relative; width: 132px; height: 132px;
  filter: drop-shadow(0 0 20px color-mix(in srgb, var(--c, var(--ember)) 35%, transparent));
  transform: scale(.4); opacity: 0;
  will-change: transform, opacity;
}
.fx-stage.go .fx-emblem { animation: fxEmblemIn 1100ms var(--ease-spring) forwards; }
@keyframes fxEmblemIn {
  0%   { transform: scale(.4) rotate(-8deg); opacity: 0; }
  35%  { transform: scale(1.12) rotate(2deg); opacity: 1; }
  55%  { transform: scale(.97); }
  100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
.fx-levelcap {
  font: 700 10px var(--mono); letter-spacing: .2em; text-transform: uppercase;
  color: var(--c, var(--ember));
  opacity: 0; transform: translateY(6px);
}
.fx-rankname {
  font: 800 28px var(--disp); letter-spacing: .04em; text-transform: uppercase;
  color: var(--platinum); line-height: 1;
  opacity: 0; transform: translateY(8px);
  text-shadow: 0 2px 18px color-mix(in srgb, var(--c, var(--ember)) 30%, transparent);
}
.fx-sub {
  font: 600 12.5px var(--body); color: var(--plat-dim);
  opacity: 0; transform: translateY(6px);
}
.fx-stage.go .fx-levelcap { animation: fxTextUp 420ms var(--ease-exit) 460ms forwards; }
.fx-stage.go .fx-rankname { animation: fxTextUp 460ms var(--ease-spring) 540ms forwards; }
.fx-stage.go .fx-sub      { animation: fxTextUp 420ms var(--ease-exit) 660ms forwards; }
@keyframes fxTextUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }

/* the SOLD mintmark + the badge unlock reuse the emblem stage geometry
   with a tighter scale; the word stamps with a ring sweep. */
.fx-mintmark { width: 116px; height: 116px; }
.fx-stage.fx-tight .fx-emblem-wrap { width: 150px; height: 150px; }
.fx-stage.fx-tight .fx-emblem { width: 116px; height: 116px; }

/* ════════════════════════════════════════════════════════════════
   6 · THE GOAL-CLEAR VICTORY SWEEP · a ring of light runs around the
   HUD goal ring on first clear, plus a center burst at the ring. The
   sweep is a transient ember stroke that "completes" the ring.
   ════════════════════════════════════════════════════════════════ */
.fx-goalsweep {
  position: absolute; left: 0; top: 0;
  width: var(--d, 60px); height: var(--d, 60px);
  transform: translate(var(--x), var(--y)) translate(-50%, -50%);
}
.fx-goalsweep svg { width: 100%; height: 100%; overflow: visible; }
.fx-goalsweep .gsw {
  fill: none; stroke: var(--ember); stroke-width: 4; stroke-linecap: round;
  filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ember) 60%, transparent));
}
.fx-goalsweep.go .gsw { animation: fxGoalSweep 760ms var(--ease-exit) forwards; }
@keyframes fxGoalSweep {
  0%   { stroke-dashoffset: var(--circ, 170); opacity: 1; }
  85%  { stroke-dashoffset: 0; opacity: 1; }
  100% { stroke-dashoffset: 0; opacity: 0; }
}

/* the contract gold sweep: one diagonal light pass across the whole map
   frame, behind the pins, then gone. Token gradient (--grad-ember). */
.fx-goldpass {
  position: absolute; inset: 0; opacity: 0;
  background: linear-gradient(115deg,
    transparent 38%,
    color-mix(in srgb, var(--ember-hi) 26%, transparent) 50%,
    transparent 62%);
  transform: translateX(-40%);
  will-change: transform, opacity;
}
.fx-goldpass.go { animation: fxGoldPass 900ms var(--ease-exit) forwards; }
@keyframes fxGoldPass {
  0%   { opacity: 0; transform: translateX(-45%); }
  30%  { opacity: 1; }
  100% { opacity: 0; transform: translateX(45%); }
}

/* the sold vignette: a radial ember scrim that blooms and clears (the room
   lights up for a beat). The one full-bleed moment; never persists. */
.fx-vignette {
  position: absolute; inset: 0; opacity: 0;
  background: radial-gradient(120% 90% at 50% 42%,
    transparent 55%,
    color-mix(in srgb, var(--ember) 9%, transparent) 100%);
  will-change: opacity;
}
.fx-vignette.go { animation: fxVignette 700ms var(--ease-exit) forwards; }
@keyframes fxVignette {
  0%   { opacity: 0; }
  30%  { opacity: 1; }
  100% { opacity: 0; }
}

/* ════════════════════════════════════════════════════════════════
   7 · THE STREAK FLAME · a bespoke vector that grows with the streak.
   Lives in the HUD, the You hub, and the tab badge (the engine returns
   the <svg>; this styles its container + the "on fire" escalation).
   The flame color escalates via inline --c on the svg (ember -> ember-hi
   -> a hot core); the heat-core gradient is contained to the glyph.
   ════════════════════════════════════════════════════════════════ */
.fx-flame { display: inline-flex; align-items: center; line-height: 0; }
.fx-flame svg { display: block; }
.fx-flame .fl-core { transform-origin: 50% 80%; }
.fx-flame.flicker .fl-core { animation: fxFlicker 1.6s var(--ease) infinite; }
@keyframes fxFlicker {
  0%, 100% { transform: scaleY(1) scaleX(1); }
  40%      { transform: scaleY(1.06) scaleX(.97); }
  70%      { transform: scaleY(.96) scaleX(1.03); }
}
/* on fire: a transient heat-haze that runs once when the state flips. */
.fx-heathaze {
  position: absolute; left: 0; top: 0;
  width: 120px; height: 120px;
  transform: translate(var(--x), var(--y)) translate(-50%, -50%);
  border-radius: 50%; opacity: 0;
  background: radial-gradient(circle,
    color-mix(in srgb, var(--ember-hi) 22%, transparent) 0%,
    transparent 70%);
  will-change: transform, opacity;
}
.fx-heathaze.go { animation: fxHaze 2000ms var(--ease) forwards; }
@keyframes fxHaze {
  0%   { opacity: 0; transform: translate(var(--x), var(--y)) translate(-50%, -50%) scale(.6); }
  20%  { opacity: .9; }
  100% { opacity: 0; transform: translate(var(--x), var(--y)) translate(-50%, -50%) scale(1.8); }
}

/* ════════════════════════════════════════════════════════════════
   8 · THE COMBO METER · a "x3 COMBO" sprite that pumps on each quick
   knock and decays. Hot-gold hue arrives inline as --c.
   ════════════════════════════════════════════════════════════════ */
.fx-combo {
  position: absolute; left: 0; top: 0;
  transform: translate(var(--x), var(--y)) translate(-50%, -50%);
  display: inline-flex; align-items: baseline; gap: 4px;
  padding: 4px 9px;
  background: color-mix(in srgb, var(--graphite-3) 78%, transparent);
  border: 1px solid color-mix(in srgb, var(--c, var(--ember-hi)) 45%, var(--line));
  border-radius: var(--r-pill);
  box-shadow: var(--e3);
  color: var(--c, var(--ember-hi));
  font: 800 13px var(--body); letter-spacing: .02em;
  white-space: nowrap; opacity: 0;
  will-change: transform, opacity;
}
.fx-combo .fx-combo-n { font-size: 16px; font-variant-numeric: tabular-nums; }
.fx-combo .fx-combo-k { font: 700 8.5px var(--mono); letter-spacing: .16em; text-transform: uppercase; }
.fx-combo.pump { animation: fxComboPump 420ms var(--ease-spring) forwards; }
@keyframes fxComboPump {
  0%   { opacity: 1; transform: translate(var(--x), var(--y)) translate(-50%, -50%) scale(1.28); }
  40%  { transform: translate(var(--x), var(--y)) translate(-50%, -50%) scale(.96); }
  70%  { opacity: 1; transform: translate(var(--x), var(--y)) translate(-50%, -50%) scale(1.04); }
  100% { opacity: 1; transform: translate(var(--x), var(--y)) translate(-50%, -50%) scale(1); }
}
.fx-combo.decay { animation: fxComboDecay 360ms var(--ease) forwards; }
@keyframes fxComboDecay { from { opacity: 1; } to { opacity: 0; transform: translate(var(--x), var(--y)) translate(-50%, -50%) scale(.9); } }

/* ════════════════════════════════════════════════════════════════
   9 · THE SOUND TOGGLE · a small bespoke control the You hub / topbar
   overflow can drop. Token chrome; the "on" accent is ember.
   ════════════════════════════════════════════════════════════════ */
.fx-sound-toggle {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 7px 11px;
  background: var(--graphite);
  border: 1px solid var(--line); border-radius: var(--r-control);
  box-shadow: var(--e1), var(--e-inset);
  color: var(--plat-dim); font: 600 12.5px var(--body);
  transition: color var(--m-fast) var(--ease), border-color var(--m-fast) var(--ease),
              background var(--m-fast) var(--ease), box-shadow var(--m-fast) var(--ease),
              transform var(--m-press) var(--ease-press);
}
.fx-sound-toggle:hover {
  color: var(--platinum); border-color: var(--line-hi);
  background: var(--graphite-3); box-shadow: var(--e2), var(--e-inset);
}
.fx-sound-toggle:focus-visible { outline: none; box-shadow: var(--ring-ember), var(--e-inset); }
.fx-sound-toggle:active { box-shadow: var(--e-press); transform: translateY(1px) scale(.99); }
.fx-sound-toggle.on { color: var(--platinum); border-color: color-mix(in srgb, var(--ember) 50%, var(--line)); }
.fx-sound-toggle .fx-sound-led {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--plat-faint); flex: 0 0 auto;
}
.fx-sound-toggle.on .fx-sound-led { background: var(--ember); }
.fx-sound-toggle svg { color: currentColor; }

/* ════════════════════════════════════════════════════════════════
   10 · COUNT-UP · the element animates its value; while ticking it gets
   a hairline emphasis so the change reads. No travel; just the number.
   ════════════════════════════════════════════════════════════════ */
.fx-counting { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }
.fx-tick { animation: fxTick var(--m-pulse) var(--ease-spring); }
@keyframes fxTick {
  0%   { transform: translateY(0); }
  40%  { transform: translateY(-2px); }
  100% { transform: translateY(0); }
}

/* ════════════════════════════════════════════════════════════════
   REDUCED MOTION · the hard requirement. Every effect degrades to an
   instant register: the reward APPEARS (number flashes, emblem shows,
   ribbon reads) but nothing travels and no particle moves. We let the
   ribbon + stage hold briefly via opacity so the message is readable,
   and we suppress all transform-based travel + particle spawns.
   (The engine ALSO checks reduced-motion in JS and never spawns shards;
   this block is the belt-and-braces CSS half of the contract.)
   ════════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  .fx-ring, .fx-ring.go,
  .fx-shard, .fx-shard.go,
  .fx-spark, .fx-spark.go,
  .fx-goalsweep.go .gsw,
  .fx-goldpass.go,
  .fx-vignette.go,
  .fx-heathaze.go,
  .fx-combo.pump, .fx-combo.decay,
  .fx-emblem-sweep,
  .fx-tick { animation: none !important; }

  /* the flyup still flashes its value once, in place */
  .fx-flyup.go { animation: fxFlash 600ms steps(1, end) forwards !important; opacity: 1; }
  @keyframes fxFlash { 0% { opacity: 1; } 80% { opacity: 1; } 100% { opacity: 0; } }

  /* the ribbon appears and holds, no slide */
  .fx-ribbon { transform: none; }
  .fx-ribbon.go { animation: fxRibbonStill var(--dur, 1600ms) steps(1, end) forwards !important; }
  @keyframes fxRibbonStill { 0% { opacity: 1; } 90% { opacity: 1; } 100% { opacity: 0; } }

  /* the level-up emblem appears instantly, no sweep, no overshoot */
  .fx-stage.go .fx-emblem { animation: none !important; transform: scale(1); opacity: 1; }
  .fx-stage.go .fx-levelcap, .fx-stage.go .fx-rankname, .fx-stage.go .fx-sub {
    animation: none !important; opacity: 1; transform: none;
  }
  .fx-stage.go { animation: fxStageIn 1ms steps(1) forwards; }
  /* and dismisses instantly; fx.js removes the node on its own timer */
  .fx-stage.out { animation: fxStageOut 1ms steps(1) forwards; }

  .fx-flame.flicker .fl-core { animation: none; }
  .fx-emblem { filter: none; }

  /* no press travel on the sound toggle */
  .fx-sound-toggle:active { transform: none; }
}
