*{ box-sizing:border-box; margin:0; padding:0; }
html,body{
  width:100%; height:100%; overflow:hidden;
  background:#050301;
}
/* invisible, unrendered probe -- exists purely so JS can read out how many
   real pixels `100svh` resolves to on THIS device right now (see
   svhProbePx() in app.js). svh is spec-guaranteed to be the SMALLEST the
   viewport can ever get (i.e. safe even with the browser's address bar
   fully expanded), so it can never itself be the cause of content
   rendering above/behind that chrome -- it's the one number here that's
   structurally incapable of ever being too tall. Reading it into JS lets
   --vvh be clamped so it can only ever match or shrink below this safe
   ceiling, never exceed it. */
#svhProbe{ position:fixed; visibility:hidden; pointer-events:none; width:1px; height:100svh; top:-9999px; }

/* ---------------- ambient fill: covers the letterbox bars ----------------
   the poster keeps a fixed aspect ratio, so any viewport shaped differently
   leaves bars -- sides on a wide desktop window, top/bottom on a phone.
   Instead of flat black, each bar shows a huge, heavily blurred, darkened
   copy of the current background, like a screen's ambient-lighting mode --
   it reads as intentional atmosphere pulled from the art itself rather
   than empty space, and it's the same technique on every screen shape, so
   there's no separate mobile-only fix to keep in sync. */
.ambient-bg{
  position:fixed; inset:-10%;
  z-index:0;
  background-size:cover;
  background-position:center;
  filter:blur(min(6vw, 60px)) saturate(1.35) brightness(0.5);
  transform:scale(1.15) rotate(0deg);
  opacity:0;
  transition:opacity .6s ease;
  pointer-events:none;
  /* a slow, barely-there drift so the letterbox bars read as deliberate
     ambient lighting instead of a static blurred photo sitting there doing
     nothing -- most noticeable (and most worth the extra life) on a phone,
     where the bars are the top/bottom strip rather than a thin desktop
     side margin. Pure transform, so it's effectively free on the compositor. */
  animation:ambientDrift 18s ease-in-out infinite;
}
.ambient-bg.active{ opacity:1; }
/* the drift animation above used to run on BOTH ambient-normal and
   ambient-switch forever, from page load to tab close, regardless of which
   one was actually visible -- only opacity was ever gated by .active, never
   the animation itself. That's two large (2.4x the poster, heavily blurred)
   fixed layers permanently occupying compositor/GPU texture budget at all
   times, one of them fully invisible the whole time. Matches the same
   "permanently-promoted layer paid for nothing" class of bug already found
   and fixed on .smoke-cover-layer/.fire-edge-item/.flame-item elsewhere in
   this file (see their own :not(.active) rules) -- this element was simply
   missed in that pass. Pausing the inactive one's animation is invisible
   (it's already at opacity:0) and frees real, continuous GPU cost that was
   never buying anything, which is exactly the kind of standing pressure
   that can make an unrelated later transition (the switch button, the
   marquee) look like it stalled or lost its own layer on a memory/GPU-
   constrained phone. */
.ambient-bg:not(.active){ animation-play-state:paused; }
@keyframes ambientDrift{
  0%,100%{ transform:scale(1.15) rotate(0deg); }
  50%{ transform:scale(1.26) rotate(1.6deg); }
}

/* ---------------- skeleton-ring ambient fill -------------------------
   the same "fill the letterbox bars" job as .ambient-bg above, but this
   one is centred EXACTLY on the poster's own centre (via --poster-w/-left/
   -top, the same vars the nav badges use) and scaled bigger than the
   poster itself -- so the ring's middle sits directly underneath the
   poster's opaque bg-plate and gets fully covered by it, while only the
   outer arcs of skeletons, which extend past the poster's shorter
   dimension, actually poke out into the bars. No clip-path or mask
   needed: the poster is opaque and simply painted on top (z-index:1 vs
   this element's 0, same tier + DOM order as .ambient-bg, which already
   relies on the identical trick). On a phone that means skeletons top and
   bottom; on a wide desktop window it's the same box, same math, but the
   bars are on the sides instead -- one rule, both shapes, because it's
   derived from the poster's actual measured box rather than hardcoded per
   breakpoint. */
.skeleton-bg{
  position:fixed;
  left:calc(var(--poster-left, 0px) + var(--poster-w, 900px) / 2);
  top:calc(var(--poster-top, 0px) + var(--poster-w, 900px) / 0.8119 / 2);
  transform:translate(-50%,-50%);
  width:calc(var(--poster-w, 900px) * 2.4);
  height:calc(var(--poster-w, 900px) * 2.4);
  z-index:0;
  background-repeat:no-repeat;
  background-position:center center;
  background-size:contain;
  opacity:0;
  transition:opacity .6s ease;
  pointer-events:none;
  /* depth treatment: colour is baked directly into the image now (a real
     dark-shadow -> theme-colour duotone grade, done pixel-by-pixel -- see
     the comment in app.js on why a live CSS hue-rotate/sepia filter was
     dropped in favour of this), so all that's needed on top is a soft-
     focus/glow "sitting behind the platform" depth cue -- set via JS
     (see the skeleton-bg block in app.js), NOT as a static filter
     declaration here. A `filter` present in the stylesheet from first
     paint, on an element this large (2.4x the poster, mostly positioned
     off past the edges of the viewport) whose `background-image` only
     gets assigned a moment later by JS, hit a real Chromium rendering
     bug during testing: the browser committed a blank/stale filtered
     layer at first paint and then never re-rasterized it once the image
     actually arrived -- the element was permanently invisible despite
     every computed style (opacity, filter, background-image) reading
     back completely correctly. Re-assigning `filter` via JS *after* the
     image is set forces the fresh rasterization the browser otherwise
     skipped. Confirmed via direct A/B testing before shipping this. */
  animation:skeletonDrift 22s ease-in-out infinite;
}
.skeleton-bg.active{ opacity:.85; }
/* same gap, same fix as .ambient-bg:not(.active) above -- the inactive
   skeleton ring was drift-animating forever at opacity:0, paying compositor
   cost for a layer nobody could ever see. */
.skeleton-bg:not(.active){ animation-play-state:paused; }
@keyframes skeletonDrift{
  0%,100%{ transform:translate(-50%,-50%) scale(1) rotate(0deg); }
  50%{ transform:translate(-50%,-50%) scale(1.05) rotate(-1deg); }
}
@media (prefers-reduced-motion: reduce){
  .skeleton-bg, .ambient-bg{ animation:none; }
}

/* posterWrap: fixed, anchored flush at the real top-left corner (0,0 has
   no ambiguity about which viewport frame it means -- unlike a percentage
   centre-point, which is exactly where the previous two attempts at this
   bug both still went wrong on a real WhatsApp in-app browser), sized to
   the same progressively-more-authoritative height chain the poster uses
   (vh -> svh -> --vvh, see below), with the poster simply centred inside
   it via flexbox. This turns "compute the correct centre point of a
   viewport that might be measured three different ways" into "size this
   box correctly, then let the browser's own flex layout centre inside it"
   -- flexbox centring works off the box's OWN rendered height, so as long
   as this wrapper's height is right, the centring inside it always is too,
   with no separate centre-point math that can independently drift out of
   sync with it.

   `100dvh` was tried here too, as the theoretically most-authoritative
   layer of all -- it didn't hold up. A user screen recording caught the
   exact bug it was meant to fix happening in a NEW way after adding it:
   the poster briefly shrinking to a tiny centred rectangle then growing
   back (the original vh-jank shape svh was added to prevent in the first
   place), and separately, content rendering ABOVE the address bar after a
   page reload. `dvh` is defined to be the LIVE, continuously-updating
   viewport size, which means by definition it can report a transient or
   momentarily-wrong value while the browser's own chrome is still
   animating or hasn't recalculated yet after a fresh navigation -- exactly
   the two symptoms caught on video. Removed. `svh` is the safer choice
   structurally: it's spec-guaranteed to be the SMALLEST the viewport can
   ever get, i.e. sized for the worst case (chrome fully expanded) already,
   which makes it structurally incapable of causing this "overflows past
   the chrome" bug -- see svhProbePx() in app.js for how --vvh is now kept
   from ever exceeding that same safe ceiling. */
.poster-wrap{
  position:fixed;
  left:0; top:0;
  width:100vw;
  height:100vh;
  height:100svh;
  height:var(--vvh, 100svh);
  display:flex;
  align-items:center;
  justify-content:center;
  z-index:1;
  pointer-events:none;
}
.poster-wrap > .poster{ pointer-events:auto; }

.poster{
  position:relative;
  /* "fit inside the viewport, whichever dimension runs out first" -- the
     artwork keeps its own fixed 0.8119 ratio always, letterboxed rather
     than cropped on every screen shape (a normal wide desktop window's
     sides, a phone's top/bottom). A crop-to-fill version of this was tried
     for phones (swapping min() for max() so the art grows to cover the
     whole screen, no bars at all) -- it measured fine here, but on a real
     phone it cropped hard enough to push UI like the switch-up button
     outside the visible area and broke the layout outright, almost
     certainly because a phone browser's own address-bar-driven viewport
     height doesn't behave like the fixed number this was tested against.
     Letterboxing is the one behaviour that's actually been verified
     reliable on a real device, so it stays universal; see .ambient-bg
     above for how the resulting bars get filled instead of left flat. */
  width:min(100vw, calc(100vh * 0.8119));
  height:min(100vh, calc(100vw / 0.8119));
  /* the recording the user sent shows this box visibly SHRINK to a tiny
     centred rectangle and then grow back to full-screen, right in the
     middle of a switch-up transition -- with no CSS transform involved
     anywhere in that animation (checked), the only way a fixed, non-
     transformed box's own width/height visibly jumps like that is if the
     values feeding its formula actually changed for a moment. Plain `vh`
     is exactly that: iOS Safari's address bar hiding/showing (which can
     happen mid-interaction, not just on a deliberate scroll) reports a
     transitional viewport height for a frame or two while it animates,
     and because this box's WIDTH formula also multiplies by 100vh, a
     brief bad height reading shrinks both dimensions at once -- precisely
     the small-rect-then-grow shape on video. `svh` ("small viewport
     height") is defined to always report the height available once the
     browser's UI has settled to its most-collapsed state, so it can't
     transiently wobble while that UI is animating the way plain vh can --
     trading a very occasional sliver of extra top/bottom letterbox (which
     the ambient-bg fill already exists to make look intentional) for a
     poster box that never visibly jumps size mid-interaction. Browsers
     that don't understand `svh` at all just ignore this whole line and
     keep using the vh version above, so this is purely additive. */
  width:min(100vw, calc(100svh * 0.8119));
  height:min(100svh, calc(100vw / 0.8119));
  /* final override: some embedded in-app browsers (WhatsApp's own WebView,
     confirmed via a user report + screenshots -- their partner's phone
     showed the top of the poster cut off and a black gap at the bottom,
     fixed only by pinch-zooming) report a taller-than-actually-visible
     window at first paint, before their own mini chrome finishes settling
     a moment later -- and critically, never fire a plain `resize` event
     when that happens, so both the vh and svh rules above stay locked to
     the wrong number indefinitely. `--vvh` is a JS-maintained custom
     property (see updateVVH() in app.js) driven by the visualViewport API,
     the one browser API built specifically to track the ACTUAL visible
     viewport live, in exactly these WebViews. Falls back to 100svh via the
     var()'s second argument until JS has run (or on a browser where it
     never runs), so this is purely additive over the two rules above, same
     as svh was purely additive over plain vh. Critically, updateVVH() now
     clamps whatever it writes here to never exceed 100svh's own safe
     ceiling (see svhProbePx() in app.js) -- svh can only ever ask for a
     SMALLER box than this, never a taller one, which is what keeps this
     override from ever being the thing that pushes content up behind the
     browser's own chrome again. */
  width:min(100vw, calc(var(--vvh, 100svh) * 0.8119));
  height:min(var(--vvh, 100svh), calc(100vw / 0.8119));
  overflow:hidden;
  background:#050301;
  box-shadow:0 0 80px rgba(0,0,0,0.6), 0 0 160px 40px rgba(0,0,0,0.35);
}

/* ---------------- background plate ---------------- */
.bg-plate{
  position:absolute; inset:0;
  width:100%; height:100%;
  object-fit:cover;
  opacity:0;
  transition:opacity .5s linear;
  z-index:1;
  /* no will-change here on purpose -- this plate's own transform is never
     animated (only opacity crossfades between the two plates), so the old
     will-change:transform was permanently promoting both full-poster-sized
     images to their own composited GPU layers for zero benefit. Layer-count
     inspection during the "still lags on retap" investigation found 105
     live composited layers on a page that only actually needs a handful --
     removing unused will-change declarations like this one is the fix. */
}
.bg-plate.active{ opacity:1; }

/* ---------------- real wallpaper text (live text) -------------------------
   back in its own original section (the real Canva text box's own band),
   not the full poster -- but always visible in that band from the very
   first frame, always moving left-to-right, never faded/slid in. */
.text-layer{
  position:absolute;
  overflow:hidden;
  z-index:2;
  display:flex;
  flex-direction:column;
  justify-content:space-evenly;
  color:#fdfdfd;
  font-family:'Anton', Arial, Helvetica, sans-serif;
  font-weight:700;
  line-height:1.4;
  letter-spacing:.015em;
  opacity:0;
  text-shadow:0 0 6px rgba(255,255,255,0.25);
  /* the scrolling band's own overflow:hidden used to cut the text off in a
     dead-straight horizontal line along its top/bottom edge -- see the
     per-layer mask-image on .text-normal/.text-switch below, which fades
     that edge AND cuts a soft hole out of the whole crowded centre column
     (mic, smoke, wordmark, canes all stack there) so the marquee text
     never visually fights with that cluster in the first place. */
}
.text-layer.active{ opacity:.85; }
.text-layer .row{
  overflow:hidden;
  white-space:nowrap;
  flex:0 0 auto;
}
.text-layer .track{
  display:flex;
  white-space:nowrap;
  width:max-content;
  will-change:transform;
  /* forces its own stable GPU layer for the whole scroll -- without this,
     some mobile GPUs occasionally repaint a row from software instead of
     compositing the existing layer on a busy frame (lots else animating
     at once), which shows up as a line of text flashing out for a frame
     or two mid-scroll before snapping back; a persistent 3D layer avoids
     the repaint entirely. */
  transform:translate3d(0,0,0);
  backface-visibility:hidden;
  /* promoting this to its own GPU-composited layer (above) is what fixed
     the mid-scroll flicker, but WebKit/Blink both drop LCD subpixel text
     antialiasing the moment a text layer is GPU-composited (subpixel AA
     needs to know the exact solid background colour behind it, which a
     compositor layer sitting on top of other moving art can't guarantee)
     -- falling back to grayscale AA, which reads as a soft/blurry step
     down from the crisp subpixel text everywhere else on the page. Forcing
     the same grayscale antialiasing explicitly (instead of leaving it to
     silently swap between subpixel/grayscale as layers get promoted and
     dropped) keeps the marquee text visually consistent rather than
     flickering sharp-to-soft, and text-rendering:optimizeLegibility asks
     for the browser's higher-quality glyph shaping/kerning pass so the
     grayscale result is still as crisp as that mode gets. */
  -webkit-font-smoothing:antialiased;
  -moz-osx-font-smoothing:grayscale;
  text-rendering:optimizeLegibility;
}
.text-layer .track span{ white-space:pre; }
.text-normal{
  left:-10.3%; top:51.67%; width:119.4%; height:17.98%;
  /* poster-relative, not viewport-relative -- see syncPosterScale() in
     app.js. A vw unit here only tracks the browser window's own width,
     which on a normal wide desktop window is nothing like the poster's
     own rendered width once it's letterboxed down to its fixed aspect
     ratio, and the text came out oversized for its band as a result. */
  font-size:calc(var(--poster-w, 900px) * 0.01114);
  /* two things stacked in one mask: a top/bottom edge fade (so the band's
     own clip line never reads as a hard cut), PLUS a soft hole punched out
     of the centre column -- the mic, smoke, wordmark and canes all stack
     up in that same column, and relying on z-index alone still let the
     text visually bleed through wherever any of that art has a soft or
     transparent edge (which is most of it -- none of it is a hard solid
     silhouette). Removing the text there entirely, rather than trusting
     translucent art to fully cover it, is what actually stops the "text
     cutting through the smoke/canes" look for good. Coordinates here are
     in THIS element's own box (it's offset/wider than the poster itself),
     converted from the poster-relative centre the mic/wordmark cluster
     actually sits at. mask-composite:subtract on the second (hole) layer
     is what carves it out of the first (fade) layer instead of adding to
     it. */
  /* widened after the first pass still showed a sliver of text right up
     against the smoke's own hard edge -- the hole's estimated size didn't
     quite reach as far as the art actually does. Erring generously large
     here (and pushing the opaque core out to 70% before it starts fading)
     trades a little extra hidden text for never showing that seam again. */
  mask-image:
    linear-gradient(to bottom, transparent 0%, #000 14%, #000 84%, transparent 100%),
    radial-gradient(ellipse 31.4% 78% at 53% 50%, #000 0%, #000 70%, transparent 100%);
  mask-composite: add, subtract;
  -webkit-mask-image:linear-gradient(to bottom, transparent 0%, #000 14%, #000 84%, transparent 100%);
}
.text-switch{
  left:-1.42%; top:52.66%; width:102.8%; height:16%;
  font-size:calc(var(--poster-w, 900px) * 0.01142);
  /* same idea as .text-normal above, re-centred and re-sized for this
     element's own (differently offset/sized) box. */
  mask-image:
    linear-gradient(to bottom, transparent 0%, #000 14%, #000 84%, transparent 100%),
    radial-gradient(ellipse 36.5% 78% at 52% 50%, #000 0%, #000 70%, transparent 100%);
  mask-composite: add, subtract;
  -webkit-mask-image:linear-gradient(to bottom, transparent 0%, #000 14%, #000 84%, transparent 100%);
}
/* seamless infinite scroll per row: track content is duplicated 2x by JS,
   so translating exactly -50% of the track's own width loops perfectly.
   rows are stacked (their real "paragraph lines") and staggered slightly
   for an organic, non-mechanical feel. */
.text-normal .track{ animation:scrollLeftToRight linear infinite; animation-duration:var(--dur,30s); }
.text-switch .track{ animation:scrollRightToLeft linear infinite; animation-duration:var(--dur,13s); }
/* the two marquee layers both exist in the DOM at all times (so the swap
   between them can crossfade), but only one is ever visible -- yet with
   `will-change:transform` on every row's track (needed to stop the earlier
   flicker), ALL 20 rows across both layers were being kept on the GPU as
   live, separately-composited layers simultaneously, all the time. A
   trace showed the browser's per-frame "Commit" step (syncing that layer
   tree to the compositor) costing over 200ms on a throttled CPU -- by far
   the biggest cost on the page -- and having twice as many live layers as
   necessary is exactly the kind of thing that inflates it. Pausing the
   hidden layer's animation drops it out of the active layer count
   entirely until its theme is actually showing. */
.text-layer:not(.active) .track,
.text-layer:not(.active) .text-smoke .puff{
  animation-play-state:paused;
  /* pausing alone (tried first) did NOT release the composited layer --
     confirmed by re-tracing after that change and seeing the same ~200ms
     Commit cost and the same 105-layer count. will-change:transform is a
     static promotion hint; it stays in effect regardless of whether the
     animation using it is actually running. Explicitly overriding it back
     to `auto` here is what actually lets the browser drop the hidden
     theme's 9-11 rows out of the compositor's layer tree, on top of them
     already being paused. */
  will-change:auto;
}
@keyframes scrollLeftToRight{
  0%{ transform:translateX(-50%); }
  100%{ transform:translateX(0%); }
}
@keyframes scrollRightToLeft{
  0%{ transform:translateX(0%); }
  100%{ transform:translateX(-50%); }
}

/* ---------------- decorative smoke over the lettering, right side --------
   the baked smoke-cover art naturally trails off toward its own top-right
   corner (that's just where the source crop ends), and sitting right at
   the edge of the marquee band it used to read as an accidental chop
   rather than a deliberate layer. This puts a real, intentional wisp of
   smoke over the text band itself, right where it was asked for -- same
   idea as the sugarcane groups framing the sides: a purposeful foreground
   accent, not a leftover image edge. Pure CSS blurred-gradient puffs (same
   technique as the rest of the smoke on the page), so there's no hard
   image edge to ever chop again, and it lives inside the same clipped
   band the marquee text does, so it can never spill outside it either. */
.text-layer .text-smoke{
  position:absolute; inset:0;
  pointer-events:none;
}
.text-smoke .puff{
  position:absolute;
  border-radius:50%;
  aspect-ratio:1;
  filter:blur(calc(var(--poster-w, 900px) * 0.01));
  animation:textSmokeDrift var(--dur, 12s) ease-in-out infinite;
  animation-delay:var(--delay, 0s);
}
.text-normal .text-smoke .puff{
  background:radial-gradient(circle, rgba(238,238,238,0.68), rgba(205,205,205,0.28) 55%, transparent 78%);
}
.text-switch .text-smoke .puff{
  background:radial-gradient(circle, rgba(242,228,214,0.68), rgba(215,180,155,0.3) 55%, transparent 78%);
}
@keyframes textSmokeDrift{
  0%,100%{ transform:translate(0,0) scale(1); opacity:.55; }
  50%{ transform:translate(-3%,-8%) scale(1.15); opacity:.8; }
}

/* ---------------- flowing energy wisps behind the mic ----------------
   two mirrored instances (left/right) hugging the mic's own neck/shoulder
   area, where the turntable footage's matte occasionally runs thin -- see
   the HTML comment on #energyWrap for the stacking. The source art's white
   background was cut to a real alpha channel when the asset was prepared
   (a mix-blend-mode:multiply first pass washed out to a pale, colourless
   smudge against this page's own busy, light background art there, so a
   proper alpha cutout replaced it) -- this is a plain transparent-PNG
   overlay now, no blend trick needed. Positioned off the same 50%/42%
   centre the mic art itself uses, so the wisp always tracks the mic's own
   on-screen position at any poster size. */
.energy-wrap{
  position:absolute; inset:0;
  z-index:3;
  pointer-events:none;
}
.energy-item{
  position:absolute;
  left:50%; top:40%;
  width:46%;
  opacity:0;
  transform-origin:50% 50%;
  animation:energyFlow 9s ease-in-out infinite;
}
.energy-left{ transform:translate(-88%,-46%); }
.energy-right{ transform:translate(-12%,-46%) scaleX(-1); }
.energy-item.energy-normal{ animation-delay:-1.6s; }
.energy-item.energy-right{ animation-delay:-4.4s; }
/* only the active theme's pair is ever actually visible/animating -- the
   inactive pair sits fully loaded at opacity:0 so the crossfade on switch
   is instant, same pattern as every other themed layer on this page. */
.energy-normal{ display:none; }
.energy-switch{ display:none; }
.poster:not(.switched) .energy-normal{ display:block; }
.poster.switched .energy-switch{ display:block; }
/* "flowy, slow-motion... glow dimmer and darker slowly" -- opacity and a
   little drift/scale, nothing sharp or sudden. Left/right instances are
   offset in their animation-delay above so the two sides never breathe in
   perfect lockstep. */
@keyframes energyFlow{
  0%   { opacity:.28; filter:brightness(.85) saturate(.9); }
  35%  { opacity:.62; filter:brightness(1.15) saturate(1.15); }
  65%  { opacity:.5;  filter:brightness(1.0)  saturate(1.05); }
  100% { opacity:.28; filter:brightness(.85) saturate(.9); }
}
.energy-left{ animation-name:energyFlowLeft; }
.energy-right{ animation-name:energyFlowRight; }
@keyframes energyFlowLeft{
  0%   { opacity:.28; filter:brightness(.85) saturate(.9);  transform:translate(-88%,-46%) scale(1) rotate(0deg); }
  35%  { opacity:.62; filter:brightness(1.15) saturate(1.15); transform:translate(-91%,-49%) scale(1.06) rotate(-2deg); }
  65%  { opacity:.5;  filter:brightness(1.0)  saturate(1.05); transform:translate(-86%,-45%) scale(1.02) rotate(1deg); }
  100% { opacity:.28; filter:brightness(.85) saturate(.9);  transform:translate(-88%,-46%) scale(1) rotate(0deg); }
}
@keyframes energyFlowRight{
  0%   { opacity:.28; filter:brightness(.85) saturate(.9);  transform:translate(-12%,-46%) scaleX(-1) scale(1) rotate(0deg); }
  35%  { opacity:.6;  filter:brightness(1.15) saturate(1.15); transform:translate(-9%,-49%) scaleX(-1) scale(1.06) rotate(2deg); }
  65%  { opacity:.48; filter:brightness(1.0)  saturate(1.05); transform:translate(-14%,-45%) scaleX(-1) scale(1.02) rotate(-1deg); }
  100% { opacity:.28; filter:brightness(.85) saturate(.9);  transform:translate(-12%,-46%) scaleX(-1) scale(1) rotate(0deg); }
}

/* ---------------- the rotating mic: real turntable sprite ---------------- */
.fg-canvas{
  position:absolute; inset:0;
  width:100%; height:100%;
  z-index:3;
  /* small, tight shadow only -- the old 30px-blur version spread a big soft
     dark halo well past the mic's own silhouette and read as a grey smudge
     with a hard-ish edge next to it. this stays close to the mic body.
     Deepened/offset slightly further for a real "sitting on the stage"
     contact shadow rather than a flat sticker outline -- still well short
     of the old smudge-inducing blur radius. */
  filter:drop-shadow(0 7px 11px rgba(0,0,0,0.55));
  /* the mic used to be one of the depth-parallax layers driven from JS
     (app.js DEPTH_LAYERS), sliding opposite the cursor on every pointermove
     across the whole poster ("moves a lot / shifts in every direction" /
     "changes directions on hover") -- removed there entirely. A plain CSS
     hover-scale replaced it for a round, then got asked to shrink, then
     got asked to just not be there at all: this canvas is now fully inert
     on hover, same as it sits at rest, no transform of any kind. Left as
     a no-op transform-origin/no active transition rather than deleting
     the properties outright, since a future hover treatment (if ever
     wanted again) would want the origin already sitting at the mic's own
     visual centre instead of the poster's. */
  transform-origin:50% 42%;
  transition:filter .2s ease;
}
.fg-backing{ filter:none; }
/* the whole hover glow/shadow system that used to live here (the outline
   glow on .fg-canvas, .fg-shadow's per-frame silhouette, .mic-hover-glow's
   dark-boundary rings) has been removed entirely per direct feedback: no
   hover effect on the mic at all anymore, desktop included -- it's back
   to being fully inert on hover, exactly as it was before any of that was
   ever added. */

/* ---------------- sugarcane: real positions, two depth groups ---------------- */
.cane-group{ position:absolute; inset:0; pointer-events:none;
  /* the group itself never animates transform -- only its individual
     .cane-item children do (via sway-cane), and each of those already gets
     its own compositor layer automatically because it's actively animating
     a transform property. Promoting the whole group on top of that was a
     redundant extra layer. */
}
.cane-back{ z-index:4; }
.cane-front{ z-index:6; }
.cane-item{
  position:absolute;
  transform-origin:bottom center;
  animation:sway-cane 4.4s ease-in-out infinite;
}
.cane-item img{
  display:block; width:100%; height:100%;
  filter:drop-shadow(0 0 10px rgba(90,220,120,0.35));
  /* no will-change:filter -- this filter has no `transition` on it, so it
     never animates, it just snaps between the two fixed values the instant
     .poster.switched toggles. will-change was permanently promoting all 11
     cane images (3 back + 8 front) onto their own composited layer AND
     forcing the browser to keep a standing filter-effect render target for
     each, for a property that changes maybe twice a minute. This was one
     of the largest single contributors to the page's composited-layer
     count and directly inflates the per-frame "Commit" cost. */
}
.poster.switched .cane-item img{
  filter:drop-shadow(0 0 10px rgba(255,90,70,0.4))
    grayscale(0.2) sepia(0.8) saturate(3.2) hue-rotate(-35deg) brightness(0.9) contrast(1.05);
}
@keyframes sway-cane{
  0%,100%{ transform:rotate(-2deg); }
  50%{ transform:rotate(2.2deg); }
}

/* ---------------- real smoke art: covers the mic/wordmark seam -----------
   their own reference cloud (extracted from the composite they made,
   colour-graded and upscaled), not a procedural blur -- sits above the
   back canes and the mic, behind the wordmark and the front canes, so it
   reads as real depth instead of a flat sticker. Positioned over the
   known gap (same band the mic's own alpha stops short of), breathing
   gently in a loop so it never looks like a static paste-on. */
.smoke-cover-layer{
  position:absolute;
  z-index:4;
  pointer-events:none;
  opacity:0;
  transform-origin:50% 85%;
  transition:opacity .5s ease;
  animation:smokeCoverBreathe 9s ease-in-out infinite;
  filter:drop-shadow(0 6px 18px rgba(0,0,0,0.35));
  will-change:opacity, transform;
}
.smoke-cover-layer.active{ opacity:.98; }
.smoke-cover-layer:not(.active){ animation-play-state:paused; will-change:auto; }
/* real cartoon smoke-plume art (clean vector-style illustration, fully
   transparent on all four edges already -- no photo haze to fight, so
   nothing here needs to fight a hard box edge the way the old photo-based
   cutout did). Its own pixel aspect ratio is 285:385 -- this box's own
   width/height are set to that exact same ratio (accounting for the
   poster's own 0.8119 ratio: box-aspect = (width% / height%) * 0.8119)
   so it renders at its native proportions with no stretching and nothing
   trimmed off any side. */
/* anchored from the BOTTOM now, slightly past the poster's own bottom
   edge, instead of a top offset -- reads as the plume genuinely emerging
   up from the bottom of the screen (matching the reference composition)
   rather than a patch floating mid-frame. The feathered bottom edge on
   the asset itself (see the recolour script) means nothing important is
   lost to the overflow:hidden clip here. */
/* enlarged and pushed further past the poster's own bottom edge than
   before -- the art's own plume tapers to nothing well before the edge of
   its box, so simply reaching bottom:0 on the box was not enough to make
   the visible smoke itself reach the bottom of the screen; making the
   whole plume bigger and sinking its box further past the edge is what
   actually gets real, still-solid smoke pixels down to the bottom in both
   themes. Same 285:385 aspect ratio preserved, just scaled up. */
/* the real art (see the asset's own alpha profile -- it billows out around
   its own vertical middle and fades back to FULLY transparent by ~95% down
   its own canvas, a puff floating in clear air below it, not a column
   reaching its own edge) can only ever be made a bit bigger before it's
   just wasted transparent space -- it will never alone reach the poster's
   true bottom edge no matter how large or how far down this box goes. That
   job now belongs to the strengthened safety-net mist below
   (.smoke-cover-fill / FILL_PUFFS in app.js), which continues the same
   colour-graded haze on down from where this real art's own taper ends.
   This box only grew modestly from its original size. */
.smoke-cover-normal{ left:40%; bottom:-6%; width:40%; height:41.9%; }
.smoke-cover-switch{ left:40%; bottom:-6%; width:40%; height:41.9%; animation-duration:6.5s; }

/* second plume -- same real art, mirrored (scaleX(-1)) and twisted a
   little (a small rotate), bigger than the first, sitting immediately to
   ITS left so the two together read as one wider cloud spanning the
   bottom-centre instead of two identical copies. Existing at all purely to
   bury the bare white cane stems that were showing through the letter
   gaps under the A/D/B of the wordmark -- this sits at the same z-index,
   behind the wordmark and in front of the mic stem/back canes, just wider
   and further left than the original. transform-origin stays at the same
   50%/85% "base of the plume" point as the first one so the mirror/rotate
   pivot around where the smoke visually emerges from, not its box corner. */
.smoke-cover-layer.smoke-cover-normal-2,
.smoke-cover-layer.smoke-cover-switch-2{
  left:18%; bottom:-9%; width:50%; height:52.4%;
  transform-origin:50% 85%;
  transform:scaleX(-1) rotate(-7deg);
  /* a slightly darker, slightly less saturated grade than the first plume
     so the two don't read as one exact duplicate stamped twice. */
  filter:drop-shadow(0 6px 18px rgba(0,0,0,0.35)) brightness(.9) saturate(.92);
  animation-name:smokeCoverBreatheMirrored;
  animation-duration:10.5s;
  animation-delay:-2.4s;
}
.smoke-cover-switch-2{ animation-duration:7.2s; }
@keyframes smokeCoverBreatheMirrored{
  0%,100%{ transform:scaleX(-1) rotate(-7deg) scale(1) translateY(0); }
  50%{ transform:scaleX(-1) rotate(-7deg) scale(1.04) translateY(-1.2%); }
}

@keyframes smokeCoverBreathe{
  0%,100%{ transform:scale(1) translateY(0); }
  50%{ transform:scale(1.045) translateY(-1.4%); }
}

/* soft safety-net mist, UNDER the real smoke art (lower z-index), so any
   sliver of the flat gap the real art's own silhouette doesn't quite
   reach is still never bare -- same gentle breathing, just quieter. */
.smoke-cover-fill{
  position:absolute;
  bottom:var(--b, 18%); left:var(--l, 50%);
  width:var(--w, 30%); aspect-ratio:1.6;
  z-index:4;
  pointer-events:none;
  border-radius:50%;
  background:radial-gradient(ellipse, rgba(200,206,202,0.4), rgba(150,160,155,0.16) 55%, transparent 78%);
  filter:blur(9px);
  transform:translate(-50%,0) scale(1);
  animation:smokeCoverPulse var(--dur, 10s) ease-in-out infinite;
  animation-delay:var(--delay, 0s);
}
/* re-matched to the anime-style smoke plume's own warm duotone (ember
   brown -> rust -> cream) instead of the old flat orange tint, so this
   safety-net mist actually reads as the SAME smoke instead of a
   differently-coloured haze sitting underneath it. */
.poster.switched .smoke-cover-fill{
  background:radial-gradient(ellipse, rgba(214,132,84,0.42), rgba(120,55,35,0.18) 55%, transparent 78%);
}
@keyframes smokeCoverPulse{
  0%,100%{ transform:translate(-50%,0) scale(1); opacity:.55; }
  50%{ transform:translate(-50%,-6%) scale(1.12); opacity:.78; }
}

/* the low band right at the true bottom edge -- noticeably stronger and
   less blurred than the quiet safety-net puffs above (see the JS comment
   on FILL_PUFFS/`low`), so it actually reads as smoke continuing down to
   the bottom of the screen instead of fading into nothing shortly after
   the real art's own taper ends. */
.smoke-cover-fill-low{
  filter:blur(5px);
  background:radial-gradient(ellipse, rgba(205,210,206,0.62), rgba(150,160,155,0.32) 55%, transparent 80%);
}
.smoke-cover-fill-low{ animation-duration: var(--dur, 10s); }
.smoke-cover-fill-low{ opacity:.85; }
@keyframes smokeCoverPulseLow{
  0%,100%{ transform:translate(-50%,0) scale(1); opacity:.7; }
  50%{ transform:translate(-50%,-4%) scale(1.08); opacity:.92; }
}
.smoke-cover-fill-low{ animation-name:smokeCoverPulseLow; }
.poster.switched .smoke-cover-fill-low{
  background:radial-gradient(ellipse, rgba(224,150,100,0.62), rgba(130,60,38,0.32) 55%, transparent 80%);
}

/* ---------------- big edge fire: red-screen only ---------------- */
.fire-edge-group{
  position:absolute; inset:0;
  z-index:7;
  pointer-events:none;
  opacity:0;
  transition:opacity .5s ease;
}
.poster.switched .fire-edge-group{ opacity:1; }
.fire-edge-item{
  position:absolute;
  filter:drop-shadow(0 0 22px rgba(255,80,10,0.55));
  /* fade the flame's own top edge into the scene instead of ending in a
     hard silhouette line against the cane/background behind it */
  -webkit-mask-image:linear-gradient(to bottom, transparent 0%, #000 22%);
  mask-image:linear-gradient(to bottom, transparent 0%, #000 22%);
  will-change:transform, opacity;
}
.fire-edge-item img{ display:block; width:100%; height:100%; opacity:.92; }
.fire-edge-item.fe-a{ animation:flicker-a 1.5s ease-in-out infinite; }
.fire-edge-item.fe-b{ animation:flicker-b 1.9s ease-in-out infinite .35s; }
.fire-edge-item.fe-c{ animation:flicker-a 1.7s ease-in-out infinite .6s; }
/* the fire edge is red-screen only (.fire-edge-group opacity:0 on the
   green screen) but its 3 items were flickering and staying composited
   the whole time regardless, same wasted-layer pattern as the marquee.
   Stop them and release their layers whenever the red screen isn't showing. */
.poster:not(.switched) .fire-edge-item{ animation-play-state:paused; will-change:auto; }

/* ---------------- wordmark: real transform (position + rotation) ---------------- */
/* used to be ONE <img> whose .src got reassigned on every single retap
   (normal <-> switch PNG). The screen recording caught exactly what that
   costs: the wordmark visibly vanishing for a beat on repeated retaps --
   an <img> shows nothing at all in the gap between its old frame and the
   new src finishing decode, and on a phone under real memory/CPU pressure
   (see the sprite-sheet notes elsewhere in this file) that decode can take
   long enough to actually see. Every other themed pair on this page
   (bg-plate, text-layer, smoke-cover-layer) already avoids this exact trap
   by keeping BOTH images permanently in the DOM, decoded once, and just
   crossfading opacity between them -- applying that same proven pattern
   here means there is no swap left to stall on. */
.wordmark{
  position:absolute;
  left:16.01%; top:74.27%; width:73.13%;
  z-index:5;
  opacity:0;
  transform:rotate(-7.65deg) translateY(14px);
  transform-origin:center;
  /* deliberately NO transition here by default -- see the note on
     .nav-toggle/.handle-badge earlier in this file for why: a CSS
     transition's real-world duration is only a suggestion once the main
     thread is busy (exactly what's happening for the ~1s right after a
     tap, mid mega-transition), and testing this crossfade under that same
     load showed the opacity swap not even STARTING for 900ms+ and taking
     over a second past its nominal 0.5s to finish -- during which the
     OLD-themed wordmark just sits there fully visible, wrong colour for
     the new scene. .entering (below) opts back into a transition just for
     the one-time page-load fade-in, where there's no such contention. */
  animation:bounce 1.7s cubic-bezier(.34,1.4,.4,1) infinite;
  pointer-events:none;
  /* a dark contact shadow UNDER the coloured glow -- the glow alone reads
     as a flat sticker floating on the scene; a real dark shadow cast down
     and slightly behind it is what actually grounds it against the smoke
     and canes behind it, the same "real light source" logic as the mic's
     own drop-shadow above. */
  filter:drop-shadow(0 8px 10px rgba(0,0,0,0.5)) drop-shadow(0 0 12px rgba(90,220,120,0.3));
}
.wordmark.entering{ transition:opacity .5s ease; }
/* only actually opaque once BOTH the one-time entrance has happened
   (.shown) AND this particular instance is the currently active theme
   (.active) -- the inactive one sits fully loaded/decoded at opacity:0
   underneath, ready to crossfade in instantly with zero decode cost. */
.wordmark.shown.active{ opacity:1; }
.wordmark-switch{
  animation-duration:3.4s;
  filter:drop-shadow(0 8px 10px rgba(0,0,0,0.5)) drop-shadow(0 0 16px rgba(255,60,30,0.55));
}
@keyframes bounce{
  0%,100%{ transform:rotate(-7.65deg) translateY(0); }
  50%{ transform:rotate(-7.65deg) translateY(-6%); }
}

/* ---------------- fire: individual flame cutouts, low near the base ---------------- */
.flame-group{ position:absolute; inset:0; z-index:7; pointer-events:none; opacity:0; transition:opacity .5s ease; }
.poster.switched .flame-group{ opacity:1; }
.flame-item{ position:absolute; filter:drop-shadow(0 0 16px rgba(255,80,10,0.65)); will-change:transform, opacity; }
.flame-item img{ display:block; width:100%; height:100%; }
.flame-item.f-a{ animation:flicker-a 1.3s ease-in-out infinite; }
.flame-item.f-b{ animation:flicker-b 1.6s ease-in-out infinite .3s; }
/* the one centred flame under the wordmark -- own class so its glow could
   be turned up without also brightening flame_04, which shares the same
   flicker timing but sits off to the side and was never too dim. */
.flame-item.f-center{
  animation:flicker-a 1.3s ease-in-out infinite;
  filter:drop-shadow(0 0 16px rgba(255,80,10,0.65)) drop-shadow(0 0 30px rgba(255,120,30,0.55));
}
/* same fix as the fire-edge items just above: red-screen only, so stop
   flickering and release the layer on the green screen. */
.poster:not(.switched) .flame-item{ animation-play-state:paused; will-change:auto; }
@keyframes flicker-a{ 0%,100%{ opacity:.85; transform:scale(1); } 50%{ opacity:1; transform:scale(1.06); } }
@keyframes flicker-b{ 0%,100%{ opacity:.7; transform:scale(1); } 50%{ opacity:.95; transform:scale(1.08); } }

/* ---------------- soft ambient glow near the button ---------------- */
.glow{
  position:absolute;
  left:68.24%; top:3.89%; width:43.51%; height:33.39%;
  z-index:7;
  background:radial-gradient(circle at 45% 45%, rgba(255,90,50,0.5), transparent 60%);
  /* blurred relative to the poster's own rendered size, not a fixed pixel
     count -- a flat 14px blur looked soft enough on a big desktop poster
     but on a small phone poster the same box is much smaller, so that
     same 14px softened barely any of its edge and the gradient's own
     rectangular bounds showed through as a faint glowing square (visible
     as a green or red outline depending on the theme). Scaling the blur
     with --poster-w keeps the edge equally soft at every size. */
  filter:blur(calc(var(--poster-w, 900px) * 0.09));
  pointer-events:none;
  transition:background 1.1s ease;
}
.poster:not(.switched) .glow{
  background:radial-gradient(circle at 45% 45%, rgba(70,255,120,0.45), transparent 60%);
}

/* ---------------- switch-up button ----------------
   the same clip (their cooler "video button" footage) plays in both
   states now — matching the screen: green button on the green screen,
   red on the red screen, with a matching glow, instead of swapping to a
   flatter separate asset. */
.switch-btn{
  position:absolute;
  left:80.3%; top:14.33%; width:15.44%;
  aspect-ratio:1;
  z-index:8;
  background:none; border:none; cursor:pointer; padding:0;
  /* dark grounding shadow first, then the two coloured glow passes -- same
     "real object catching real light" logic as the mic/wordmark above.
     Without it the button reads as a flat glowing sticker; with it, it
     reads as a physical raised disc sitting on the poster. */
  filter:drop-shadow(0 6px 8px rgba(0,0,0,0.5)) drop-shadow(0 0 10px rgba(255,60,40,0.55)) drop-shadow(0 0 24px rgba(255,60,40,0.3));
  transition:transform .2s ease, filter .3s ease;
}
.poster:not(.switched) .switch-btn{
  filter:drop-shadow(0 6px 8px rgba(0,0,0,0.5)) drop-shadow(0 0 10px rgba(80,255,120,0.6)) drop-shadow(0 0 26px rgba(80,255,120,0.35));
}
.switch-btn:hover{ transform:scale(1.05); }
.switch-btn:active{ transform:scale(0.94); }
/* the circular clip lives on its OWN element now, never on the same one as
   `filter` -- Safari/WebKit has a long-standing bug where an element with
   both `overflow:hidden` + `border-radius` AND a `filter` (the drop-shadow
   glow above) stops clipping its own corners correctly, so the canvas's
   real square edge shows through past the circle on real iPhones (visible
   as a faint square/black corner around the button) even though it never
   showed in this session's own Chromium testing. Splitting them onto two
   elements -- filter on the button, clip on this inner span -- sidesteps
   the bug entirely since neither element has both at once. */
.switch-btn-clip{
  display:block; width:100%; height:100%;
  border-radius:50%;
  overflow:hidden;
}
.btn-canvas{ display:block; width:100%; height:100%; border-radius:50%; transition:filter .3s ease; }
.poster:not(.switched) .btn-canvas{ filter:hue-rotate(122deg) saturate(1.15) brightness(1.03); }
.switch-btn.hidden-during-mega{ opacity:0; transition:opacity .12s ease; }

/* ---------------- background music disc ----------------
   fixed to the viewport (never the poster) so it never gets cropped by
   the poster's own cover-fill edges on a phone, sized/positioned off the
   exact same --poster-w/-left/-top formula as .nav-toggle, mirrored to
   the opposite corner for a symmetric top row: hamburger, handle badge,
   disc. Two elements deep on purpose -- the OUTER .disc-wrap owns the
   hover/press scale (a plain, cheap `transform` on click/hover), the
   INNER .disc-ring owns the dark rim + theme glow (never transformed, so
   it's never in the same paint pass as the scale), and the img inside
   THAT owns the infinite spin. Splitting hover-scale from spin-rotation
   across two different elements means both can use `transform`
   independently without one fighting/overwriting the other's value the
   way a single shared transform would. */
.disc-wrap{
  position:fixed;
  /* anchored purely off --poster-left/--poster-w, the same two custom
     properties nav-toggle uses and nothing else, so it never drifts on a
     pinch-zoom/viewport-chrome change the way a `100vw`-based `right`
     value did in an earlier version (see the git history/changelog for
     that story if it resurfaces).

     Size and position here come directly from the user marking up an
     actual screenshot of the live page (a coordinate grid, disc hidden,
     real pixel numbers), then two rounds of verbal correction on top --
     no mockup-to-page scale transfer involved. At the 390px-wide
     viewport the grid was drawn against: started as a 60px circle at
     left:273/top:200 (matching the switch-up button's own 60.2px size),
     then shrunk to 50.7px (0.87x the switch-up button, "smaller than the
     switch-up button just a lil") around the SAME center point rather
     than the same top-left corner, so shrinking doesn't also silently
     shift it -- new left:277.65/top:204.65. Converted to poster-relative
     fractions (subtracting the poster's own top offset -- 181.83 at this
     viewport -- before dividing by poster-w, same as every other fixed
     UI element here): width/height 50.7/390 = 0.13, left 277.65/390 =
     0.7119, top (204.65-181.83)/390 = 0.0585. */
  /* nudged right and up from the 0.7119/0.0585 coordinates above -- the
     rotating mic was sitting too close under the disc at 0.7119, and
     pulling the disc right on its own would have crowded the switch-up
     button's own top-left corner (button box roughly x:[0.803,0.957],
     y:[0.1765,0.331] in these same poster-w-relative units), so it moved
     up at the same time to keep a clear gap above the button instead of
     just sliding sideways into it. Still well short of the poster's own
     top edge, not pushed into the corner. */
  left:max(10px, calc(var(--poster-left, 0px) + var(--poster-w, 900px) * 0.745));
  top:max(10px, calc(var(--poster-top, 0px) + var(--poster-w, 900px) * 0.032));
  z-index:50;
  width:calc(var(--poster-w, 900px) * 0.13);
  height:calc(var(--poster-w, 900px) * 0.13);
  /* same reasoning as nav-toggle's own min-width floor -- only engages at
     a genuinely extreme, near-unusable poster width, never during
     ordinary resizing. */
  min-width:40px; min-height:40px;
  cursor:pointer;
  transition:transform .2s ease;
}
.disc-wrap:hover{ transform:scale(1.08); }
.disc-wrap:active{ transform:scale(0.95); }
.disc-ring{
  display:block; width:100%; height:100%;
  border-radius:50%;
  overflow:hidden;
  background:#0a0a0a;
  /* same dark rim + grounding shadow as .switch-btn-clip above, so the two
     circular controls on the page read as one family, plus a soft themed
     glow (green normally, red once switched) matching every other themed
     glow on the page. All box-shadow, never filter -- a static, non-
     animating cost, not a per-frame one. */
  box-shadow:
    0 0 0 2px rgba(0,0,0,0.6),
    0 6px 14px rgba(0,0,0,0.45),
    inset 0 0 0 1px rgba(255,255,255,0.06),
    0 0 10px rgba(80,255,120,0.55),
    0 0 22px rgba(80,255,120,0.3);
  transition:box-shadow .3s ease;
}
body.switched .disc-ring{
  box-shadow:
    0 0 0 2px rgba(0,0,0,0.6),
    0 6px 14px rgba(0,0,0,0.45),
    inset 0 0 0 1px rgba(255,255,255,0.06),
    0 0 10px rgba(255,90,60,0.6),
    0 0 24px rgba(255,90,60,0.35);
}
@keyframes discSpin{ to{ transform:rotate(360deg); } }
.disc-img{
  display:block; width:100%; height:100%;
  /* a slow, deliberate turn -- this is a decorative loop, not a real
     spinning-up turntable, so it just runs at one gentle constant speed
     the whole time it's playing. animation-play-state:paused by default
     (same technique already used for every inactive-state layer
     elsewhere on this page) means a silent/paused disc costs the
     animation engine nothing at all, not even a running-but-invisible
     instance. */
  animation:discSpin 3.6s linear infinite;
  animation-play-state:paused;
  will-change:auto;
}
.disc-wrap.playing .disc-img{ animation-play-state:running; will-change:transform; }

/* ---------------- smoke: pure CSS, drifting across from the cane base ----
   no bitmap at all -- every puff is a blurred radial-gradient circle, so
   there is no crop/mask boundary that could ever read as a hard edge. Each
   puff rises AND drifts sideways from one edge to the other over its own
   cycle (--l0 -> --l1, set per-cluster in JS, well past 0%/100% so it's
   always entering/leaving off-screen, never popping in mid-frame), and is
   really 2-3 overlapping circles for a less perfectly-round, more organic
   plume, exactly like the reference build's traveling smoke-puff clouds. */
.smoke-wrap{
  position:absolute; inset:0;
  z-index:9; pointer-events:none;
  overflow:hidden;
}
.smoke-puff{
  position:absolute;
  bottom:var(--b, 4%);
  /* fixed now -- see the note on @keyframes smokeRise below for why the
     drift that used to happen here (animating `left`) moved to transform */
  left:var(--l0, 50%);
  width:var(--w, 9%);
  aspect-ratio:1;
  border-radius:50%;
  background:radial-gradient(circle, rgba(232,232,232,0.95), rgba(200,200,200,0.48) 55%, transparent 78%);
  filter:blur(4px);
  opacity:0;
  transform:translate(-50%,0) scale(0.5);
  animation:smokeRise var(--dur, 8s) ease-out infinite;
  animation-delay:var(--delay, 0s);
}
.smoke-puff.p2{ width:calc(var(--w, 9%) * 0.62); filter:blur(3px); }
.smoke-puff.p3{ width:calc(var(--w, 9%) * 0.5); filter:blur(3px); }

/* the sideways drift used to animate the `left` property directly
   (0% -> --l0, 100% -> --l1). `left` is a layout property, so the browser
   was recomputing layout for these 18 elements on every single animation
   frame, forever, even with the page just sitting there -- easily the
   single biggest cause of the page feeling sluggish on a real phone (a
   CPU trace confirmed the main thread was in nonstop native rendering
   work, not JS, with this as the prime suspect). Moving the drift onto
   `transform: translateX()` instead does the exact same visual motion but
   fully on the compositor/GPU thread -- no layout, no main-thread repaint
   per frame. --dx (set per-puff in app.js) is the drift distance in
   poster-relative percentage points, converted to real pixels via the
   same --poster-w custom property everything else on the page scales
   with, since a percentage inside translateX() is relative to the
   element's own tiny width, not the poster's. */
@keyframes smokeRise{
  0%{ transform:translate(-50%,0) translateX(0px) scale(0.5); opacity:0; }
  12%{ opacity:.85; }
  55%{ opacity:.72; }
  100%{ transform:translate(-50%,-380%) translateX(calc(var(--poster-w, 900px) * var(--dx, 0) / 100)) scale(2.3); opacity:0; }
}
.poster.switched .smoke-puff{
  /* a warm-tinted smoke, not the fire's own orange -- the earlier orange
     puffs blended straight into the busy orange/pink background behind
     them and effectively disappeared. Real smoke drifting past firelight
     reads as a lit grey, not the flame colour itself, so this stays close
     to the normal-state smoke (which shows up clearly against the same
     background elsewhere on this screen) with just a warm cast. */
  background:radial-gradient(circle, rgba(238,224,210,0.88), rgba(210,175,150,0.4) 55%, transparent 78%);
  filter:blur(4px) drop-shadow(0 0 3px rgba(40,15,5,0.25));
  animation-duration:calc(var(--dur, 8s) * 0.46);
}

/* ---------------- press-flash ---------------- */
.mega{ position:fixed; inset:0; z-index:60; pointer-events:none; opacity:0; }
.mega-wash{
  position:fixed; inset:-50vmax;
  background:radial-gradient(circle, #fff 0%, #ffb0a0 20%, #ff2f3d 42%, #7a0d15 65%, transparent 80%);
  opacity:0;
  /* will-change only while the transition is actually running (below) --
     this element sits idle almost all the time, no reason to keep it
     permanently promoted to its own layer. */
}
.mega.run .mega-wash{ will-change:transform, opacity; }
/* direction-aware flash colour: red wash going INTO the red screen (default,
   matches the original), green wash going back to the green/normal screen --
   same "match the screen you're arriving at" rule the button glow follows. */
.mega.to-normal .mega-wash{
  background:radial-gradient(circle, #fff 0%, #b0ffc0 20%, #2fff6a 42%, #0d7a2a 65%, transparent 80%);
}
.mega.run .mega-wash{ animation:megaWashRun 0.95s cubic-bezier(.16,.75,.2,1) forwards; }
@keyframes megaWashRun{
  0%{ opacity:0; transform:scale(0.05); }
  40%{ opacity:1; transform:scale(1); }
  70%{ opacity:1; transform:scale(1.1); }
  100%{ opacity:0; transform:scale(1.25); }
}
.mega-rays{
  position:absolute; left:50%; top:50%;
  width:340%; height:340%;
  transform:translate(-50%,-50%);
  background:conic-gradient(from 0deg,
    transparent 0deg, rgba(255,80,60,0.55) 8deg, transparent 20deg,
    transparent 40deg, rgba(255,220,120,0.5) 48deg, transparent 60deg,
    transparent 90deg, rgba(255,80,60,0.55) 98deg, transparent 110deg,
    transparent 150deg, rgba(255,220,120,0.5) 158deg, transparent 170deg,
    transparent 210deg, rgba(255,80,60,0.55) 218deg, transparent 230deg,
    transparent 260deg, rgba(255,220,120,0.5) 268deg, transparent 280deg,
    transparent 320deg, rgba(255,80,60,0.55) 328deg, transparent 340deg);
  border-radius:50%;
  opacity:0;
}
.mega.run .mega-rays{ will-change:transform, opacity; }
.mega.to-normal .mega-rays{ filter:hue-rotate(110deg); }
.mega.run{ animation:megaRun 0.95s cubic-bezier(.16,.75,.2,1) forwards; }
.mega.run .mega-rays{ animation:megaRaysRun 0.95s linear forwards; }
@keyframes megaRun{ 0%{ opacity:1; } 55%{ opacity:1; } 100%{ opacity:0; } }
@keyframes megaRaysRun{
  0%{ opacity:0; transform:translate(-50%,-50%) rotate(0deg) scale(0.3); }
  50%{ opacity:1; transform:translate(-50%,-50%) rotate(300deg) scale(1); }
  100%{ opacity:0; transform:translate(-50%,-50%) rotate(600deg) scale(1.3); }
}

/* ---------------- transition-screen skeletons + logo badge --------------
   their real dancing-skeleton-ring art, spun in from the flash and rotating
   opposite the light rays (rays go clockwise, skeletons go counter); the
   round logo badge sits dead-centre, never rotating -- just a small bounce
   and a glow that matches whichever screen is coming in, exactly like the
   reference ideation clip but compressed into the same quick press-flash
   instead of playing out the full 5s video. */
.mega-skeletons{
  position:absolute; left:50%; top:50%;
  width:62vmin; height:62vmin;
  transform:translate(-50%,-50%) rotate(0deg) scale(0.6);
  opacity:0;
  filter:drop-shadow(0 0 18px rgba(255,60,40,0.6));
}
/* same reasoning as .mega-wash/.mega-rays above: a real compositor layer
   only while the flash is actually running, not the rest of the time --
   scoped to `.mega.run` so app.js dropping that class the instant a
   transition finishes (see finishTransition()) demotes these back down
   right away instead of leaving them permanently promoted between taps. */
.mega.run .mega-skeletons,
.mega.run .mega-logo{ will-change:transform, opacity; }
.mega.to-normal .mega-skeletons{
  filter:drop-shadow(0 0 18px rgba(80,255,120,0.6)) hue-rotate(90deg) saturate(1.3);
}
.mega.run .mega-skeletons{ animation:megaSkeletonsRun 0.95s cubic-bezier(.16,.75,.2,1) forwards; }
@keyframes megaSkeletonsRun{
  0%{ opacity:0; transform:translate(-50%,-50%) rotate(0deg) scale(0.55); }
  35%{ opacity:1; transform:translate(-50%,-50%) rotate(-70deg) scale(1); }
  70%{ opacity:1; transform:translate(-50%,-50%) rotate(-150deg) scale(1.04); }
  100%{ opacity:0; transform:translate(-50%,-50%) rotate(-230deg) scale(1.1); }
}
.mega-logo{
  position:absolute; left:50%; top:50%;
  width:22vmin; height:22vmin;
  object-fit:contain;
  transform:translate(-50%,-50%) scale(0.5);
  opacity:0;
  filter:drop-shadow(0 0 14px rgba(90,255,120,0.85)) drop-shadow(0 0 28px rgba(60,255,90,0.5));
}
/* switching TO the red state: the badge's own gold flames/wood tones shift
   toward a hot red so the logo actually turns red with everything else,
   not just its glow. */
.mega.to-switch .mega-logo{
  filter:hue-rotate(-38deg) saturate(1.6) brightness(1.05)
         drop-shadow(0 0 14px rgba(255,90,40,0.9)) drop-shadow(0 0 30px rgba(255,50,20,0.6));
}
.mega.to-normal .mega-logo{
  filter:drop-shadow(0 0 14px rgba(90,255,120,0.85)) drop-shadow(0 0 28px rgba(60,255,90,0.5));
}
.mega.run .mega-logo{ animation:megaLogoRun 0.95s cubic-bezier(.16,.75,.2,1) forwards; }
@keyframes megaLogoRun{
  0%{ opacity:0; transform:translate(-50%,-50%) scale(0.4); }
  30%{ opacity:1; transform:translate(-50%,-50%) scale(1); }
  45%{ transform:translate(-50%,-50%) scale(0.92); }
  60%{ transform:translate(-50%,-50%) scale(1.04); }
  75%{ transform:translate(-50%,-50%) scale(0.97); }
  100%{ opacity:0; transform:translate(-50%,-50%) scale(1); }
}

/* ---------------- hamburger nav ----------------
   positioned and sized as a percentage of the poster itself (the same
   pattern every other UI element on this page already uses), so it lands
   in exactly the same spot relative to the artwork at any screen size,
   and stays properly clipped/contained within the poster's own frame. */
.nav-toggle{
  position:fixed;
  /* clamped with max() -- on a phone (the cover-fill media query above),
     the poster's own left edge is deliberately cropped off-screen, so
     anchoring purely to it would push this off-screen too, right back to
     the original bug. 14px is a plain, always-on-screen fallback for
     whenever poster-left is negative or small; on a normal letterboxed
     desktop window poster-left is comfortably positive, so the computed
     value wins there instead and this still sits glued to the artwork's
     own corner exactly like before. */
  left:max(14px, calc(var(--poster-left, 0px) + var(--poster-w, 900px) * 0.032));
  top:max(14px, calc(var(--poster-top, 0px) + var(--poster-w, 900px) / 0.8119 * 0.034));
  z-index:50;
  width:calc(var(--poster-w, 900px) * 0.062);
  height:calc(var(--poster-w, 900px) * 0.062);
  min-width:34px; min-height:34px;
  border-radius:50%;
  border:1px solid rgba(120,255,150,0.4);
  background:rgba(6,14,8,0.55);
  backdrop-filter:blur(6px);
  -webkit-backdrop-filter:blur(6px);
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:5px;
  cursor:pointer;
  padding:0;
  /* a resting dark contact shadow plus a faint inner top highlight -- gives
     the disc real sculpted depth even before the coloured glow kicks in on
     hover, instead of looking like a flat tinted circle floating in place. */
  box-shadow:0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.07), 0 0 0 rgba(80,255,120,0);
  transition:box-shadow .3s ease, border-color .3s ease, transform .2s ease, background .3s ease;
}
.nav-toggle:hover{ transform:scale(1.06); box-shadow:0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.07), 0 0 18px rgba(80,255,120,0.45); }
.nav-toggle:active{ transform:scale(0.94); }
/* the nav used to hide itself entirely on the red/switched screen -- now
   themed for both states instead (see the body.switched overrides on
   .nav-toggle/.nav-menu below), so it stays available everywhere. */
.nav-toggle span{
  display:block;
  width:44%;
  height:2px;
  border-radius:2px;
  background:#a8ffc0;
  transition:transform .3s ease, opacity .2s ease, background .3s ease;
}
body.switched .nav-toggle{ border-color:rgba(255,120,90,0.45); }
body.switched .nav-toggle:hover{ box-shadow:0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.07), 0 0 18px rgba(255,90,60,0.5); }
body.switched .nav-toggle span{ background:#ffc3ae; }
.nav-toggle.open span:nth-child(1){ transform:translateY(7px) rotate(45deg); }
.nav-toggle.open span:nth-child(2){ opacity:0; }
.nav-toggle.open span:nth-child(3){ transform:translateY(-7px) rotate(-45deg); }

.nav-scrim{
  position:fixed; inset:0;
  z-index:48;
  background:rgba(2,5,3,0.4);
  opacity:0;
  pointer-events:none;
  transition:opacity .3s ease;
}
.nav-scrim.open{ opacity:1; pointer-events:auto; }

.nav-menu{
  position:fixed;
  left:max(14px, calc(var(--poster-left, 0px) + var(--poster-w, 900px) * 0.032));
  /* used to be its own separately-tuned percentage (0.098) that just
     happened to clear the button's own separately-tuned percentage
     (0.034) at ONE particular poster size -- at a small poster-w the
     button hits its 34px min-height floor while the gap between the two
     formulas shrinks below that floor, and the menu's rounded top corner
     ends up drawn right under the button (the overlap in the screenshot).
     Deriving this directly from the button's own top + the button's own
     height + a fixed breathing-room gap means there is no longer a second
     number that has to coincidentally agree with the first -- the menu
     physically cannot start above the button ends, at any screen size. */
  top:calc(max(14px, var(--poster-top, 0px) + var(--poster-w, 900px) / 0.8119 * 0.034) + max(34px, var(--poster-w, 900px) * 0.062) + 10px);
  z-index:49;
  display:flex; flex-direction:column;
  gap:3px;
  width:min(70%, calc(var(--poster-w, 900px) * 0.44));
  padding:calc(var(--poster-w, 900px) * 0.014);
  border-radius:16px;
  border:1px solid rgba(120,255,150,0.35);
  background:
    linear-gradient(165deg, rgba(20,50,28,0.85), rgba(5,12,7,0.88)),
    rgba(6,14,8,0.72);
  backdrop-filter:blur(12px);
  -webkit-backdrop-filter:blur(12px);
  box-shadow:0 14px 44px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.05);
  opacity:0;
  transform:translateY(-8px) scale(0.98);
  pointer-events:none;
  transition:opacity .22s ease, transform .22s ease;
}
.nav-menu.open{ opacity:1; transform:translateY(0) scale(1); pointer-events:auto; }
body.switched .nav-menu{
  border-color:rgba(255,120,90,0.4);
  background:
    linear-gradient(165deg, rgba(60,24,16,0.85), rgba(15,6,4,0.88)),
    rgba(14,6,5,0.72);
}
/* a small branded label above the links -- matching the poster-style
   display face used everywhere else (the marquee, the wordmark's own
   letterforms) instead of the plain system sans that made this panel
   feel bolted-on rather than part of the same design. */
.nav-menu-label{
  font-family:'Anton', Arial, Helvetica, sans-serif;
  font-weight:400;
  font-size:calc(var(--poster-w, 900px) * 0.0135);
  letter-spacing:.14em;
  text-transform:uppercase;
  color:rgba(168,255,192,0.55);
  padding:calc(var(--poster-w, 900px) * 0.006) calc(var(--poster-w, 900px) * 0.013) calc(var(--poster-w, 900px) * 0.008);
  margin-bottom:2px;
  border-bottom:1px solid rgba(120,255,150,0.18);
}
body.switched .nav-menu-label{ color:rgba(255,195,174,0.6); border-bottom-color:rgba(255,120,90,0.2); }

/* ---------------- diagnostics readout ----------------
   used to be a plain grey monospace debug dump (Object.assign'd inline
   styles, no theming) -- restyled to read as an intentional little
   system-status readout instead of leftover dev output: a real monospace
   face instead of the browser's generic one, a themed neon colour with a
   soft glow matching every other accent on the page (green normally, red
   once switched, same as .nav-menu-label above), and a bordered/blurred
   chip instead of a flat text-on-black box. Still bottom-left, still
   small, still out of the way -- the goal was a better costume for the
   same corner, not a bigger or louder one. */
.diag-panel{
  position:fixed; left:10px; bottom:10px; z-index:99999;
  font-family:'JetBrains Mono', 'SF Mono', Menlo, monospace;
  font-weight:500;
  font-size:10.5px;
  letter-spacing:.06em;
  color:rgba(168,255,192,0.75);
  text-shadow:0 0 6px rgba(80,255,120,0.45);
  background:
    linear-gradient(165deg, rgba(20,50,28,0.55), rgba(5,12,7,0.6)),
    rgba(6,14,8,0.4);
  border:1px solid rgba(120,255,150,0.28);
  border-radius:7px;
  padding:5px 9px;
  backdrop-filter:blur(6px);
  -webkit-backdrop-filter:blur(6px);
  pointer-events:none;
  white-space:pre;
  box-shadow:0 4px 14px rgba(0,0,0,0.35);
}
body.switched .diag-panel{
  color:rgba(255,197,176,0.78);
  text-shadow:0 0 6px rgba(255,110,70,0.5);
  background:
    linear-gradient(165deg, rgba(60,24,16,0.55), rgba(15,6,4,0.6)),
    rgba(14,6,5,0.4);
  border-color:rgba(255,120,90,0.3);
}
.nav-menu a, .nav-menu-btn{
  display:flex; align-items:center; gap:calc(var(--poster-w, 900px) * 0.014);
  padding:calc(var(--poster-w, 900px) * 0.012) calc(var(--poster-w, 900px) * 0.013);
  border-radius:11px;
  color:#eafff0;
  font-family:'Anton', Arial, Helvetica, sans-serif;
  font-weight:400;
  font-size:calc(var(--poster-w, 900px) * 0.0175);
  letter-spacing:.03em;
  text-decoration:none;
  transition:background .15s ease, transform .15s ease;
  /* .nav-menu-btn is a real <button> (it needs to run JS, not just link
     somewhere) sitting in the same list as the plain <a> links above --
     reset the parts a button brings that a link doesn't, so it reads as
     one more identical menu row, not an obviously different form control. */
  width:100%; background:transparent; border:none; cursor:pointer; text-align:left;
}
.nav-menu a:hover, .nav-menu-btn:hover{ background:rgba(120,255,150,0.14); transform:translateX(2px); }
body.switched .nav-menu a:hover, body.switched .nav-menu-btn:hover{ background:rgba(255,120,90,0.16); }
.nav-menu a .nav-icon, .nav-menu-btn .nav-icon{
  display:inline-flex; align-items:center; justify-content:center;
  width:calc(var(--poster-w, 900px) * 0.03); height:calc(var(--poster-w, 900px) * 0.03);
  min-width:22px; min-height:22px;
  flex:0 0 auto;
  border-radius:50%;
  background:rgba(120,255,150,0.16);
  border:1px solid rgba(120,255,150,0.3);
  color:#a8ffc0;
  font-family:'Anton', Arial, Helvetica, sans-serif;
  font-size:calc(var(--poster-w, 900px) * 0.012);
  font-weight:400;
}
body.switched .nav-menu a .nav-icon, body.switched .nav-menu-btn .nav-icon{
  background:rgba(255,120,90,0.2);
  border-color:rgba(255,120,90,0.4);
  color:#ffc3ae;
}
.nav-menu a.nav-handle{ margin-top:4px; border-top:1px solid rgba(255,255,255,0.12); padding-top:calc(var(--poster-w, 900px) * 0.015); }
.nav-menu-btn.nav-handle{ margin-top:4px; border-top:1px solid rgba(255,255,255,0.12); padding-top:calc(var(--poster-w, 900px) * 0.015); }
/* the plain `display:flex` above and the browser's own built-in `[hidden]
   { display:none }` rule are equal specificity (one class, one attribute
   selector), so without this explicit rule, source order alone decides --
   and it decided wrong here, meaning `hidden` on #navOpenBrowser did
   nothing and it stayed visible in a normal browser (see app.js: it's
   un-hidden only once an in-app WebView is actually detected). This makes
   the attribute win outright, regardless of source order. */
.nav-menu-btn[hidden]{ display:none; }

/* ---------------- creator handle badge, centred above the mic ---------------- */
.handle-badge{
  position:fixed;
  left:calc(var(--poster-left, 0px) + var(--poster-w, 900px) * 0.5);
  top:calc(var(--poster-top, 0px) + var(--poster-w, 900px) / 0.8119 * 0.009);
  transform:translateX(-50%) scale(1);
  z-index:10;
  display:inline-flex; align-items:center; gap:1px;
  padding:0.55em 1.05em;
  border-radius:999px;
  border:1px solid rgba(120,255,150,0.55);
  background:rgba(6,16,9,0.42);
  backdrop-filter:blur(4px);
  -webkit-backdrop-filter:blur(4px);
  color:#eafff0;
  font-family:Arial, Helvetica, sans-serif;
  font-weight:800;
  font-size:calc(var(--poster-w, 900px) * 0.0185);
  letter-spacing:.01em;
  text-decoration:none;
  text-shadow:0 0 10px rgba(90,255,140,0.5);
  /* dark contact shadow underneath the colour glow, same reasoning as the
     button/wordmark/mic above -- keeps this pill looking like it's sitting
     in front of the scene rather than a flat glowing sticker. */
  box-shadow:0 4px 14px rgba(0,0,0,0.4), 0 0 16px rgba(80,255,120,0.25);
  /* only the hover/press bounce is transitioned now. border-color, color,
     text-shadow, background and box-shadow used to transition too, which
     is what made the badge stay green-glowing on the red screen: those 5
     properties all queue onto the same animation clock as the press-flash
     and every other effect firing in that same instant, and under that
     load the transition could take several real seconds to actually reach
     its red end-state -- easy to still be green whenever you looked. These
     now snap the instant body.switched changes, so the badge is always the
     right colour on the very next paint regardless of how busy the page is
     right at that moment. */
  transition:transform .22s cubic-bezier(.34,1.56,.64,1);
}
.handle-badge:hover, .handle-badge:focus-visible{ transform:translateX(-50%) scale(1.09); box-shadow:0 4px 14px rgba(0,0,0,0.4), 0 0 26px rgba(80,255,120,0.45); }
.handle-badge:active{ transform:translateX(-50%) scale(0.97); }
.handle-badge .handle-at{ opacity:.7; margin-right:1px; }
body.switched .handle-badge{
  border-color:rgba(255,120,90,0.6);
  color:#fff0ea;
  text-shadow:0 0 10px rgba(255,90,60,0.55);
  box-shadow:0 4px 14px rgba(0,0,0,0.4), 0 0 16px rgba(255,90,60,0.3);
}
body.switched .handle-badge:hover{ box-shadow:0 4px 14px rgba(0,0,0,0.4), 0 0 26px rgba(255,90,60,0.5); }

#grain{
  position:fixed; inset:0; z-index:70;
  pointer-events:none;
  opacity:.1;
  mix-blend-mode:overlay;
}

/* ---------------- in-app-browser notice ----------------
   Instagram's and WhatsApp's own built-in browsers (not real Chrome/Safari,
   a thinner embedded WebView each app ships itself) are where the transition
   lag / stuck-button reports have consistently traced back to an actual
   platform ceiling, not a bug here -- see app.js's isInAppBrowser() for the
   detection and reasoning. This is a small, dismissible, non-blocking
   banner, not a forced redirect -- it never navigates or opens anything on
   its own, it only surfaces the option and explains where to find it. */
.iab-banner{
  position:fixed;
  /* the always-on diagnostics readout (#diagPanel) sits fixed at
     left:10px bottom:10px with a higher z-index than this banner, so on a
     narrow phone the two shared the same bottom-left corner and the
     readout visually clipped the banner's last line. 48px clears its
     ~28px height with room to spare regardless of exact font metrics on
     any device, so the two never overlap again. */
  left:50%; bottom:calc(48px + env(safe-area-inset-bottom, 0px));
  transform:translateX(-50%) translateY(12px);
  z-index:99998;
  width:min(92vw, 420px);
  /* stacked (text, then a full-width action button) rather than a side-by-
     side row -- a row layout squeezed the text against the button and, on
     a long platform name, wrapped to a 3rd line that the fixed-height row
     didn't account for, reading as cut off. Stacking removes that ceiling
     entirely: the banner simply grows to fit whatever text it's showing. */
  display:flex; flex-direction:column; gap:9px;
  padding:12px 14px;
  border-radius:14px;
  background:
    linear-gradient(165deg, rgba(20,50,28,0.92), rgba(5,12,7,0.94)),
    rgba(6,14,8,0.9);
  border:1px solid rgba(120,255,150,0.3);
  box-shadow:0 8px 24px rgba(0,0,0,0.45);
  color:#eafff0;
  font-family:Arial, Helvetica, sans-serif;
  opacity:0;
  pointer-events:none;
  transition:opacity .35s ease, transform .35s ease;
}
.iab-banner.shown{ opacity:1; transform:translateX(-50%) translateY(0); pointer-events:auto; }
body.switched .iab-banner{
  background:
    linear-gradient(165deg, rgba(60,24,16,0.92), rgba(15,6,4,0.94)),
    rgba(14,6,5,0.9);
  border-color:rgba(255,120,90,0.32);
  color:#fff0ea;
}
.iab-banner-row{ display:flex; align-items:flex-start; gap:8px; }
.iab-banner-text{ flex:1 1 auto; font-size:12.5px; line-height:1.4; padding-top:1px; }
.iab-banner-close{
  flex:0 0 auto;
  width:22px; height:22px; line-height:20px; text-align:center;
  border-radius:50%; border:1px solid rgba(255,255,255,0.22);
  background:transparent; color:rgba(255,255,255,0.65);
  font-size:14px; font-family:inherit; cursor:pointer; padding:0;
}
.iab-banner-open{
  width:100%;
  font-family:inherit; font-size:13px; font-weight:700;
  border-radius:10px; border:1px solid rgba(120,255,150,0.45);
  background:rgba(120,255,150,0.16); color:#eafff0;
  padding:9px 10px; cursor:pointer;
  transition:background .15s ease;
}
.iab-banner-open:hover{ background:rgba(120,255,150,0.26); }
body.switched .iab-banner-open{ border-color:rgba(255,120,90,0.45); background:rgba(255,120,90,0.16); color:#fff0ea; }
body.switched .iab-banner-open:hover{ background:rgba(255,120,90,0.26); }
.iab-banner-hint{ font-size:10.5px; opacity:.7; text-align:center; }
