/* ===========================================================================
   effects.css — the 12 technique engines.

   Loaded after skins.css. Same one-way dependency: nothing in styles.css or
   skins.css may reference anything here, so the app is fully styled and fully
   skinned with this file absent.

   KEYED BY SLOT, NOT BY EFFECT. Selectors read
       html[data-slot-<slot>="<effect-id>"]
   which is what makes "one worn per slot" structurally true rather than merely
   enforced in JS — there is exactly one attribute per slot, so a second card
   effect has physically nowhere to live.

   THE READABILITY RULE IS NOW A WARNING, NOT A WALL. Earlier sessions forbade
   anything that recoloured a category chip or rarity mark. The user's call:
   choosing a cosmetic that trades legibility for spectacle is a legitimate kind
   of fun. So those effects ship, they carry `warn` in the catalog so the Market
   says so plainly, and the intensity dial is the way back. What is NOT allowed
   is an effect that reduces readability *without saying so*.

   PARTICLE COUNTS are set in JS (effects-run.js), not here, because the
   intensity dial scales them. This file only knows how a particle moves.

   ---------------------------------------------------------------------------
   LAYERING BANDS — the whole app, not just this file.

   Compiled by auditing every z-index across styles.css, skins.css and this
   file. Pick a band, don't invent a number. If something needs to sit between
   two bands, that's a sign the bands are wrong — say so rather than using 951.

     -1        decoration BEHIND its own element's background. Rings and
               borders drawn as a pseudo-element on a `position:relative`
               parent, which is visible only where it overhangs (see the
               avatar rank ring). Works because a relatively-positioned,
               z-index:auto parent creates no stacking context — the moment
               something upstream does, a -1 child disappears entirely.
     0         AMBIENT layers, behind the whole app: particle layers, the
               aurora, the ambient flash, the skin's ambience overlay.
     1         the app itself (.app-shell) and content inside a card that has
               a layer behind it.
     2-9       decoration ABOVE content but inside one element — the sweep
               sheen (2), sparks and floating numbers (5).
     10-99     attached popups that belong to a control: tooltips (10),
               the notification panel (60).
     500       map furniture, which has to clear Leaflet's own panes.
     900-949   persistent chrome: the top bar (900), the account menu (900).
     940       full-window one-shot celebrations. Deliberately ABOVE the top
               bar (so a level-up covers everything) and BELOW modals (so it
               never obscures a dialog you have to answer).
     950-999   transient overlays: search results, the loot modal.
     1000      modal overlays that take the whole screen.
     1010+     toasts. Above modals on purpose — a toast reports what just
               happened, and one hidden behind a dialog is one nobody reads.

   TWO RULES THAT COST US BUGS ALREADY, both worth keeping:

   1. A COSMETIC MUST NOT CHANGE LAYOUT FOR SOMEONE NOT WEARING IT. Every rule
      in this file hangs off a `html[data-slot-*]` attribute. An unscoped rule
      here reaches every page of an account that owns nothing — which is how
      `z-index:1` ended up on the children of every card in the app.
   2. NEVER ANIMATE A PROPERTY THAT AFFECTS DOCUMENT FLOW, and never let a
      fixed-position decoration extend past the viewport. `inset:-20%` on a
      fixed overlay put 254px of horizontal overflow on every page and broke
      the zero-overflow-at-390px guarantee. Pan a gradient inside a stationary
      box instead.
   =========================================================================== */

/* ---------------------------------------------------------------------------
   Shared particle chrome. Every falling/rising/wandering layer is a
   `.fx-layer` full of `.fx-p` children; only the keyframes differ.
   --------------------------------------------------------------------------- */
.fx-layer{
  position:absolute; inset:0; overflow:hidden; pointer-events:none;
  z-index:0; border-radius:inherit;
}
.fx-layer.fx-fixed{ position:fixed; z-index:0; }
.fx-p{ position:absolute; color:var(--fx-tint, var(--text-accent)); line-height:0; }
.fx-p svg{ display:block; }

/* Content has to out-rank a layer that sits inside a card or panel, or the
   particles paint over the words.

   SCOPED to the slots that actually insert a layer. The first version applied
   this to every card in the app unconditionally, which silently gave a
   stacking context to the children of every record card, quest card and Camp
   panel whether or not any effect was worn — changing how absolutely-positioned
   descendants stack on pages that had no cosmetics at all. A cosmetic must
   never alter layout for someone who isn't wearing it. */
/* `:not(.fx-layer)` is load-bearing, not tidiness. Without it this rule hits
   the particle layer itself — it IS a direct child of the card — forcing it to
   position:relative, which collapses it to a zero-height flex item at the foot
   of the card. The particles then animate inside a box with no height and the
   effect looks broken in a way that inspecting the particles never reveals,
   because the particles are fine; their container isn't. */
html[data-on-fall-card] .record-card > *:not(.fx-layer),
html[data-on-fall-card] .loot-card > *:not(.fx-layer),
html[data-on-fall-card] .quest-card > *:not(.fx-layer),
html[data-on-fall-card] .shared-card > *:not(.fx-layer),
html[data-on-fall-card] .codex-card > *:not(.fx-layer),
html[data-on-rise-card] .record-card > *:not(.fx-layer),
html[data-on-rise-card] .loot-card > *:not(.fx-layer),
html[data-on-rise-card] .quest-card > *:not(.fx-layer),
html[data-on-rise-card] .shared-card > *:not(.fx-layer),
html[data-on-rise-card] .codex-card > *:not(.fx-layer),
html[data-on-fall-title] .page-header > *:not(.fx-layer){ position:relative; z-index:1; }

/* =================== 1. FALL =================== */
@keyframes fx-fall{
  0%{ transform:translate(0,-16px) rotate(0); opacity:0; }
  12%{ opacity:.85; }
  50%{ transform:translate(18px,45vh) rotate(140deg); }
  88%{ opacity:.7; }
  100%{ transform:translate(-10px,102vh) rotate(300deg); opacity:0; }
}
/* Inside a box the travel is the box, not the viewport — a card-scoped leaf
   that falls 102vh is a leaf you never see. */
@keyframes fx-fall-box{
  0%{ transform:translate(0,-16px) rotate(0); opacity:0; }
  15%{ opacity:.8; }
  100%{ transform:translate(-8px,140px) rotate(280deg); opacity:0; }
}
.fx-fall > .fx-p{ top:-16px; animation:fx-fall linear infinite; }
.fx-fall.fx-box > .fx-p{ animation-name:fx-fall-box; }

/* A page header is one line tall. fx-fall-box travels 140px, so inside a 32px
   band with overflow:hidden the particle was clipped almost immediately and the
   effect read as broken. Its own keyframe, scaled to the strip it lives in. */
@keyframes fx-fall-strip{
  0%{ transform:translate(0,-10px) rotate(0); opacity:0; }
  20%{ opacity:.85; }
  80%{ opacity:.6; }
  100%{ transform:translate(-6px,54px) rotate(190deg); opacity:0; }
}
.fx-fall.fx-strip > .fx-p{ animation-name:fx-fall-strip; }

/* Rain is the same engine with a different particle: no rotation, faster. */
.fx-rain > .fx-p{
  top:-14px; width:1.5px; height:12px; border-radius:1px;
  background:linear-gradient(transparent, var(--fx-tint, #7fb8d8));
  animation:fx-rain linear infinite;
}
@keyframes fx-rain{
  0%{ transform:translateY(-14px); opacity:0; }
  15%{ opacity:.7; }
  100%{ transform:translateY(105vh); opacity:.3; }
}
.fx-rain.fx-box > .fx-p{ animation-name:fx-rain-box; }
@keyframes fx-rain-box{
  0%{ transform:translateY(-14px); opacity:0; }
  15%{ opacity:.7; }
  100%{ transform:translateY(150px); opacity:.25; }
}

/* =================== 2. RISE =================== */
@keyframes fx-rise{
  0%{ transform:translate(0,0) scale(1); opacity:1; background:#ffd24a; }
  60%{ background:var(--fx-tint, #ff7a3a); }
  100%{ transform:translate(var(--sway,8px),-70vh) scale(.2); opacity:0; background:#c9302a; }
}
@keyframes fx-rise-box{
  0%{ transform:translate(0,0) scale(.6); opacity:0; }
  20%{ opacity:.9; }
  100%{ transform:translate(var(--sway,8px),-120px) scale(1.1); opacity:0; }
}
.fx-rise > .fx-p{
  bottom:-6px; width:3px; height:3px; border-radius:50%;
  background:var(--fx-tint,#ff9a4a); animation:fx-rise ease-out infinite;
}
.fx-rise.fx-box > .fx-p{ animation-name:fx-rise-box; }

/* =================== CARD PARTICLES ===================
   Leaf Litter and Fermenting, rebuilt to match the user's own gallery rather
   than reusing the generic box keyframes. Two things were wrong before: the
   motion was a straight drop instead of their S-curve drift, and the travel was
   a fixed 140px, which is longer than most cards are tall — so a leaf spent
   most of its life clipped.

   TRAVEL IS NOW MEASURED. effects-run.js reads each card's height and sets
   --fx-drop on the particle, so the same keyframe works in a 70px inventory
   card and a 160px loot card. Percentages can't do this: a % inside translate()
   resolves against the PARTICLE's own size, not its container.  */

/* --- Leaf Litter: the user's leaf-fall curve, card-scaled ---
   Their original: 50% at (18px, 60px) rot 120deg, 100% at (-8px, 150px) rot
   280deg over a 130px stage. Kept the same proportions and rotations so it
   drifts the same way, with the distances expressed against the card. */
@keyframes fx-leaf-card{
  0%{   transform:translate(0, -14px) rotate(0deg); opacity:0; }
  10%{  opacity:.9; }
  50%{  transform:translate(16px, calc(var(--fx-drop,110px) * 0.42)) rotate(120deg); }
  88%{  opacity:.85; }
  100%{ transform:translate(-8px, var(--fx-drop,110px)) rotate(280deg); opacity:0; }
}
.fx-fall.fx-leaf > .fx-p{ top:-14px; animation:fx-leaf-card linear infinite; }
.fx-fall.fx-leaf > .fx-p svg{ display:block; }

/* --- Fermenting: the user's potion bubbles ---
   Outlined rather than filled is what makes them read as liquid instead of as
   sparks, and the 0.5 -> 1.15 scale is theirs: a bubble grows as it rises. */
@keyframes fx-bubble-card{
  0%{   transform:translateY(0) scale(.5); opacity:0; }
  20%{  opacity:.9; }
  100%{ transform:translateY(calc(var(--fx-drop,110px) * -1)) scale(1.15); opacity:0; }
}
.fx-rise.fx-brew > .fx-p{
  bottom:-8px; border-radius:50%; background:none;
  border:1px solid var(--fx-tint, rgba(190,140,255,.6));
  animation:fx-bubble-card ease-in infinite;
}
/* The vessel. Their demo tinted the whole stage; here it's a wash at the foot
   of the card only, so the card's own surface colour still reads and the
   category stripe is untouched. */
.fx-brew::after{
  content:""; position:absolute; left:0; right:0; bottom:0; height:55%;
  background:linear-gradient(to top, rgba(140,90,220,.16), transparent);
  pointer-events:none;
}

/* =================== 3. WANDER =================== */
@keyframes fx-wander{
  0%{ transform:translate(0,0); }
  25%{ transform:translate(30px,-18px); }
  50%{ transform:translate(-14px,-34px); }
  75%{ transform:translate(22px,-8px); }
  100%{ transform:translate(-6px,10px); }
}
@keyframes fx-blink{ 0%,100%{ opacity:.1; } 50%{ opacity:1; } }
.fx-wander > .fx-p{
  width:4px; height:4px; border-radius:50%;
  background:var(--fx-tint,#e8ff9a); box-shadow:0 0 6px var(--fx-tint,#e8ff9a);
  animation:fx-wander ease-in-out infinite alternate, fx-blink ease-in-out infinite;
}

/* =================== 4. FLASH =================== */
@keyframes fx-flash{
  0%,88%,94%,100%{ opacity:0; }
  89%,90%{ opacity:1; }
  91%{ opacity:.2; }
  92%{ opacity:.8; }
}
.fx-flash{
  position:absolute; inset:0; pointer-events:none; z-index:0; opacity:0;
  background:radial-gradient(ellipse at 60% 20%, var(--fx-tint, rgba(180,200,255,.55)), transparent 55%);
  animation:fx-flash 9s infinite;
}
.fx-flash.fx-fixed{ position:fixed; }

/* =================== 5. SWEEP =================== */
/* One highlight travelling across a clipped box. Four of the user's mockups —
   the currency glint, the rarity shine, the gilded title, the progress shine —
   are this, at four sizes. */
@keyframes fx-sweep{ 0%,60%{ left:-75%; } 90%,100%{ left:150%; } }
.fx-sweep{ position:relative; overflow:hidden; }
.fx-sweep::after{
  content:""; position:absolute; top:-40%; left:-75%; width:42%; height:180%;
  background:linear-gradient(105deg, transparent, var(--fx-sheen, rgba(255,240,190,.42)), transparent);
  transform:rotate(14deg); pointer-events:none; z-index:2;
  animation:fx-sweep var(--fx-dur,3.6s) ease-in-out infinite;
}
/* Clipped to the glyphs instead of the box — gold-leaf lettering. */
.fx-sweep-text{
  background:linear-gradient(100deg,#8a6d2a 20%,#ffe9a8 40%,#d4af37 60%,#8a6d2a 80%);
  background-size:250% 100%; -webkit-background-clip:text; background-clip:text;
  color:transparent; animation:fx-sweep-text 6s ease-in-out infinite;
}
@keyframes fx-sweep-text{ 0%,100%{ background-position:100% 0; } 50%{ background-position:0 0; } }

/* =================== 6. BREATHE =================== */
@keyframes fx-breathe{
  0%,100%{ box-shadow:0 0 5px var(--fx-glow-dim, rgba(91,156,242,.14)); }
  50%{ box-shadow:0 0 18px var(--fx-glow, rgba(91,156,242,.45)),
                  inset 0 0 10px var(--fx-glow-in, rgba(91,156,242,.16)); }
}

/* Candle: same idea, irregular stops so it guts rather than pulses. */
@keyframes fx-candle{
  0%,100%{ box-shadow:0 0 16px 2px rgba(255,174,104,.32); }
  30%{ box-shadow:0 0 23px 4px rgba(255,174,104,.5); }
  45%{ box-shadow:0 0 12px 2px rgba(255,174,104,.24); }
  70%{ box-shadow:0 0 21px 3px rgba(255,190,120,.44); }
}

/* =================== 7. CONIC =================== */
@property --fx-ang{ syntax:'<angle>'; initial-value:0deg; inherits:false; }
@keyframes fx-conic{ to{ --fx-ang:360deg; } }
/* Where @property isn't supported the ring simply doesn't turn — a static
   gradient border, which still looks deliberate. Nothing breaks. */

/* =================== 8. MARCH =================== */
@keyframes fx-march{ to{ background-position:12px 0,-12px 100%,0 -12px,100% 12px; } }

/* =================== 9. LEVITATE =================== */
@keyframes fx-bob{ 0%,100%{ transform:translateY(0); } 50%{ transform:translateY(-9px); } }
@keyframes fx-bobsh{ 0%,100%{ transform:scaleX(1); opacity:.5; } 50%{ transform:scaleX(.6); opacity:.26; } }

/* =================== 10. SURFACE =================== */
@keyframes fx-surface{ to{ background-position:300% 0; } }
@keyframes fx-surface-y{ to{ background-position:0 300%; } }
.fx-surface{
  background:linear-gradient(120deg,#1e3a4a,#2a5468 25%,#1e3a4a 50%,#2a5468 75%,#1e3a4a) !important;
  background-size:300% 100% !important;
  animation:fx-surface 5s linear infinite;
}
.fx-surface-y{
  background:linear-gradient(200deg,#1e3a4a,#2a5468 25%,#1e3a4a 50%,#2a5468 75%,#1e3a4a) !important;
  background-size:100% 300% !important;
  animation:fx-surface-y 7s linear infinite;
}
@keyframes fx-aurora{ to{ transform:rotate(360deg); } }
.fx-aurora{
  position:fixed; inset:-40%; pointer-events:none; z-index:0;
  background:conic-gradient(from 90deg at 50% 50%, transparent 0deg,
    rgba(80,220,160,.18) 60deg, rgba(90,140,240,.16) 140deg, transparent 220deg,
    rgba(160,90,220,.14) 300deg, transparent 360deg);
  filter:blur(18px); animation:fx-aurora 22s linear infinite;
}

/* =================== 11. POP =================== */
@keyframes fx-pop{
  0%{ transform:scale(.6) rotate(-16deg); }
  58%{ transform:scale(1.3) rotate(7deg); }
  100%{ transform:scale(1) rotate(0); }
}
.fx-pop{ animation:fx-pop .42s cubic-bezier(.34,1.56,.64,1); display:inline-block; }
/* `inline-block` is here only so a transform APPLIES — an inline element
   ignores one. An element that is already a block, flex or grid box does not
   need it and is actively harmed by it: the quest tick is a flex container
   centring its check mark, and forcing inline-block made the mark jump for the
   420ms the animation ran. Restore it for the hosts that have their own
   display; add to this list rather than removing the inline-block, which the
   chips and toggles still need. */
.quest-cbox.fx-pop{ display:flex; }

/* The burst layer — an effect drawn at a control's position rather than inside
   it, for controls whose click destroys them (see `burstFrom` in
   effects-run.js). Fixed, so it is immune to the re-render AND to scrolling
   during its 700ms life; zero-size, so its children position from its centre;
   pointer-events off, so it can never eat the next click.
   Band 940, the one-shot celebrations — this is their anchored sibling. It
   fires on the same kind of moment as the full-window ones and just happens to
   start at a control instead of at the screen. See the layering table at the
   top of this file; pick a band, don't invent a number. */
.fx-burst-layer{
  position:fixed; width:0; height:0; z-index:940; pointer-events:none;
}
.fx-burst-layer .fx-spark{ transform-origin:center; }
.fx-burst-ghost{
  position:absolute; left:0; top:0; transform:translate(-50%,-50%);
  box-sizing:border-box; pointer-events:none;
}
@keyframes fx-burst{
  0%{ transform:translate(0,0) scale(1); opacity:1; }
  100%{ transform:translate(var(--bx),var(--by)) scale(.3); opacity:0; }
}
.fx-spark{
  position:absolute; font-size:10px; color:var(--fx-tint,var(--coin));
  animation:fx-burst .62s ease-out forwards; pointer-events:none; z-index:5;
}
.fx-float{
  position:absolute; font-family:var(--font-sans); font-size:11px; font-weight:600;
  color:var(--coin); animation:fx-burst .75s ease-out forwards;
  pointer-events:none; z-index:5; --bx:2px; --by:-26px;
}

/* =================== 12. DRAW =================== */
@keyframes fx-draw{ 0%{ stroke-dashoffset:220; } 55%,82%{ stroke-dashoffset:0; } 100%{ stroke-dashoffset:0; opacity:0; } }
@keyframes fx-dpop{ 0%,45%{ opacity:0; transform:scale(0); } 60%,82%{ opacity:1; transform:scale(1); } 100%{ opacity:0; } }
.fx-draw path{ stroke-dasharray:220; stroke-dashoffset:220; animation:fx-draw 4s ease-in-out forwards; }
.fx-draw circle{ opacity:0; animation:fx-dpop 4s ease-in-out forwards; transform-origin:center; }
.fx-draw-overlay{
  position:fixed; left:50%; top:50%; transform:translate(-50%,-50%);
  pointer-events:none; z-index:940;
  /* Sized as a share of the viewport rather than a fixed pixel box: a 180px
     vine in the middle of a 27-inch screen reads as a doodle, not a reward. */
  width:min(46vw, 420px); height:min(46vh, 400px);
  display:flex; align-items:center; justify-content:center;
}
.fx-draw-overlay svg{ width:100%; height:100%; overflow:visible; }

/* ===========================================================================
   SLOT BINDINGS — which engine attaches to which surface for which effect id.
   =========================================================================== */

/* --- titles --- */
html[data-on-sweep-title] .page-header h1{ }
html[data-on-surface-title] .page-header h1{
  background:linear-gradient(100deg,var(--text-success),var(--text-accent),#a96cff,var(--border-accent),var(--text-success));
  background-size:300% 100%; -webkit-background-clip:text; background-clip:text;
  color:transparent; animation:fx-surface 7s linear infinite;
}

/* --- nav --- */
html[data-on-breathe-nav] .nav-item.active{
  animation:fx-breathe 3s ease-in-out infinite;
  --fx-glow:rgba(91,156,242,.4);
}

/* --- avatar --- */
html[data-on-breathe-avatar] .account-avatar,
html[data-on-breathe-avatar] .profile-avatar{ animation:fx-breathe 3s ease-in-out infinite; }
html[data-on-levitate-avatar] .account-avatar,
html[data-on-levitate-avatar] .profile-avatar{ animation:fx-bob 3.2s ease-in-out infinite; }

/* Gold Standard — the rank ring. Drawn as a pseudo-element BEHIND the avatar
   rather than as a wrapper, so it needs no extra markup and works on both the
   top-bar chip and the profile sheet, which are rendered by different files.
   Turns slowly at rest and spins up on hover, which is the whole read: it's
   idling, and it notices you. */
html[data-on-conic-avatar] .account-avatar,
html[data-on-conic-avatar] .profile-avatar{ position:relative; }
html[data-on-conic-avatar] .account-avatar::before,
html[data-on-conic-avatar] .profile-avatar::before{
  content:""; position:absolute; inset:-3px; border-radius:50%;
  padding:2px;
  background:conic-gradient(from var(--fx-ang,0deg), #d4af37, #7a5c1e, #ffe9a8, #d4af37);
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude;
  animation:fx-conic 6s linear infinite;
  pointer-events:none;
}
html[data-on-conic-avatar] .account-avatar:hover::before,
html[data-on-conic-avatar] .profile-avatar:hover::before{ animation-duration:1.2s; }

/* --- currency --- */
html[data-on-sweep-currency] .coin-total{ position:relative; overflow:hidden; }
html[data-on-sweep-currency] .coin-total::after{
  content:""; position:absolute; top:-40%; left:-75%; width:42%; height:180%;
  background:linear-gradient(105deg,transparent,rgba(255,240,190,.5),transparent);
  transform:rotate(14deg); animation:fx-sweep 3.6s ease-in-out infinite; pointer-events:none;
}

/* Mana Orb — the currency glyph itself becomes a turning ring. The icon is
   hidden rather than removed so the markup and the screen-reader text are
   untouched; only the picture changes. */
html[data-on-conic-currency] .coin-total i,
html[data-on-conic-currency] .coin-clicker i{ visibility:hidden; position:relative; display:inline-block; width:1em; height:1em; }
html[data-on-conic-currency] .coin-total i::after,
html[data-on-conic-currency] .coin-clicker i::after{
  content:""; visibility:visible; position:absolute; inset:0; margin:auto;
  width:1em; height:1em; border-radius:50%;
  background:conic-gradient(from var(--fx-ang,0deg), #9fd0ff, #3a6fd8 55%, #1d3a7a, #9fd0ff);
  animation:fx-conic 3s linear infinite;
}
html[data-on-conic-currency] .coin-total,
html[data-on-conic-currency] .coin-clicker{ position:relative; }

/* --- progress --- */
html[data-on-sweep-progress] .xp-fill,
html[data-on-sweep-progress] .quest-bar-fill{ position:relative; overflow:hidden; }
html[data-on-sweep-progress] .xp-fill::after,
html[data-on-sweep-progress] .quest-bar-fill::after{
  content:""; position:absolute; inset:0;
  background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.4) 50%,transparent 70%);
  transform:translateX(-100%); animation:fx-barshine 2.2s ease-in-out infinite;
}
@keyframes fx-barshine{ to{ transform:translateX(100%); } }
/* Ants Ahead. This previously set `position:relative` and NOTHING ELSE — it was
   sold, equippable, and completely invisible. The track is the bar's own
   background, so the crawl goes on an inset pseudo-element that starts where
   the fill ends; `--fill` is set by the page that draws the bar, and the
   fallback covers bars that don't report it. */
html[data-on-march-progress] .xp-bar,
html[data-on-march-progress] .quest-bar{ position:relative; overflow:hidden; }
html[data-on-march-progress] .xp-bar::after,
html[data-on-march-progress] .quest-bar::after{
  content:""; position:absolute; top:0; bottom:0; right:0; left:0;
  pointer-events:none; border-radius:inherit;
  background-image:
    repeating-linear-gradient(90deg, var(--text-muted) 0 5px, transparent 5px 10px),
    repeating-linear-gradient(90deg, var(--text-muted) 0 5px, transparent 5px 10px);
  background-size:10px 1.5px, 10px 1.5px;
  background-position:0 0, 0 100%;
  background-repeat:repeat-x, repeat-x;
  opacity:.65;
  animation:fx-march-track .9s linear infinite;
}
@keyframes fx-march-track{ to{ background-position:10px 0, -10px 100%; } }

/* --- cards at rest --- */
html[data-on-surface-card] .record-card{
  background:linear-gradient(120deg,#1e3a4a,#2a5468 25%,#1e3a4a 50%,#2a5468 75%,#1e3a4a);
  background-size:300% 100%; animation:fx-surface 5s linear infinite;
}

/* --- card states --- */
html[data-on-march-cardstate] .record-card.is-need{
  border-color:transparent !important;
  background-image:
    repeating-linear-gradient(90deg,var(--text-muted) 0 6px,transparent 6px 12px),
    repeating-linear-gradient(90deg,var(--text-muted) 0 6px,transparent 6px 12px),
    repeating-linear-gradient(0deg,var(--text-muted) 0 6px,transparent 6px 12px),
    repeating-linear-gradient(0deg,var(--text-muted) 0 6px,transparent 6px 12px);
  background-size:12px 1.5px,12px 1.5px,1.5px 12px,1.5px 12px;
  background-position:0 0,0 100%,0 0,100% 0;
  background-repeat:repeat-x,repeat-x,repeat-y,repeat-y;
  animation:fx-march .9s linear infinite;
}
/* "Active" is not a class the quest board writes — the real states are
   .not-started, .complete and .selected. An in-progress quest is the one that
   is neither started-but-untouched nor finished, so it's expressed as the
   absence of both rather than as a class that would have to be added to
   quests.js. Nothing outside this file changes to support the effect. */
html[data-on-breathe-cardstate] .quest-card:not(.not-started):not(.complete){
  animation:fx-breathe 2.8s ease-in-out infinite;
}
html[data-on-levitate-cardstate] .record-card.selected,
html[data-on-levitate-cardstate] .quest-card.selected{
  animation:fx-bob 3.2s ease-in-out infinite;
}
html[data-on-conic-cardstate] .record-card.selected,
html[data-on-conic-cardstate] .quest-card.selected{
  position:relative;
}
html[data-on-conic-cardstate] .record-card.selected::before,
html[data-on-conic-cardstate] .quest-card.selected::before{
  content:""; position:absolute; inset:0; border-radius:inherit;
  padding:var(--fx-ring, 3px);
  background:conic-gradient(from var(--fx-ang,0deg),
    var(--border-accent), #a96cff, var(--text-success), var(--border-accent));
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude;
  animation:fx-conic 4s linear infinite;
  pointer-events:none;
}
/* Legendary Breath. The first version targeted `.loot-card.lr-legendary`, a
   combination the app NEVER renders — `RARITY_CLASS` is applied to the rarity
   MARK (`.loot-rarity`, `.loot-rarity-text`, `.loot-chip-dot`, `.loot-tier`),
   never to the card itself. So it was sold as "the full treatment for a
   Legendary find" and in practice put a glow on a nine-pixel text label.
   Now it targets the marks that actually exist, and the catalog says so.

   Motion only: the rarity COLOUR is untouched, because that's the one thing on
   a loot row carrying information rather than decoration. */
html[data-on-sweep-cardstate] .loot-rarity.lr-legendary,
html[data-on-sweep-cardstate] .loot-rarity-text.lr-legendary,
html[data-on-sweep-cardstate] .loot-chip-dot.lr-legendary{
  animation:fx-breathe 3.4s ease-in-out infinite;
  --fx-glow:rgba(224,189,82,.45);
  --fx-glow-dim:rgba(224,189,82,.12);
  border-radius:4px;
}
/* The tier section header is the one place a Legendary owns a whole block, so
   that's where the sheen has room to read. */
html[data-on-sweep-cardstate] .loot-tier.lr-legendary .loot-tier-head,
html[data-on-sweep-cardstate] .loot-tier.lr-legendary > .loot-row{
  position:relative; overflow:hidden;
}
html[data-on-sweep-cardstate] .loot-tier.lr-legendary .loot-tier-head::after{
  content:""; position:absolute; top:-40%; left:-80%; width:40%; height:180%;
  background:linear-gradient(105deg,transparent,rgba(255,235,170,.35),transparent);
  animation:fx-sweep 4.2s ease-in-out infinite; pointer-events:none;
}

/* --- panels --- */

/* --- chips --- */
html[data-on-levitate-chip] .chip,
html[data-on-levitate-chip] .cat-dot,
html[data-on-levitate-chip] .loot-chip{
  animation:fx-bob 2.6s ease-in-out infinite;
}
/* Staggered so a row of tags drifts rather than marching in unison — chips in
   lockstep read as the whole page moving, not as the chips being alive. The
   spread is prime-ish fractions of the cycle so the pattern doesn't resolve. */
html[data-on-levitate-chip] .chip:nth-of-type(7n+1),
html[data-on-levitate-chip] .loot-chip:nth-of-type(7n+1){ animation-delay:-.13s; animation-duration:2.9s; }
html[data-on-levitate-chip] .chip:nth-of-type(7n+2),
html[data-on-levitate-chip] .loot-chip:nth-of-type(7n+2){ animation-delay:-.71s; animation-duration:2.4s; }
html[data-on-levitate-chip] .chip:nth-of-type(7n+3),
html[data-on-levitate-chip] .loot-chip:nth-of-type(7n+3){ animation-delay:-1.29s; animation-duration:3.1s; }
html[data-on-levitate-chip] .chip:nth-of-type(7n+4),
html[data-on-levitate-chip] .loot-chip:nth-of-type(7n+4){ animation-delay:-.42s; animation-duration:2.7s; }
html[data-on-levitate-chip] .chip:nth-of-type(7n+5),
html[data-on-levitate-chip] .loot-chip:nth-of-type(7n+5){ animation-delay:-1.87s; animation-duration:2.2s; }
html[data-on-levitate-chip] .chip:nth-of-type(7n+6),
html[data-on-levitate-chip] .loot-chip:nth-of-type(7n+6){ animation-delay:-.96s; animation-duration:3.3s; }
html[data-on-levitate-chip] .chip:nth-of-type(7n+7),
html[data-on-levitate-chip] .loot-chip:nth-of-type(7n+7){ animation-delay:-2.31s; animation-duration:2.55s; }

/* ===========================================================================
   PREVIEWS — the live demos on the Market's shop cards and loadout tiles.

   REBUILT: the first version drew every technique on the same neutral grey
   tile, so a currency sweep and a card sweep looked identical and nothing told
   you which surface you were buying for. A preview has to show THE THING.

   So there are now sixteen MOCK COMPONENTS (`.fxm-*`) — a tiny record card, a
   coin pill, an avatar, a nav rail, a progress bar — and each effect is bound
   to the one it actually lands on. `Effects.mockFor()` picks which.

   Two deliberate decisions:

   1. MOCKS USE THEIR OWN CLASS NAMES, not the app's. If a preview contained a
      real `.record-card`, every effect the viewer already has equipped would
      apply to all 39 previews at once, and the shop would show you what you're
      wearing instead of what you're buying.
   2. THE @KEYFRAMES ARE SHARED with the real effects; only the binding is
      restated. That's not duplication that can drift into a lie — the motion is
      literally the same declaration. And the binding genuinely has to differ: a
      leaf falls 140px inside a real card and 44px inside a 64px preview.
   =========================================================================== */

.fxp{
  position:relative; height:64px; border-radius:6px; overflow:hidden;
  background:var(--surface-0); border:0.5px solid var(--border);
  display:flex; align-items:center; justify-content:center;
  margin-bottom:8px; flex-shrink:0;
}
.fxp-dot{ position:absolute; width:3px; height:3px; border-radius:50%; background:var(--text-accent); }

/* ---------- the mock components ---------- */
.fxm{ position:relative; }

/* a whole tiny app: top bar, nav rail, content */
.fxm-window{ width:96px; height:46px; border-radius:4px; overflow:hidden;
  background:var(--surface-1); border:0.5px solid var(--border); display:flex; flex-direction:column; }
.fxm-window .top{ height:8px; background:var(--surface-2); flex-shrink:0; }
.fxm-window .row{ flex:1; display:flex; }
.fxm-window .rail{ width:20px; background:var(--surface-2); }
.fxm-window .body{ flex:1; padding:4px; display:flex; flex-direction:column; gap:3px; }
.fxm-window .body i{ height:3px; border-radius:2px; background:var(--surface-3); display:block; }
.fxm-window .body i:nth-child(2){ width:70%; }

/* the rail on its own, emphasised */
.fxm-sidebar .rail{ width:26px; background:var(--surface-2); position:relative; overflow:hidden; }

/* a page heading */
.fxm-title{ font-size:13px; font-weight:600; letter-spacing:.01em; color:var(--text-primary); white-space:nowrap; }

/* two nav rows, the first active */
.fxm-nav{ width:82px; display:flex; flex-direction:column; gap:3px; }
.fxm-nav .item{ display:flex; align-items:center; gap:5px; height:14px; padding:0 6px;
  border-radius:4px; font-size:9px; color:var(--text-muted); }
.fxm-nav .item.on{ background:var(--surface-2); color:var(--text-primary); position:relative; }
.fxm-nav .item i{ width:5px; height:5px; border-radius:1px; background:currentColor; opacity:.7; flex-shrink:0; }

/* the account avatar */
.fxm-avatar{ width:30px; height:30px; border-radius:50%; background:var(--surface-2);
  border:0.5px solid var(--border-strong); display:flex; align-items:center; justify-content:center;
  font-size:10px; font-weight:600; color:var(--text-accent); }

/* the currency pill */
.fxm-coin{ display:inline-flex; align-items:center; gap:4px; font-size:11px;
  color:var(--coin); background:rgba(224,189,82,.12); border:0.5px solid rgba(224,189,82,.3);
  border-radius:999px; padding:3px 10px; overflow:hidden; }
.fxm-coin b{ display:inline-flex; width:9px; height:9px; border-radius:50%;
  background:var(--coin); position:relative; }

/* an XP / quest bar */
.fxm-bar{ width:74px; height:7px; border-radius:999px; background:var(--surface-2); overflow:hidden; }
.fxm-bar i{ display:block; height:100%; width:58%; border-radius:999px;
  background:var(--text-success); position:relative; overflow:hidden; }

/* a record card, and its three states */
.fxm-card{ width:78px; padding:6px 7px; border-radius:5px;
  background:var(--surface-1); border:0.5px solid var(--border);
  border-left:2px solid var(--text-success);
  display:flex; flex-direction:column; gap:3px; overflow:hidden; }
.fxm-card i{ display:block; height:3px; border-radius:2px; background:var(--surface-3); }
.fxm-card i:first-child{ width:80%; background:var(--text-muted); opacity:.7; }
.fxm-card i:last-child{ width:52%; }
.fxm-card.need{ border-style:dashed; border-left-color:var(--text-muted); opacity:.85; }
.fxm-card.sel{ border-color:var(--border-accent); border-left-color:var(--border-accent); }
.fxm-card.quest{ border-left-color:var(--border-accent); }
.fxm-card.quest .qbar{ height:3px; border-radius:2px; background:var(--surface-3); overflow:hidden; }
.fxm-card.quest .qbar b{ display:block; height:100%; width:45%; background:var(--border-accent); }

/* a Legendary rarity mark */
.fxm-rarity{ display:inline-flex; align-items:center; gap:4px; font-size:9px;
  letter-spacing:.06em; text-transform:uppercase; color:#e0bd52; padding:2px 6px; border-radius:4px; }

/* a Camp panel */
.fxm-panel{ width:82px; padding:6px 7px; border-radius:5px;
  background:var(--surface-1); border:0.5px solid var(--border);
  display:flex; flex-direction:column; gap:3px; }
.fxm-panel i{ display:block; height:3px; border-radius:2px; background:var(--surface-3); }
.fxm-panel i:first-child{ width:45%; background:var(--text-muted); opacity:.6; }
.fxm-panel.empty{ align-items:center; justify-content:center; min-height:34px; }
.fxm-panel.empty span{ font-size:9px; color:var(--text-muted); font-style:italic; }

/* a category chip */
.fxm-chip{ display:inline-flex; align-items:center; font-size:9px; padding:3px 9px;
  border-radius:999px; background:rgba(122,190,100,.17); color:var(--text-success); }

/* a quest step row */
.fxm-step{ display:inline-flex; align-items:center; gap:6px; font-size:9px; color:var(--text-secondary); }
.fxm-step i{ width:11px; height:11px; border-radius:3px; border:1.5px solid var(--text-muted);
  display:inline-block; flex-shrink:0; }

/* ---------- per-effect bindings ----------
   Each names the effect and the mock it stands on. Keyframes are the shared
   ones the real effects use. */

/* --- ambience: particles/washes over the whole tiny app --- */
.fxp[data-fx^="fall-window"] .fxp-dot,
.fxp[data-fx="fall-title"] .fxp-dot,
.fxp[data-fx="fall-card"] .fxp-dot,
.fxp[data-fx="fall-sidebar"] .fxp-dot{ top:-4px; animation:fxp-fall 2.6s linear infinite; }
.fxp .fxp-dot:nth-of-type(1){ animation-delay:0s; }
.fxp .fxp-dot:nth-of-type(2){ animation-delay:.9s; }
.fxp .fxp-dot:nth-of-type(3){ animation-delay:1.7s; }
@keyframes fxp-fall{ 0%{ transform:translateY(0) rotate(0); opacity:0; } 15%{ opacity:1; }
  100%{ transform:translateY(66px) rotate(200deg); opacity:0; } }

.fxp[data-fx="rise-window"] .fxp-dot,
.fxp[data-fx="rise-avatar"] .fxp-dot{ bottom:-4px; background:var(--coin); animation:fxp-rise 2.4s ease-out infinite; }

/* Fermenting previews as what it is: outlined bubbles growing as they rise,
   over the brew-light at the foot of the card. */
.fxp[data-fx="rise-card"] .fxp-dot{
  bottom:-4px; width:7px; height:7px; background:none;
  border:1px solid rgba(190,140,255,.75);
  animation:fxp-brew 2.4s ease-in infinite;
}
.fxp[data-fx="rise-card"] .fxp-dot:nth-of-type(2){ width:5px; height:5px; }
.fxp[data-fx="rise-card"] .fxp-dot:nth-of-type(3){ width:9px; height:9px; }
.fxp[data-fx="rise-card"] .fxm-card::after{
  content:""; position:absolute; left:0; right:0; bottom:0; height:55%;
  background:linear-gradient(to top, rgba(140,90,220,.28), transparent);
  border-radius:inherit; pointer-events:none;
}
@keyframes fxp-brew{ 0%{ transform:translateY(0) scale(.5); opacity:0; }
  20%{ opacity:.95; } 100%{ transform:translateY(-46px) scale(1.15); opacity:0; } }

/* Leaf Litter previews with real leaf colour rather than a plain accent dot. */
.fxp[data-fx="fall-card"] .fxp-dot{ width:5px; height:5px; border-radius:1px 3px 1px 3px; }
.fxp[data-fx="fall-card"] .fxp-dot:nth-of-type(1){ background:#c9762f; }
.fxp[data-fx="fall-card"] .fxp-dot:nth-of-type(2){ background:#8a9a3d; }
.fxp[data-fx="fall-card"] .fxp-dot:nth-of-type(3){ background:#d9a441; }
@keyframes fxp-rise{ 0%{ transform:translateY(0) scale(1); opacity:1; }
  100%{ transform:translateY(-58px) scale(.3); opacity:0; } }

.fxp[data-fx="wander-window"] .fxp-dot,
.fxp[data-fx="wander-sidebar"] .fxp-dot,
.fxp[data-fx="wander-nav"] .fxp-dot{ width:4px; height:4px; background:#e8ff9a; box-shadow:0 0 6px #e8ff9a;
  animation:fxp-wander 4s ease-in-out infinite alternate, fx-blink 1.8s ease-in-out infinite; }
@keyframes fxp-wander{ 0%{ transform:translate(0,0); } 50%{ transform:translate(14px,-10px); }
  100%{ transform:translate(-8px,5px); } }

/* particles confined to the rail on the sidebar mocks */
.fxp[data-fx="fall-sidebar"] .fxp-dot,
.fxp[data-fx="wander-sidebar"] .fxp-dot{ left:auto; right:auto; }

.fxp[data-fx="surface-window"]::before{
  content:""; position:absolute; inset:-40%; pointer-events:none;
  background:conic-gradient(from 90deg at 50% 50%, transparent 0deg, rgba(80,220,160,.3) 60deg,
    rgba(90,140,240,.28) 140deg, transparent 220deg, rgba(160,90,220,.26) 300deg, transparent 360deg);
  filter:blur(9px); animation:fx-aurora 12s linear infinite;
}

/* --- sidebar --- */
.fxp[data-fx="surface-sidebar"] .rail{
  background:linear-gradient(200deg,#1e3a4a,#2a5468 25%,#1e3a4a 50%,#2a5468 75%,#1e3a4a);
  background-size:100% 300%; animation:fx-surface-y 4s linear infinite;
}

/* --- titles --- */
.fxp[data-fx="sweep-title"] .fxm-title{
  background:linear-gradient(100deg,#8a6d2a 20%,#ffe9a8 40%,#d4af37 60%,#8a6d2a 80%);
  background-size:250% 100%; -webkit-background-clip:text; background-clip:text;
  color:transparent; animation:fx-sweep-text 4s ease-in-out infinite;
}
.fxp[data-fx="surface-title"] .fxm-title{
  background:linear-gradient(100deg,var(--text-success),var(--text-accent),#a96cff,var(--border-accent),var(--text-success));
  background-size:300% 100%; -webkit-background-clip:text; background-clip:text;
  color:transparent; animation:fx-surface 4s linear infinite;
}

/* --- nav --- */
.fxp[data-fx="wander-nav"] .fxp-dot{ left:8px; top:14px; animation:fx-blink 1.6s ease-in-out infinite; }
.fxp[data-fx="breathe-nav"] .item.on{ animation:fx-breathe 2.4s ease-in-out infinite;
  --fx-glow:rgba(91,156,242,.55); --fx-glow-in:rgba(91,156,242,.2); }

/* --- avatar --- */
.fxp[data-fx="breathe-avatar"] .fxm-avatar{ animation:fx-breathe 2.4s ease-in-out infinite;
  --fx-glow:rgba(91,156,242,.55); }
.fxp[data-fx="levitate-avatar"] .fxm-avatar{ animation:fx-bob 2.4s ease-in-out infinite; }
.fxp[data-fx="levitate-avatar"]::after{ content:""; position:absolute; left:50%; margin-left:-13px; bottom:12px;
  width:26px; height:4px; border-radius:50%; background:rgba(0,0,0,.55); animation:fx-bobsh 2.4s ease-in-out infinite; }
.fxp[data-fx="conic-avatar"] .fxm-avatar::before{
  content:""; position:absolute; inset:-3px; border-radius:50%; z-index:-1;
  background:conic-gradient(from var(--fx-ang,0deg), #d4af37, #7a5c1e, #ffe9a8, #d4af37);
  animation:fx-conic 3s linear infinite;
}

/* --- currency --- */
.fxp[data-fx="sweep-currency"] .fxm-coin::after{
  content:""; position:absolute; top:-40%; left:-80%; width:45%; height:180%;
  background:linear-gradient(105deg,transparent,rgba(255,240,190,.7),transparent);
  transform:rotate(14deg); animation:fx-sweep 2.6s ease-in-out infinite;
}
.fxp[data-fx="conic-currency"] .fxm-coin b{
  background:conic-gradient(from var(--fx-ang,0deg), #9fd0ff, #3a6fd8 55%, #1d3a7a, #9fd0ff);
  animation:fx-conic 2.4s linear infinite;
}
.fxp[data-fx="pop-currency"] .fxm-coin{ animation:fxp-pop 2.4s cubic-bezier(.34,1.56,.64,1) infinite; }
.fxp[data-fx="pop-currency"]::after{
  content:"+1"; position:absolute; left:58%; top:14px; font-size:9px; font-weight:600;
  color:var(--coin); animation:fxp-float 2.4s ease-out infinite;
}
@keyframes fxp-float{ 0%,55%{ transform:translateY(0); opacity:0; } 62%{ opacity:1; }
  100%{ transform:translateY(-18px); opacity:0; } }
@keyframes fxp-pop{ 0%,55%,100%{ transform:scale(1); } 66%{ transform:scale(1.18); } }

/* --- progress --- */
.fxp[data-fx="sweep-progress"] .fxm-bar i::after{
  content:""; position:absolute; inset:0;
  background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.55) 50%,transparent 70%);
  transform:translateX(-100%); animation:fx-barshine 2.2s ease-in-out infinite;
}
.fxp[data-fx="march-progress"] .fxm-bar{ position:relative; }
.fxp[data-fx="march-progress"] .fxm-bar::after{
  content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none;
  background-image:
    repeating-linear-gradient(90deg,var(--text-muted) 0 4px,transparent 4px 8px),
    repeating-linear-gradient(90deg,var(--text-muted) 0 4px,transparent 4px 8px);
  background-size:8px 1.5px,8px 1.5px; background-position:0 0,0 100%;
  background-repeat:repeat-x,repeat-x; opacity:.7;
  animation:fxp-march-track .8s linear infinite;
}
@keyframes fxp-march-track{ to{ background-position:8px 0,-8px 100%; } }

/* --- cards --- */
.fxp[data-fx="surface-card"] .fxm-card{
  background:linear-gradient(120deg,#1e3a4a,#2a5468 25%,#1e3a4a 50%,#2a5468 75%,#1e3a4a);
  background-size:300% 100%; animation:fx-surface 3.4s linear infinite;
}
.fxp[data-fx="march-cardstate"] .fxm-card{
  border-color:transparent;
  background-image:
    repeating-linear-gradient(90deg,var(--text-muted) 0 5px,transparent 5px 10px),
    repeating-linear-gradient(90deg,var(--text-muted) 0 5px,transparent 5px 10px),
    repeating-linear-gradient(0deg,var(--text-muted) 0 5px,transparent 5px 10px),
    repeating-linear-gradient(0deg,var(--text-muted) 0 5px,transparent 5px 10px);
  background-size:10px 1.5px,10px 1.5px,1.5px 10px,1.5px 10px;
  background-position:0 0,0 100%,0 0,100% 0;
  background-repeat:repeat-x,repeat-x,repeat-y,repeat-y;
  animation:fxp-march .8s linear infinite;
}
@keyframes fxp-march{ to{ background-position:10px 0,-10px 100%,0 -10px,100% 10px; } }
.fxp[data-fx="breathe-cardstate"] .fxm-card{ animation:fx-breathe 2.4s ease-in-out infinite;
  --fx-glow:rgba(91,156,242,.5); }
.fxp[data-fx="levitate-cardstate"] .fxm-card{ animation:fx-bob 2.4s ease-in-out infinite; }
.fxp[data-fx="levitate-cardstate"]::after{ content:""; position:absolute; left:50%; margin-left:-20px; bottom:8px;
  width:40px; height:4px; border-radius:50%; background:rgba(0,0,0,.5); animation:fx-bobsh 2.4s ease-in-out infinite; }
.fxp[data-fx="conic-cardstate"] .fxm-card::before{
  content:""; position:absolute; inset:-2px; border-radius:6px; z-index:-1;
  background:conic-gradient(from var(--fx-ang,0deg), var(--border-accent), #a96cff, var(--text-success), var(--border-accent));
  animation:fx-conic 3s linear infinite;
}
.fxp[data-fx="sweep-cardstate"] .fxm-rarity{ animation:fx-breathe 2.6s ease-in-out infinite;
  --fx-glow:rgba(224,189,82,.55); --fx-glow-dim:rgba(224,189,82,.14); }

/* --- panels --- */

/* --- chips --- */
.fxp[data-fx="levitate-chip"] .fxm-chip{ animation:fx-bob 2.2s ease-in-out infinite; }

/* --- toggles: these fire on click, so the preview loops the click --- */
.fxp[data-fx="pop-toggle"] .fxm-chip{ animation:fxp-pop 2.2s cubic-bezier(.34,1.56,.64,1) infinite; }
.fxp[data-fx="pop-steps"] .fxm-step i{
  animation:fxp-check 2.4s cubic-bezier(.34,1.56,.64,1) infinite;
}
@keyframes fxp-check{
  0%,45%{ background:transparent; transform:scale(1); }
  58%{ background:var(--text-success); border-color:var(--text-success); transform:scale(1.25); }
  70%,100%{ background:var(--text-success); border-color:var(--text-success); transform:scale(1); }
}
.fxp[data-fx="pop-steps"] .fxp-dot{
  width:4px; height:4px; background:var(--coin); left:16px; top:26px;
  animation:fxp-spark 2.4s ease-out infinite;
}
.fxp[data-fx="pop-steps"] .fxp-dot:nth-of-type(1){ --px:-13px; --py:-11px; }
.fxp[data-fx="pop-steps"] .fxp-dot:nth-of-type(2){ --px:13px; --py:-11px; }
.fxp[data-fx="pop-steps"] .fxp-dot:nth-of-type(3){ --px:0; --py:-16px; }
@keyframes fxp-spark{ 0%,48%{ transform:translate(0,0) scale(0); opacity:0; }
  62%{ opacity:1; transform:translate(calc(var(--px)*.6),calc(var(--py)*.6)) scale(1); }
  100%{ transform:translate(var(--px),var(--py)) scale(.3); opacity:0; } }

/* --- moments: drawn over the tiny app, because that's where they land --- */
.fxp[data-fx="draw-quest"] svg, .fxp[data-fx="draw-levelup"] svg,
.fxp[data-fx="draw-craft"] svg, .fxp[data-fx="draw-harvest"] svg,
.fxp[data-fx="draw-lootbox"] svg, .fxp[data-fx="draw-battler"] svg{
  position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); overflow:visible;
}
.fxp[data-fx="draw-quest"] path, .fxp[data-fx="draw-levelup"] path,
.fxp[data-fx="draw-craft"] path, .fxp[data-fx="draw-harvest"] path,
.fxp[data-fx="draw-lootbox"] path, .fxp[data-fx="draw-battler"] path{
  stroke-dasharray:80; stroke-dashoffset:80; animation:fxp-draw 3.2s ease-in-out infinite;
}
@keyframes fxp-draw{ 0%{ stroke-dashoffset:80; } 55%,80%{ stroke-dashoffset:0; }
  100%{ stroke-dashoffset:0; opacity:0; } }

@media (prefers-reduced-motion: reduce){
  .fxp *, .fxp::after, .fxp::before, .fxp *::after, .fxp *::before{ animation:none !important; }
}

/* ===========================================================================
   INTENSITY
   Calm halves every duration and JS drops particle counts to zero. Full speeds
   things up slightly and raises the counts. The dial is a multiplier on what
   each effect already declares, never a second set of values to keep in step.
   =========================================================================== */
html[data-intensity="calm"] *{ animation-duration:calc(var(--fx-dur, 3s) * 2) !important; }
html[data-intensity="calm"] .fx-layer{ display:none; }
html[data-intensity="full"] .fx-p{ opacity:1; }

/* ===========================================================================
   REDUCED MOTION — a blanket stop. Everything here is decoration; nothing's
   meaning depends on it moving. Colours, patterns and sigils stay.
   =========================================================================== */
@media (prefers-reduced-motion: reduce){
  .fx-layer, .fx-aurora, .fx-flash, .fx-flash-once{ display:none !important; }
  /* Broad on purpose. Every animation in this file is decoration, and naming
     each effect's own selector would mean editing this block every time the
     catalog grew — which is the maintenance it would eventually lose. */
  [class*="fx-"],
  .page-header h1, .account-avatar, .profile-avatar, .nav-item.active,
  .record-card, .quest-card, .loot-card, .camp-panel, .chip, .cat-dot,
  .loot-chip, .loot-rarity, .xp-fill, .quest-bar-fill, .xp-bar, .quest-bar,
  .coin-total, .coin-total i, .sidebar,
  .coin-total i::after, .account-avatar::before, .profile-avatar::before,
  .record-card::before, .record-card::after, .quest-card::before,
  .xp-fill::after, .quest-bar-fill::after, .coin-total::after{
    animation:none !important;
  }
}

/* ===========================================================================
   THE FEED SLOTS — worn by a POST, not by the page.

   Every other rule in this file hangs off `html[data-slot-*]`, because every
   other effect decorates what YOU are looking at. These decorate what OTHER
   PEOPLE see when they read something you wrote, so they hang off
   `.post[data-fx-*]` — an attribute on the CARD.

   THAT DISTINCTION IS A BOUNDARY, not a style choice. It is the same reasoning
   as `data-skin-card` vs `data-skin` in skins.css: a post must not be able to
   give the reader an aurora. Two things make it hold, both structural:

     1. These attributes are only ever written onto `.post`, never onto <html>,
        so an html-scoped rule can never be triggered by a post.
     2. Every selector below is prefixed with `.post`, so even if an attribute
        leaked upward it would match nothing.

   If you add a feed effect, keep both halves. A rule here that starts with
   `html` for a feed slot is a bug.

   Layering inside a card follows the table at the top of this file: the wash is
   band 0 (behind the content), content is band 1, edge decoration is band 2-9.
   =========================================================================== */

.post { position: relative; }

/* ---- postFrame ---- */
.post[data-fx-frame-ants] {
  border-color: transparent;
  background-image:
    repeating-linear-gradient(90deg, var(--text-muted) 0 6px, transparent 6px 12px),
    repeating-linear-gradient(90deg, var(--text-muted) 0 6px, transparent 6px 12px),
    repeating-linear-gradient(0deg, var(--text-muted) 0 6px, transparent 6px 12px),
    repeating-linear-gradient(0deg, var(--text-muted) 0 6px, transparent 6px 12px);
  background-size: 12px 1px, 12px 1px, 1px 12px, 1px 12px;
  background-position: 0 0, 0 100%, 0 0, 100% 0;
  background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
  animation: fx-post-ants 0.9s linear infinite;
}
@keyframes fx-post-ants {
  to { background-position: 12px 0, -12px 100%, 0 -12px, 100% 12px; }
}

.post[data-fx-frame-arcane] { border-color: transparent; }
.post[data-fx-frame-arcane]::before {
  content: ""; position: absolute; inset: -1px; border-radius: inherit;
  padding: 1px; pointer-events: none; z-index: 2;
  background: conic-gradient(from var(--fx-ang, 0deg), #4f8ef7, #9d6bc9, #2dd4c8, #4f8ef7);
  /* Border-only: the mask punches the middle out so the card's own surface
     shows through rather than being painted over. */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  animation: fx-post-spin 4s linear infinite;
}
@keyframes fx-post-spin { to { --fx-ang: 360deg; } }

.post[data-fx-frame-legend] {
  border-color: rgba(212,175,55,0.55);
  animation: fx-post-breathe 3.4s ease-in-out infinite;
}
@keyframes fx-post-breathe {
  0%, 100% { box-shadow: 0 0 4px rgba(212,175,55,0.12); }
  50%      { box-shadow: 0 0 18px rgba(212,175,55,0.34); }
}
.post[data-fx-frame-legend]::after,
.post[data-fx-frame-shine]::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  pointer-events: none; z-index: 2;
  background-size: 300% 100%;
  animation: fx-post-sweep 4.2s ease-in-out infinite;
}
.post[data-fx-frame-legend]::after {
  background-image: linear-gradient(105deg, transparent 42%, rgba(255,235,170,0.20) 50%, transparent 58%);
}
.post[data-fx-frame-shine]::after {
  background-image: linear-gradient(105deg, transparent 45%, rgba(200,230,255,0.16) 50%, transparent 55%);
  animation-duration: 3.4s;
}
@keyframes fx-post-sweep {
  0%, 55% { background-position: 130% 0; }
  100%    { background-position: -30% 0; }
}

/* ---- postBack: behind the words, inside the card ----
   The layer is spawned by effects-run.js, like every other particle host. */
.post > .fx-post-wash {
  position: absolute; inset: 0; border-radius: inherit;
  overflow: hidden; pointer-events: none; z-index: 0;
}
/* Card content sits above the wash. The `:not()` guard is the same one the
   record cards needed: the layer is a direct child too, and matching it would
   force it to `position: relative` and collapse it to zero height. */
.post:has(> .fx-post-wash) > *:not(.fx-post-wash):not(.fx-layer) { position: relative; z-index: 1; }

.post[data-fx-back-surface] > .fx-post-wash {
  background: conic-gradient(from 90deg at 50% 50%, transparent 0deg,
    rgba(80,220,160,0.16) 60deg, rgba(90,140,240,0.14) 140deg, transparent 220deg,
    rgba(160,90,220,0.12) 300deg, transparent 360deg);
  filter: blur(16px);
  animation: fx-post-aurora 16s linear infinite;
}
@keyframes fx-post-aurora { to { transform: rotate(360deg); } }

/* ---- postName ---- */
.post[data-fx-name-gild] .post-name {
  background-image: linear-gradient(100deg, #8a6d2a 20%, #ffe9a8 40%, #d4af37 60%, #8a6d2a 80%);
  background-size: 250% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  animation: fx-post-gild 6s linear infinite;
}
.post[data-fx-name-aurora] .post-name {
  background-image: linear-gradient(100deg, #4ade80, #5aa8b8, #9d6bc9, #4f8ef7, #4ade80);
  background-size: 300% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  animation: fx-post-gild 7s linear infinite;
}
@keyframes fx-post-gild { to { background-position: 250% 0; } }

.post[data-fx-name-ink] .post-name {
  color: transparent;
  text-shadow: 0 0 9px rgba(238,236,230,0.55);
  transition: color 0.6s ease, text-shadow 0.6s ease;
}
.post[data-fx-name-ink]:hover .post-name {
  color: var(--text-primary); text-shadow: 0 0 0 rgba(238,236,230,0);
}

/* ---- postBadge ----
   NOTHING HERE MAY CREATE A STACKING CONTEXT ON `.bdg` — no transform, filter,
   opacity or z-index on the badge itself. Gold Standard's ring therefore goes on
   the WRAPPER span, not on the badge. See the rules at the top of badge.css;
   this is the trap that eats rank rings. */
.post[data-fx-badge-ignite] .bdg { transition: box-shadow 0.35s; }
.post[data-fx-badge-ignite] .bdg:hover { box-shadow: 0 0 16px rgba(255,138,74,0.5); }
.post[data-fx-badge-frost] .bdg { transition: box-shadow 0.4s; }
.post[data-fx-badge-frost] .bdg:hover {
  box-shadow: inset 0 0 12px rgba(168,216,232,0.4), 0 0 12px rgba(168,216,232,0.3);
}
.post[data-fx-badge-bloom] .post-head > [data-card-user] {
  display: inline-block; transition: transform 0.45s cubic-bezier(.34,1.56,.64,1);
}
.post[data-fx-badge-bloom] .post-head > [data-card-user]:hover { transform: scale(1.12) rotate(-6deg); }
.post[data-fx-badge-rank] .post-head > [data-card-user] {
  display: inline-block; border-radius: 50%; padding: 2px;
  background: conic-gradient(from var(--fx-ang, 0deg), #d4af37, #7a5c1e, #ffe9a8, #d4af37);
  animation: fx-post-spin 6s linear infinite;
}
.post[data-fx-badge-rank] .post-head > [data-card-user]:hover { animation-duration: 1.2s; }

/* ---- postAct ---- */
.post[data-fx-act-vine] .post-act { position: relative; }
.post[data-fx-act-vine] .post-act::after {
  content: ""; position: absolute; left: 0; bottom: 1px; height: 2px; width: 100%;
  transform: scaleX(0); transform-origin: left; border-radius: 2px;
  background: linear-gradient(90deg, var(--text-success), #a8d46b);
  transition: transform 0.4s cubic-bezier(.3,1,.4,1);
}
.post[data-fx-act-vine] .post-act:hover::after,
.post[data-fx-act-vine] .post-act:active::after { transform: scaleX(1); }

.post[data-fx-act-molten] .post-act { transition: background 0.35s, box-shadow 0.35s, color 0.35s; }
.post[data-fx-act-molten] .post-act:hover {
  background: linear-gradient(160deg, rgba(58,36,24,0.9), rgba(74,42,20,0.9));
  color: #ffd8b8;
  box-shadow: 0 3px 14px rgba(255,120,50,0.28), inset 0 -3px 7px rgba(255,138,74,0.3);
}

@media (prefers-reduced-motion: reduce) {
  .post[data-fx-frame-ants],
  .post[data-fx-frame-arcane]::before,
  .post[data-fx-frame-legend],
  .post[data-fx-frame-legend]::after,
  .post[data-fx-frame-shine]::after,
  .post > .fx-post-wash,
  .post[data-fx-name-gild] .post-name,
  .post[data-fx-name-aurora] .post-name,
  .post[data-fx-badge-rank] .post-head > [data-card-user] {
    animation: none !important;
  }
}

/* ---- the post mock, and the feed effects' preview binding ----
   THE BINDING IS THE MOCK ITSELF. Every other preview restates its effect at
   preview scale, because a leaf falls 140px in a real card and 44px in a 64px
   tile. A feed effect needs no such restatement: the mock IS a `.post` carrying
   the live `data-fx-*` attribute, so the shop shows the exact rule the feed
   shows and the two cannot drift.

   The grouped selector below is what makes that a real binding rather than an
   accident — it sizes the mock down and slows the motion so seventeen tiles
   animating at once stay calm, and it is the one place to adjust how feed
   effects read in the shop. */
[data-fx="frame-arcane"] .fxm-post,
[data-fx="frame-legend"] .fxm-post,
[data-fx="frame-ants"] .fxm-post,
[data-fx="frame-shine"] .fxm-post,
[data-fx="back-fall"] .fxm-post,
[data-fx="back-wander"] .fxm-post,
[data-fx="back-rise"] .fxm-post,
[data-fx="back-surface"] .fxm-post,
[data-fx="name-gild"] .fxm-post,
[data-fx="name-ink"] .fxm-post,
[data-fx="name-aurora"] .fxm-post,
[data-fx="badge-bloom"] .fxm-post,
[data-fx="badge-ignite"] .fxm-post,
[data-fx="badge-frost"] .fxm-post,
[data-fx="badge-rank"] .fxm-post,
[data-fx="act-vine"] .fxm-post,
[data-fx="act-molten"] .fxm-post {
  transform: scale(0.92);
  animation-duration: 5s;
}

.fxm-post {
  width: 132px;
  padding: 7px 8px;
  border-radius: 8px;
  background: var(--surface-1);
  border: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.fxm-post .post-head { display: flex; align-items: center; gap: 5px; }
.fxm-post .bdg {
  width: 16px; height: 16px; border-radius: 50%;
  background: var(--surface-3); color: var(--text-secondary);
  font-size: 7px; display: flex; align-items: center; justify-content: center;
}
.fxm-post .post-name { font-size: 8.5px; color: var(--text-primary); }
.fxm-post .line { height: 3px; border-radius: 2px; background: var(--surface-3); }
.fxm-post .line.short { width: 62%; }
.fxm-post .post-act {
  align-self: flex-start; font-size: 7.5px; padding: 2px 6px;
  border-radius: 999px; background: var(--surface-2); color: var(--text-muted);
}

/* ===========================================================================
   COVERED SHELL LAYERS. Every stacked shell layer is its own document, and
   each mounts its own ambience — two layers deep meant three particle fields
   composited over each other with only one visible. The shell stamps
   `shell-covered` on every document that has another layer on top of it (and
   on the map while any layer is open); the decorative full-viewport work
   stops entirely. display:none (not visibility) so the animations genuinely
   halt. The class comes off when the layer surfaces again.
   =========================================================================== */
html.shell-covered .fx-layer.fx-fixed,
html.shell-covered .fx-aurora,
html.shell-covered body::before { display: none; }
