/* ============================================================================
   KNULL STORE — knull-effects.css
   Round 1 (2026-07-20), picked from the first lab:
     E5 E6 E9 E11 E17 E18 E19 E20 E21 E22 E23 E25 E26 E27 E34 E39 E41 E42.
   Round 2 (2026-07-29), picked from effects-lab.html:
     L14 L17 L28 L32 L44 — see the ROUND 2 block at the bottom.
   Round 3 (2026-07-29), picked from effects-lab-2.html:
     L74 L107 — see the ROUND 3 block at the bottom.
   Ship together with knull-effects.js (the JS-driven ones need it).

   Trigger conventions:
     · hover effects   — just add the class, they react on :hover
     · one-shot plays  — add the class, then call KnullFX.play(el) or add "go"
     · ambient loops   — add the class, they run forever
     · JS-driven       — call the KnullFX helper listed next to it
   ============================================================================ */

/* ---- E5 · GOLD SWEEP REVEAL (one-shot: add .go) ------------------------- */
/* a blade of light sweeps across the element, content revealed behind it   */
.kn-fx-sweep{position:relative;}
/* fails open -- see the cascade note below */
.kn-fx-sweep > *{opacity:0;animation:kn-unhide 0s linear 1.4s forwards;}
.kn-fx-sweep::after{
  content:""; position:absolute; top:0; bottom:0; width:3px; left:-6%; opacity:0;
  background:linear-gradient(180deg,transparent,#8b30ef,transparent);
  box-shadow:0 0 22px 4px rgba(139,48,239,.5); pointer-events:none;
}
.kn-fx-sweep.go::after{animation:kn-sweep 1s cubic-bezier(.6,.05,.3,1) both;}
.kn-fx-sweep.go > *{animation:kn-sweep-reveal 1s both;}
@keyframes kn-sweep{0%{left:-6%;opacity:0}12%{opacity:1}88%{opacity:1}100%{left:104%;opacity:0}}
@keyframes kn-sweep-reveal{0%,30%{opacity:0;clip-path:inset(0 100% 0 0)}
  95%{opacity:1;clip-path:inset(0 -8% 0 0)}100%{opacity:1;clip-path:none}}

/* ---- E6 · STAGGER CASCADE (one-shot: KnullFX.cascade(parent)) ----------- */
/* children rise in one after another; JS stamps --i on each child          */
/* FAILS OPEN, deliberately. These children are hidden by CSS and unhidden by
   a JS-added .go, so anything that emits the class without playing the effect
   deletes the content -- a missing KnullFX, a throw inside it, a repaint that
   re-renders the markup with nobody left to animate it. That has now happened
   three times in this app.

   The animation below costs nothing and runs no JS: after 1.4s the children
   become visible whatever happened. .kn-fx-cascade.go > * is (0,2,0) against
   this rule's (0,1,0), so the real effect still wins whenever it plays. The
   backstop is only ever seen when something has already gone wrong. */
.kn-fx-cascade > *{opacity:0;animation:kn-unhide 0s linear 1.4s forwards;}
@keyframes kn-unhide{to{opacity:1}}
.kn-fx-cascade.go > *{
  animation:kn-rise .5s cubic-bezier(.2,.8,.2,1) both;
  animation-delay:calc(var(--i,0) * 70ms);
}
@keyframes kn-rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}

/* ---- E9 · SHIMMER SWEEP (hover) ----------------------------------------- */
/* a diagonal sheen crosses the face once per hover                          */
.kn-fx-sheen{position:relative;overflow:hidden;isolation:isolate;}
/* LEFT, not transform — see the note on z-bandsweep in shell.css. An animating
   transform here composited the whole card and greyed its text for the length
   of the sweep. The rotate stays static, which does not promote anything.
   Animating left also drops the container-query units the old version needed to
   know how far to travel, so the @supports fallback is gone with them. */
.kn-fx-sheen::after{
  /* behind the content -- see the z-index note on z-bandsweep in shell.css.
     This sheen is fainter than that band, but it is still a translucent wash
     travelling over live text, and it dulled everything it passed. */
  content:""; position:absolute; top:-30%; bottom:-30%; width:46px; left:-70px;
  z-index:-1; transform:rotate(18deg); pointer-events:none;
  background:linear-gradient(90deg,transparent,rgba(139,48,239,.14),transparent);
}
.kn-fx-sheen:hover::after{animation:kn-sheen .9s cubic-bezier(.4,.1,.3,1);}
@keyframes kn-sheen{from{left:-70px}to{left:calc(100% + 70px)}}

/* ---- E11 · HOVER BLOOM (hover) ------------------------------------------ */
/* lifts 5px, gold glow blooms wide — for clickable cards                    */
/* The lift moves TOP, not transform, and that is not a style preference.
   A transform promotes the element to its own compositing layer, and Chromium
   cannot subpixel-antialias text on a layer whose background is transparent --
   which every card here is. So the text silently drops to grayscale AA the
   moment you hover and snaps back when you leave: the whole card appears to go
   soft and then sharpen again. Landon reported exactly that.
   top on an already-positioned element paints at a new offset without a layer,
   without touching antialiasing, and without reflowing anything around it.
   The :where() is 0-specificity on purpose -- it supplies position/top only
   for elements that have not set their own, and loses to any module rule. */
:where(.kn-fx-bloom){position:relative;top:0;}
.kn-fx-bloom{transition:top .22s ease, box-shadow .25s ease;}
.kn-fx-bloom:hover{
  top:-5px;
  box-shadow:0 0 34px -8px rgba(139,48,239,.55), 0 22px 40px -22px #000;
}

/* ---- E17 · PNL FLASH (one-shot: KnullFX.flash(el, up)) ------------------ */
/* the OG Vortex flash — neon green burst on gains, red on losses           */
.kn-fx-flash-up{animation:kn-flashup 1.1s ease both;
  color:var(--kn-green); text-shadow:var(--kn-glow-green);}
.kn-fx-flash-dn{animation:kn-flashdn 1.1s ease both;
  color:var(--kn-red); text-shadow:var(--kn-glow-red);}
@keyframes kn-flashup{0%{background:rgba(46,230,168,0)}
  22%{background:rgba(46,230,168,.20); box-shadow:0 0 26px -8px #2ee6a8}
  100%{background:rgba(46,230,168,0)}}
@keyframes kn-flashdn{0%{background:rgba(242,85,74,0)}
  22%{background:rgba(242,85,74,.20); box-shadow:0 0 26px -8px #f2554a}
  100%{background:rgba(242,85,74,0)}}

/* ---- E18 · LINE DRAW-ON (KnullFX.drawOn(svgPath); area: .kn-fx-after) --- */
/* SVG chart lines draw themselves; pair the area fill with .kn-fx-after so
   it fades in once the line lands                                           */
.kn-fx-after{opacity:0;}
.kn-fx-after.go{animation:kn-fade-in .7s .8s both;}
@keyframes kn-fade-in{from{opacity:0}to{opacity:1}}

/* ---- E19 · HEARTBEAT DOT (ambient) -------------------------------------- */
/* upgrade any .kn-dot with .heartbeat — soft halo breathes instead of blink */
.kn-dot.heartbeat{position:relative;animation:none;}
.kn-dot.heartbeat::after{
  content:""; position:absolute; inset:-5px; border-radius:50%;
  background:rgba(139,48,239,.25);
  animation:kn-heart 1.6s ease-in-out infinite;
}
@keyframes kn-heart{0%,100%{transform:scale(.7);opacity:.9}50%{transform:scale(1.5);opacity:.15}}

/* ---- E20 · GLOW TRAIL (one-shot: KnullFX.trail(el)) --------------------- */
/* value flares bright on update, glow decays away                           */
.kn-fx-trail{animation:kn-trail 1.4s ease both;}
@keyframes kn-trail{
  0%{text-shadow:0 0 0 rgba(139,48,239,0)}
  18%{text-shadow:0 0 26px rgba(139,48,239,.95), 0 0 60px rgba(139,48,239,.55)}
  100%{text-shadow:0 0 0 rgba(139,48,239,0)}}

/* ---- E21 · DRIFTING EMBERS (ambient: KnullFX.embers(container)) --------- */
/* tiny gold sparks float up through the area; container needs overflow      */
.kn-fx-embers{position:relative;overflow:hidden;}
.kn-ember{
  position:absolute; bottom:-6px; width:3px; height:3px; border-radius:50%;
  background:var(--z-accent); box-shadow:0 0 7px 1px rgba(139,48,239,.75);
  pointer-events:none; animation:kn-float linear infinite;
}
@keyframes kn-float{
  0%{transform:translateY(0) translateX(0);opacity:0}
  12%{opacity:.9}80%{opacity:.5}
  100%{transform:translateY(var(--rise,-140px)) translateX(var(--dx,8px));opacity:0}}

/* ---- E22 · BREATHING VEINS (ambient overlay) ---------------------------- */
/* drop <div class="kn-fx-veins"></div> inside a positioned box — a patch of
   marble slowly glows and dims, like something alive under the stone        */
.kn-fx-veins{
  position:absolute; inset:0; pointer-events:none;
  background:var(--kn-ember-marble,url("assets/marble.jpg")) center/220% no-repeat;
  mix-blend-mode:screen; animation:kn-breathe 5.5s ease-in-out infinite;
}
@keyframes kn-breathe{
  0%,100%{opacity:.25;filter:brightness(1.4)}
  50%{opacity:.85;filter:brightness(3.2) sepia(.5) saturate(1.4)}}

/* ---- E23 · AURORA DRIFT (ambient) --------------------------------------- */
/* the warm top light slowly wanders side to side — for cards or the page    */
@property --kn-ax{syntax:"<percentage>";initial-value:20%;inherits:false;}
.kn-fx-aurora{position:relative;}
.kn-fx-aurora::after{
  content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none;
  background:radial-gradient(60% 55% at var(--kn-ax) -10%,rgba(160,132,244,.18),transparent 65%);
  animation:kn-aurora 7s ease-in-out infinite alternate;
}
.kn-fx-aurora.slow::after{animation-duration:26s;}
@keyframes kn-aurora{to{--kn-ax:80%}}

/* ---- E25 · CANDLE FLICKER (ambient) ------------------------------------- */
/* the glow flickers faintly like candlelight — meant for .kn-nav.on         */
.kn-fx-candle{animation:kn-candle 3.4s linear infinite;}
@keyframes kn-candle{
  0%,100%{box-shadow:0 0 20px -8px rgba(139,48,239,.28)}
  8%{box-shadow:0 0 24px -7px rgba(139,48,239,.42)}
  13%{box-shadow:0 0 18px -8px rgba(139,48,239,.24)}
  26%{box-shadow:0 0 26px -6px rgba(139,48,239,.4)}
  41%{box-shadow:0 0 19px -8px rgba(139,48,239,.30)}
  55%{box-shadow:0 0 27px -6px rgba(139,48,239,.45)}
  70%{box-shadow:0 0 21px -8px rgba(139,48,239,.26)}
  84%{box-shadow:0 0 25px -7px rgba(139,48,239,.38)}}

/* ---- E34 · UNDERLINE SWEEP (hover) -------------------------------------- */
/* a gold underline draws itself under links and headings                    */
.kn-fx-underline{position:relative;padding-bottom:5px;transition:color .18s;}
/* width, not scaleX. A transitioning transform on this bar composited the
   heading it underlines, so the words above it dimmed while the line drew. */
.kn-fx-underline::after{
  content:""; position:absolute; left:0; bottom:0; width:0; height:1.5px;
  background:linear-gradient(90deg,#3d1080,#8b30ef,#8b30ef);
  transition:width .45s cubic-bezier(.3,.1,.3,1);
  box-shadow:0 0 8px rgba(139,48,239,.6);
}
.kn-fx-underline:hover{color:var(--kn-muted);}
.kn-fx-underline:hover::after{width:100%;}

/* ---- E39 · WIN PULSE (one-shot: KnullFX.pulse(card, up)) ---------------- */
/* the whole card breathes one neon pulse — green on a gain, red on a loss  */
.kn-fx-pulse-up{animation:kn-pulse-up 1.3s ease both;}
.kn-fx-pulse-dn{animation:kn-pulse-dn 1.3s ease both;}
@keyframes kn-pulse-up{0%,100%{box-shadow:var(--kn-glow-card)}
  25%{box-shadow:0 0 36px -6px rgba(46,230,168,.55)}}
@keyframes kn-pulse-dn{0%,100%{box-shadow:var(--kn-glow-card)}
  25%{box-shadow:0 0 36px -6px rgba(242,85,74,.55)}}

/* ---- E41 · VALUE BREATHE (ambient) -------------------------------------- */
/* a live number's glow slowly breathes while connected — very subtle       */
.kn-fx-breathe{animation:kn-vbreathe 4.2s ease-in-out infinite;}
@keyframes kn-vbreathe{
  0%,100%{text-shadow:0 0 10px rgba(139,48,239,.18)}
  50%{text-shadow:0 0 26px rgba(139,48,239,.5), 0 0 52px rgba(139,48,239,.25)}}

/* ---- E42 · ROW GLOW-FADE (one-shot: KnullFX.rowFlash(row, up)) ---------- */
/* an updated table row's background glows neon and fades — the PnL flash
   for tables                                                                */
.kn-fx-row-up{animation:kn-row-up 1.6s ease both;}
.kn-fx-row-dn{animation:kn-row-dn 1.6s ease both;}
@keyframes kn-row-up{0%{background:rgba(46,230,168,0)}
  18%{background:rgba(46,230,168,.16);
      box-shadow:0 0 22px -6px rgba(46,230,168,.6), inset 0 0 14px rgba(46,230,168,.12)}
  100%{background:rgba(46,230,168,0)}}
@keyframes kn-row-dn{0%{background:rgba(242,85,74,0)}
  18%{background:rgba(242,85,74,.16);
      box-shadow:0 0 22px -6px rgba(242,85,74,.6), inset 0 0 14px rgba(242,85,74,.12)}
  100%{background:rgba(242,85,74,0)}}

/* ---- E26 · GOLD RIPPLE (click: KnullFX.ripple(button) binds it) --------- */
.kn-fx-ripple{position:relative;overflow:hidden;}
.kn-ripple{
  position:absolute; border-radius:50%; pointer-events:none;
  background:radial-gradient(circle,rgba(139,48,239,.5),rgba(139,48,239,.12) 60%,transparent 70%);
  transform:translate(-50%,-50%) scale(0); animation:kn-rip .7s ease-out both;
}
@keyframes kn-rip{to{transform:translate(-50%,-50%) scale(1);opacity:0}}

/* ---- E27 · TEXT SHINE (hover, or .auto to loop) ------------------------- */
/* a gold sheen sweeps through the letters                                    */
.kn-fx-shine{
  background:linear-gradient(100deg,var(--z-faint) 0%,var(--z-faint) 38%,#8b30ef 50%,var(--z-faint) 62%,var(--z-faint) 100%);
  background-size:240% 100%; background-position:100% 0;
  -webkit-background-clip:text; background-clip:text;
  color:transparent; -webkit-text-fill-color:transparent;
}
.kn-fx-shine:hover{animation:kn-shine 1.2s cubic-bezier(.4,.1,.4,1);}
.kn-fx-shine.auto{animation:kn-shine 1.2s cubic-bezier(.4,.1,.4,1) infinite;
  animation-delay:0s; animation-duration:3.6s;}
@keyframes kn-shine{from{background-position:100% 0}to{background-position:0 0}}
/* keep white headings white when the shine class is used on them at rest:
   apply .kn-fx-shine only to elements that should read gold-gray until swept */

/* ═══ ROUND 2 · picked 2026-07-29 ═════════════════════════════════════════ */

/* ---- L14 · LETTER CASCADE (one-shot: KnullFX.letters(el)) ---------------- */
/* characters arrive one by one — for the wordmark and page titles           */
/* FAILS OPEN like E6: the letters are hidden by CSS and unhidden by a JS
   .go, so the kn-unhide backstop below hands the word back after 1.4s
   whatever happened. Do not remove it. See trap 4 in CLAUDE.md.             */
.kn-fx-letters > i{font-style:normal;display:inline-block;
  opacity:0;animation:kn-unhide 0s linear 1.4s forwards;}
.kn-fx-letters.go > i{
  animation:kn-letter .5s cubic-bezier(.2,.8,.2,1) both;
  animation-delay:calc(var(--i,0) * 50ms);
}
@keyframes kn-letter{from{opacity:0;transform:translateY(14px) rotate(-8deg)}
  to{opacity:1;transform:none}}

/* ---- L17 · SKEW SLIDE (one-shot: add .go, or KnullFX.play) --------------- */
/* a row slides in leaning and straightens — for feed and leaderboard rows.
   Nothing is hidden at rest, so no backstop is needed: without .go the row
   simply sits there.                                                        */
.kn-fx-skew.go{animation:kn-skew .9s cubic-bezier(.2,.8,.2,1) both;}
@keyframes kn-skew{from{opacity:0;transform:translateX(-40px) skewX(14deg)}
  to{opacity:1;transform:none}}

/* ---- L28 · BORDER TRACE (hover) ----------------------------------------- */
/* a purple ring draws itself around the edge — for buttons and clickables.
   Runs on a ::after so it never touches the element's own border-color
   (which is pinned app-wide — trap 4 in the app notes).                     */
.kn-fx-trace{position:relative;overflow:hidden;}
.kn-fx-trace::after{
  content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none;
  box-shadow:inset 0 0 0 1px var(--z-accent,#8b30ef);
  clip-path:inset(0 100% 0 0);
  transition:clip-path .38s cubic-bezier(.6,.05,.3,1);
}
.kn-fx-trace:hover::after{clip-path:inset(0);}

/* ---- L32 · CHARGE (hover) ----------------------------------------------- */
/* the figure gets hotter under the cursor — it lights up rather than growing.

   REBUILT 2026-07-29, and the scale was only half the reason. The first cut was
   filter:brightness(1.6) saturate(1.5) plus transform:scale(1.07), and BOTH of
   those hand the element its own compositing layer. On a layer with a
   transparent background Chromium drops text to grayscale antialiasing, so the
   number you hovered to read went soft — the same defect that was pulled out of
   every card, button and calendar cell in this app on the same day. Deleting
   the transform alone would have left the filter doing it.

   A text-shadow does not composite, so this version cannot soften anything.
   The glow is currentColor, which is what keeps the original's one genuinely
   good property: it rides whatever colour the figure already is, so a green
   number glows green, a red one red, and a neutral one neutral, without this
   rule ever knowing which. Same trick as .status-halo in the store.          */
.kn-fx-charge{transition:text-shadow .25s ease;}
.kn-fx-charge:hover{text-shadow:0 0 16px currentColor, 0 0 40px currentColor;}

/* ---- L44 · BOUNCING DOTS (ambient) -------------------------------------- */
/* three dots take turns hopping — the app's waiting/loading indicator.
   Markup: <span class="kn-fx-dots"><i></i><i></i><i></i></span>             */
.kn-fx-dots{display:inline-flex;align-items:flex-end;line-height:1;}
.kn-fx-dots > i{
  display:inline-block; width:7px; height:7px; margin:0 3px; border-radius:50%;
  background:var(--z-accent,#8b30ef); animation:kn-dots 1.2s ease-in-out infinite;
}
.kn-fx-dots > i:nth-child(2){animation-delay:.15s;}
.kn-fx-dots > i:nth-child(3){animation-delay:.3s;}
@keyframes kn-dots{0%,100%{transform:translateY(0);opacity:.4}
  50%{transform:translateY(-9px);opacity:1}}

/* ═══ ROUND 3 · picked 2026-07-29 ═════════════════════════════════════════ */

/* ---- L74 · SPARKLINE DRAW (one-shot: add .go) ---------------------------- */
/* the CSS-only twin of E18. E18 measures the path with JS (getTotalLength)
   and is right for ONE big chart; this one takes the length from a --len
   custom property so a whole table of sparklines animates with no JS call
   per row. Set --len a little OVER the true path length — too long just
   delays the start, too short leaves a visible stub.
   Markup: <svg class="kn-fx-draw" style="--len:220"><path …></svg>          */
.kn-fx-draw path{stroke-dasharray:var(--len,220);stroke-dashoffset:var(--len,220);}
.kn-fx-draw.go path{animation:kn-draw 1.2s cubic-bezier(.45,.05,.35,1) both;}
@keyframes kn-draw{to{stroke-dashoffset:0}}

/* ---- L107 · LABEL SWAP (one-shot: add .go, or KnullFX.play) -------------- */
/* the label rolls up and out, the confirmation rolls in under it — for
   copy buttons and anything whose result is a single word.
   Markup: <span class="kn-fx-swap"><i class="a">Copy</i><i class="b">Copied</i></span>
   Nothing is hidden at rest: .b is merely parked below the box and clipped,
   so without .go the control still reads correctly.                         */
.kn-fx-swap{position:relative;display:inline-grid;place-items:center;overflow:hidden;}
.kn-fx-swap > i{grid-area:1/1;font-style:normal;white-space:nowrap;}
.kn-fx-swap > .b{transform:translateY(120%);opacity:0;color:var(--kn-green,#2ee6a8);}
.kn-fx-swap.go > .a{animation:kn-swap-out .45s .1s ease both;}
.kn-fx-swap.go > .b{animation:kn-swap-in .45s .1s ease both;}
@keyframes kn-swap-out{to{transform:translateY(-120%);opacity:0}}
@keyframes kn-swap-in{to{transform:translateY(0);opacity:1}}

/* ---- REDUCED MOTION ------------------------------------------------------
   Found while building the motion store: several effects hide their content in
   CSS and only reveal it when an animation runs. .kn-fx-sweep and .kn-fx-cascade
   both set `> * { opacity:0 }`, and .kn-fx-after starts at 0 — so if the
   animation never plays, the content is not "unanimated", it is GONE. The
   cascade is real markup in brokers, journal and the dashboard.

   Honouring the preference therefore cannot just be "turn the animations off";
   it has to hand the content back first. Everything below is opacity and
   nothing else — layout is untouched, so no reflow, and the ambient loops that
   would otherwise run forever are stopped rather than merely shortened.       */
@media (prefers-reduced-motion: reduce) {
  .kn-fx-sweep > *, .kn-fx-cascade > *, .kn-fx-after,
  .kn-fx-letters > i { opacity: 1; }
  .kn-fx-sweep::after { display: none; }
  .kn-fx-sweep.go > *, .kn-fx-cascade.go > *, .kn-fx-after.go,
  .kn-fx-letters.go > i, .kn-fx-skew.go { animation: none; }

  /* the sparkline must be a finished line, not an invisible one */
  .kn-fx-draw path { stroke-dasharray: none; stroke-dashoffset: 0; }
  .kn-fx-draw.go path { animation: none; }
  /* the swap still swaps — it carries meaning — it just does it at once */
  .kn-fx-swap.go > .a, .kn-fx-swap.go > .b { animation-duration: .01ms; }

  /* the round-2 ambient loader */
  .kn-fx-dots > i { animation: none; opacity: .7; }

  /* the ambient loops: no reason for these to run for someone who asked for
     stillness, and they are the ones that never stop on their own */
  .kn-fx-breathe, .kn-fx-candle, .kn-fx-shine.auto,
  .kn-fx-aurora::after, .kn-fx-veins, .kn-dot.heartbeat::after { animation: none; }

  /* one-shots still fire, because they carry meaning — a value changed — but
     they resolve immediately instead of playing out */
  .kn-fx-flash-up, .kn-fx-flash-dn, .kn-fx-pulse-up, .kn-fx-pulse-dn,
  .kn-fx-row-up, .kn-fx-row-dn, .kn-fx-trail { animation-duration: .01ms; }
}
