/* Pawball 5.0 - ambient motion layer
   Every rule here is additive: no existing component is restyled, only
   enhanced with slow, quiet movement. All animations honour the shared
   --pb-play switch (paused while the tab is hidden) and are wiped entirely
   by the global reduced-motion rule in theme.css. Transform, translate,
   opacity and background-position only - nothing that forces layout. */

body { --pb-play: running; }
body.pb-hidden { --pb-play: paused; }

/* ---------- living park background ----------
   Injected by ambient.js as the first child of #app (inline position:
   absolute, inset:-3%, z-index:0, pointer-events:none). The wallpaper copy
   drifts and swells so gently the movement is felt rather than seen; two
   soft light fields cross it out of phase like passing sunlight. */
.pb-amb { overflow: hidden; }
.pb-amb-wall {
  position: absolute;
  inset: 0;
  background: url("../images/wallpaper.png") center center / cover no-repeat;
  animation: pbWallDrift 40s ease-in-out infinite alternate;
  animation-play-state: var(--pb-play, running);
  will-change: transform;
}
@keyframes pbWallDrift {
  0%   { transform: scale(1.05) translate3d(0, 0, 0); }
  50%  { transform: scale(1.09) translate3d(-1.9%, 1%, 0); }
  100% { transform: scale(1.06) translate3d(1.8%, -0.9%, 0); }
}
.pb-amb-sun,
.pb-amb-sun2 {
  position: absolute;
  inset: -12%;
  pointer-events: none;
  animation-play-state: var(--pb-play, running);
  will-change: transform, opacity;
}
.pb-amb-sun {
  background: radial-gradient(46% 38% at 24% 12%, rgba(255, 244, 200, .95), rgba(255, 244, 200, 0) 70%);
  animation: pbSunA 24s ease-in-out infinite alternate;
}
.pb-amb-sun2 {
  background: radial-gradient(52% 44% at 78% 26%, rgba(255, 250, 226, .85), rgba(255, 250, 226, 0) 72%);
  animation: pbSunB 33s ease-in-out infinite alternate;
}
@keyframes pbSunA {
  0%   { opacity: .1; transform: translate3d(-4%, -1.6%, 0); }
  100% { opacity: .95; transform: translate3d(4.5%, 2.4%, 0); }
}
@keyframes pbSunB {
  0%   { opacity: .9; transform: translate3d(3.8%, 1.8%, 0); }
  100% { opacity: .08; transform: translate3d(-4%, -1.5%, 0); }
}
/* a broad shaft of sunlight sweeps the park, and a soft cloud shadow
   follows its own slower path - the two together make the light feel
   weather-driven rather than looped */
.pb-amb-beam {
  position: absolute;
  inset: -15% -35%;
  pointer-events: none;
  background: linear-gradient(72deg, rgba(255, 250, 215, 0) 40%, rgba(255, 250, 215, .55) 50%, rgba(255, 250, 215, 0) 60%);
  mix-blend-mode: overlay;
  animation: pbBeam 27s ease-in-out infinite alternate;
  animation-play-state: var(--pb-play, running);
  will-change: transform, opacity;
}
@keyframes pbBeam {
  0%   { transform: translate3d(-22%, 0, 0); opacity: .45; }
  50%  { opacity: .95; }
  100% { transform: translate3d(22%, 0, 0); opacity: .5; }
}
.pb-amb-cloud {
  position: absolute;
  top: -20%;
  left: -45%;
  width: 65%;
  height: 90%;
  pointer-events: none;
  border-radius: 50%;
  background: radial-gradient(50% 50% at 50% 50%, rgba(46, 66, 48, .3), rgba(46, 66, 48, 0) 70%);
  filter: blur(30px);
  animation: pbCloud 52s linear infinite;
  animation-play-state: var(--pb-play, running);
  will-change: transform, opacity;
}
@keyframes pbCloud {
  0%   { transform: translate3d(0, 0, 0); opacity: 0; }
  12%  { opacity: .85; }
  50%  { transform: translate3d(120%, 6%, 0); opacity: .6; }
  88%  { opacity: .85; }
  100% { transform: translate3d(230%, 0, 0); opacity: 0; }
}

/* pollen / seed motes: tiny, blurred, few, always rising at their own pace */
.pb-amb-p {
  position: absolute;
  top: 102%;
  left: var(--x, 50%);
  width: var(--size, 6px);
  height: var(--size, 6px);
  border-radius: 50%;
  background: rgba(255, 252, 232, 1);
  box-shadow: 0 0 10px rgba(255, 248, 210, .95), 0 0 3px rgba(255, 255, 245, 1);
  filter: blur(.6px);
  opacity: 0;
  animation: pbMote var(--dur, 34s) linear var(--delay, 0s) infinite;
  animation-play-state: var(--pb-play, running);
  will-change: transform, opacity;
}
@keyframes pbMote {
  0%   { transform: translate3d(0, 0, 0); opacity: 0; }
  8%   { opacity: var(--o, .5); }
  55%  { transform: translate3d(calc(var(--sway, 20px) * .6), -58vh, 0); }
  88%  { opacity: var(--o, .5); }
  100% { transform: translate3d(var(--sway, 20px), -112vh, 0); opacity: 0; }
}

/* ---------- breathing surfaces ----------
   A faint pool of warm light on each panel swells and settles on its own
   randomised cycle (durations/delays set inline by ambient.js), so the
   dashboard never inhales in unison. Opacity-only: the compositor handles
   the whole thing. */
.panel { position: relative; }
.panel::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(130% 85% at 18% -6%, rgba(255, 248, 222, .55), rgba(255, 248, 222, 0) 62%);
  opacity: 0;
  animation: pbBreath var(--pb-b-dur, 11s) ease-in-out var(--pb-b-delay, 0s) infinite alternate;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbBreath {
  0%   { opacity: .08; }
  100% { opacity: .65; }
}
/* the crest takes one slow breath alongside the club name */
.hud-identity img {
  animation: pbCrestBreath 7s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbCrestBreath {
  0%, 100% { translate: 0 0; scale: 1; }
  50%      { translate: 0 -1.2px; scale: 1.025; }
}

/* ---------- travelling light sweeps ----------
   ambient.js adds .pb-sweep to one eligible button or card at a time, on a
   randomised clock, and removes it when the pass finishes. The sheen lives
   on a pseudo-element clipped by the element's own radius - no overflow
   changes, so badges hanging outside cards are never cut off. */
.pb-sweep { position: relative; }
.pb-sweep::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(105deg, rgba(255, 253, 242, 0) 36%, rgba(255, 253, 242, .55) 50%, rgba(255, 253, 242, 0) 64%);
  background-size: 280% 100%;
  background-repeat: no-repeat;
  animation: pbSheen 1.7s cubic-bezier(.4, .1, .3, 1) forwards;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbSheen {
  0%   { background-position: 130% 0; opacity: 0; }
  12%  { opacity: 1; }
  88%  { opacity: 1; }
  100% { background-position: -40% 0; opacity: 0; }
}

/* occasional soft golden bloom on an important control - one cycle, gone */
.pb-glowpulse {
  animation: pbGlowPulse 3s ease-in-out forwards;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbGlowPulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(217, 169, 60, 0); }
  45%      { box-shadow: 0 0 0 1px rgba(217, 169, 60, .4), 0 0 22px rgba(217, 169, 60, .45); }
}

/* ---------- bars that catch the light ----------
   Reputation-style stage bars and the pressure bar carry a slow sheen that
   slides along the filled portion. background-position on a 6px strip:
   effectively free. */
.rel-bar span,
.pressure .bar span { position: relative; }
.rel-bar span::after,
.pressure .bar span::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(100deg, rgba(255, 255, 255, 0) 38%, rgba(255, 255, 255, .75) 50%, rgba(255, 255, 255, 0) 62%);
  background-size: 320% 100%;
  background-repeat: no-repeat;
  opacity: .75;
  animation: pbBarSheen 4.6s linear infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbBarSheen {
  0%, 55% { background-position: 160% 0; }
  100%    { background-position: -60% 0; }
}

/* ---------- presence ----------
   Online dots breathe softly all the time; the one-shot .pulse class in
   components.css is more specific, so a contact coming online still gets
   its single strong pulse before settling back into the breath. */
.online-dot {
  animation: pbDotBreathe 3s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbDotBreathe {
  0%, 100% { transform: scale(1); box-shadow: 0 1px 3px rgba(40, 74, 42, .35); }
  50%      { transform: scale(1.25); box-shadow: 0 1px 3px rgba(40, 74, 42, .35), 0 0 8px rgba(53, 168, 83, .75); }
}
.convo-head .presence-label:not(.is-typing) {
  animation: pbPresenceBreath 5s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbPresenceBreath {
  0%, 100% { opacity: 1; }
  50%      { opacity: .6; }
}

/* ---------- messenger thread light ----------
   A large, very faint pool of warm light drifts behind the frosted thread
   (injected once by ambient.js as the first child of .convo). The thread's
   translucent background lets it glow through without ever sitting on top
   of a bubble. */
.convo { position: relative; }
.pb-thread-light {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  border-radius: inherit;
}
.pb-thread-light::before {
  content: "";
  position: absolute;
  inset: -35%;
  background: radial-gradient(40% 36% at 30% 30%, rgba(255, 248, 218, .7), rgba(255, 248, 218, 0) 70%);
  animation: pbThreadLight 26s ease-in-out infinite alternate;
  animation-play-state: var(--pb-play, running);
  will-change: transform, opacity;
}
@keyframes pbThreadLight {
  0%   { transform: translate3d(-9%, -6%, 0); opacity: .4; }
  100% { transform: translate3d(11%, 9%, 0); opacity: .85; }
}

/* ---------- portraits quietly alive ----------
   1-2px independent float using the standalone translate property, so it
   composes with any existing transform (hover scales, pitch crops) instead
   of overriding it. Durations/delays randomised inline by ambient.js. */
.pb-float {
  animation: pbAvFloat var(--pb-f-dur, 4.6s) ease-in-out var(--pb-f-delay, 0s) infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbAvFloat {
  0%, 100% { translate: 0 0; }
  50%      { translate: 0 -2px; }
}

/* ---------- pitch light ----------
   One broad, soft band of sunlight crosses the grass and returns, under the
   existing warm-haze ::after so the desaturation treats it like part of the
   turf. Avatars sit at z-index 1 and are never touched. */
.pitch::before {
  content: "";
  position: absolute;
  inset: -6% -30%;
  z-index: 0;
  pointer-events: none;
  background: linear-gradient(78deg, rgba(255, 252, 230, 0) 38%, rgba(255, 252, 230, .38) 50%, rgba(255, 252, 230, 0) 62%);
  animation: pbPitchLight 13s ease-in-out infinite alternate;
  animation-play-state: var(--pb-play, running);
  will-change: transform;
}
@keyframes pbPitchLight {
  0%   { transform: translate3d(-16%, 0, 0); }
  100% { transform: translate3d(16%, 0, 0); }
}

/* ---------- new-message glow ----------
   ambient.js tags only the newest genuinely-appended bubble; the glow is a
   box-shadow transition (class on for ~1s, then removed), so it plays once,
   fades naturally, and can never loop or fight the slide-in animation. */
.bubble.me, .bubble.them { transition: box-shadow .55s ease; }
.bubble.me.pb-msg-glow {
  box-shadow: var(--shadow-card), 0 0 0 2px rgba(78, 158, 94, .5), 0 0 20px rgba(78, 158, 94, .55);
}
.bubble.them.pb-msg-glow {
  box-shadow: var(--shadow-card), 0 0 0 2px rgba(217, 169, 60, .55), 0 0 20px rgba(236, 202, 128, .65);
}

/* ---------- send button ready state ----------
   While the composer holds text, the send button carries a slow, warm
   pulse - an invitation, not an alarm. Cleared the moment the field empties. */
.btn-send.pb-ready {
  animation: pbSendReady 2.4s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbSendReady {
  0%, 100% { box-shadow: 0 0 0 0 rgba(62, 142, 79, 0); }
  50%      { box-shadow: 0 0 0 2.5px rgba(62, 142, 79, .32), 0 0 14px rgba(62, 142, 79, .45); }
}

/* ---------- reduced-motion carve-out ----------
   theme.css collapses every animation to .01ms under Reduce Motion. The
   ambient layer is the product here, not decoration on top of it, so its
   gentle, non-vestibular movement (slow opacity pools, drifting light,
   1-2px floats) is restored at full length. Anything brisk stays killed. */
@media (prefers-reduced-motion: reduce) {
  .pb-amb-wall { animation-duration: 40s !important; animation-iteration-count: infinite !important; }
  .pb-amb-sun { animation-duration: 24s !important; animation-iteration-count: infinite !important; }
  .pb-amb-sun2 { animation-duration: 33s !important; animation-iteration-count: infinite !important; }
  .pb-amb-p { animation-duration: var(--dur, 34s) !important; animation-iteration-count: infinite !important; }
  .pb-amb-beam { animation-duration: 27s !important; animation-iteration-count: infinite !important; }
  .pb-amb-cloud { animation-duration: 52s !important; animation-iteration-count: infinite !important; }
  .panel::after { animation-duration: var(--pb-b-dur, 11s) !important; animation-iteration-count: infinite !important; }
  .hud-identity img { animation-duration: 7s !important; animation-iteration-count: infinite !important; }
  .pb-sweep::before { animation-duration: 1.7s !important; }
  .pb-glowpulse { animation-duration: 3s !important; }
  .rel-bar span::after,
  .pressure .bar span::after { animation-duration: 4.6s !important; animation-iteration-count: infinite !important; }
  .online-dot { animation-duration: 3s !important; animation-iteration-count: infinite !important; }
  .convo-head .presence-label:not(.is-typing) { animation-duration: 5s !important; animation-iteration-count: infinite !important; }
  .pb-thread-light::before { animation-duration: 26s !important; animation-iteration-count: infinite !important; }
  .pb-float { animation-duration: var(--pb-f-dur, 4.6s) !important; animation-iteration-count: infinite !important; }
  .pitch::before { animation-duration: 13s !important; animation-iteration-count: infinite !important; }
  .btn-send.pb-ready { animation-duration: 2.4s !important; animation-iteration-count: infinite !important; }
  .bubble.me, .bubble.them { transition-duration: .55s !important; }
}

/* ============================================================ */
/* v5.1.26 world layer: time of day, seasons, tension, wildlife */
/* ============================================================ */

/* ---------- seasonal particles ----------
   The particle set is re-dressed by ambient.js per season. Risers keep the
   pollen keyframes; snow, blossom and autumn leaves fall instead. */
.pb-p-fall {
  animation: pbFallP var(--dur, 26s) linear var(--delay, 0s) infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbFallP {
  0%   { transform: translate3d(0, -12vh, 0) rotate(0deg); opacity: 0; }
  8%   { opacity: var(--o, .7); }
  55%  { transform: translate3d(calc(var(--sway, 30px) * .7), 55vh, 0) rotate(var(--spin, 160deg)); }
  92%  { opacity: var(--o, .7); }
  100% { transform: translate3d(var(--sway, 30px), 118vh, 0) rotate(calc(var(--spin, 160deg) * 2)); opacity: 0; }
}
.pb-amb-p.snow {
  background: rgba(255, 255, 255, .95);
  box-shadow: 0 0 8px rgba(255, 255, 255, .8);
  filter: blur(.5px);
}
.pb-amb-p.blossom {
  background: #F6D7DD;
  box-shadow: 0 0 7px rgba(246, 205, 214, .8);
  border-radius: 55% 45% 60% 40%;
  filter: none;
}
.pb-amb-p.leaf {
  background: linear-gradient(135deg, #C98F32, #A9622C);
  box-shadow: 0 1px 4px rgba(90, 60, 20, .3);
  border-radius: 62% 38% 58% 42%;
  filter: none;
}
/* winter cools the whole scene a touch; summer warms it */
body.pb-season-winter .pb-amb-wall { filter: saturate(.82) brightness(1.03) hue-rotate(8deg); }
body.pb-season-summer .pb-amb-wall { filter: saturate(1.1) brightness(1.02); }
body.pb-season-winter .pb-amb-beam { opacity: .3; }

/* ---------- matchday tension ----------
   Inside three days of a fixture the Next Match panel warms and the
   pressure bar picks up a slow double heartbeat; on the day itself the
   existing gold matchday Continue button gains a charged breathing edge. */
.match-panel.pb-near {
  border-color: var(--line-gold);
  animation: pbNearGlow 4.5s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbNearGlow {
  0%, 100% { box-shadow: var(--shadow-panel); }
  50%      { box-shadow: var(--shadow-panel), 0 0 0 1px rgba(192, 153, 90, .5), 0 0 22px rgba(217, 169, 60, .3); }
}
.match-panel.pb-near .pressure .bar span {
  animation: pbHeartbeat 2.8s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbHeartbeat {
  0%, 55%, 100% { box-shadow: 0 0 0 0 rgba(198, 75, 60, 0); }
  62%           { box-shadow: 0 0 8px rgba(198, 75, 60, .7); }
  70%           { box-shadow: 0 0 2px rgba(198, 75, 60, .2); }
  78%           { box-shadow: 0 0 10px rgba(198, 75, 60, .8); }
}
.btn-continue.matchday {
  animation: pbCharged 2.6s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbCharged {
  0%, 100% { box-shadow: 0 0 0 2px var(--gold-bright), 0 0 20px rgba(217, 169, 60, .65), 0 3px 14px rgba(150, 110, 30, .45); }
  50%      { box-shadow: 0 0 0 3px var(--gold-bright), 0 0 34px rgba(217, 169, 60, .95), 0 3px 14px rgba(150, 110, 30, .45); }
}

/* ---------- typing presence ----------
   While a contact is composing, their list row and strip card carry a
   quiet live glow, so a reply can be felt arriving before it lands. */
.msg-item.pb-typing-live,
.char-card.pb-typing-live {
  animation: pbLiveGlow 1.8s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbLiveGlow {
  0%, 100% { box-shadow: var(--shadow-card); }
  50%      { box-shadow: var(--shadow-card), 0 0 0 1px rgba(63, 139, 76, .45), 0 0 12px rgba(63, 139, 76, .4); }
}

/* ---------- unread pull ----------
   Unread conversations shimmer softly every few seconds until opened. */
.msg-item.unread { position: relative; }
.msg-item.unread::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(105deg, rgba(255, 247, 220, 0) 38%, rgba(255, 247, 220, .5) 50%, rgba(255, 247, 220, 0) 62%);
  background-size: 300% 100%;
  background-repeat: no-repeat;
  animation: pbUnreadPull 4.2s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbUnreadPull {
  0%, 55% { background-position: 150% 0; }
  90%, 100% { background-position: -50% 0; }
}
.msg-item .unread-badge {
  animation: pbBadgeBreath 2.6s ease-in-out infinite;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbBadgeBreath {
  0%, 100% { box-shadow: 0 0 0 0 rgba(198, 75, 60, 0); }
  50%      { box-shadow: 0 0 8px rgba(198, 75, 60, .6); }
}

/* ---------- dog idle life ----------
   One dog at a time settles its weight: a small dip and lean, then still. */
.pb-settle {
  animation: pbSettle .8s ease-in-out 1;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbSettle {
  0%, 100% { translate: 0 0; rotate: 0deg; }
  40%      { translate: 0 1.6px; rotate: -1.4deg; }
  70%      { translate: 0 .4px; rotate: .6deg; }
}

/* ---------- wildlife & fireflies ----------
   Tiny invented visuals only: a distant bird silhouette crossing the sky,
   a small butterfly wandering the lower park in spring/summer, and warm
   fireflies near the ground once evening falls. */
.pb-bird {
  position: absolute;
  left: -6vw;
  width: var(--size, 13px);
  height: auto;
  color: rgba(42, 56, 42, .6);
  pointer-events: none;
  animation: pbBirdFly var(--dur, 18s) linear forwards;
  animation-play-state: var(--pb-play, running);
  will-change: transform;
}
.pb-bird svg { display: block; width: 100%; height: auto; overflow: visible; }
.pb-bird .wing {
  transform-origin: 50% 60%;
  animation: pbFlap .5s ease-in-out infinite alternate;
  animation-play-state: var(--pb-play, running);
}
@keyframes pbBirdFly {
  0%   { transform: translate3d(0, 0, 0); opacity: 0; }
  4%   { opacity: 1; }
  50%  { transform: translate3d(55vw, var(--dip, 14px), 0); }
  96%  { opacity: 1; }
  100% { transform: translate3d(112vw, 0, 0); opacity: 0; }
}
@keyframes pbFlap {
  from { transform: scaleY(1); }
  to   { transform: scaleY(.25); }
}
.pb-butterfly {
  position: absolute;
  pointer-events: none;
  width: 9px;
  height: 8px;
  animation: pbFlutterPath var(--dur, 16s) ease-in-out forwards;
  animation-play-state: var(--pb-play, running);
  will-change: transform;
}
.pb-butterfly span {
  position: absolute;
  top: 1px;
  width: 4px;
  height: 6px;
  border-radius: 60% 60% 45% 45%;
  background: #EAC87E;
  box-shadow: 0 0 4px rgba(230, 196, 120, .6);
  animation: pbWingL .22s ease-in-out infinite alternate;
  animation-play-state: var(--pb-play, running);
}
.pb-butterfly span:last-child {
  right: 0;
  animation-name: pbWingR;
}
.pb-butterfly span:first-child { left: 0; transform-origin: 100% 50%; }
.pb-butterfly span:last-child { transform-origin: 0% 50%; }
@keyframes pbWingL { from { transform: rotateY(0deg) rotate(-8deg); } to { transform: rotateY(70deg) rotate(-8deg); } }
@keyframes pbWingR { from { transform: rotateY(0deg) rotate(8deg); } to { transform: rotateY(-70deg) rotate(8deg); } }
@keyframes pbFlutterPath {
  0%   { transform: translate3d(0, 0, 0); opacity: 0; }
  6%   { opacity: 1; }
  25%  { transform: translate3d(9vw, -30px, 0); }
  50%  { transform: translate3d(17vw, 12px, 0); }
  75%  { transform: translate3d(26vw, -22px, 0); }
  94%  { opacity: 1; }
  100% { transform: translate3d(34vw, -6px, 0); opacity: 0; }
}
.pb-firefly {
  position: absolute;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: #FFD87A;
  pointer-events: none;
  opacity: 0;
  animation:
    pbFlyWander var(--dur, 15s) ease-in-out var(--delay, 0s) infinite alternate,
    pbFlyGlow var(--pulse, 2.6s) ease-in-out var(--delay, 0s) infinite;
  animation-play-state: var(--pb-play, running);
  will-change: transform, opacity;
}
@keyframes pbFlyWander {
  0%   { transform: translate3d(0, 0, 0); }
  33%  { transform: translate3d(var(--wx, 34px), calc(var(--wy, -22px) * .6), 0); }
  66%  { transform: translate3d(calc(var(--wx, 34px) * .3), var(--wy, -22px), 0); }
  100% { transform: translate3d(calc(var(--wx, 34px) * -.5), calc(var(--wy, -22px) * .3), 0); }
}
@keyframes pbFlyGlow {
  0%, 100% { opacity: .05; box-shadow: 0 0 2px rgba(255, 216, 122, .2); }
  50%      { opacity: .95; box-shadow: 0 0 10px rgba(255, 216, 122, .9), 0 0 20px rgba(255, 200, 90, .45); }
}

/* ---------- ambient audio toggle (HUD) ---------- */
#pb-audio-btn.muted { opacity: .55; }
#pb-audio-btn .pb-spk-on { display: block; }
#pb-audio-btn .pb-spk-off { display: none; }
#pb-audio-btn.muted .pb-spk-on { display: none; }
#pb-audio-btn.muted .pb-spk-off { display: block; }

/* world-layer reduced-motion carve-out (same rationale as above) */
@media (prefers-reduced-motion: reduce) {
  .pb-p-fall { animation-duration: var(--dur, 26s) !important; animation-iteration-count: infinite !important; }
  .match-panel.pb-near { animation-duration: 4.5s !important; animation-iteration-count: infinite !important; }
  .match-panel.pb-near .pressure .bar span { animation-duration: 2.8s !important; animation-iteration-count: infinite !important; }
  .btn-continue.matchday { animation-duration: 2.6s !important; animation-iteration-count: infinite !important; }
  .msg-item.pb-typing-live, .char-card.pb-typing-live { animation-duration: 1.8s !important; animation-iteration-count: infinite !important; }
  .msg-item.unread::before { animation-duration: 4.2s !important; animation-iteration-count: infinite !important; }
  .msg-item .unread-badge { animation-duration: 2.6s !important; animation-iteration-count: infinite !important; }
  .pb-settle { animation-duration: .8s !important; }
  .pb-bird { animation-duration: var(--dur, 18s) !important; }
  .pb-bird .wing { animation-duration: .5s !important; animation-iteration-count: infinite !important; }
  .pb-butterfly { animation-duration: var(--dur, 16s) !important; }
  .pb-butterfly span { animation-duration: .22s !important; animation-iteration-count: infinite !important; }
  .pb-firefly { animation-duration: var(--dur, 15s), var(--pulse, 2.6s) !important; animation-iteration-count: infinite, infinite !important; }
}

/* ============================================================ */
/* v5.1.31 refinement pass                                      */
/* ============================================================ */

/* ---------- thread scroll depth cues ----------
   Overlays share the thread's grid cell (.convo row 2), so they hug its
   edges with no measuring. Visible only while more conversation exists in
   that direction; a soft ink gradient, never a hard line. */
/* explicit placement: overlapping a cell must never push the auto-placed
   thread or composer into a phantom row */
.convo > .convo-head { grid-row: 1; grid-column: 1; }
.convo > .convo-thread { grid-row: 2; grid-column: 1; }
.convo > #compose-wrap { grid-row: 3; grid-column: 1; }
.pb-fade-top,
.pb-fade-bottom {
  grid-row: 2;
  grid-column: 1;
  z-index: 2;
  height: 26px;
  pointer-events: none;
  opacity: 0;
  transition: opacity .3s ease;
}
.pb-fade-top {
  align-self: start;
  background: linear-gradient(180deg, rgba(40, 60, 35, .16), rgba(40, 60, 35, 0));
}
.pb-fade-bottom {
  align-self: end;
  background: linear-gradient(0deg, rgba(40, 60, 35, .14), rgba(40, 60, 35, 0));
}
.pb-fade-top.on,
.pb-fade-bottom.on { opacity: 1; }

/* ---------- typing preview in the conversation list ----------
   While a contact composes, their row reads "typing…" in a live green -
   the reply can be felt arriving from anywhere on the dashboard. */
.msg-item.pb-typing-live .preview {
  color: var(--ok);
  font-style: italic;
  font-weight: 600;
}

/* ---------- send button rest / ready states ----------
   Empty composer: the button rests, slightly desaturated. First character:
   full colour returns (plus the existing pb-ready pulse). */
.btn-send { transition: filter .25s ease, opacity .25s ease, transform .09s ease; }
.btn-send.pb-idle { filter: saturate(.5) brightness(.98); opacity: .72; }
.btn-send.pb-idle:hover { filter: saturate(.7) brightness(1.04); opacity: .85; }

/* the signpost joins the boot choreography as the left column's final beat */
@media (prefers-reduced-motion: no-preference) {
  #app.boot .col-left > .panel.signpost { opacity: 0; animation: pbPanelIn .55s cubic-bezier(.22, .61, .36, 1) .56s forwards; }
}
