/* Extracted from tavern.html (dc-runtime slimming): the page stylesheet. Pure CSS, no DC
   bindings, no runtime deps. Loaded via <link> in tavern.html; copied to dist by the site build. */
  * { box-sizing: border-box; }
  /* `-webkit-touch-callout:none` is the touch half of the existing `user-select:none`:
     without it iOS Safari raises its own "copy / share image" callout on the very
     press-and-hold this page uses to open a card tooltip (issue #113). The other half
     of that suppression — the Android long-press context menu — is the global
     `contextmenu` preventDefault in `_onContextMenu`. */
  /* THE page font, declared once on the document rather than on the board root. Everything
     the game draws is descended from <body>, so one declaration here reaches every surface —
     including the ones that deliberately live OUTSIDE the board root: the dragged card's
     `position:fixed` overlay, the buy fly-in, the beam's tag layer (`#tb-tagtop`) and the FX
     layer (`#tb-fx`). Those are appended next to / under <body>, so while the font was set on
     the root <div> they inherited the UA default and every un-styled glyph inside them — a
     dragged card's stat numbers, its Tier badge, the highlighted Warband minion's cloned
     gems — rendered in SERIF, i.e. a card changed typeface the moment you picked it up. */
  body { margin: 0; background: #ead9b6; font-family: 'Nunito', sans-serif; -webkit-user-select: none; -ms-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
  body * { -webkit-user-select: none; -ms-user-select: none; user-select: none; }
  /* Pull-to-refresh opt-out (issue #218). The board is a DRAG surface and every drag is
     downward — Recruit -> Hand, Hand -> Warband — while the document itself never scrolls
     in landscape (scrollHeight === clientHeight, so scrollTop is pinned at 0, i.e. at the
     overscroll boundary permanently). With the root scroller left at the initial
     `overscroll-behavior-y: auto`, Chrome for Android claims that downward travel as
     pull-to-refresh and RELOADS the page — and a reload starts a NEW match, because no
     game state is persisted anywhere, so a mis-aimed buy throws the run away. `none` is
     the one property that governs that gesture; it also drops the overscroll glow, which
     a fixed-geometry board has no use for. Declared on BOTH html and body: the viewport's
     used value comes from the root element, and the body rule keeps the two agreeing for
     anything that reads the computed style off `document.body`. Scoped to this page
     because tavern.css is linked only by tavern.html — the landing, about and library
     pages keep their normal scrolling. */
  html, body { overscroll-behavior: none; }
  /* The tavern sign carries the dev sandbox's press-and-hold, and a link wrapping an image is
     natively draggable: a real press fires `dragstart`, Chrome enters its drag loop and stops
     delivering `pointerup`, stranding the gesture and leaving a touch device unable to answer
     further input until the drag ends. `draggable="false"` on both elements is the primary
     guard (see tavern.html); this is the CSS half, which also covers the brand TEXT — a
     selection drag off a link starts the same loop. */
  [data-testid="tavern-brand"], [data-testid="tavern-brand"] * { -webkit-user-drag: none; user-drag: none; }
  /* Defense in depth for the same gesture: the card tiles already carry `touch-action:none`
     inline (that is what lets the drag start on touch), but the ZONES that hold them did
     not — so the gaps between tiles, the empty half of a 0/7 Warband row and the whole
     lower band of the board still handed a vertical swipe straight to the browser before
     the opt-out above was consulted. Keyed on the `data-tb-zone` attribute rather than
     three more inline styles, so a zone added later inherits it. `touch-action` composes
     down the ancestor chain, so this also covers the drop targets' children. */
  [data-tb-zone] { touch-action: none; }
  /* Tribe icons: the illustrated tribe portraits (img/tribes/*.webp), circle-cropped.
     `object-fit:cover` on a square box means the circle takes the illustration's centre
     with no squash; the ring is an OUTER shadow (an inset one would paint under the
     <img> and never show). Used by the top-bar "available tribes" strip and, at a
     smaller size, by the Settings tribe grid — both size the wrapper, not this rule. */
  .tb-tribe-icon {
    width: 100%; height: 100%; display: block;
    border-radius: 50%; object-fit: cover;
    box-shadow: 0 0 0 1.5px rgba(70,58,40,.45);
  }
  .tb-lift { transition: transform .1s ease, box-shadow .1s ease; }
  .tb-lift:hover { transform: translateY(-5px) rotate(-.5deg); box-shadow: 0 10px 14px rgba(74,57,30,.28); }
  .tb-hand { transition: left .22s cubic-bezier(.2,.85,.3,1), transform .22s cubic-bezier(.2,.85,.3,1), box-shadow .22s ease; }
  .tb-hand:hover { box-shadow: 0 20px 26px rgba(70,58,40,.36); }
  .tb-slot { transition: left .28s cubic-bezier(.22,.72,.26,1), transform .28s cubic-bezier(.22,.72,.26,1); }
  .tb-noanim .tb-slot:not(.tb-hslot) { transition: none !important; }
  .tb-cardin { transition: transform .12s ease, box-shadow .12s ease; }
  .tb-cardin:hover { transform: translateY(-5px) rotate(-.5deg); box-shadow: 0 10px 14px rgba(74,57,30,.28); }
  /* COMBAT HOVER GUTTER (issue #378). A combat row lays its tiles ~14px apart, so between
     every pair of minions there is a strip of nothing. That is harmless while the row is
     still: the pointer is where the player put it. It is not harmless while the row MOVES —
     a death re-centres the whole warband under a stationary pointer, and the minion the
     player was reading slides off leaving the pointer in the strip, where there is no minion
     to describe and the tooltip has to close. The player did not stop pointing at the row.
     Half the gap on each side gives the row a continuous hover surface, so reading a fight
     is not interrupted by the row breathing under the cursor. Painted nowhere; battle rows
     only, because outside combat the same strip is also the drag corridor between two tiles.
     The offsets are against the card's PADDING box, so ~3px of each goes on clearing its own
     border before it reaches the gap — hence 12, not the 7 that half a gap would suggest. */
  .tb-zone-battle .tb-cardin::before {
    content: ''; position: absolute; top: 0; bottom: 0; left: -12px; right: -12px;
  }
  .tb-handin { transition: transform .2s cubic-bezier(.2,.85,.3,1), box-shadow .2s ease; }
  .tb-handin:hover { box-shadow: 0 20px 26px rgba(70,58,40,.36); }
  .tb-dragging .tb-cardin:hover { transform: none; box-shadow: 0 5px 9px rgba(70,58,40,.24); }
  .tb-dragging .tb-handin:hover, .tb-hpaim .tb-handin:hover { box-shadow: 0 6px 10px rgba(70,58,40,.24); }
  .tb-hpaim .tb-zone-board .tb-cardin:hover { transform: none; box-shadow: 0 5px 9px rgba(70,58,40,.24); }
  .tb-hpaim .tb-lift:hover { transform: none; box-shadow: 0 3px 0 rgba(70,58,40,.16); }
  .tb-dragging, .tb-dragging * { cursor: grabbing !important; }
  /* ===================== CARD STAT BUBBLES =====================
     ONE definition of the Attack/Health bubble, shared by every surface that draws a card:
     the Recruit tile, the Warband tile, the Hand tile, a Discover option, the discover
     fly-in and the hover tooltip. Before this each of those six restated the same dozen
     declarations inline, which is how the numbers ended up able to disagree with each other
     about their own typeface.

     Two roles, and a tile picks the arrangement it needs:
       .tb-stat-gem  — the coloured DISC
       .tb-stat-num  — the NUMBER printed on it
     A tile whose row OVERLAPS (Recruit / Warband / Hand) puts the two on SEPARATE elements
     so the numbers can be lifted a layer above the discs and stay legible where neighbouring
     gems collide; a tile that is drawn alone (tooltip, Discover) puts BOTH classes on the
     SAME element. `.tb-stat-gem` is declared AFTER `.tb-stat-num` on purpose so that the
     combined element takes the disc's dark border and not the number layer's transparent one.

     .tb-stat-atk / .tb-stat-hp carry only the side the bubble hangs off. The stable reader
     hooks (`tb-atk`, `tb-hp`, `tb-atk-gem`, `tb-hp-gem`, `tb-tip-atk`, `tb-tip-hp`, `tb-stat`)
     are untouched and stay on the same elements — specs still read a stat structurally.

     Geometry here is the DEFAULT (the 30px bubble the Hand, tooltip, Discover and fly-in all
     use). The Recruit / Warband tiles size theirs from live layout values and override
     bottom/left/right/width/height/font-size inline; nothing else is ever restated, and the
     font-family is not restated ANYWHERE — it is inherited from <body>. */
  .tb-stat-num, .tb-stat-gem { position: absolute; bottom: -10px; width: 30px; height: 30px; }
  .tb-stat-atk { left: -9px; }
  .tb-stat-hp  { right: -9px; }
  .tb-stat-num {
    border: 3px solid transparent;
    display: flex; align-items: center; justify-content: center;
    font-weight: 900; font-size: 14px;
  }
  .tb-stat-gem { border: 3px solid #463a28; border-radius: 50%; box-shadow: 0 2px 0 rgba(70,58,40,.25); }
  .tb-stat-gem.tb-stat-atk { background-color: #f0b450; }
  .tb-stat-gem.tb-stat-hp  { background-color: #d96a5e; }
  /* A Hero Power tile whose usage limit (or any other gate) is spent renders greyed. web-v5's
     tile is a <div role=button aria-disabled>, so it gets no native :disabled styling — this
     is the "grey out" half of heroPowerBlockedReason()'s contract, the half web/'s native
     <button:disabled> gets for free. Keyed on aria-disabled, one rule covers BOTH tiles (the
     primary and King of Duality's second) and every reason the gate can return, with no
     per-power logic. opacity/filter are not set inline on the tile, so no !important is
     needed. The tile stays hoverable (no pointer-events:none) so its title still explains the
     reason. opacity 0.45 matches web/ (.match-sandbox button:disabled). */
  .tb-hp-btn[aria-disabled="true"] { opacity: 0.45; filter: grayscale(0.85); }
  /* The manual Refresh tile when a no-refresh source (Golganneth's Tempest) blocks it, or the
     seat cannot afford it — the same "greyed + reason tooltip" contract web/'s native
     <button:disabled> Refresh gave for free (issue #37). web-v5's tile is a <div>, so it needs
     the explicit rule. Keyed on aria-disabled, driven by refreshAvailability() — no
     anomaly-specific check. Same 0.45 opacity / grayscale as the Hero Power tile above. The
     tile stays hoverable (no pointer-events:none) so its title still explains the reason. */
  .tb-refresh-btn[aria-disabled="true"] { opacity: 0.45; filter: grayscale(0.85); }
  /* Freeze tile, blocked (issue #187). Same treatment, same reasoning, driven by the
     boundary's freezeAvailability() — the tile used to be unconditionally teal and
     clickable even when the engine's freeze() would reject the toggle (Golganneth's
     Tempest freezes the Tavern itself, so a manual Freeze has nothing to do). */
  .tb-freeze-btn[aria-disabled="true"] { opacity: 0.45; filter: grayscale(0.85); }
  /* Portrait-phone board layout (issue #140). A width-fit collapses the 1.52:1 board to
     ~0.28 scale on a 0.46:1 phone, leaving ~72% of the viewport empty. In portrait fit()
     instead scales the board to fill a usable share of the viewport HEIGHT and lets it
     overflow the width. The root becomes a viewport-sized SCROLL BOX (overflow:auto, top-left)
     so the wider board scrolls into reach on both axes — crucially, the overflow is contained
     INSIDE the root rather than widening the document: a mobile browser inflates its layout
     viewport to any content wider than the screen, which would both shrink the board again and
     displace every `position:fixed` overlay (the Discover / hero draft) off the visible slice.
     Keeping the document at viewport width pins the layout viewport so those overlays still
     centre. Landscape / desktop never get this class. */
  .tb-portrait-scroll {
    align-items: flex-start !important;
    justify-content: flex-start !important;
    overflow: auto !important;
    width: 100%;
    height: 100vh; height: 100dvh;
    /* `contain`, not `none` (issue #218): this box is genuinely meant to pan on both axes,
       so the scroll must keep working — what must NOT happen is the pan CHAINING out to
       the document once the box hits its edge, which is the same pull-to-refresh the
       `html, body` rule above shuts off for the landscape board. */
    overscroll-behavior: contain;
  }
  /* Landscape-phone compaction. The board is ONE fixed 1280x842 design fitted by a single
     uniform scale, and on a phone held sideways (~2.16:1 against the board's 1.52:1) that fit
     is HEIGHT-bound: `scale` is availH/boardH, the board paints ~544x358 inside an 844x390
     screen, and ~300px of empty page sits down the sides where the extra width has nowhere to
     go. Width cannot be spent — but height CAN: `fit()` measures the frame's live
     `offsetHeight`, so every pixel of dead vertical space removed from the design raises the
     scale, which spends the leftover WIDTH on a bigger board. That is the whole mechanism here.

     What is trimmed is padding, row gaps and label margins — never a card, a font size or any
     geometry a coordinate conversion reads. The card tiles and the 940px-wide zone containers
     the drag hit-test lays out against are untouched; the zones simply sit closer together, and
     `_insertIndex` / `_targetSlotUnder` measure their live rects, so nothing needs to be told.

     Measured on an 844x390 phone: design height 842 -> ~757 and the page gutter 32 -> 6, i.e.
     scale 0.425 -> ~0.51 (+19%) and the side blank 300px -> ~195px.

     Everything is `!important` because the values it overrides are inline template styles, and
     scoped under `.tb-compact` — a class `fit()` puts on the root ONLY for a viewport that is
     both landscape and <= 500px tall. A desktop, a laptop, a tablet and the portrait-scroll
     layout never see any of it. */
  .tb-compact { padding: 3px !important; }
  .tb-compact .tb-topbar { padding-top: 5px !important; padding-bottom: 5px !important; }
  .tb-compact .tb-main { padding-top: 6px !important; padding-bottom: 8px !important; gap: 6px !important; }
  .tb-compact .tb-rowhead { margin-bottom: 5px !important; }
  /* The Recruit and Warband zones each carry a 10px bottom margin; the Hand zone (last in the
     column) carries none, so it is not listed. */
  .tb-compact [data-tb-zone="shop"], .tb-compact [data-tb-zone="board"] { margin-bottom: 4px !important; }
  /* ===================== APP SETTINGS PANEL =====================
     Its responsive treatment is NOT here. The ⚙ overlay is the same settings surface the
     landing page renders, and while each document styled its own copy the two drifted:
     this one grew every control for thumbs on `(pointer: coarse) and (orientation:
     landscape)` while the landing one shrank its chrome on the same screen, so the board
     copy came out 13-30% larger than the landing copy on every control they share.
     Both now link web-v5/public/settings-panel.css and take their sizes from it — the
     `bg-set-*` classes in tavern.html are its hooks. Nothing about the panel belongs in
     this file: a rule here can only ever reach one of the two panels, which is the shape
     of the bug it caused. */
  /* The Tavern row's deal-in used to be a `tb-spread` keyframe applied here. The motion now
     belongs to fx (web-v5/src/fx/), which owns every arrival in the row so that a row
     deal-in and a slot refilling behind a consumed minion cannot drift apart. */
  @keyframes tb-ds-banner {
    from { transform: translateX(-50%) translateY(-26px); }
    to   { transform: translateX(-50%) translateY(0); }
  }
  .tb-ds-card:hover { filter: brightness(1.06); }
  .tb-hpaim .tb-ds-card:hover { filter: none; }
  /* Eligible targeted-battlecry target (bcPick mode): a pulsing green ring + a
     pointer cursor mark the clickable friendly Mech / Pirate / minion. */
  @keyframes tb-bc-target-pulse {
    0%, 100% { box-shadow: 0 0 0 3px #a8f25f, 0 0 12px 3px rgba(162,245,100,.85); }
    50%      { box-shadow: 0 0 0 4px #c6fa7d, 0 0 10px 3px rgba(190,255,124,.9); }
  }
  .is-battlecry-valid-target { animation: tb-bc-target-pulse 1.4s ease-in-out infinite; cursor: pointer !important; border-radius: 12px; }
  /* ALREADY CHOSEN as the first of a two-pick Hero Power (Spirit Swap). The eligible tiles
     around it pulse green; this one must not read as "still pickable", so it takes a STEADY
     gold ring and a check badge instead of the pulse — a picked minion and a merely eligible
     one used to look identical, which is what made a half-finished cast invisible (issue
     #163). Declared after `.is-battlecry-valid-target` so it wins the box-shadow. */
  @keyframes tb-hp-chosen-pulse {
    0%, 100% { box-shadow: 0 0 0 4px #f5c04a, 0 0 14px 4px rgba(245, 192, 74, .75); }
    50%      { box-shadow: 0 0 0 4px #ffd873, 0 0 12px 4px rgba(255, 216, 115, .9); }
  }
  .is-hp-chosen { animation: tb-hp-chosen-pulse 1.8s ease-in-out infinite !important; border-radius: 12px; }
  .is-hp-chosen::after {
    content: "✓";
    /* Top-LEFT, not centred: the tile's name banner runs across the top-centre and a badge
       parked there hides the very name the awaiting banner is talking about. */
    position: absolute; top: -10px; left: -10px;
    z-index: 5; pointer-events: none;
    width: 24px; height: 24px; border-radius: 50%;
    background-color: #f5c04a; border: 2.5px solid #463a28;
    box-shadow: 0 2px 0 rgba(70, 58, 40, .3);
    font-family: 'Oswald', sans-serif; font-weight: 700; font-size: 14px; line-height: 20px;
    text-align: center; color: #463a28;
  }
  @keyframes tb-ds-glowpulse {
    0%, 100% { box-shadow: 0 0 0 3px #a8f25f, 0 0 13px 4px rgba(162,245,100,1), 0 10px 18px rgba(18,12,5,.45); }
    50%      { box-shadow: 0 0 0 4px #c6fa7d, 0 0 11px 3.5px rgba(190,255,124,1), 0 10px 18px rgba(18,12,5,.45); }
  }
  .tb-ds-glow { animation: tb-ds-glowpulse 1.6s ease-in-out infinite; }
  /* Buy Trinket — a tile the seat cannot afford. The pick was ALREADY refused (the
     tile is aria-disabled and _selectDiscover rejects it), but until the buried
     button modal was removed the player never looked at these tiles, so the refusal
     had no face: an unaffordable Trinket looked exactly like an affordable one and a
     click just did nothing. Dim it, grey it, and take it out of the pointer path —
     the same treatment web/'s `.trinket-tile.unaffordable` gets. The tile stays in
     the modal (the player must see what they cannot yet buy) and the mandatory modal
     stays open. `filter` also beats the `.tb-ds-card:hover` brightness above. */
  .tb-ds-card.trinket-tile.unaffordable,
  .tb-ds-card.trinket-tile.unaffordable:hover {
    opacity: .4;
    filter: grayscale(.7);
    cursor: not-allowed;
    pointer-events: none;
  }
  .tb-ds-card.trinket-tile.unaffordable .tb-ds-glow { animation: none; box-shadow: 0 10px 18px rgba(18,12,5,.45); }
  @keyframes tb-hide-flash {
    0%   { box-shadow: 0 5px 0 rgba(70,58,40,.32), inset 0 0 0 44px rgba(255,255,255,.92); }
    55%  { box-shadow: 0 5px 0 rgba(70,58,40,.32), inset 0 0 0 44px rgba(255,255,255,.38); }
    100% { box-shadow: 0 5px 0 rgba(70,58,40,.32), inset 0 0 0 44px rgba(255,255,255,0); }
  }
  @keyframes tb-btn-white {
    0%, 100% { filter: drop-shadow(0 0 4px rgba(255,255,255,.6)); }
    50%      { filter: drop-shadow(0 0 12px rgba(255,255,255,.98)); }
  }
  .tb-ds-flash { animation: tb-hide-flash .9s ease-out both, tb-btn-white 1.7s ease-in-out infinite; }
  @keyframes tb-cbanner-glow {
    0%, 100% { filter: drop-shadow(0 0 5px rgba(128,196,255,.55)); }
    50%      { filter: drop-shadow(0 0 13px rgba(150,208,255,.95)); }
  }
  .tb-ds-cbanner { animation: tb-ds-banner .3s ease-out both, tb-cbanner-glow 1.9s ease-in-out infinite; }
  @keyframes tb-battle-fade { from { opacity: 0; } to { opacity: 1; } }
  @keyframes tb-battle-rayin { from { opacity: 0; } to { opacity: .55; } }
  @keyframes tb-battle-fog1 {
    0%   { transform: translate(-50%,-50%) scale(1);    opacity: .5; }
    30%  { transform: translate(-46%,-54%) scale(1.22); opacity: .72; }
    55%  { transform: translate(-55%,-47%) scale(.9);   opacity: .44; }
    80%  { transform: translate(-49%,-52%) scale(1.14); opacity: .66; }
    100% { transform: translate(-50%,-50%) scale(1);    opacity: .5; }
  }
  @keyframes tb-battle-fog2 {
    0%   { transform: translate(-50%,-50%) scale(1.1);  opacity: .4; }
    40%  { transform: translate(-53%,-46%) scale(.85);  opacity: .6; }
    70%  { transform: translate(-47%,-55%) scale(1.25); opacity: .34; }
    100% { transform: translate(-50%,-50%) scale(1.1);  opacity: .4; }
  }
  @keyframes tb-battle-fog3 {
    0%   { transform: translate(-50%,-50%) scale(.95);  opacity: .55; }
    25%  { transform: translate(-52%,-53%) scale(1.3);  opacity: .38; }
    60%  { transform: translate(-48%,-48%) scale(1.05); opacity: .68; }
    100% { transform: translate(-50%,-50%) scale(.95);  opacity: .55; }
  }
  @keyframes tb-battle-popout {
    0%   { transform: scale(1); }
    22%  { transform: scale(1.12); }
    100% { transform: scale(0); }
  }
  @keyframes tb-ray-breathe1 { 0%,100% { opacity: .28; } 50% { opacity: .62; } }
  @keyframes tb-ray-breathe2 { 0%,100% { opacity: .5; }  45% { opacity: .2; } }
  @keyframes tb-ray-breathe3 { 0%,100% { opacity: .38; } 60% { opacity: .7; } }
  @keyframes tb-ray-sway { 0%,100% { transform: rotate(-3.5deg); } 50% { transform: rotate(3.5deg); } }
  @keyframes tb-battle-burst {
    0%   { transform: translate(-50%,-50%) scale(.15); opacity: 0; }
    45%  { opacity: 1; }
    100% { transform: translate(-50%,-50%) scale(1); opacity: .95; }
  }
  /* Pop the emblem straight to its resting size and hold — no overshoot, no
     settle-shrink, no opacity drift once shown. The only "movement" after it
     lands is the white blink below. */
  @keyframes tb-battle-pop {
    0%   { transform: scale(.6); opacity: 0; }
    100% { transform: scale(1); opacity: 1; }
  }
  /* White blink only: a quick flash that fully clears — kept crisp so it doesn't
     linger as a semi-transparent veil over the banner. */
  @keyframes tb-battle-flash {
    0%, 45% { opacity: 0; }
    62%     { opacity: .9; }
    100%    { opacity: 0; }
  }
  @keyframes tb-battle-out { from { opacity: 1; } to { opacity: 0; } }
  .tb-battle-emblem { animation: tb-battle-pop .28s ease-out both; }
  .tb-battle-emblem-out { animation: tb-battle-popout .5s cubic-bezier(.5,-0.2,.7,.9) both; }
  .tb-battle-fadeout { animation: tb-battle-out .42s ease-in both; }
  .tb-ds-aura { animation: tb-dspulse 1.9s ease-in-out infinite; will-change: opacity; }
  @keyframes tb-dspulse {
    0%, 100% { opacity: .58; }
    50%      { opacity: .74; }
  }
  .tb-reborn-aura { animation: tb-rbpulse 1.7s ease-in-out infinite; will-change: opacity; }
  @keyframes tb-rbpulse {
    0%, 100% { opacity: .72; }
    50%      { opacity: 1; }
  }
  .tb-stealth-a { animation: tb-st-drift 7.3s ease-in-out infinite; will-change: opacity, transform; }
  .tb-stealth-b { animation: tb-st-churn 9.7s ease-in-out infinite; will-change: opacity, transform; }
  @keyframes tb-st-drift {
    0%   { transform: translate(-4%, 3%)  scale(1.10) rotate(-2deg); opacity: .62; }
    19%  { transform: translate(5%, -2%)  scale(1.20) rotate(3deg);  opacity: .72; }
    37%  { transform: translate(-2%, 5%)  scale(1.05) rotate(-1deg); opacity: .54; }
    58%  { transform: translate(4%, 2%)   scale(1.18) rotate(2deg);  opacity: .70; }
    76%  { transform: translate(-5%, -3%) scale(1.08) rotate(-3deg); opacity: .58; }
    100% { transform: translate(-4%, 3%)  scale(1.10) rotate(-2deg); opacity: .62; }
  }
  @keyframes tb-st-churn {
    0%   { transform: translate(3%, -3%)  scale(1.16) rotate(3deg);  opacity: .34; }
    22%  { transform: translate(-4%, 2%)  scale(1.06) rotate(-2deg); opacity: .48; }
    46%  { transform: translate(2%, -4%)  scale(1.24) rotate(1deg);  opacity: .30; }
    67%  { transform: translate(-3%, 4%)  scale(1.10) rotate(-3deg); opacity: .50; }
    88%  { transform: translate(4%, 1%)   scale(1.14) rotate(2deg);  opacity: .38; }
    100% { transform: translate(3%, -3%)  scale(1.16) rotate(3deg);  opacity: .34; }
  }
  /* Windfury wind effect — see the block below. */
  /* Wind: soft light-streaks blow fast and smooth ALONG their own long axis
     toward the bright tip; the tilt leans the path downward (gravity). The body
     is never rigid — as it flies it weaves gently across its line (soft vertical
     wave) and BREATHES in length (scaleX up / scaleY down, like a stretching
     wisp), so it reads as fast flowing air, not a fixed bar. Travel is evenly
     spaced under linear timing, so speed stays near-constant the whole way. A
     light blur softens the edges; staggered durations/delays desync the streaks.
     No shape-shearing filter — the motion alone carries the turbulence. */
  .tb-wind-1 { animation: tb-wind-1 1.5s  linear infinite; filter: blur(.5px) brightness(1.22); will-change: transform, opacity; }
  .tb-wind-2 { animation: tb-wind-2 2.0s  linear infinite; animation-delay:-0.8s;  filter: blur(.5px) brightness(1.22); will-change: transform, opacity; }
  .tb-wind-3 { animation: tb-wind-3 1.7s  linear infinite; animation-delay:-1.15s; filter: blur(.55px) brightness(1.22); will-change: transform, opacity; }
  .tb-wind-4 { animation: tb-wind-4 2.25s linear infinite; animation-delay:-0.45s; filter: blur(.5px) brightness(1.22); will-change: transform, opacity; }
  /* down-left streaks: local -x points down-left (negative tilt), tip on the left */
  @keyframes tb-wind-1 {
    0%   { transform: rotate(-12deg) translate(140%, 4%)   scale(.9,1.06);  opacity: 0; }
    10%  { opacity: .72; }
    25%  { transform: rotate(-12deg) translate(68%, -22%)  scale(1.12,.9); }
    50%  { transform: rotate(-12deg) translate(-4%, 20%)   scale(1.2,.82); }
    75%  { transform: rotate(-12deg) translate(-76%, -16%) scale(1.08,.92); }
    90%  { opacity: .4; }
    100% { transform: rotate(-12deg) translate(-148%, 6%)  scale(.9,1.06);  opacity: 0; }
  }
  @keyframes tb-wind-3 {
    0%   { transform: rotate(-18deg) translate(138%, -3%)  scale(.92,1.05); opacity: 0; }
    12%  { opacity: .68; }
    25%  { transform: rotate(-18deg) translate(66%, 22%)   scale(1.14,.88); }
    50%  { transform: rotate(-18deg) translate(-6%, -18%)  scale(1.2,.82); }
    75%  { transform: rotate(-18deg) translate(-78%, 15%)  scale(1.06,.93); opacity:.4;}
    100% { transform: rotate(-18deg) translate(-146%, -4%) scale(.9,1.06);  opacity: 0; }
  }
  /* down-right streaks: local +x points down-right (positive tilt), mirrored tip on the right */
  @keyframes tb-wind-2 {
    0%   { transform: rotate(13deg) translate(-140%, -4%)  scale(.9,1.06);  opacity: 0; }
    10%  { opacity: .7; }
    25%  { transform: rotate(13deg) translate(-68%, 20%)   scale(1.12,.9); }
    50%  { transform: rotate(13deg) translate(4%, -20%)    scale(1.2,.82); }
    75%  { transform: rotate(13deg) translate(75%, 16%)    scale(1.06,.93); }
    90%  { opacity: .4; }
    100% { transform: rotate(13deg) translate(146%, -6%)   scale(.9,1.06);  opacity: 0; }
  }
  @keyframes tb-wind-4 {
    0%   { transform: rotate(9deg) translate(-142%, 3%)    scale(.92,1.05); opacity: 0; }
    12%  { opacity: .66; }
    25%  { transform: rotate(9deg) translate(-70%, -20%)   scale(1.13,.89); }
    50%  { transform: rotate(9deg) translate(2%, 18%)      scale(1.2,.82); }
    75%  { transform: rotate(9deg) translate(74%, -14%)    scale(1.05,.94); opacity:.38;}
    100% { transform: rotate(9deg) translate(150%, 4%)     scale(.9,1.06);  opacity: 0; }
  }
  @keyframes tb-frost-puff { 0% { transform: scale(.3); opacity: .9; } 100% { transform: scale(1.35); opacity: 0; } }

/* ===================== ROTATE GATE (handheld in portrait) =====================
   The phone outline turns a quarter-turn and holds there, so the instruction is
   legible as a motion and not only as a sentence. `prefers-reduced-motion` stops
   the turn but keeps the icon: the shape alone still reads as "phone, sideways". */
.tb-rotate-gate-icon {
  animation: tb-rotate-gate-turn 2.6s ease-in-out infinite;
}
@keyframes tb-rotate-gate-turn {
  0%, 22%   { transform: rotate(0deg); }
  45%, 78%  { transform: rotate(-90deg); }
  100%      { transform: rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
  .tb-rotate-gate-icon { animation: none; transform: rotate(-90deg); }
}
