#pingMap{position:fixed;inset:0;z-index:0}
#pingMap .maplibregl-control-container,#pingMap .mapboxgl-control-container{display:none !important}

/* Carve-out for the POI heatmap legend (a maplibregl.IControl
   mounted into .maplibregl-ctrl-top-left — see renderTownPoiHeatmaps
   in public/js/ping-landing.js). The blanket-hide above was there
   to suppress MapLibre's logo/attribution/nav chrome on the hero
   map, but it also hides our legend because IControls are wrapped
   in the same .maplibregl-control-container.
   The :has() rule reveals the container ONLY when our legend is
   present, and the sibling rule hides anything else that would
   otherwise share the top-left slot (MapLibre's logo is the
   usual suspect, since we pass logoPosition:'top-left'). */
#pingMap .maplibregl-control-container:has(.town-poi-heatmap-key){
    display:block !important;
}
#pingMap .maplibregl-ctrl-top-left > :not(.town-poi-heatmap-key){
    display:none !important;
}
.ping-bg-image{
    position:fixed;top:0;left:0;right:0;height:50vh;
    z-index:1;
    background-size:cover;background-position:center top;background-repeat:no-repeat;
    opacity:0.35;
    mask-image:linear-gradient(to bottom,black 30%,transparent 100%);
    -webkit-mask-image:linear-gradient(to bottom,black 30%,transparent 100%);
    pointer-events:none;
    /* will-change hints the compositor to keep the element on its own
       layer, so transitioning opacity doesn't force a layer promote
       mid-animation (which was the most likely cause of the show-side
       transition getting dropped while the hide-side still worked). */
    will-change:opacity;
    transition-property:opacity;
    transition-duration:.9s;
    transition-timing-function:ease;
}
@media(max-height:600px){.ping-bg-image{height:40vh}}
/* When the intro modal flies in, the GamifEYE hero art it uses for its
   own hero image duplicates what .ping-bg-image is already showing as
   the map-surround backdrop. Fading the backdrop to 0 removes that
   redundancy — the intro card becomes the sole carrier of the hero
   imagery and the globe reads cleaner through the gap above/below the
   card. The .9s fade (vs the card's .65s fly-in) means the backdrop
   lingers just long enough to visually "hand off" the art to the card
   as it arrives from above. Toggled directly on the element (not via a
   body class) so there's no ancestor-cascade indirection that browsers
   might optimise away mid-transition. */
.ping-bg-image.is-faded{opacity:0;}

/* The old .ping-top branding card + .ping-lang-wrap rules lived here.
   Removed alongside their markup in ping-landing.blade.php — the intro
   modal (#spinIntroModal) now owns the landing-page headline/CTA/lang
   chooser. Kept .ping-bg-image above because it still backs the map. */

/* ── Spin The Globe button ──
   Visuals live on .spin-globe-pill so the same pill can be dropped into
   any container (currently the intro modal body). The previous
   fixed-positioning rules scoped to #spinGlobeBtn were removed when the
   button moved inside the intro modal — it now lays out inline within
   the modal's CTA row. The id is retained for JS selector compatibility
   (see ping-landing.js: document.getElementById('spinGlobeBtn')). */
.spin-globe-pill{
    display:inline-flex;flex-direction:column;align-items:center;gap:4px;
    padding:18px 38px;
    background:rgba(10,14,30,0.55);
    -webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);
    border:1.5px solid rgba(0,255,213,0.55);
    border-radius:999px;
    color:#fff;font-family:'Fredoka',sans-serif;font-weight:700;
    cursor:pointer;
    box-shadow:0 10px 40px rgba(0,255,213,0.22),inset 0 0 0 1px rgba(255,255,255,0.05);
    transition:transform .25s cubic-bezier(.22,1,.36,1),opacity .35s ease,box-shadow .25s ease,border-color .25s ease;
    animation:spinGlobePulse 2.6s ease-in-out infinite;
}
.spin-globe-pill:hover{transform:scale(1.05);border-color:rgba(0,255,213,0.9);box-shadow:0 14px 50px rgba(0,255,213,0.38),0 0 0 6px rgba(0,255,213,0.1);}
.spin-globe-pill:active{transform:scale(0.98);}
.spin-globe-pill:focus-visible{outline:2px solid #00ffd5;outline-offset:4px;}
.spin-globe-pill .spin-label{font-size:17px;letter-spacing:1.5px;text-transform:uppercase;text-shadow:0 1px 8px rgba(0,0,0,0.5);}
.spin-globe-pill .spin-sub{font-size:10px;letter-spacing:2px;text-transform:uppercase;color:rgba(255,255,255,0.75);font-weight:500;}
@keyframes spinGlobePulse{
    0%,100%{box-shadow:0 10px 40px rgba(0,255,213,0.22),inset 0 0 0 1px rgba(255,255,255,0.05);}
    50%{box-shadow:0 14px 52px rgba(0,255,213,0.45),inset 0 0 0 1px rgba(255,255,255,0.05);}
}
@media(max-width:500px){
    .spin-globe-pill{padding:14px 26px;}
    .spin-globe-pill .spin-label{font-size:14px;letter-spacing:1.2px;}
    .spin-globe-pill .spin-sub{font-size:9px;letter-spacing:1.4px;}
}

/* ── Spin modals (9:16 story card) ──
   Two dialogs share this card shell so the visual language between the
   intro and the flyin result feels like one system:
     - #spinIntroModal is the landing-page CTA surface.
     - #spinModal is the result modal shown after the globe flies to a place.
   Keep structural rules on combined selectors so they stay in lockstep;
   per-modal overrides (intro body padding, no close/stamp on intro, etc.)
   live in their own blocks further down. */
#spinModal, #spinIntroModal{
    position:fixed;inset:0;
    z-index:40;
    display:none;
    align-items:center;justify-content:center;
    background:rgba(5,8,18,0.58);
    -webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
    opacity:0;
    transition:opacity .3s ease;
}
#spinModal.visible, #spinIntroModal.visible{display:flex;opacity:1;}
#spinModal .spin-card, #spinIntroModal .spin-card{
    position:relative;
    width:420px;max-width:90vw;
    aspect-ratio:9/16;
    max-height:92vh;
    border-radius:20px;
    overflow:hidden;
    background:#14182a;
    /* Stacked box-shadows:
         1. ambient drop shadow (same as before, adjusted to sit below the ring)
         2. solid 2px magenta ring — drawn as a shadow (not a CSS `border`)
            so the hero image can still bleed right up to the card edge
            without a content-inset band, and so `overflow:hidden` never
            clips the ring against the rounded corners.
         3. outer magenta glow echoing the .spin-intro-name-hero text. */
    box-shadow:
        0 30px 80px rgba(0,0,0,0.6),
        0 0 0 2px #ff2bd6,
        0 0 28px rgba(255,43,214,0.38);
    transform:scale(0.92) translateY(-120vh);
    opacity:0;
    font-family:'Fredoka',sans-serif;
    display:flex;flex-direction:column;
}
/* When the inline auth panel takes over the card body IN MS-WIN
   MODE, release the 9/16 aspect lock so the card can grow as tall
   as its content (still capped by max-height:92vh). Without this
   the win variant — heading + tabs + form + submit + Spin Again —
   overflows its fixed-ratio body and the last button gets clipped
   on phone-height viewports.
   Scoped to `.spin-auth-panel.is-mswin` so the guest-facing
   Login/Register sub-view on the intro modal (opened from the
   disabled "GamifEYE A Location" CTA) keeps the card at its
   intended 9/16 portrait ratio — the trimmed-down form (no
   heading, no notice, no Back/Spin-Again button) fits
   comfortably without the release. :has() is widely supported
   in all evergreen browsers we target. */
#spinModal .spin-card:has(.spin-body.is-authpanel .spin-auth-panel.is-mswin),
#spinIntroModal .spin-card:has(.spin-body.is-authpanel .spin-auth-panel.is-mswin){
    aspect-ratio:auto;
}
#spinModal.visible .spin-card, #spinIntroModal.visible .spin-card{
    animation:spinFlyIn .65s cubic-bezier(.4,0,.2,1) forwards;
}
@keyframes spinFlyIn{
    0%   {transform:scale(0.92) translateY(-120vh);opacity:0;}
    82%  {transform:scale(0.98) translateY(24px);  opacity:1;}
    100% {transform:scale(1)    translateY(0);     opacity:1;}
}
/* ── Spin-modal fly-out ──
   Reverse of spinFlyIn for when the intro modal dismisses itself on a
   Spin-The-Globe click. Scoped selector (.is-flying-out.visible) beats
   the fly-in rule above on specificity so the new animation takes over
   cleanly. .visible is kept ON during the animation so display:flex
   stays put; the JS strips both classes together on animationend.
   Also applied to #spinModal in case a future code path wants to
   dismiss the result modal with the same motion language. */
#spinIntroModal.is-flying-out.visible .spin-card,
#spinModal.is-flying-out.visible .spin-card{
    animation:spinFlyOut .5s cubic-bezier(.4,0,.2,1) forwards;
}
/* Backdrop fades in lock-step. Higher specificity than the .visible
   opacity:1 rule, and the base .3s opacity transition handles the
   easing — no extra transition declaration needed. */
#spinIntroModal.is-flying-out.visible,
#spinModal.is-flying-out.visible{
    opacity:0;
}
@keyframes spinFlyOut{
    0%   {transform:scale(1)    translateY(0);     opacity:1;}
    100% {transform:scale(0.92) translateY(-120vh);opacity:0;}
}
#spinModal .spin-hero-wrap, #spinIntroModal .spin-hero-wrap{
    position:relative;
    width:100%;
    aspect-ratio:16/9;
    flex:0 0 auto;
    overflow:hidden;
    background:#0a0a1a;
}
#spinModal .spin-hero, #spinIntroModal .spin-hero{
    position:absolute;inset:0;
    background-size:cover;background-position:center;
    -webkit-mask-image:linear-gradient(to bottom,black 0%,black 72%,transparent 100%);
            mask-image:linear-gradient(to bottom,black 0%,black 72%,transparent 100%);
    /* Base opacity transition so sub-view dim effects (e.g. the
       GamifEYED Towns merge-tray reveal, which fades the hero to
       50% after the tray zooms in) animate both on enter AND on
       exit. Without a default transition defined here the closing
       direction would simply snap back to full opacity as soon as
       the gated rule stopped matching — with it, the hero un-dims
       on the same cadence it dimmed on. .spin-hero-alt layers get
       their own longer 420ms transition further down so their
       crossfades stay independent of this. */
    transition:opacity .35s ease;
}
/* Secondary hero layers painted with the per-tab auth-flow art,
   stacked on top of the default mascot. Both held at opacity:0 by
   default and crossfaded in/out purely via CSS — no extra JS needed.

   Two independent gates fire off :has() on the enclosing card:
     1. .spin-hero-alt-login    fades in when the card is in
                                .is-authpanel mode AND the panel's
                                Login tab carries .active.
     2. .spin-hero-alt-register fades in when the card is in
                                .is-authpanel mode AND the panel's
                                Register tab carries .active.
   Switching tabs (spinAuthSetTab in ping-landing.js toggles .active
   on [data-spin-auth-tab] buttons) reshuffles which selector
   matches, so the two layers crossfade against each other as the
   user flips between Login and Register. Closing the panel strips
   .is-authpanel, both selectors stop matching, and both layers fade
   back to 0 in lockstep — revealing the default cosmic mascot
   underneath.

   Kept pointer-events:none so they never intercept taps on the lens
   pill, language switcher, or close button stacked above the hero.
   The aspect-ratio + cover sizing comes from the base .spin-hero
   rule above, so all three layers paint pixel-aligned and every
   transition is a pure opacity crossfade — no width/height/transform
   drift, no shade-mask misalignment. */
#spinModal .spin-hero-alt, #spinIntroModal .spin-hero-alt{
    opacity:0;
    pointer-events:none;
    transition:opacity 420ms ease;
}
#spinModal .spin-card:has(.spin-body.is-authpanel .spin-auth-panel [data-spin-auth-tab="login"].active) .spin-hero-alt-login,
#spinIntroModal .spin-card:has(.spin-body.is-authpanel .spin-auth-panel [data-spin-auth-tab="login"].active) .spin-hero-alt-login,
#spinModal .spin-card:has(.spin-body.is-authpanel .spin-auth-panel [data-spin-auth-tab="register"].active) .spin-hero-alt-register,
#spinIntroModal .spin-card:has(.spin-body.is-authpanel .spin-auth-panel [data-spin-auth-tab="register"].active) .spin-hero-alt-register{
    opacity:1;
}
/* Authed base hero gate. Fires off the .is-authed modifier the
   blade emits on .spin-intro-body for logged-in users (see the
   `auth()->check()` conditional on that element), so the "operator
   on the comms tower" mascot replaces the guest cosmic mascot from
   the first paint for authed viewers. Gated only on .is-authed —
   NOT on the authpanel/login/register gates above — so the auth-
   flow crossfades continue to layer cleanly on top for the guest-
   facing auth panel without this hero fighting them. */
#spinIntroModal .spin-card:has(.spin-intro-body.is-authed) .spin-hero-alt-authed{
    opacity:1;
}
/* "GamifEYE A Location" hero gate. Fires off the .is-gfeimport
   modifier that the #gamifeyeLocationBtn CTA adds to
   .spin-intro-body when the user enters the importer sub-view,
   and fades back out when the close pill / Back button strip
   the modifier. Uses the same 420ms opacity crossfade as the
   Login/Register gates above so every hero swap on this card
   shares one motion cadence. Layered on top of .spin-hero-alt-
   authed (later in DOM) so authed users see the new "blasting
   minion territories" art slide over the base operator mascot,
   then the operator art fades back through when the sub-view
   closes. */
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfeimport) .spin-hero-alt-location{
    opacity:1;
}
/* "GamifEYED Towns" hero gate. Mirrors the .is-gfeimport gate
   above, wired to the .is-gfetowns body modifier that the
   #gamifeyeTownsBtn CTA toggles when the user enters the tabbed
   Your Places / Help sub-view. Same 420ms opacity crossfade. */
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfetowns) .spin-hero-alt-towns{
    opacity:1;
}
#spinModal .spin-hero-shade, #spinIntroModal .spin-hero-shade{
    position:absolute;inset:0;
    background:linear-gradient(to bottom,rgba(0,0,0,0) 55%,rgba(20,24,42,0.8) 92%,rgba(20,24,42,1) 100%);
}
#spinModal .spin-body, #spinIntroModal .spin-body{
    flex:1 1 auto;
    padding:20px 24px 24px;
    color:#fff;
    display:flex;flex-direction:column;gap:10px;
    min-height:0;
}
#spinModal .spin-name, #spinIntroModal .spin-name{
    font-size:28px;font-weight:700;line-height:1.1;
    text-shadow:0 2px 14px rgba(0,0,0,0.5);
}
@media(max-width:500px){
    #spinModal .spin-card, #spinIntroModal .spin-card{border-radius:16px;}
    #spinModal .spin-name, #spinIntroModal .spin-name{font-size:24px;}
}

/* ── Intro modal: body layout + CTA row ──
   Overrides that are specific to the intro dialog. The card/hero rules
   above already handle the shell; this block handles the two things the
   intro needs that the result modal doesn't: a roomier description and a
   centered pill CTA anchored to the bottom of the card. */
#spinIntroModal .spin-intro-desc{
    font-size:18px;line-height:1.35;font-weight:700;
    letter-spacing:0.2px;
    color:rgba(255,255,255,0.9);
    text-align:center;
    flex:0 0 auto;
}
/* CTA sits directly below the description, not pushed to the
   bottom of the card. An earlier iteration used `margin-top:auto`
   to spring a single Spin-The-Globe button to the card footer,
   which worked visually when the description was the only thing
   above it. With the guest view now stacking two CTAs (Spin +
   GamifEYE A Location) the gap read as disconnected from the
   copy, so we drop the auto push and let the group sit snug
   under the description on every auth state. */
#spinIntroModal .spin-intro-cta{
    margin-top:0;
    padding-top:18px;
    display:flex;justify-content:center;
}
/* Inside the intro modal the pill sits inline in normal flow. The base
   .spin-globe-pill rule already drops the fixed-positioning that the
   previous standalone button used, so nothing else is needed here. */

/* .is-authed CTA override used to re-anchor the pill up under the
   "Welcome [First]!" greeting after the guest rule pinned it to
   the bottom. Now that the base .spin-intro-cta rule already
   sits the group directly under the description on both auth
   states, this override is redundant — intentionally left as a
   no-op block + comment instead of deleted so anyone grepping
   for .is-authed .spin-intro-cta finds the context. */

/* ── Intro modal: title split into preamble + hero line ──
   The title carries the marketing hook, so "Play The Planet" is sized
   up and set bold to become the visual anchor of the card; the
   preamble ("GamifEYE Lets You") sits smaller and lighter above it.
   Using flex-column + gap means the two lines stay visually connected
   without relying on a <br> (easier to restyle per breakpoint). */
#spinIntroModal .spin-intro-name{
    display:flex;flex-direction:column;gap:2px;
    line-height:1.05;
    text-align:center;
}
#spinIntroModal .spin-intro-name-lead{
    font-size:18px;font-weight:700;
    color:rgba(255,255,255,0.82);
    letter-spacing:0.2px;
}
#spinIntroModal .spin-intro-name-hero{
    font-size:44px;font-weight:700;
    line-height:1.0;
    letter-spacing:-0.5px;
    color:#ff2bd6;
    text-shadow:0 2px 18px rgba(255,43,214,0.45),0 2px 16px rgba(0,0,0,0.55);
}
/* Authed "Welcome [First]!" variant: single line + smaller type so
   a long first name never has to wrap or push the CTA off the card.
   We keep the same magenta colour and text-shadow as the guest hero
   line so the greeting still reads as a proper hero title — it's
   just a calmer, more personal note rather than a marketing pitch. */
#spinIntroModal .spin-intro-name.is-welcome .spin-intro-name-hero{
    font-size:32px;
    letter-spacing:-0.3px;
}
/* Post-claim celebration title ("[Place] Has Been Added / To Your
   Territories"). Rendered by spinCelebrateInit() when the hero
   loads with ?celebrate=1. The first line is the shorter of the
   two for most place names and gets a touch more weight; the
   second line is the static trailer at a slightly softer size so
   the place name carries the emphasis. Sized smaller than the
   Welcome variant because two lines of 32px would push the CTA
   off a phone-height card. */
#spinIntroModal .spin-intro-name.is-celebrate{
    gap:4px;
}
#spinIntroModal .spin-intro-name.is-celebrate .spin-intro-name-hero{
    font-size:26px;
    letter-spacing:-0.2px;
    line-height:1.1;
}
#spinIntroModal .spin-intro-name.is-celebrate .spin-intro-celebrate-line2{
    font-size:22px;
    opacity:0.95;
}
@media(max-width:500px){
    #spinIntroModal .spin-intro-name-lead{font-size:15px;}
    #spinIntroModal .spin-intro-name-hero{font-size:34px;}
    #spinIntroModal .spin-intro-name.is-welcome .spin-intro-name-hero{font-size:26px;}
    #spinIntroModal .spin-intro-name.is-celebrate .spin-intro-name-hero{font-size:22px;}
    #spinIntroModal .spin-intro-name.is-celebrate .spin-intro-celebrate-line2{font-size:18px;}
    #spinIntroModal .spin-intro-desc{font-size:15px;}
}

/* ── Spin modals: hero corner pills (language + auth) ──
   Two pills live in the top corners of both the intro and result
   modals:
     - top-right: language chooser (.spin-intro-lang) — intro modal only,
       because the result modal's top-right is reserved for .spin-close.
     - top-left:  auth indicator (.spin-intro-auth) — on BOTH modals.
   They share a single glass-on-hero trigger skin so they read as a
   matched pair whenever both are visible. The language-switcher
   component ships its own dropdown menu styles; only the trigger is
   re-skinned here. The auth indicator is a plain <a> with the same
   .nav-lang-btn class so it inherits the trigger styles one-for-one. */
#spinIntroModal .spin-intro-lang,
#spinIntroModal .spin-intro-auth,
#spinModal .spin-intro-auth{
    position:absolute;
    top:12px;
    z-index:2;
}
#spinIntroModal .spin-intro-lang{right:12px;}
#spinIntroModal .spin-intro-auth,
#spinModal .spin-intro-auth{left:12px;}

/* Shared glass pill skin for BOTH corner triggers on BOTH modals.
   Matches the language chooser's look exactly — same background alpha,
   border, radius, typography, padding, and drop-shadow. */
#spinIntroModal .spin-intro-lang .nav-lang-btn,
#spinIntroModal .spin-intro-auth .nav-lang-btn,
#spinModal .spin-intro-auth .nav-lang-btn{
    background:rgba(10,14,30,0.55) !important;
    -webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
    border:1px solid rgba(255,255,255,0.22) !important;
    border-radius:999px !important;
    color:#fff !important;
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    padding:0.35rem 0.7rem !important;
    box-shadow:0 4px 14px rgba(0,0,0,0.35);
    text-decoration:none;
    line-height:1;
}
#spinIntroModal .spin-intro-lang .nav-lang-btn:hover,
#spinIntroModal .spin-intro-auth .nav-lang-btn:hover,
#spinModal .spin-intro-auth .nav-lang-btn:hover{
    background:rgba(10,14,30,0.75) !important;
    border-color:rgba(255,255,255,0.32) !important;
}
/* Auth pill icon sized to match the flag sprite in the language trigger
   (.nav-lang-flag is 1.1rem). Without this it would render at bootstrap's
   default text size, making the two pills look asymmetric. */
.spin-auth-icon{
    font-size:1.1rem;
    line-height:1;
    display:inline-flex;
    align-items:center;
}
/* Avatar variant of the leading slot. Occupies the same visual footprint
   as .spin-auth-icon (and the flag sprite in the language trigger) so
   swapping between logged-in/out states doesn't cause the pill to
   reflow. object-fit:cover handles non-square source images; the thin
   white ring mirrors the pill's own border so the avatar feels like it
   belongs to the same glass surface. */
.spin-auth-avatar{
    width:1.3rem;
    height:1.3rem;
    border-radius:50%;
    object-fit:cover;
    display:block;
    flex:0 0 auto;
    border:1px solid rgba(255,255,255,0.35);
    background:rgba(255,255,255,0.08);
}
/* When the avatar is itself a click target (authed user, inline
   change-avatar trigger) we set cursor + hover ring to signal that
   clicking it does something distinct from the surrounding pill,
   which opens the menu. The JS on [data-spin-avatar-change] calls
   stopPropagation so the two gestures don't collide. Scoped to
   #spinIntroModal because the result-modal pill's avatar has no
   bound handler (the avatar partial lives only in the intro body)
   and would otherwise show a misleading hover affordance. */
#spinIntroModal .spin-auth-avatar[data-spin-avatar-change]{
    cursor:pointer;
    transition:box-shadow 0.15s ease, border-color 0.15s ease;
}
#spinIntroModal .spin-auth-avatar[data-spin-avatar-change]:hover,
#spinIntroModal .spin-auth-avatar[data-spin-avatar-change]:focus-visible{
    border-color:rgba(255,43,214,0.9);
    box-shadow:0 0 0 2px rgba(255,43,214,0.35);
    outline:none;
}
@media(max-width:500px){
    .spin-auth-avatar{width:1.15rem;height:1.15rem;}
}

/* ── Auth dropdown trigger + menu ──
   Dropdown visuals mirror the language chooser's menu (see inline CSS
   in components/language-switcher.blade.php) so the two pills feel
   like a matched pair: same card background, radius, shadow, option
   hover behaviour, transition timing. Key differences:
     - Menu anchors to the LEFT (not right), because the auth pill sits
       in the top-left of the hero.
     - Option row has a leading icon slot (the language menu uses a
       flag sprite in that slot; we use bi-icons here).
     - The "danger" modifier (logout) tints red on hover. */
.spin-auth-dropdown{
    position:relative;
}
.spin-auth-chevron{
    font-size:0.7rem;
    line-height:1;
    margin-left:0.1rem;
    transition:transform 0.2s ease;
}
.spin-auth-dropdown.open .spin-auth-chevron{
    transform:rotate(180deg);
}
.spin-auth-menu{
    position:absolute;
    top:calc(100% + 0.5rem);
    left:0;
    min-width:180px;
    background:rgba(30,30,35,0.98);
    border:1px solid rgba(255,255,255,0.15);
    border-radius:10px;
    box-shadow:0 8px 32px rgba(0,0,0,0.4);
    padding:0.5rem;
    opacity:0;
    visibility:hidden;
    transform:translateY(-10px);
    transition:opacity 0.2s ease,transform 0.2s ease,visibility 0.2s ease;
    z-index:1000;
    font-family:'Fredoka',sans-serif;
}
.spin-auth-dropdown.open .spin-auth-menu{
    opacity:1;
    visibility:visible;
    transform:translateY(0);
}
.spin-auth-option{
    display:flex;
    align-items:center;
    gap:0.6rem;
    width:100%;
    padding:0.6rem 0.75rem;
    border-radius:6px;
    text-decoration:none;
    color:rgba(255,255,255,0.88);
    font-family:inherit;
    font-size:0.9rem;
    font-weight:500;
    text-align:left;
    line-height:1.2;
    /* Neutralise browser button defaults — this rule is applied to both
       <a> (authed items: Dashboard/Profile/Logout) and <button> (guest
       items: Login/Register, which swap the card body inline instead of
       navigating). Without these resets, <button> inherits a grey fill,
       outset border, and center-aligned system-font text. */
    background:transparent;
    border:none;
    cursor:pointer;
    appearance:none;
    -webkit-appearance:none;
    transition:background 0.15s ease,color 0.15s ease;
    white-space:nowrap;
}
.spin-auth-option:hover,
.spin-auth-option:focus{
    background:rgba(255,255,255,0.1);
    color:#fff;
    outline:none;
}
.spin-auth-option-danger:hover,
.spin-auth-option-danger:focus{
    background:rgba(255,43,214,0.18);
    color:#ff9be7;
}
.spin-auth-option-icon{
    font-size:1rem;
    line-height:1;
    width:1.1rem;
    text-align:center;
    opacity:0.85;
    flex:0 0 auto;
}
@media(max-width:500px){
    #spinIntroModal .spin-intro-lang,
    #spinIntroModal .spin-intro-auth,
    #spinModal .spin-intro-auth{top:10px;}
    #spinIntroModal .spin-intro-lang{right:10px;}
    #spinIntroModal .spin-intro-auth,
    #spinModal .spin-intro-auth{left:10px;}
    #spinIntroModal .spin-intro-lang .nav-lang-btn,
    #spinIntroModal .spin-intro-auth .nav-lang-btn,
    #spinModal .spin-intro-auth .nav-lang-btn{font-size:12px;padding:0.3rem 0.6rem !important;}
    .spin-auth-icon{font-size:1rem;}
}
#spinModal .spin-breadcrumb{
    font-size:10px;letter-spacing:2.5px;text-transform:uppercase;
    color:#00ffd5;font-weight:500;
}
/* .spin-name is styled by the shared #spinModal, #spinIntroModal block
   above; no per-modal override needed. */
#spinModal .spin-extract{
    font-size:13.5px;line-height:1.55;font-weight:400;
    color:rgba(255,255,255,0.88);
    flex:0 1 auto;min-height:0;
    overflow:hidden;
    display:-webkit-box;-webkit-line-clamp:12;-webkit-box-orient:vertical;
}
#spinModal .spin-actions{display:flex;flex-direction:column;gap:8px;margin-top:auto;padding-top:8px;}
#spinModal .spin-actions-row{display:flex;gap:10px;}
#spinModal .spin-btn{
    flex:1;padding:11px 14px;border-radius:12px;
    border:1px solid rgba(255,255,255,0.14);
    background:rgba(255,255,255,0.08);color:#fff;
    font-family:'Fredoka',sans-serif;font-weight:600;font-size:13px;letter-spacing:0.4px;
    cursor:pointer;transition:background .2s,border-color .2s,transform .15s;
}
#spinModal .spin-btn:hover{background:rgba(255,255,255,0.14);border-color:rgba(255,255,255,0.22);}
#spinModal .spin-btn:active{transform:scale(0.98);}
#spinModal .spin-btn-primary{
    background:rgba(0,255,213,0.18);
    border-color:rgba(0,255,213,0.6);
    color:#b9fff1;
}
#spinModal .spin-btn-primary:hover{background:rgba(0,255,213,0.3);border-color:rgba(0,255,213,0.85);color:#fff;}
#spinModal .spin-wiki-link{
    align-self:flex-start;
    font-size:10px;letter-spacing:1.5px;text-transform:uppercase;
    color:rgba(255,255,255,0.6);text-decoration:none;font-weight:500;
}
#spinModal .spin-wiki-link:hover{color:#00ffd5;}
#spinModal .spin-close{
    position:absolute;top:12px;right:12px;
    width:34px;height:34px;border-radius:50%;
    border:1px solid rgba(255,255,255,0.18);
    background:rgba(10,10,26,0.55);
    color:#fff;font-size:18px;line-height:1;cursor:pointer;
    display:flex;align-items:center;justify-content:center;
    -webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
    transition:background .2s;
}
#spinModal .spin-close:hover{background:rgba(0,255,213,0.25);border-color:rgba(0,255,213,0.55);}
@media(max-width:500px){
    /* .spin-card border-radius and .spin-name font-size are handled by
       the shared media-query block above. Only the result-modal extract
       override lives here. */
    #spinModal .spin-extract{font-size:12.5px;-webkit-line-clamp:10;}
}

/* ── Minesweeper launch: bounce-off modal animation + accent button ── */
#spinModal .spin-btn-mine{
    background:rgba(233,30,140,0.15);
    border-color:rgba(233,30,140,0.5);
    color:#ffb3d6;
    display:inline-flex;align-items:center;justify-content:center;gap:6px;
}
#spinModal .spin-btn-mine:hover{background:rgba(233,30,140,0.28);border-color:rgba(233,30,140,0.85);color:#fff;}
#spinModal .spin-btn-dungeon{
    background:rgba(138,43,226,0.15);
    border-color:rgba(138,43,226,0.5);
    color:#d7b8ff;
    display:inline-flex;align-items:center;justify-content:center;gap:6px;
}
#spinModal .spin-btn-dungeon:hover{background:rgba(138,43,226,0.28);border-color:rgba(138,43,226,0.85);color:#fff;}
@keyframes spinFlyUp{
    0%{transform:scale(1) translateY(0);opacity:1;}
    18%{transform:scale(0.98) translateY(24px);opacity:1;}
    100%{transform:scale(0.92) translateY(-120vh);opacity:0;}
}
#spinModal.flying-up{pointer-events:none;}
#spinModal.flying-up .spin-card{
    animation:spinFlyUp .65s cubic-bezier(.4,0,.2,1) forwards;
    transition:none;
}

/* ── Territory-claimed "won" state: reuses the spin card as the
   congrats surface. The stamp slams onto the hero, the standard
   action row is hidden and only the "Enter your aura" button is
   shown. The hero edges pick up a cyan glow to signal victory. */
#spinModal .spin-claim-stamp{
    position:absolute;
    top:50%;left:50%;
    transform:translate(-50%,-50%) rotate(-14deg) scale(0.4);
    display:flex;flex-direction:column;align-items:center;gap:2px;
    padding:14px 28px;
    border:4px solid #00ffd5;
    border-radius:10px;
    color:#00ffd5;
    font-family:'JetBrains Mono',ui-monospace,monospace;
    text-transform:uppercase;letter-spacing:2px;
    background:rgba(10,14,30,0.35);
    -webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);
    box-shadow:0 0 18px rgba(0,255,213,0.45), inset 0 0 12px rgba(0,255,213,0.2);
    text-shadow:0 2px 10px rgba(0,0,0,0.6);
    opacity:0;pointer-events:none;
    white-space:nowrap;
    z-index:3;
}
#spinModal .spin-claim-stamp-top{font-size:10px;letter-spacing:4px;font-weight:500;opacity:0.8;}
#spinModal .spin-claim-stamp-main{font-size:30px;font-weight:800;line-height:1;letter-spacing:4px;}
#spinModal .spin-claim-stamp-bottom{font-size:9px;letter-spacing:3px;font-weight:500;opacity:0.75;}
#spinModal .spin-card.won .spin-claim-stamp{
    animation:spinStampSlam .55s cubic-bezier(.2,.9,.3,1.3) .35s forwards;
}
@keyframes spinStampSlam{
    0%  {opacity:0;transform:translate(-50%,-50%) rotate(-14deg) scale(2.4);}
    60% {opacity:1;transform:translate(-50%,-50%) rotate(-14deg) scale(0.92);}
    80% {opacity:1;transform:translate(-50%,-50%) rotate(-14deg) scale(1.05);}
    100%{opacity:1;transform:translate(-50%,-50%) rotate(-14deg) scale(1);}
}
#spinModal .spin-card.won{
    box-shadow:0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(0,255,213,0.35), 0 0 60px rgba(0,255,213,0.22);
}
#spinModal .spin-card.won .spin-hero-wrap::after{
    content:'';position:absolute;inset:0;
    box-shadow:inset 0 0 60px rgba(0,255,213,0.25);
    pointer-events:none;
}
#spinModal .spin-card.won .spin-close,
#spinModal .spin-card.won .spin-actions-row{display:none !important;}
#spinModal .spin-btn-aura{
    display:none;
    flex:1;
    background:rgba(0,255,213,0.2);
    border-color:rgba(0,255,213,0.65);
    color:#c7fff3;
    gap:6px;align-items:center;justify-content:center;
    font-weight:700;letter-spacing:0.6px;
    box-shadow:0 0 20px rgba(0,255,213,0.25);
}
#spinModal .spin-btn-aura:hover{background:rgba(0,255,213,0.32);border-color:rgba(0,255,213,0.9);color:#fff;}
#spinModal .spin-card.won #spinModalEnterAura{display:inline-flex;}

/* ── Lens + Aura balance overlays ──
   Authed-only magenta pills (Lenses primary, Aura secondary) that
   slam onto the hero with the same scale-in rhythm as the
   "Territory CLAIMED" stamp above, keeping all three surfaces in
   one motion language. Rendered by the
   public.partials.spin-lens-overlay partial inside both modals'
   .spin-hero-wrap, stacked in a flex column wrapper so centring
   is owned by the parent and each pill is free to animate its own
   scale without fighting a per-pill translate. The claim stamp
   still owns the centre on a territory win — the .won rule below
   hides the whole stack to avoid badges fighting for the same spot.
   .spin-hero-overlays sits at z-index:1 (above the hero image +
   shade, BELOW the two corner pills at z-index:2 so their open
   dropdowns layer cleanly over the balance stack). */
.spin-hero-overlays{
    position:absolute;
    top:50%;left:50%;
    transform:translate(-50%,-50%);
    display:flex;flex-direction:column;align-items:center;gap:8px;
    pointer-events:none;
    z-index:1;
}
.spin-lens-overlay,
.spin-aura-overlay{
    display:inline-flex;align-items:center;
    border:2px solid #ff2bd6;
    border-radius:999px;
    background:rgba(10,14,30,0.55);
    -webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
    color:#fff;
    font-family:'Fredoka',sans-serif;
    font-weight:700;
    text-shadow:0 2px 10px rgba(0,0,0,0.6);
    white-space:nowrap;
    /* Start scaled up + invisible so the slam keyframes animate
       from "dropped in from the sky" to "locked in at 1×". */
    transform:scale(0.4);
    opacity:0;
}
.spin-lens-overlay{
    gap:10px;
    padding:9px 18px 9px 16px;
    box-shadow:0 0 18px rgba(255,43,214,0.45), inset 0 0 12px rgba(255,43,214,0.15);
}
.spin-lens-overlay-icon{
    font-size:18px;color:#ff2bd6;
    /* Matches the dashboard's lens-badge orientation so the
       hexagon reads the same way across the product. */
    transform:rotate(90deg);
    line-height:1;
}
.spin-lens-overlay-count{
    font-size:22px;line-height:1;letter-spacing:0.3px;
}
.spin-lens-overlay-label{
    font-family:'JetBrains Mono',ui-monospace,monospace;
    font-size:10px;letter-spacing:2px;text-transform:uppercase;
    opacity:0.8;
    margin-left:2px;
}
/* Aura pill: slightly smaller everywhere — thinner border, shorter
   padding, softer glow, and two-thirds type scale — so the pair
   reads as primary (Lenses: active spend currency) + metric (Aura:
   cumulative area footprint) rather than two peer badges. */
.spin-aura-overlay{
    gap:8px;
    padding:6px 14px 6px 12px;
    border-width:1px;
    border-color:rgba(255,43,214,0.7);
    box-shadow:0 0 12px rgba(255,43,214,0.3), inset 0 0 8px rgba(255,43,214,0.1);
}
.spin-aura-overlay-icon{
    font-size:14px;color:#ff2bd6;line-height:1;
}
.spin-aura-overlay-count{
    font-size:16px;line-height:1;letter-spacing:0.2px;
}
.spin-aura-overlay-label{
    font-family:'JetBrains Mono',ui-monospace,monospace;
    font-size:9px;letter-spacing:1.8px;text-transform:uppercase;
    opacity:0.75;
    margin-left:2px;
}
/* Fire the slam once the modal is visible on screen. Using
   .visible on the modal (rather than putting the animation on the
   element itself) means a second spin that re-opens the result
   modal also re-triggers the slam, not just the first paint.
   The aura pill delay (0.82s) is timed to start just as the lens
   slam is settling, so the two read as a cascade rather than a
   simultaneous double-stamp. */
#spinIntroModal.visible .spin-lens-overlay,
#spinModal.visible .spin-lens-overlay{
    animation:spinBalanceSlam .55s cubic-bezier(.2,.9,.3,1.3) .5s forwards;
}
#spinIntroModal.visible .spin-aura-overlay,
#spinModal.visible .spin-aura-overlay{
    animation:spinBalanceSlam .55s cubic-bezier(.2,.9,.3,1.3) .82s forwards;
}
@keyframes spinBalanceSlam{
    0%  {opacity:0;transform:scale(2.4);}
    60% {opacity:1;transform:scale(0.92);}
    80% {opacity:1;transform:scale(1.05);}
    100%{opacity:1;transform:scale(1);}
}
#spinModal .spin-card.won .spin-hero-overlays{display:none;}
/* Mid-spin feedback floater. Two variants share the same visual
   shell and animation so the two kinds of spin read as members of
   the same family, with only the copy + icon changing between them:
   
     .spin-lens-debit-float — Lens-debit spin. Server atomically
       decremented bonus_honeycomb_balance; client plays a "-1 [hex]"
       tile so the cost feels like a real transaction rather than a
       silent deduction.
     .spin-free-spin-float  — Free daily spin. Server burned one of
       the user's 5 daily free spins; client plays a "✨ Free Spin!"
       tile so the user gets an equally strong "something happened"
       beat but can also tell at a glance that no Lens was spent.
   
   Both are fixed-positioned because by the time the server
   response lands the intro modal (and its lens pill) has already
   flown off-screen, so we can't anchor to the pill itself. Sat at
   z-index:10 (above the hero + corner pills) so the affordance is
   unambiguous regardless of what else is on screen. */
.spin-lens-debit-float,
.spin-free-spin-float{
    position:fixed;
    top:28%;left:50%;
    transform:translate(-50%,0);
    display:inline-flex;align-items:center;gap:8px;
    padding:10px 18px;
    border:2px solid #ff2bd6;
    border-radius:999px;
    background:rgba(10,14,30,0.6);
    -webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
    color:#fff;
    font-family:'Fredoka',sans-serif;
    font-weight:700;
    font-size:24px;line-height:1;
    letter-spacing:-0.2px;
    box-shadow:0 0 22px rgba(255,43,214,0.55), inset 0 0 12px rgba(255,43,214,0.2);
    text-shadow:0 2px 10px rgba(0,0,0,0.6);
    pointer-events:none;
    z-index:10;
    opacity:0;
    animation:spinLensDebitFloat 1.8s cubic-bezier(.2,.7,.3,1) forwards;
    white-space:nowrap;
}
.spin-lens-debit-float .bi-hexagon-half{
    /* Match the in-pill orientation so the debit icon reads as the
       same token the user has been staring at in the Lens overlay. */
    transform:rotate(90deg);
    color:#ff2bd6;
    font-size:22px;
    line-height:1;
}
.spin-lens-debit-float-label{
    font-size:26px;
}
/* Free-spin variant: leading sparkle glyph + "Free Spin!" copy.
   Slightly smaller label than the lens-debit variant's "-1" (which
   is a 2-char atom) because the word-based label is longer and
   would otherwise crowd the pill on narrow viewports. */
.spin-free-spin-float .bi-stars{
    color:#ffd166;
    font-size:22px;
    line-height:1;
}
.spin-free-spin-float-label{
    font-size:22px;
}
@keyframes spinLensDebitFloat{
    0%   {opacity:0;  transform:translate(-50%, 16px) scale(0.9);}
    18%  {opacity:1;  transform:translate(-50%, 0)    scale(1);}
    75%  {opacity:1;  transform:translate(-50%, -80px) scale(1);}
    100% {opacity:0;  transform:translate(-50%, -140px) scale(0.92);}
}
@media(max-width:500px){
    .spin-lens-debit-float,
    .spin-free-spin-float{padding:8px 14px;font-size:20px;gap:6px;}
    .spin-lens-debit-float .bi-hexagon-half,
    .spin-free-spin-float .bi-stars{font-size:18px;}
    .spin-lens-debit-float-label{font-size:22px;}
    .spin-free-spin-float-label{font-size:18px;}
}
/* Hero dim, authed-only.
   After the lens+aura slams finish (aura starts .82s, runs .55s →
   ends ~1.37s), dim the hero image to 50% opacity so the two
   balance pills become the focal point of the composition. Gated
   by :has(.spin-aura-overlay) because the aura pill only renders
   for authed users — guests keep the full-strength hero mascot
   driving the marketing pitch. Fill mode forwards so the dim
   persists after the animation; re-opening the modal (second spin)
   plays it again from scratch. Scoped to the spin-hero element
   only so the claim-stamp + overlays above it stay full-strength. */
/* Dim the authed hero mascot to 50%. The authed PNG has transparent
   regions (the operator is cut out against a dark tower silhouette),
   so if we left the guest base layer underneath at any non-zero
   opacity it would bleed through those holes. Hence the partner
   rule further down drives the base layer all the way to 0 when
   the aura pill is present, and only the authed layer actually
   renders the dimmed 50% silhouette. */
#spinIntroModal.visible:has(.spin-aura-overlay) .spin-hero-alt-authed,
#spinModal.visible:has(.spin-aura-overlay) .spin-hero-alt-authed{
    animation:spinHeroDim .55s ease-out 1.4s forwards;
}
/* Hide the guest base hero entirely for authed users (post-slam).
   Was previously also dimmed to 50% via spinHeroDim, but since the
   authed mascot now sits on top at 50% AND has alpha transparency,
   a base at 50% showed through the authed mascot's transparent
   regions — giving the "guest mascot ghosting behind the authed
   art" artefact. Running this layer to 0 instead leaves the authed
   mascot as the sole visible hero at its dimmed 50%. */
#spinIntroModal.visible:has(.spin-aura-overlay) .spin-hero:not(.spin-hero-alt):not(.spin-hero-shade),
#spinModal.visible:has(.spin-aura-overlay) .spin-hero:not(.spin-hero-alt):not(.spin-hero-shade){
    animation:spinHeroHide .55s ease-out 1.4s forwards;
}
@keyframes spinHeroHide{
    from{opacity:1;}
    to  {opacity:0;}
}
@keyframes spinHeroDim{
    from{opacity:1;}
    to  {opacity:0.5;}
}
@media(max-width:500px){
    .spin-hero-overlays{gap:6px;}
    .spin-lens-overlay{padding:8px 14px 8px 12px;gap:8px;}
    .spin-lens-overlay-icon{font-size:16px;}
    .spin-lens-overlay-count{font-size:20px;}
    .spin-lens-overlay-label{font-size:9px;letter-spacing:1.6px;}
    .spin-aura-overlay{padding:5px 11px 5px 10px;gap:6px;}
    .spin-aura-overlay-icon{font-size:12px;}
    .spin-aura-overlay-count{font-size:14px;}
    .spin-aura-overlay-label{font-size:8px;letter-spacing:1.4px;}
}

/* ── Minesweeper HUD ── */
#msHud{
    position:fixed;top:14px;left:50%;transform:translateX(-50%);
    z-index:45;
    display:none;align-items:center;gap:14px;
    padding:8px 18px;border-radius:999px;
    background:rgba(10,14,30,0.7);
    -webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);
    border:1px solid rgba(233,30,140,0.35);
    color:#fff;font-family:'JetBrains Mono',ui-monospace,monospace;font-size:12px;
    box-shadow:0 10px 30px rgba(0,0,0,0.35);
    pointer-events:none;
}
#msHud.visible{display:inline-flex;}
#msHud .ms-hud-item{display:inline-flex;align-items:center;gap:4px;letter-spacing:0.5px;}
#msHud .ms-hud-icon{font-size:12px;}
#msHud #msHudMines{color:#ff5a5a;font-weight:700;}
#msHud #msHudTimer{color:#00ffd5;font-weight:700;}
#msHud #msHudRevealed{color:#ffa726;font-weight:700;}
#msHud #msHudDemos{color:rgba(255,255,255,0.6);font-weight:500;letter-spacing:1.2px;text-transform:uppercase;font-size:10px;padding-left:6px;border-left:1px solid rgba(255,255,255,0.14);margin-left:2px;}
@media(max-width:500px){
    #msHud{gap:10px;padding:6px 14px;font-size:11px;}
    #msHud #msHudDemos{display:none;}
}

/* ── Minesweeper game-over + auth modal (shared overlay styles) ── */
.ms-overlay{
    position:fixed;inset:0;z-index:50;
    display:none;align-items:center;justify-content:center;
    background:rgba(5,8,18,0.72);
    -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
}
.ms-overlay.visible{display:flex;}
.ms-box{
    background:rgba(20,24,42,0.96);
    border:1px solid rgba(255,255,255,0.1);
    border-radius:16px;
    padding:32px 36px;text-align:center;
    min-width:300px;max-width:calc(100vw - 40px);
    color:#fff;font-family:'Fredoka',sans-serif;
    box-shadow:0 30px 80px rgba(0,0,0,0.6);
}
.ms-box h2{
    margin:0 0 8px;font-size:26px;font-weight:700;
    font-family:'JetBrains Mono',ui-monospace,monospace;letter-spacing:1.5px;
}
.ms-box h2.win{color:#00ffd5;}
.ms-box h2.lose{color:#ff5a5a;}
.ms-box .ms-sub{font-size:14px;color:rgba(255,255,255,0.75);margin-bottom:20px;line-height:1.45;}
.ms-box .ms-btn-row{display:flex;gap:10px;justify-content:center;margin-top:16px;flex-wrap:wrap;}
.ms-btn{
    padding:10px 22px;border-radius:24px;
    font-family:'JetBrains Mono',ui-monospace,monospace;
    font-size:11px;letter-spacing:1.5px;text-transform:uppercase;font-weight:700;
    border:1px solid rgba(255,255,255,0.15);
    background:transparent;color:rgba(255,255,255,0.8);
    cursor:pointer;transition:all .2s;
    text-decoration:none;display:inline-flex;align-items:center;justify-content:center;
}
.ms-btn:hover{border-color:rgba(255,255,255,0.35);color:#fff;}
.ms-btn-primary{
    background:rgba(0,255,213,0.18);border-color:rgba(0,255,213,0.6);color:#b9fff1;
}
.ms-btn-primary:hover{background:rgba(0,255,213,0.32);border-color:rgba(0,255,213,0.9);color:#fff;}

/* Inline auth tabs + form (reused inside .spin-auth-panel — the
   retired #msAuthModal "You win!" green modal used to host these
   too, but its markup and id-scoped rules have been removed). */
.ms-auth-tabs{display:flex;gap:0;margin-bottom:18px;border-bottom:1px solid rgba(255,255,255,0.1);}
.ms-auth-tabs button{
    flex:1;padding:10px 0;
    font-family:'JetBrains Mono',ui-monospace,monospace;
    font-size:11px;letter-spacing:1.5px;text-transform:uppercase;
    background:none;border:none;border-bottom:2px solid transparent;
    color:rgba(255,255,255,0.55);cursor:pointer;transition:all .2s;
}
.ms-auth-tabs button.active{color:#00ffd5;border-bottom-color:#00ffd5;}
.ms-auth-form{display:none;}
.ms-auth-form.active{display:block;}
.ms-auth-form input{
    display:block;width:100%;box-sizing:border-box;
    margin-bottom:10px;padding:11px 14px;
    font-family:'Fredoka',sans-serif;font-size:14px;color:#fff;
    background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);
    border-radius:8px;outline:none;transition:border-color .2s;
}
.ms-auth-form input:focus{border-color:#00ffd5;}
.ms-auth-form input::placeholder{color:rgba(255,255,255,0.4);}
.ms-auth-error{color:#ff5a5a;font-size:12px;margin-bottom:10px;min-height:14px;}
.ms-auth-form button[type="submit"]{
    width:100%;margin-top:6px;padding:11px 18px;border-radius:10px;border:none;cursor:pointer;
    font-family:'JetBrains Mono',ui-monospace,monospace;
    font-size:12px;letter-spacing:1.5px;text-transform:uppercase;font-weight:700;
    background:#00ffd5;color:#0a0a1a;transition:transform .15s,filter .2s;
}
.ms-auth-form button[type="submit"]:hover{filter:brightness(1.1);}
.ms-auth-form button[type="submit"]:active{transform:scale(0.98);}

/* ── Inline auth panel (intro + spin-result modals) ──
   Rendered once per modal via public/partials/spin-auth-panel.blade.php.
   Hidden by default. When the pill's Login or Register item is clicked,
   JS adds .is-authpanel to the enclosing .spin-body; the rules below
   then hide every native sibling in that body and reveal the panel.

   The forms share structural (.ms-auth-tabs, .ms-auth-form, .ms-auth-
   error) classes with the Minesweeper win-gate so layout + show/hide
   behaviour stay in sync, but every visual property (accent colour,
   font family, spacing) is overridden below so the panel matches the
   magenta/Fredoka brand of the intro modal instead of the win-gate's
   teal/JetBrains-Mono arcade aesthetic. */
.spin-auth-panel{
    display:none;
    flex:1 1 auto;
    text-align:left;
    padding-top:8px;
    gap:14px;
}
/* When the body is in auth-panel mode, every direct child except the
   panel itself is removed from layout. Two body flavours share this
   rule: .spin-intro-body (intro modal) and the result modal's plain
   .spin-body — the selector is intentionally generic so both pick it
   up without per-modal CSS. */
#spinModal .spin-body.is-authpanel > *:not(.spin-auth-panel),
#spinIntroModal .spin-body.is-authpanel > *:not(.spin-auth-panel){
    display:none !important;
}
#spinModal .spin-body.is-authpanel > .spin-auth-panel,
#spinIntroModal .spin-body.is-authpanel > .spin-auth-panel{
    display:flex;flex-direction:column;
}

/* ── Panel-scoped overrides: magenta + Fredoka + more breathing room ──
   Each selector is scoped under .spin-auth-panel so the Minesweeper
   win-gate (which reuses the same .ms-auth-* classes but is themed
   arcade/teal on purpose) is unaffected. */

/* Tabs: wider gap from the card top, Fredoka, magenta active state. */
.spin-auth-panel .ms-auth-tabs{
    margin-top:6px;margin-bottom:22px;
    border-bottom:1px solid rgba(255,255,255,0.12);
}
.spin-auth-panel .ms-auth-tabs button{
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    letter-spacing:0.3px;
    text-transform:none;
    font-weight:500;
    padding:12px 0;
    color:rgba(255,255,255,0.55);
}
.spin-auth-panel .ms-auth-tabs button.active{
    color:#ff2bd6;
    border-bottom-color:#ff2bd6;
    font-weight:600;
}
/* Pulse the "Register Free" text only. Targets the inner span
   so the button itself keeps its normal tab styling — no border,
   background, or transform weirdness on the button frame. */
.spin-auth-panel-tab-pulse-text{
    display:inline-block;
    animation:spinAuthTabTextPulse 1.2s ease-in-out infinite;
}
@keyframes spinAuthTabTextPulse{
    0%,100%{opacity:1;transform:scale(1);}
    50%    {opacity:0.55;transform:scale(1.08);}
}

/* Per-form heading: magenta title + muted description. Lives at
   the top of each .ms-auth-form and is visible only in the default
   (non-MS-win) view — the winhead takes over that role when the
   panel is opened from the minesweeper claim flow, so we suppress
   this head in .is-mswin mode further down. Title/description copy
   comes straight from the Blade partial; no sizing differences
   between Login and Register so the two tabs stay pixel-identical
   up to the error row. */
.spin-auth-panel .ms-auth-form .spin-auth-panel-formhead{
    margin:0 0 14px;
    text-align:center;
}
.spin-auth-panel .ms-auth-form .spin-auth-panel-formtitle{
    margin:0 0 4px;
    font-family:'Fredoka',sans-serif;
    font-size:18px;
    font-weight:600;
    line-height:1.2;
    color:#ff2bd6;
    letter-spacing:0.2px;
}
.spin-auth-panel .ms-auth-form .spin-auth-panel-formdesc{
    margin:0;
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    line-height:1.35;
    color:rgba(255,255,255,0.7);
}
.spin-auth-panel.is-mswin .ms-auth-form .spin-auth-panel-formhead{
    display:none;
}

/* Inputs: taller, Fredoka, magenta focus ring. */
.spin-auth-panel .ms-auth-form input{
    margin-bottom:14px;
    padding:13px 16px;
    font-family:'Fredoka',sans-serif;
    font-size:15px;
    border-radius:10px;
}
.spin-auth-panel .ms-auth-form input:focus{
    border-color:#ff2bd6;
    box-shadow:0 0 0 3px rgba(255,43,214,0.18);
}

/* Paired-input row: Login puts email + password together, Register
   puts first-name + email together. The row owns the vertical cadence
   so its children can drop their individual bottom margins, and
   min-width:0 stops the inputs from overflowing their flex cell on
   narrow cards (flex items default to min-content, which is wider than
   the column when placeholder text is long). Stacks again under 500px
   so the form stays usable on phones. */
.spin-auth-panel .ms-auth-form .spin-auth-row{
    display:flex;
    gap:10px;
    margin-bottom:14px;
}
.spin-auth-panel .ms-auth-form .spin-auth-row > input{
    flex:1 1 0;
    min-width:0;
    margin-bottom:0;
}
@media(max-width:500px){
    .spin-auth-panel .ms-auth-form .spin-auth-row{
        flex-direction:column;
        gap:0;
    }
    .spin-auth-panel .ms-auth-form .spin-auth-row > input{
        margin-bottom:14px;
    }
}

/* Errors: slightly bigger so they don't get lost under the taller
   inputs; Fredoka keeps the whole panel in one typographic voice. */
.spin-auth-panel .ms-auth-error{
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    margin-bottom:12px;
    min-height:16px;
}

/* Registration notice that replaces the password + confirm row. Sets
   the expectation that the server will email a password, in the same
   Fredoka/magenta voice as the rest of the panel. A thin magenta left
   rule makes it read as "informational" rather than a plain paragraph,
   and the soft glass background ties it to the rest of the card. */
.spin-auth-panel .ms-auth-form .spin-auth-panel-notice{
    margin:4px 0 16px;
    padding:12px 14px;
    border-radius:10px;
    border-left:3px solid #ff2bd6;
    background:rgba(255,43,214,0.08);
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    line-height:1.5;
    letter-spacing:0.1px;
    color:rgba(255,255,255,0.88);
}

/* Primary submit: magenta fill with a soft glow, Fredoka, more top
   margin so it doesn't crowd the last input. White text because
   magenta + near-black would be lower contrast than white on magenta. */
.spin-auth-panel .ms-auth-form button[type="submit"]{
    margin-top:10px;
    padding:13px 20px;
    border-radius:12px;
    font-family:'Fredoka',sans-serif;
    font-size:14px;
    letter-spacing:0.4px;
    text-transform:none;
    font-weight:600;
    background:#ff2bd6;
    color:#fff;
    box-shadow:0 6px 20px rgba(255,43,214,0.3);
}
.spin-auth-panel .ms-auth-form button[type="submit"]:hover{
    filter:brightness(1.08);
    box-shadow:0 8px 26px rgba(255,43,214,0.42);
}

/* Back: low-emphasis, Fredoka, roomier click target so it reads as
   navigation rather than a system button. */
.spin-auth-panel-back{
    align-self:center;
    margin-top:10px;
    padding:10px 16px;
    background:none;border:none;cursor:pointer;
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    letter-spacing:0.2px;
    font-weight:500;
    color:rgba(255,255,255,0.55);
    transition:color .15s ease;
}
.spin-auth-panel-back:hover{color:#fff;}
.spin-auth-panel-back > span:first-child{margin-right:6px;}

/* ── Win-mode heading + Spin Again button ──
   Hidden by default; revealed when JS adds .is-mswin to the panel
   root (see showWinAuthModal() in public/js/ping-landing.js). Used
   to give the post-minesweeper-win auth flow context — "Login Or
   Register / To Add [Place] To Your Territories" — and swap the
   subtle Back link for a prominent Spin Again CTA that matches the
   result modal's own primary button styling. */
.spin-auth-panel-winhead{display:none;}
.spin-auth-panel-winagain{display:none;}
.spin-auth-panel.is-mswin .spin-auth-panel-winhead{
    display:block;
    text-align:center;
    margin-top:2px;
    margin-bottom:10px;
}
.spin-auth-panel.is-mswin .spin-auth-panel-wintitle{
    font-family:'Fredoka',sans-serif;
    font-weight:600;
    font-size:17px;
    line-height:1.22;
    color:#ff2bd6;
    margin:0 0 4px;
    letter-spacing:0.2px;
}
.spin-auth-panel.is-mswin .spin-auth-panel-wintitle [data-spin-auth-winplace]{
    color:#fff;
}
.spin-auth-panel.is-mswin .spin-auth-panel-winsub{
    font-family:'Fredoka',sans-serif;
    font-size:12px;
    color:rgba(255,255,255,0.6);
    margin:0;
}
/* In win-mode, compact the inline form so the heading + tabs +
   fields + notice + submit + Spin Again all fit on a ~640px-tall
   body without clipping. Each rule here shaves a handful of pixels
   off the vertical rhythm the non-win panel uses. */
.spin-auth-panel.is-mswin .ms-auth-tabs{
    margin-top:2px;
    margin-bottom:14px;
}
.spin-auth-panel.is-mswin .ms-auth-form input{
    margin-bottom:10px;
}
/* The auto-generated-password notice is redundant in the win-gate
   (the user already has context from the heading and is mid-claim),
   and removing it also tightens the card vertically so the Spin
   Again CTA sits close to Create Account instead of separated by
   a full paragraph block. The notice stays in the pill's normal
   Login/Register flow where the explanation is still useful. */
.spin-auth-panel.is-mswin .ms-auth-form .spin-auth-panel-notice{
    display:none;
}
.spin-auth-panel.is-mswin .ms-auth-form button[type="submit"]{
    margin-top:4px;
}
/* Swap the subtle Back link for the primary Spin Again pill so the
   "walk away without signing up" affordance is first-class rather
   than a footer afterthought. Kept tight to the submit button above
   so the two CTAs read as a paired "finish claim / skip to next
   spin" row rather than two unrelated footer actions. */
.spin-auth-panel.is-mswin .spin-auth-panel-back{display:none;}
.spin-auth-panel.is-mswin .spin-auth-panel-winagain{
    display:block;
    align-self:stretch;
    width:100%;
    margin-top:4px;
}
@media(max-width:500px){
    .spin-auth-panel.is-mswin .spin-auth-panel-wintitle{font-size:16px;}
    .spin-auth-panel.is-mswin .spin-auth-panel-winsub{font-size:11px;}
}

/* ── Inline onboarding prompts (intro modal only) ──
   Two partials can render inside #spinIntroModal .spin-body.spin-intro-body:
     - public/partials/spin-avatar-prompt.blade.php   (always for @auth)
     - public/partials/spin-verify-prompt.blade.php   (unverified or post-verify)
   Both can be in the DOM simultaneously (authed user with an avatar
   who hasn't verified: avatar partial is dormant, verify partial is
   active), so we use two independent body modifiers — one per active
   prompt — rather than a shared one. JS coordinates which is live:
     - .is-avatarprompt → avatar partial takes over the .spin-body
     - .is-verifyprompt → verify partial takes over the .spin-body
   Clicking the avatar image in the pill swaps verify→avatar; Cancel
   on the avatar swaps it back so the verify prompt reappears if the
   user still needs to verify. */
#spinIntroModal .spin-body.is-avatarprompt > *:not(.spin-avatar-prompt){
    display:none !important;
}
#spinIntroModal .spin-body.is-avatarprompt > .spin-avatar-prompt{
    display:flex;
    flex-direction:column;
}
#spinIntroModal .spin-body.is-verifyprompt > *:not(.spin-verify-prompt){
    display:none !important;
}
#spinIntroModal .spin-body.is-verifyprompt > .spin-verify-prompt{
    display:flex;
    flex-direction:column;
}

.spin-avatar-prompt{
    display:none;
    flex:1 1 auto;
    gap:14px;
    padding-top:4px;
    text-align:center;
    font-family:'Fredoka',sans-serif;
}

/* Two-state visibility inside the prompt. Default state shows the
   empty tile; adding .is-cropping from JS flips to the crop stage +
   Apply/Cancel row. We use a class modifier rather than the HTML5
   [hidden] attribute because .spin-avatar-tile and .spin-avatar-crop
   set explicit display values below, which would otherwise win the
   specificity fight and keep all three elements visible at once. */
.spin-avatar-prompt:not(.is-cropping) > .spin-avatar-crop,
.spin-avatar-prompt:not(.is-cropping) > .spin-avatar-actions{
    display:none;
}
.spin-avatar-prompt.is-cropping > .spin-avatar-tile,
.spin-avatar-prompt.is-cropping > .spin-avatar-dismiss-row{
    display:none;
}

/* Opt-in Cancel row. Only rendered when the user already has an avatar
   (server-side gate in the partial); visible only in the empty-state
   tile view — once the user has picked an image, the Apply/Cancel row
   takes over that role, so this row hides to avoid duplication. */
.spin-avatar-dismiss-row{
    display:flex;
    justify-content:center;
    margin-top:4px;
}
.spin-avatar-dismiss-row .spin-avatar-btn{
    min-width:160px;
}

/* Title + description. Title matches the visual weight of the auth
   panel tabs' active state; description is the same quiet grey as
   the auth-panel placeholder/back text. */
.spin-avatar-prompt__head{margin:0 0 4px;}
.spin-avatar-prompt__title{
    margin:0 0 6px;
    font-family:'Fredoka',sans-serif;
    font-size:17px;
    font-weight:600;
    letter-spacing:0.2px;
    color:#ff2bd6;
}
.spin-avatar-prompt__desc{
    margin:0;
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    line-height:1.5;
    color:rgba(255,255,255,0.7);
}

/* Clickable square. Uses aspect-ratio for a perfect square at any
   width, capped so very short viewports don't push the Apply button
   off-screen. min() keeps it responsive without a media query for
   small screens. Dashed magenta border reads as "drop zone" even
   though we only support click-to-pick (keeps the visual language
   familiar without over-promising drag-and-drop). */
.spin-avatar-tile{
    appearance:none;
    display:block;
    width:min(260px, 60%);
    aspect-ratio:1 / 1;
    margin:4px auto 0;
    padding:0;
    background:rgba(255,43,214,0.06);
    border:2px dashed rgba(255,43,214,0.45);
    border-radius:18px;
    cursor:pointer;
    font-family:inherit;
    color:inherit;
    transition:background .15s ease, border-color .15s ease, transform .15s ease;
}
.spin-avatar-tile:hover{
    background:rgba(255,43,214,0.12);
    border-color:rgba(255,43,214,0.75);
    transform:translateY(-1px);
}
.spin-avatar-tile:focus-visible{
    outline:none;
    border-color:#ff2bd6;
    box-shadow:0 0 0 3px rgba(255,43,214,0.25);
}
.spin-avatar-tile__inner{
    display:flex;
    flex-direction:column;
    align-items:center;
    justify-content:center;
    gap:10px;
    width:100%;height:100%;
}
.spin-avatar-tile__icon{
    font-size:44px;
    color:#ff2bd6;
    line-height:1;
}
.spin-avatar-tile__hint{
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    font-weight:500;
    letter-spacing:0.3px;
    color:rgba(255,255,255,0.7);
    padding:0 12px;
}

/* Crop stage: shares the same footprint as the tile so the two
   states feel like one surface transforming. Cropper.js mounts an
   <img> inside .__mount; we constrain that img so the cropper's
   own layout still fills the square cleanly. */
.spin-avatar-crop{
    display:block;
    width:min(260px, 60%);
    aspect-ratio:1 / 1;
    margin:4px auto 0;
    background:rgba(10,14,30,0.55);
    border:1px solid rgba(255,43,214,0.35);
    border-radius:18px;
    overflow:hidden;
}
.spin-avatar-crop__mount{
    width:100%;height:100%;
    display:block;
}
.spin-avatar-crop__mount img{
    display:block;
    max-width:100%;
    max-height:100%;
}

/* Error: Fredoka + a subtle red tint so it reads as "something went
   wrong" without shouting. min-height reserves layout space so a new
   error doesn't jump the Apply button around. */
.spin-avatar-error{
    min-height:16px;
    margin:2px 0 0;
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    color:#ff7a9a;
}
.spin-avatar-error:empty{display:none;}

/* Action row: Apply (magenta fill, mirrors .spin-auth-panel submit)
   and Cancel (ghost, muted). Stacked-on-mobile avoided by default —
   both buttons are short enough to sit on one row down to ~340px. */
.spin-avatar-actions{
    display:flex;
    gap:10px;
    justify-content:center;
    align-items:center;
    margin-top:6px;
    flex-wrap:wrap;
}
.spin-avatar-btn{
    appearance:none;
    padding:12px 20px;
    border-radius:12px;
    font-family:'Fredoka',sans-serif;
    font-size:14px;
    font-weight:600;
    letter-spacing:0.3px;
    cursor:pointer;
    transition:filter .15s ease, box-shadow .15s ease, background .15s ease, color .15s ease;
}
.spin-avatar-btn:disabled{opacity:0.55;cursor:progress;}
.spin-avatar-btn--primary{
    background:#ff2bd6;
    color:#fff;
    border:1px solid #ff2bd6;
    box-shadow:0 6px 20px rgba(255,43,214,0.3);
}
.spin-avatar-btn--primary:hover:not(:disabled){
    filter:brightness(1.08);
    box-shadow:0 8px 26px rgba(255,43,214,0.42);
}
.spin-avatar-btn--ghost{
    background:transparent;
    color:rgba(255,255,255,0.7);
    border:1px solid rgba(255,255,255,0.2);
}
.spin-avatar-btn--ghost:hover:not(:disabled){
    color:#fff;
    border-color:rgba(255,255,255,0.4);
    background:rgba(255,255,255,0.05);
}

/* Shrink the tile a touch on narrow cards so title + tile + buttons
   comfortably fit in a single scroll-free view. */
@media(max-width:500px){
    .spin-avatar-tile,
    .spin-avatar-crop{
        width:min(220px, 72%);
    }
    .spin-avatar-tile__icon{font-size:38px;}
}

/* ── Inline email-verify prompt (intro modal only) ──
   Shares the .is-avatarprompt body modifier with the avatar-upload
   prompt (see rules higher up). The two partials are mutually
   exclusive server-side. Copy-styles mirror the auth/avatar panels
   (Fredoka, generous whitespace, centered) while the button itself
   mirrors the dashboard's .dash-map-verify-badge state machine so the
   verify UX feels identical across surfaces. */
.spin-verify-prompt{
    display:none;
    flex:1 1 auto;
    gap:18px;
    padding-top:4px;
    text-align:center;
    align-items:center;
    justify-content:flex-start;
    font-family:'Fredoka',sans-serif;
}
.spin-verify-prompt__head{margin:0 0 4px;}
.spin-verify-prompt__title{
    margin:0 0 8px;
    font-family:'Fredoka',sans-serif;
    font-weight:600;
    font-size:22px;
    line-height:1.2;
    color:#ff2bd6;
    letter-spacing:0.01em;
}
.spin-verify-prompt__desc{
    margin:0;
    font-family:'Fredoka',sans-serif;
    font-weight:400;
    font-size:14px;
    line-height:1.45;
    color:rgba(255,255,255,0.72);
    max-width:360px;
}

/* The button IS the UI — no secondary actions, mirroring the
   dashboard's badge. Colors/keyframes match .dash-map-verify-badge
   exactly; only the scale is dialed up for modal prominence. */
.spin-verify-btn{
    appearance:none;
    -webkit-appearance:none;
    background:transparent;
    border:1.5px solid transparent;
    cursor:pointer;
    font-family:'Fredoka',sans-serif;
    font-weight:600;
    font-size:15px;
    line-height:1.2;
    letter-spacing:0.04em;
    text-transform:uppercase;
    padding:0.85rem 1.4rem;
    border-radius:12px;
    display:inline-flex;
    align-items:center;
    justify-content:center;
    gap:0.55rem;
    transition:background .2s ease, color .2s ease, border-color .2s ease, opacity .2s ease;
    min-width:min(280px, 90%);
    max-width:100%;
}
.spin-verify-btn i{font-size:0.95em;}
.spin-verify-btn:disabled,
.spin-verify-btn[aria-disabled="true"]{cursor:default;}

/* Red pulsing — initial unverified state, matches dashboard. */
.spin-verify-btn.unverified{
    background:rgba(239,68,68,0.25);
    color:#f87171;
    border-color:rgba(248,113,113,0.45);
    animation:spin-verify-pulse 2s ease-in-out infinite;
}
.spin-verify-btn.unverified:hover{
    background:rgba(239,68,68,0.4);
    color:#fca5a5;
}

/* Orange — email sent, awaiting user to click the link. */
.spin-verify-btn.verify-pending{
    background:rgba(245,158,11,0.3);
    color:#fbbf24;
    border-color:rgba(251,191,36,0.45);
}
.spin-verify-btn.verify-pending:not(:disabled):hover{
    background:rgba(245,158,11,0.45);
    color:#fde68a;
}

/* Green — verified. Two rendering paths:
     1. Blade renders a non-interactive <span class="spin-verify-btn verified">
        in the post-verify success state (see spin-verify-prompt.blade.php).
     2. JS flips the button to this class on the rare already-verified
        edge case (other tab verified first) before reloading.
   Neither should look clickable, so cursor is reset. The success pill
   sizes to content (dropping the generous call-to-action min-width) so
   the "Verified" confirmation doesn't look absurdly wide next to a
   short countdown string. */
.spin-verify-btn.verified{
    background:rgba(22,163,74,0.28);
    color:#4ade80;
    border-color:rgba(74,222,128,0.45);
    animation:none;
    cursor:default;
    min-width:0;
    width:auto;
    padding:0.65rem 1.15rem;
    font-size:14px;
}

/* Transient states driven by JS during AJAX. */
.spin-verify-btn.sending{
    opacity:0.72;
    pointer-events:none;
    animation:none;
}
.spin-verify-btn.sent{
    background:rgba(22,163,74,0.22);
    color:#4ade80;
    border-color:rgba(74,222,128,0.4);
    animation:none;
}
.spin-verify-btn.failed{
    background:rgba(239,68,68,0.28);
    color:#fca5a5;
    border-color:rgba(248,113,113,0.55);
    animation:none;
}

@keyframes spin-verify-pulse{
    0%,100%{opacity:1;transform:scale(1);}
    50%{opacity:0.72;transform:scale(1.03);}
}

.spin-verify-error{
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    color:#fca5a5;
    min-height:18px;
    margin-top:-4px;
}

@media(max-width:500px){
    .spin-verify-prompt__title{font-size:19px;}
    .spin-verify-prompt__desc{font-size:13px;}
    .spin-verify-btn{
        font-size:13px;
        padding:0.75rem 1.1rem;
        min-width:0;
        width:100%;
    }
}

/* Transient status toast (demos remaining, errors) */
#msToast{
    position:fixed;left:50%;bottom:38px;transform:translate(-50%,20px);
    z-index:60;
    padding:10px 22px;border-radius:999px;
    background:rgba(10,14,30,0.85);
    -webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
    border:1px solid rgba(0,255,213,0.4);
    color:#fff;font-family:'Fredoka',sans-serif;font-size:13px;letter-spacing:0.5px;
    opacity:0;pointer-events:none;
    transition:opacity .25s ease,transform .35s cubic-bezier(.22,1,.36,1);
    box-shadow:0 10px 30px rgba(0,0,0,0.4);
}
#msToast.visible{opacity:1;transform:translate(-50%,0);}

/* Small inline spinner used by lazy-load UI (Dungeon button while
   fetching ~110 KB gz of engine, etc.). Colours track the current
   text colour, so it looks right on any button background. */
.ms-dot-spinner{display:inline-block;width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;vertical-align:middle;margin-right:4px;animation:ms-dot-spin 0.8s linear infinite}
@keyframes ms-dot-spin{to{transform:rotate(360deg)}}

/* ── GamifEYE A Location CTA + inline importer ──
   Second CTA pill that sits directly under #spinGlobeBtn on the
   landing intro modal and, when clicked by an authed user, swaps the
   modal's bottom block out for the shared gamifeye-importer form.

   The --secondary modifier softens the pulsing cyan ring of the base
   pill into a magenta accent so the two CTAs read as sibling actions
   rather than one primary + one louder primary. Disabled state is
   shared across both so a logged-out user sees the new pill faded to
   50% with a "Login Required" subtitle instead of being redirected. */
#spinIntroModal .spin-intro-cta{
    flex-direction:column;
    /* Stretch so every pill inside hits the column's full width —
       the pills' own content (label + sub-text) no longer dictates
       their width, so all three CTAs end up visually identical in
       size regardless of how long their sub-labels are or which
       auth-state subtitle is rendered. Paired with a bounded
       max-width on the inner pills below so the row still feels
       like a card element rather than edge-to-edge slabs. */
    align-items:stretch;
    /* Extra breathing room between the CTAs (was 10px). Matches the
       cadence used between the title block and the CTA group above
       so the whole intro reads as a relaxed, evenly-spaced stack. */
    gap:18px;
}
/* Equalise every pill inside the intro-modal CTA group. The base
   .spin-globe-pill rule further up is inline-flex + content-sized,
   which is ideal for the standalone floating button but leaves the
   three stacked CTAs here at different widths depending on their
   label / sub-text lengths (and the auth vs guest copy). Inside
   this scope we force a uniform box: fixed max-width, 100% column
   fill, and a fixed min-height so the label + sub stay vertically
   centred even when one pill renders a shorter sub-line. Padding
   drops the horizontal component since width is now driven by the
   column, not by the text. */
#spinIntroModal .spin-intro-cta .spin-globe-pill{
    display:flex;
    width:100%;
    max-width:320px;
    align-self:center;
    padding:16px 24px;
    min-height:72px;
    justify-content:center;
    text-align:center;
}
@media(max-width:500px){
    #spinIntroModal .spin-intro-cta{gap:14px;}
    #spinIntroModal .spin-intro-cta .spin-globe-pill{
        max-width:100%;
        padding:14px 22px;
        min-height:64px;
    }
}
.spin-globe-pill--secondary{
    animation:none;
    background:rgba(25,10,30,0.55);
    border-color:rgba(233,30,140,0.55);
    box-shadow:0 10px 40px rgba(233,30,140,0.22),inset 0 0 0 1px rgba(255,255,255,0.05);
}
.spin-globe-pill--secondary:hover{
    border-color:rgba(233,30,140,0.9);
    box-shadow:0 14px 50px rgba(233,30,140,0.38),0 0 0 6px rgba(233,30,140,0.1);
}
.spin-globe-pill--secondary:focus-visible{
    outline-color:#ff2bd6;
}
/* Third pill variant — warm amber accent for the "GamifEYED Towns"
   CTA so the three stacked pills form a three-colour neon set
   (cyan primary / magenta secondary / amber tertiary) and each is
   distinguishable at a glance. Mirrors the --secondary block above
   exactly in structure so all three variants share one motion /
   hover / focus language; only the accent colour channel changes. */
.spin-globe-pill--tertiary{
    animation:none;
    background:rgba(30,22,5,0.55);
    border-color:rgba(255,189,43,0.55);
    box-shadow:0 10px 40px rgba(255,189,43,0.22),inset 0 0 0 1px rgba(255,255,255,0.05);
}
.spin-globe-pill--tertiary:hover{
    border-color:rgba(255,189,43,0.9);
    box-shadow:0 14px 50px rgba(255,189,43,0.38),0 0 0 6px rgba(255,189,43,0.1);
}
.spin-globe-pill--tertiary:focus-visible{
    outline-color:#ffbd2b;
}
/* Applies to both .spin-globe-pill and the --secondary variant. */
.spin-globe-pill[disabled],
.spin-globe-pill[aria-disabled="true"]{
    opacity:0.5;
    cursor:not-allowed;
    pointer-events:none;
    animation:none;
}
/* "Login required" state for the guest GamifEYE-A-Location CTA.
   Matches the disabled look (50% opacity, no pulse) but stays
   clickable: a click opens the inline auth panel on the same
   card. Keep `cursor:pointer` explicit and DON'T set
   `pointer-events:none` so the button receives events. */
.spin-globe-pill.is-login-required{
    opacity:0.5;
    cursor:pointer;
    animation:none;
}
.spin-globe-pill.is-login-required:hover{
    opacity:0.75;
}

/* ── Landing-mounted .gfe-importer container ──
   Hidden until the "GamifEYE A Location" CTA adds .is-gfeimport to
   the body. When visible, the original name/desc/CTA block collapses
   out of flow so the importer fills the same real estate. The form
   itself is styled by /css/gamifeye-importer.css, which is emitted
   @once by partials/gamifeye-importer.blade.php — so both the landing
   modal and the dashboard sheet render the same form visually. */
#spinIntroModal .spin-intro-gfeimport{
    display:none;
    flex-direction:column;
    padding:2px;
}
#spinIntroModal .spin-intro-body.is-gfeimport .spin-intro-name,
#spinIntroModal .spin-intro-body.is-gfeimport .spin-intro-desc,
#spinIntroModal .spin-intro-body.is-gfeimport .spin-intro-cta{
    display:none;
}
#spinIntroModal .spin-intro-body.is-gfeimport .spin-intro-gfeimport{
    display:flex;
}

/* ── Landing-mounted .gfe-towns container ──
   Parallel to .spin-intro-gfeimport above. Hidden until the
   "GamifEYED Towns" CTA (#gamifeyeTownsBtn) adds .is-gfetowns to
   the body; at that point the welcome title/desc + primary CTA
   group collapse out and the tabbed towns panel takes over the
   same real estate. Tab visuals live further down and mirror the
   .gfe-loc-tabs treatment used by the importer partial so both
   sub-views share one tab language. */
#spinIntroModal .spin-intro-gfetowns{
    display:none;
    flex-direction:column;
    padding:2px;
    /* Part of the flex-fill chain that lets .gfe-towns-list claim
       whatever vertical space is left in the card after the hero,
       tabs, and title/description have taken theirs. Without
       flex:1 + min-height:0 the list would fall back to its
       content height and the last row would get clipped by the
       card's 9/16 aspect-ratio cap + max-height:92vh on tall
       users' lists. */
    flex:1 1 auto;
    min-height:0;
}
#spinIntroModal .spin-intro-body.is-gfetowns .spin-intro-name,
#spinIntroModal .spin-intro-body.is-gfetowns .spin-intro-desc,
#spinIntroModal .spin-intro-body.is-gfetowns .spin-intro-cta{
    display:none;
}
#spinIntroModal .spin-intro-body.is-gfetowns .spin-intro-gfetowns{
    display:flex;
}

/* Tabbed "Your Places" / "Help" panel for the GamifEYED Towns
   sub-view. Classes mirror the .gfe-importer .gfe-loc-tabs pattern
   in /css/gamifeye-importer.css so the two sub-views read with the
   same tab affordance — magenta active underline, muted inactive
   state, Fredoka throughout. Styles are scoped under
   #spinIntroModal .gfe-towns so they don't leak to any other
   .gfe-towns mount we might add later. */
#spinIntroModal .gfe-towns{
    display:flex;
    flex-direction:column;
    gap:16px;
    font-family:'Fredoka',sans-serif;
    text-align:left;
    width:100%;
    /* Fill the .spin-intro-gfetowns parent so the active panel
       below can in turn fill us. Continues the flex-fill chain
       described on .spin-intro-gfetowns. */
    flex:1 1 auto;
    min-height:0;
}
#spinIntroModal .gfe-towns-tabs{
    display:flex;
    gap:0;
    margin:0 0 6px;
    border-bottom:1px solid rgba(255,255,255,0.12);
}
#spinIntroModal .gfe-towns-tab{
    flex:1;
    background:none;
    border:none;
    border-bottom:2px solid transparent;
    padding:11px 8px;
    color:rgba(255,255,255,0.55);
    font-family:'Fredoka',sans-serif;
    font-size:13px;
    font-weight:500;
    letter-spacing:0.3px;
    cursor:pointer;
    transition:color .15s, border-color .15s;
    display:inline-flex;
    align-items:center;
    justify-content:center;
    gap:8px;
}
#spinIntroModal .gfe-towns-tab:hover:not(.active){
    color:rgba(255,255,255,0.85);
}
#spinIntroModal .gfe-towns-tab.active{
    color:#ff2bd6;
    border-bottom-color:#ff2bd6;
    font-weight:600;
}
#spinIntroModal .gfe-towns-panel{
    display:none;
    flex-direction:column;
    gap:14px;
}
#spinIntroModal .gfe-towns-panel.is-active{
    display:flex;
    /* Final link of the flex-fill chain so the list inside can
       grow to fill the card. .gfe-towns-head above it keeps its
       natural height via flex:0; only .gfe-towns-list carries
       flex:1. */
    flex:1 1 auto;
    min-height:0;
}
/* Head (title + description) stays at its natural content height
   regardless of how tall the list wants to be. */
#spinIntroModal .gfe-towns-panel.is-active .gfe-towns-head{
    flex:0 0 auto;
}
#spinIntroModal .gfe-towns-head{
    display:flex;
    flex-direction:column;
    gap:8px;
    text-align:center;
    padding:8px 0 4px;
}
#spinIntroModal .gfe-towns-title{
    margin:0;
    color:#ff2bd6;
    font-size:19px;
    font-weight:700;
    line-height:1.3;
    letter-spacing:0.2px;
}
#spinIntroModal .gfe-towns-desc{
    margin:0;
    font-size:13px;
    line-height:1.45;
    color:rgba(255,255,255,0.65);
    font-family:inherit;
}

/* ── GamifEYED Towns list ──
   Per-town cards under the "Your Places" tab head. Styles below are
   a direct port of the .territory-modal-item family used by the
   dashboard's #territoryModalOverlay slider (see
   resources/views/user/dashboard.blade.php:~601) so the landing
   surface renders cards that read identically to the ones on /aura.
   Scoped under #spinIntroModal so if we ever re-use the same class
   names elsewhere on the site we don't inherit the dashboard-tuned
   treatment by accident. */
#spinIntroModal .gfe-towns-list{
    display:flex;
    flex-direction:column;
    gap:0.85rem;
    /* Flex-fill the remaining vertical space inside the active
       panel so the list always ends ABOVE the card's bottom edge
       — no matter how many towns the user owns. Previously we
       capped with max-height:360px and let content overflow the
       card (the last row was getting clipped on portrait
       viewports where the 9/16 card isn't tall enough to hold
       hero + tabs + head + 360px). min-height:0 is needed for
       the flex-fill chain to release the list from its
       intrinsic height.
       A generous max-height is kept as an upper bound so on
       very tall desktop viewports the list doesn't stretch into
       a giant block — 560px mirrors the usual 3.5-ish-row
       comfortable reading window. */
    flex:1 1 auto;
    min-height:0;
    max-height:560px;
    overflow-y:auto;
    overflow-x:hidden;
    -webkit-overflow-scrolling:touch;
    /* Vertical padding matches the feather distance so the first/last
       card has room to fade in/out cleanly without its corner radii
       getting clipped by the mask. Left gets just 2px to keep the
       hover/focus border ring inside the container; right gets 10px
       so the custom 6px scrollbar sits in its own gutter with a
       small breathing gap between the track and each card's right
       edge (otherwise the thumb visually collides with the card
       borders, as reported by @james on landing). */
    padding:20px 10px 20px 2px;
    /* Feathered top + bottom edges — the list fades in from transparent
       over the first/last 20px of scroll so cards appear to emerge from
       the tab head above and dissolve into the card footer below. Uses
       mask-image rather than overlaid gradient elements so the effect
       tracks the card's actual background colour (whatever the modal is
       sitting on) instead of hard-coding a solid fill to match. Both
       -webkit- and standard properties emitted for Safari. */
    -webkit-mask-image:linear-gradient(
        to bottom,
        transparent 0,
        #000 20px,
        #000 calc(100% - 20px),
        transparent 100%
    );
    mask-image:linear-gradient(
        to bottom,
        transparent 0,
        #000 20px,
        #000 calc(100% - 20px),
        transparent 100%
    );
    /* Custom thin scrollbar so the dark-on-dark scroll affordance is
       legible without overpowering the cards themselves. */
    scrollbar-width:thin;
    scrollbar-color:rgba(255,255,255,0.18) transparent;
}
#spinIntroModal .gfe-towns-list::-webkit-scrollbar{width:6px;}
#spinIntroModal .gfe-towns-list::-webkit-scrollbar-thumb{
    background:rgba(255,255,255,0.18);
    border-radius:3px;
}
#spinIntroModal .territory-modal-item{
    display:flex;
    align-items:stretch;
    gap:0;
    background:rgba(255,255,255,0.06);
    border:1px solid rgba(96,165,250,0.25);
    border-radius:10px;
    cursor:pointer;
    transition:background .15s,border-color .15s;
    overflow:hidden;
    flex-shrink:0;
    touch-action:pan-y;
    text-decoration:none;
    color:inherit;
    font-family:'Fredoka',sans-serif;
}
#spinIntroModal .territory-modal-item:hover,
#spinIntroModal .territory-modal-item:active{
    background:rgba(96,165,250,0.15);
    border-color:#60a5fa;
}
#spinIntroModal .territory-modal-item-thumb{
    width:86px;
    min-height:86px;
    border-radius:10px 0 0 10px;
    object-fit:cover;
    flex-shrink:0;
}
#spinIntroModal .territory-modal-item-icon{
    width:86px;
    min-height:86px;
    border-radius:10px 0 0 10px;
    display:flex;
    align-items:center;
    justify-content:center;
    background:linear-gradient(135deg,#1e3a5f,#0f172a);
    color:#60a5fa;
    font-size:1.4rem;
    flex-shrink:0;
}
#spinIntroModal .territory-modal-item-body{
    flex:1;
    min-width:0;
    padding:0.45rem 0.5rem;
}
#spinIntroModal .territory-modal-item-name{
    font-size:0.78rem;
    font-weight:700;
    color:#e2e8f0;
    display:flex;
    align-items:center;
    gap:0.3rem;
    margin-bottom:0.15rem;
}
#spinIntroModal .territory-modal-item-country{
    font-size:0.62rem;
    color:#94a3b8;
    margin-bottom:0.1rem;
}
#spinIntroModal .territory-modal-item-extract{
    font-size:0.6rem;
    color:#cbd5e1;
    line-height:1.35;
    margin-bottom:0.15rem;
    display:-webkit-box;
    -webkit-line-clamp:2;
    -webkit-box-orient:vertical;
    overflow:hidden;
}
#spinIntroModal .territory-modal-item-meta{
    font-size:0.58rem;
    color:#64748b;
    display:flex;
    gap:0.4rem;
    flex-wrap:wrap;
}
#spinIntroModal .territory-modal-item-actions{
    flex-shrink:0;
    display:flex;
    align-items:center;
    padding:0 0.5rem;
}

/* Empty state for the "Your Places" tab when the user has zero
   claimed towns. Lower visual weight than a regular card — informs
   the user what they need to do to populate the list. */
#spinIntroModal .gfe-towns-empty{
    text-align:center;
    padding:24px 16px;
    color:rgba(255,255,255,0.6);
    font-family:'Fredoka',sans-serif;
}
#spinIntroModal .gfe-towns-empty-icon{
    font-size:32px;
    color:rgba(255,255,255,0.3);
    margin-bottom:8px;
}
#spinIntroModal .gfe-towns-empty h4{
    margin:0 0 4px;
    color:#fff;
    font-size:15px;
    font-weight:600;
}
#spinIntroModal .gfe-towns-empty p{
    margin:0;
    font-size:13px;
    line-height:1.4;
}

/* ── Hero-area merge tray ──
   Replaces the Lens/Aura balance overlays when the GamifEYED Towns
   sub-view is active. Positioned in the same centred slot inside
   .spin-hero-wrap as .spin-hero-overlays so we can crossfade one for
   the other without any layout shift. Only rendered by Blade when
   the user has at least 3 claimed towns (the server requires 3+ to
   accept a merge request). Uses the same colour language as the
   dashboard's .merge-tray-dash — blue accents, Fredoka, glassy dark
   panel — so the merge affordance reads the same on both surfaces,
   but the container silhouette is compacted (rounded card sitting
   ~70% of the hero width) to fit the landing modal's portrait hero. */
#spinIntroModal .spin-hero-merge-tray{
    position:absolute;
    top:50%;left:50%;
    /* Starts down-scaled so the tray zooms up from the hero's
       centre point when the GamifEYED Towns view opens. The scale
       component is combined with the existing translate(-50%,-50%)
       centring transform so the panel's midpoint is its transform-
       origin — it grows symmetrically out of nothing rather than
       sliding in from a corner. */
    transform:translate(-50%,-50%) scale(0.6);
    /* Narrower than the hero width so the tray reads as a
       floating panel rather than a full-width banner. Clamp at
       340px on wider viewports and leave ~22% breathing room on
       each side of narrower ones. Tighter still at the <=500px
       breakpoint below. */
    width:min(78%,340px);
    z-index:1;
    display:flex;
    flex-direction:column;
    gap:6px;
    padding:10px 14px 11px;
    border:2px solid rgba(96,165,250,0.5);
    border-radius:14px;
    background:rgba(10,14,30,0.75);
    -webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
    box-shadow:0 10px 30px rgba(0,0,0,0.45), 0 0 18px rgba(96,165,250,0.25);
    font-family:'Fredoka',sans-serif;
    /* Hidden by default — CSS below fades the tray in as soon as the
       GamifEYED Towns sub-view is opened. Pointer-events match the
       opacity so the hidden tray never swallows clicks on the pills
       underneath. */
    opacity:0;
    pointer-events:none;
    /* Opacity crossfades on the shared .35s cadence used by the
       rest of this card; the transform uses a slightly longer
       duration with a back-eased cubic-bezier so the scale-up
       lands with a tiny overshoot (satisfying "pop" feel) rather
       than a linear ramp that would read as floaty. */
    transition:opacity .35s ease, transform .45s cubic-bezier(.34,1.56,.64,1);
}

/* Crossfade trigger. When .is-gfetowns appears on .spin-intro-body,
   fade out the Lens/Aura overlays and fade in the merge tray. Both
   ends of the crossfade have the same .35s duration so they read as
   a single coordinated swap rather than two independent motions.
   :has() matches the established pattern further up in this file
   (see the .spin-intro-lang / .spin-intro-gfeclose swap rules). */
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfetowns) .spin-hero-overlays{
    opacity:0;
    pointer-events:none;
    transition:opacity .35s ease;
}
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfetowns) .spin-hero-merge-tray{
    opacity:1;
    transform:translate(-50%,-50%) scale(1);
    pointer-events:auto;
}
/* Dim the hero art AFTER the merge tray has finished zooming in,
   so the tray lands on a bright backdrop and the backdrop then
   recedes — keeping the tray as the unambiguous focal point.
   The transition-delay lines up with the tray's .45s transform
   duration so the two motions are sequenced (tray lands → hero
   dims) rather than happening in parallel.
   Only the TWO alt layers that are actually visible in this sub-
   view get dimmed to 50% — the authed operator art underneath
   and the parachute-descent art on top. The guest base mascot
   layer is driven to opacity 0 instead (partner rule below) so
   it doesn't ghost through the transparent regions of the
   authed/towns PNGs at 50%. The other .spin-hero-alt-* layers
   stay at their gated opacity:0 and are unaffected. */
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfetowns) .spin-hero-alt-authed,
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfetowns) .spin-hero-alt-towns{
    opacity:0.5;
    transition:opacity .35s ease .45s;
}
/* Hide the guest base hero while the towns sub-view is open —
   same motivation as the spinHeroHide rule further up for the
   default authed view: the authed/towns layers on top have
   alpha transparency, so a dimmed base at 50% would bleed
   through as a ghost of the guest mascot. 0 eliminates that. */
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfetowns) .spin-hero:not(.spin-hero-alt){
    opacity:0;
    transition:opacity .35s ease .45s;
}
/* The original slam keyframes on .spin-lens-overlay / .spin-aura-overlay
   set opacity:1 via fill-mode:forwards once the modal first opens.
   When we fade the whole .spin-hero-overlays group, those per-pill
   opacity:1 end states would normally win against our opacity:0 on
   the wrapper — but browsers multiply child and ancestor opacity, so
   setting opacity on the wrapper is sufficient. Define the wrapper's
   default transition here too so the fade-back-in is also smooth
   when the user closes the towns view. */
#spinIntroModal .spin-hero-overlays{
    transition:opacity .35s ease;
}

/* Port of the dashboard's .merge-tray-dash-* styles, scoped under
   #spinIntroModal so they don't leak to any other page. The compact
   tray reuses the same internal rhythm — title row + hint pill, drop
   zone next to a solid Merge button, warning line under — so a user
   familiar with the dashboard slider recognises the UI at a glance. */
#spinIntroModal .merge-tray-dash-title{
    display:flex;
    align-items:center;
    gap:0.35rem;
    font-size:0.72rem;
    font-weight:700;
    color:#e2e8f0;
    margin:0;
    flex-wrap:wrap;
}
#spinIntroModal .merge-tray-dash-title i{color:#60a5fa;}
#spinIntroModal .merge-tray-dash-hint{
    font-weight:500;
    color:#94a3b8;
    font-size:0.64rem;
    margin-left:0.2rem;
    display:inline-flex;
    gap:0.3rem;
    align-items:center;
    flex-wrap:wrap;
}
#spinIntroModal .merge-tray-dash-hint strong{color:#e2e8f0;font-weight:700;}
#spinIntroModal .merge-tray-dash-row{
    display:flex;
    gap:0.4rem;
    align-items:stretch;
}
#spinIntroModal .merge-tray-dash-drop{
    flex:1;
    min-width:0;
    min-height:42px;
    border:2px dashed rgba(148,163,184,0.4);
    border-radius:10px;
    padding:0.3rem 0.4rem;
    display:flex;
    flex-wrap:wrap;
    gap:0.3rem;
    align-items:center;
    background:rgba(15,23,42,0.6);
    transition:border-color .15s, background .15s;
}
/* Sortable.js inserts a ghost copy of the dragged item into the
   drop zone whenever the pointer enters it. Because our drop is
   flex-wrap:wrap + content-sized, that ghost (a full 86px-tall
   .territory-modal-item) pushed the drop zone several times its
   natural height and made the tray "jump" on every hover. Pull
   the ghost out of the flow so the drop keeps its static height
   and the hover feedback is purely the pulse below.
   We also hide any .territory-modal-item that ends up in the
   drop zone after onAdd (we call addToTray instead of keeping
   the DOM node, but belt-and-braces this covers the millisecond
   between Sortable's DOM insertion and our removeChild()). */
#spinIntroModal .merge-tray-dash-drop .sortable-ghost,
#spinIntroModal .merge-tray-dash-drop .territory-modal-item{
    display:none !important;
}
#spinIntroModal .merge-tray-dash-drop.is-over{
    border-color:#60a5fa;
    background:rgba(96,165,250,0.12);
}
/* Whole-tray pulse while a drag is over the drop zone. Anchored
   on .spin-hero-merge-tray via :has() so the entire panel glows
   (title row, drop zone, Merge button) rather than just the
   inner drop rectangle — gives the user a single obvious "I can
   drop here" affordance instead of a small inset cue.

   Uses a box-shadow ring that expands + fades rather than scaling
   the element so the tray's width/height stay frozen and nothing
   around it (hero, overlays, intro body) has to reflow on every
   pulse cycle. The base tray already declares a resting
   box-shadow; we keep its first two layers (drop + inner glow)
   and append the animated expanding ring as the fourth layer so
   the two compose cleanly without one overwriting the other. */
#spinIntroModal .spin-hero-merge-tray:has(.merge-tray-dash-drop.is-over){
    animation:introMergeTrayPulse 1.1s ease-in-out infinite;
}
@keyframes introMergeTrayPulse{
    0%{
        box-shadow:
            0 10px 30px rgba(0,0,0,0.45),
            0 0 18px rgba(96,165,250,0.25),
            0 0 0 0 rgba(96,165,250,0.55);
    }
    70%{
        box-shadow:
            0 10px 30px rgba(0,0,0,0.45),
            0 0 18px rgba(96,165,250,0.25),
            0 0 0 14px rgba(96,165,250,0);
    }
    100%{
        box-shadow:
            0 10px 30px rgba(0,0,0,0.45),
            0 0 18px rgba(96,165,250,0.25),
            0 0 0 0 rgba(96,165,250,0);
    }
}
/* Honour reduced-motion: keep the drop zone's colour swap (so it
   still reads as "active") but drop the tray's shadow pulse. */
@media (prefers-reduced-motion: reduce){
    #spinIntroModal .spin-hero-merge-tray:has(.merge-tray-dash-drop.is-over){
        animation:none;
    }
}
#spinIntroModal .merge-tray-dash-drop.is-empty::before{
    content:'Drag a town here to merge';
    color:#64748b;
    font-size:0.66rem;
    padding:0 0.25rem;
}
#spinIntroModal .merge-pill-dash{
    display:inline-flex;
    align-items:center;
    gap:0.25rem;
    background:rgba(96,165,250,0.18);
    border:1px solid rgba(96,165,250,0.45);
    color:#dbeafe;
    font-size:0.65rem;
    font-weight:600;
    padding:0.15rem 0.3rem 0.15rem 0.45rem;
    border-radius:999px;
    max-width:180px;
}
#spinIntroModal .merge-pill-dash-name{
    max-width:110px;
    overflow:hidden;
    text-overflow:ellipsis;
    white-space:nowrap;
}
#spinIntroModal .merge-pill-dash-listed{
    background:#fde68a;
    color:#7c2d12;
    font-size:0.54rem;
    font-weight:700;
    padding:0.02rem 0.25rem;
    border-radius:999px;
}
#spinIntroModal .merge-pill-dash-x{
    border:none;
    background:transparent;
    color:#cbd5e1;
    cursor:pointer;
    font-size:0.9rem;
    line-height:1;
    padding:0 0.05rem;
}
#spinIntroModal .merge-pill-dash-x:hover{color:#fff;}
#spinIntroModal .merge-btn-dash{
    border:none;
    border-radius:10px;
    padding:0 0.9rem;
    font-family:'Fredoka',sans-serif;
    font-weight:700;
    font-size:0.78rem;
    cursor:pointer;
    transition:transform .15s, box-shadow .15s, opacity .15s;
    align-self:stretch;
}
#spinIntroModal .merge-btn-dash-primary{
    background:linear-gradient(135deg,#60a5fa,#3b82f6);
    color:#fff;
    box-shadow:0 2px 8px rgba(59,130,246,0.35);
}
#spinIntroModal .merge-btn-dash-primary:hover:not(:disabled){transform:translateY(-1px);}
#spinIntroModal .merge-btn-dash-primary:disabled{opacity:.45;cursor:not-allowed;}
#spinIntroModal .merge-btn-dash-secondary{
    background:rgba(148,163,184,0.2);
    color:#cbd5e1;
}
#spinIntroModal .merge-btn-dash-secondary:hover{background:rgba(148,163,184,0.32);}
#spinIntroModal .merge-tray-dash-warn{
    margin:0;
    color:#fca5a5;
    font-size:0.66rem;
    line-height:1.35;
}

/* NOTE: the dashboard's .tmi-merge-btn (circular + button that
   sat on the right of each card) is intentionally omitted from
   this stylesheet. On the landing modal every card is a drag
   handle, so the redundant tap-to-add affordance was removed
   from the markup in ping-landing.blade.php. If we ever bring
   tap-to-add back we should lift the dashboard rule verbatim. */

/* Selected-state + drag-state card treatments.

   Parked state (in-tray): unlike the dashboard — which dims the
   source card to a 60% dashed-border ghost so the slider still
   shows the full inventory — the landing modal HIDES the source
   card entirely while it's in the merge tray. On the landing
   surface the tray IS the authoritative "selected" view, and the
   user explicitly asked for the list to only show
   not-yet-selected towns (cards reappear the moment the user
   removes the pill from the tray via the × button or the
   resetIntroMergeTray() reset-on-close hook).

   Drag state (sortable-ghost / sortable-chosen): unchanged from
   the dashboard — the ghost placeholder still occupies the
   dragged card's old slot at 35% opacity, and the chosen card
   gets the blue focus border while it's picked up. */
#spinIntroModal .territory-modal-item.in-tray{
    display:none;
}
#spinIntroModal .territory-modal-item.sortable-ghost{opacity:.35;}
#spinIntroModal .territory-modal-item.sortable-chosen{border-color:#60a5fa;}

/* Merge preview overlay. Same structural port as above — the
   dashboard's #mergePreviewOverlay visually, minus the slot under
   a specific page layout. This sits above everything else
   (z-index:1080) so the preview can paint cleanly on top of the
   landing modal itself. */
.merge-preview-overlay{
    display:none;
    position:fixed;
    inset:0;
    background:rgba(0,0,0,0.75);
    z-index:1080;
    align-items:center;
    justify-content:center;
    padding:1rem;
    font-family:'Fredoka',sans-serif;
}
.merge-preview-overlay.is-open{display:flex;}
.merge-preview{
    width:100%;
    /* Portrait card sized by its own content — the square hero
       on top + title + candidate card + single-row stats +
       actions naturally produce a portrait shape, so we don't
       force a hard aspect-ratio (which was clipping the body
       and producing a vertical scrollbar on the 9:16 lock).
       max-height:92vh is still a safety cap for short viewports;
       if content ever exceeds it the interior scrolls but we
       hide the scrollbar gutter below so the card edge stays
       clean against the overlay dim. */
    max-width:380px;
    max-height:92vh;
    overflow-y:auto;
    background:#0f172a;
    border:2px solid #60a5fa;
    border-radius:18px;
    padding:1.25rem;
    color:#e2e8f0;
    position:relative;
    display:flex;
    flex-direction:column;
    /* Visually hide the vertical scrollbar while keeping the
       track scrollable on wheel / touch. The preview almost
       never needs to scroll at 92vh on a modern viewport, but
       when it does we don't want a gutter stripe down the right
       edge against the magenta border ring. */
    scrollbar-width:none;
}
.merge-preview::-webkit-scrollbar{width:0;height:0;display:none;}
.merge-preview h3{
    margin:0 0 0.3rem;
    font-size:1.05rem;
    color:#fff;
    display:flex;
    align-items:center;
    gap:0.35rem;
}
.merge-preview h3 i{color:#60a5fa;}
.merge-preview-sub{
    font-size:0.78rem;
    color:#94a3b8;
    margin-bottom:0.9rem;
}
/* Close button sits on top of the hero map now (the hero bleeds
   to the card edges and pushes the close out of the plain card
   background it used to sit on). Wrap it in a semi-opaque dark
   pill so it stays legible against any basemap colour the
   candidate happens to fly into, with a blur to soften the
   tile contrast behind it. */
.merge-preview-close{
    position:absolute;
    top:10px;right:12px;
    z-index:3;
    width:30px;height:30px;
    display:flex;
    align-items:center;
    justify-content:center;
    background:rgba(15,23,42,0.7);
    -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
    border:1px solid rgba(255,255,255,0.15);
    border-radius:50%;
    color:#e2e8f0;
    font-size:1.1rem;
    line-height:1;
    cursor:pointer;
    transition:background .15s, transform .15s;
}
.merge-preview-close:hover{
    background:rgba(15,23,42,0.9);
    transform:scale(1.05);
}

/* Zoom-mode toggle mirrors the close pill on the opposite
   corner of the hero map. Same dark-blur treatment so the two
   corner affordances read as a matched pair regardless of what
   basemap colour the fly-in lands on. JS flips the inner icon
   between bi-arrows-angle-expand (currently at boundary, click
   to zoom out) and bi-arrows-angle-contract (currently at
   country, click to zoom back in), and toggles aria-label to
   match. Hidden until the map is live — spinUpHeroMap flips
   display on, teardownHeroMap flips it off again. */
.merge-preview-hero-zoom-toggle{
    position:absolute;
    top:10px;left:12px;
    z-index:3;
    width:30px;height:30px;
    display:flex;
    align-items:center;
    justify-content:center;
    background:rgba(15,23,42,0.7);
    -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
    border:1px solid rgba(255,255,255,0.15);
    border-radius:50%;
    color:#e2e8f0;
    font-size:0.95rem;
    line-height:1;
    cursor:pointer;
    transition:background .15s, transform .15s;
    padding:0;
}
.merge-preview-hero-zoom-toggle:hover{
    background:rgba(15,23,42,0.9);
    transform:scale(1.05);
}
.merge-preview-hero-zoom-toggle:disabled{
    opacity:0.6;
    cursor:progress;
    transform:none;
}

/* ── Merge-swap-confirmed slam stamp ──
   Mirrors the territory-claimed stamp on #spinModal .spin-card.won
   so the two celebratory beats share one motion language: a big
   drop-in scale from 2.4× to 1×, with the same cubic-bezier bounce
   and the same cyan palette. Fires only when the confirm handler
   adds .is-confirmed to .merge-preview-hero, sits above the shade
   layer (z-index:4 > shade's 1 > zoom pill + close pill's 3) so
   neither corner affordance can ever cover the stamp. Once the
   slam lands, the parent .is-confirmed also dims the rest of the
   hero (shade + map gain a dark-cyan tint) so the stamp owns the
   visual focus until the window.location.reload() lands. */
.merge-preview-hero-stamp,
.merge-preview-stamp{
    position:absolute;
    top:50%;left:50%;
    transform:translate(-50%,-50%) rotate(-14deg) scale(0.4);
    display:flex;flex-direction:column;align-items:center;gap:2px;
    padding:14px 28px;
    border:4px solid #00ffd5;
    border-radius:10px;
    color:#00ffd5;
    font-family:'JetBrains Mono',ui-monospace,monospace;
    text-transform:uppercase;letter-spacing:2px;
    background:rgba(10,14,30,0.35);
    -webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);
    box-shadow:0 0 18px rgba(0,255,213,0.45), inset 0 0 12px rgba(0,255,213,0.2);
    text-shadow:0 2px 10px rgba(0,0,0,0.6);
    opacity:0;pointer-events:none;
    white-space:nowrap;
    z-index:4;
}
.merge-preview-stamp-top{
    font-size:10px;letter-spacing:4px;font-weight:500;opacity:0.8;
}
.merge-preview-stamp-main{
    /* Slightly smaller than the #spinModal stamp's 30px because
       the copy here ("CONFIRMED") is three characters longer than
       the other stamp's "CLAIMED" and would otherwise crowd the
       hero's square frame on narrow cards. */
    font-size:26px;font-weight:800;line-height:1;letter-spacing:3.5px;
}
.merge-preview-stamp-bottom{
    font-size:9px;letter-spacing:3px;font-weight:500;opacity:0.75;
}
.merge-preview-hero.is-confirmed .merge-preview-stamp{
    /* Identical cadence to spinStampSlam — .55s keyframe run after
       a .35s lead-in delay, cubic-bezier bounce past 1 and back.
       Keeping the delay gives the user a beat between their click
       and the slam so the confirmation doesn't feel instant; the
       Swapping… spinner on the button is what fills that beat. */
    animation:mergePreviewStampSlam .55s cubic-bezier(.2,.9,.3,1.3) .35s forwards;
}
@keyframes mergePreviewStampSlam{
    0%  {opacity:0;transform:translate(-50%,-50%) rotate(-14deg) scale(2.4);}
    60% {opacity:1;transform:translate(-50%,-50%) rotate(-14deg) scale(0.92);}
    80% {opacity:1;transform:translate(-50%,-50%) rotate(-14deg) scale(1.05);}
    100%{opacity:1;transform:translate(-50%,-50%) rotate(-14deg) scale(1);}
}
/* Cyan inner-glow around the hero frame on confirm, same treatment
   as #spinModal .spin-card.won .spin-hero-wrap::after so the "you
   just claimed something" visual language is consistent across
   first spin AND merge swaps. pointer-events:none so it never
   eats clicks on the zoom toggle or close button. */
.merge-preview-hero.is-confirmed::after{
    content:'';
    position:absolute;
    inset:0;
    box-shadow:inset 0 0 60px rgba(0,255,213,0.35);
    pointer-events:none;
    z-index:2;
    border-radius:inherit;
}

/* ── Merge-preview hero map ──
   Full-bleed top band of the 9:16 preview card. The 1.25rem card
   padding gets negative-margined away on all three edges (top,
   left, right) so the map edge-to-edges inside the rounded
   border, and the hero's own bottom margin reintroduces the gap
   before the title. Radius inherits the card's top corners
   (18px) so the map's rounded crown aligns with the border.

   Sized via aspect-ratio rather than a fixed height so the hero
   scales proportionally with the card's own responsive width —
   the card is capped at 380px but can compress narrower on
   phones, and aspect-ratio:16/9 keeps the hero roughly a third
   of the card's vertical real estate regardless.

   position:relative is needed so .merge-preview-hero-shade and
   .merge-preview-hero-fallback can absolutely position over
   the map canvas. */
.merge-preview-hero{
    position:relative;
    margin:-1.25rem -1.25rem 0.85rem;
    /* Square hero — gives the boundary-overlay fly-in much more
       vertical breathing room than the old 16:9 band did, so the
       candidate's shape reads clearly even for tall, narrow
       towns. Matches the expected ~342px × 342px square on the
       380px-wide portrait card (after the 1.25rem bleed). */
    aspect-ratio:1/1;
    overflow:hidden;
    border-radius:16px 16px 0 0;
    background:#0b1220;
    flex-shrink:0;
}
.merge-preview-hero-map{
    position:absolute;
    inset:0;
    /* Let MapLibre's own canvas fill the container. */
}
/* MapLibre injects a descendant <canvas> inside our map div —
   make sure our rounded corners win so the canvas doesn't paint
   over the crown. */
.merge-preview-hero-map canvas,
.merge-preview-hero-map .maplibregl-canvas-container{
    width:100%;height:100%;
}
/* Visible when the candidate has no lat/lng OR maplibre failed
   to initialise. Hidden the moment spinUpHeroMap commits to a
   live map. */
.merge-preview-hero-fallback{
    position:absolute;
    inset:0;
    display:flex;
    align-items:center;
    justify-content:center;
    background:linear-gradient(135deg,#1e3a5f,#0f172a);
    color:#60a5fa;
    font-size:3rem;
}
/* Bottom shade gradient so the "Confirm merge swap" title below
   reads cleanly regardless of how colourful the basemap gets
   where the candidate lands. Sits above the map but below the
   close pill — z-index:1 on the shade, 3 on the close button
   (declared above). */
.merge-preview-hero-shade{
    position:absolute;
    inset:auto 0 0 0;
    height:55%;
    z-index:1;
    pointer-events:none;
    background:linear-gradient(
        to bottom,
        rgba(15,23,42,0) 0%,
        rgba(15,23,42,0.55) 65%,
        rgba(15,23,42,1) 100%
    );
}
.merge-cand-dash{
    display:flex;
    gap:0.7rem;
    background:rgba(96,165,250,0.08);
    border:1px solid rgba(96,165,250,0.3);
    border-radius:12px;
    padding:0.65rem;
    margin-bottom:0.7rem;
}
.merge-cand-dash-thumb{
    width:72px;height:72px;
    border-radius:10px;
    object-fit:cover;
    flex-shrink:0;
    background:#0b1220;
}
.merge-cand-dash-icon{
    width:72px;height:72px;
    border-radius:10px;
    background:linear-gradient(135deg,#1e3a5f,#0f172a);
    display:flex;
    align-items:center;
    justify-content:center;
    font-size:1.5rem;
    color:#60a5fa;
    flex-shrink:0;
}
.merge-cand-dash-body{flex:1;min-width:0;}
.merge-cand-dash-name{
    font-size:0.95rem;
    font-weight:700;
    color:#e2e8f0;
    margin-bottom:0.15rem;
}
.merge-cand-dash-country{
    font-size:0.7rem;
    color:#94a3b8;
    margin-bottom:0.3rem;
}
.merge-cand-dash-extract{
    font-size:0.7rem;
    color:#cbd5e1;
    line-height:1.4;
    display:-webkit-box;
    -webkit-line-clamp:3;
    -webkit-box-orient:vertical;
    overflow:hidden;
}
.merge-stats-dash{
    display:grid;
    /* Single row of 4 equal columns — Combined / New area /
       Difference / Merged — matching the inline layout @james
       asked for on the landing merge modal. Falls back to a
       2-column grid on very narrow viewports (below 380px)
       via the media query further down so the labels stay
       legible instead of truncating into ellipses. */
    grid-template-columns:repeat(4,1fr);
    gap:0.4rem;
    margin-bottom:0.75rem;
}
.merge-stat-dash{
    background:rgba(148,163,184,0.08);
    border:1px solid rgba(148,163,184,0.2);
    border-radius:10px;
    /* Tighter padding than the original 0.45rem 0.6rem — four
       cells sharing a 440px preview width need every px to
       avoid wrapping the values onto a second line. */
    padding:0.4rem 0.45rem;
    min-width:0;
}
.merge-stat-dash-label{
    font-size:0.55rem;
    color:#94a3b8;
    text-transform:uppercase;
    letter-spacing:0.04em;
    white-space:nowrap;
    overflow:hidden;
    text-overflow:ellipsis;
}
.merge-stat-dash-val{
    font-size:0.78rem;
    font-weight:700;
    color:#fff;
    white-space:nowrap;
}
/* Fallback to 2×2 on very narrow viewports so the compacted
   4-up row doesn't squeeze values like "48.7 km²" into a
   sub-pixel column. */
@media(max-width:400px){
    .merge-stats-dash{
        grid-template-columns:1fr 1fr;
    }
    .merge-stat-dash-label{font-size:0.6rem;}
    .merge-stat-dash-val{font-size:0.85rem;}
}
.merge-listings-warn-dash{
    background:rgba(251,191,36,0.1);
    border:1px solid rgba(251,191,36,0.4);
    color:#fbbf24;
    border-radius:10px;
    padding:0.6rem 0.75rem;
    margin-bottom:0.75rem;
    font-size:0.72rem;
    line-height:1.4;
}
.merge-listings-warn-dash strong{
    display:block;
    margin-bottom:0.2rem;
    font-size:0.78rem;
}
.merge-listings-warn-dash ul{
    margin:0.2rem 0 0.35rem 1rem;
    padding:0;
}
.merge-listings-warn-dash label{
    display:flex;
    align-items:flex-start;
    gap:0.35rem;
    margin-top:0.3rem;
    color:#fcd34d;
    cursor:pointer;
}
.merge-listings-warn-dash input{margin-top:0.15rem;}
.merge-preview-err{
    display:none;
    background:rgba(239,68,68,0.1);
    border:1px solid rgba(239,68,68,0.4);
    color:#fca5a5;
    border-radius:10px;
    padding:0.5rem 0.7rem;
    font-size:0.72rem;
    margin-bottom:0.65rem;
}
.merge-preview-actions{
    display:flex;
    gap:0.5rem;
    justify-content:flex-end;
    /* .merge-preview is now display:flex;flex-direction:column to
       support the 9:16 portrait aspect ratio. margin-top:auto
       pins the Cancel/Confirm buttons to the bottom edge of the
       card no matter how short the candidate description or
       listings list happens to be, so the portrait sheet never
       has a big empty band beneath the action row. */
    margin-top:auto;
    padding-top:0.75rem;
}
/* Buttons inside the merge-preview overlay need a duplicated base
   rule — the primary .merge-btn-dash / .merge-btn-dash-primary /
   .merge-btn-dash-secondary rules further up in this file are
   scoped under "#spinIntroModal ", but the overlay is
   position:fixed at the document root (sibling, not descendant,
   of #spinIntroModal), so none of those rules match here. Without
   this block the Cancel + Confirm swap buttons fall back to
   default user-agent <button> styling. */
.merge-preview-actions .merge-btn-dash{
    border:none;
    border-radius:10px;
    padding:0.7rem 1.15rem;
    font-family:'Fredoka',sans-serif;
    font-weight:700;
    font-size:0.85rem;
    min-width:110px;
    cursor:pointer;
    transition:transform .15s, box-shadow .15s, opacity .15s, background .15s;
}
.merge-preview-actions .merge-btn-dash-primary{
    background:linear-gradient(135deg,#60a5fa,#3b82f6);
    color:#fff;
    box-shadow:0 2px 8px rgba(59,130,246,0.35);
}
.merge-preview-actions .merge-btn-dash-primary:hover:not(:disabled){
    transform:translateY(-1px);
}
.merge-preview-actions .merge-btn-dash-primary:disabled{
    opacity:.45;
    cursor:not-allowed;
}
.merge-preview-actions .merge-btn-dash-secondary{
    background:rgba(148,163,184,0.2);
    color:#cbd5e1;
}
.merge-preview-actions .merge-btn-dash-secondary:hover{
    background:rgba(148,163,184,0.32);
}

/* Sortable drag clone z-index. The dashboard ships these as global
   rules; we scope ours to avoid any accidental bleed into other
   Sortable instances the site might add later. Using higher
   z-index (110) than the merge tray (1) + modal chrome (10) so the
   dragged card always floats above everything while in flight. */
#spinIntroModal .sortable-drag{
    z-index:1100 !important;
    opacity:.95 !important;
    box-shadow:0 10px 30px rgba(0,0,0,0.5) !important;
}
.sortable-fallback{
    z-index:1100 !important;
    opacity:.95 !important;
    box-shadow:0 10px 30px rgba(0,0,0,0.5) !important;
    pointer-events:none;
}

/* ------------------------------------------------------------------
   Dragged-clone visual rescue.

   SortableJS (with forceFallback:true / fallbackOnBody:true, which
   is what the ping-landing merge script uses) creates a clone of
   the dragged card and appends it to <body>. That clone keeps its
   classes but is no longer a descendant of #spinIntroModal — so
   every rule we scoped to "#spinIntroModal .territory-modal-item…"
   above silently stops matching, and the user sees an unstyled
   <a> tag while they drag.

   We fix it by re-declaring the minimum visual styling targeted at
   ".gfe-towns-item" (a class unique to these landing-page cards,
   added in the blade loop alongside .territory-modal-item). This
   runs regardless of where in the DOM the clone currently lives,
   but can't pollute any other page because .gfe-towns-item is
   only emitted on ping-landing.

   We intentionally re-state layout + colours here rather than
   de-scoping the base rules, to keep the normal (undragged)
   styling locked to the modal as before.
-------------------------------------------------------------------*/
.gfe-towns-item.sortable-drag,
.gfe-towns-item.sortable-fallback{
    display:flex;
    align-items:stretch;
    gap:0;
    background:rgba(255,255,255,0.06);
    border:1px solid rgba(96,165,250,0.25);
    border-radius:10px;
    overflow:hidden;
    text-decoration:none;
    color:inherit;
    font-family:'Fredoka',sans-serif;
    /* SortableJS sets explicit width/height on the fallback clone
       to match the source card's bounding rect, so we don't need
       to pin a width here — the clone will already be sized right. */
}
.gfe-towns-item.sortable-drag .territory-modal-item-thumb,
.gfe-towns-item.sortable-fallback .territory-modal-item-thumb{
    width:86px;min-height:86px;
    border-radius:10px 0 0 10px;
    object-fit:cover;
    flex-shrink:0;
}
.gfe-towns-item.sortable-drag .territory-modal-item-icon,
.gfe-towns-item.sortable-fallback .territory-modal-item-icon{
    width:86px;min-height:86px;
    border-radius:10px 0 0 10px;
    display:flex;
    align-items:center;
    justify-content:center;
    background:linear-gradient(135deg,#1e3a5f,#0f172a);
    color:#60a5fa;
    font-size:1.4rem;
    flex-shrink:0;
}
.gfe-towns-item.sortable-drag .territory-modal-item-body,
.gfe-towns-item.sortable-fallback .territory-modal-item-body{
    flex:1;min-width:0;
    padding:0.45rem 0.5rem;
}
.gfe-towns-item.sortable-drag .territory-modal-item-name,
.gfe-towns-item.sortable-fallback .territory-modal-item-name{
    font-size:0.78rem;font-weight:700;
    color:#e2e8f0;
    display:flex;align-items:center;gap:0.3rem;
    margin-bottom:0.15rem;
}
.gfe-towns-item.sortable-drag .territory-modal-item-country,
.gfe-towns-item.sortable-fallback .territory-modal-item-country{
    font-size:0.62rem;color:#94a3b8;margin-bottom:0.1rem;
}
.gfe-towns-item.sortable-drag .territory-modal-item-extract,
.gfe-towns-item.sortable-fallback .territory-modal-item-extract{
    font-size:0.6rem;color:#cbd5e1;line-height:1.35;
    margin-bottom:0.15rem;
    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;
    overflow:hidden;
}
.gfe-towns-item.sortable-drag .territory-modal-item-meta,
.gfe-towns-item.sortable-fallback .territory-modal-item-meta{
    font-size:0.58rem;color:#64748b;
    display:flex;gap:0.4rem;flex-wrap:wrap;
}
.gfe-towns-item.sortable-drag .territory-modal-item-actions,
.gfe-towns-item.sortable-fallback .territory-modal-item-actions{
    flex-shrink:0;display:flex;align-items:center;padding:0 0.5rem;
}

/* Narrow viewports: tray gets slightly tighter padding + smaller
   Merge button so the whole row stays legible on phone widths. */
@media(max-width:500px){
    #spinIntroModal .spin-hero-merge-tray{
        width:min(82%,300px);
        padding:8px 10px 9px;
    }
    #spinIntroModal .merge-tray-dash-title{font-size:0.68rem;}
    #spinIntroModal .merge-tray-dash-hint{font-size:0.6rem;}
    #spinIntroModal .merge-btn-dash{font-size:0.72rem;padding:0 0.7rem;}
}

/* ── GamifEYE-importer top-right close pill ──
   Occupies the same hero top-right slot as .spin-intro-lang.
   Default state is display:none; the :has() rule below flips
   the swap once .is-gfeimport appears on the body — language
   switcher out, close button in — so the corner always carries
   a single, contextually-relevant action. */
#spinIntroModal .spin-intro-gfeclose{
    position:absolute;
    top:12px;right:12px;
    z-index:3;
    display:none;
    width:36px;height:36px;
    align-items:center;justify-content:center;
    border-radius:50%;
    border:1px solid rgba(255,255,255,0.2);
    background:rgba(10,14,30,0.55);
    -webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
    color:#fff;
    font-size:14px;
    cursor:pointer;
    transition:background .15s,border-color .15s,transform .15s;
}
#spinIntroModal .spin-intro-gfeclose:hover{
    background:rgba(255,43,214,0.25);
    border-color:rgba(255,43,214,0.55);
    transform:scale(1.06);
}
/* Swap visibility when EITHER sub-view is active:
     - .is-gfeimport  → authed GamifEYE importer
     - .is-authpanel  → guest login/register form
   In both cases we hide the language switcher and reveal the
   close pill, so the top-right corner always shows a single
   "back out of this sub-view" affordance instead of a stranded
   language picker. :has() is already relied upon elsewhere in
   this file (see the .is-authpanel aspect-ratio release rule
   further up) so no new browser-support concern is introduced. */
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfeimport) .spin-intro-lang,
#spinIntroModal .spin-card:has(.spin-intro-body.is-authpanel) .spin-intro-lang,
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfetowns) .spin-intro-lang{
    display:none;
}
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfeimport) .spin-intro-gfeclose,
#spinIntroModal .spin-card:has(.spin-intro-body.is-authpanel) .spin-intro-gfeclose,
#spinIntroModal .spin-card:has(.spin-intro-body.is-gfetowns) .spin-intro-gfeclose{
    display:inline-flex;
}

@media(max-width:500px){
    #spinIntroModal .spin-intro-gfeclose{
        top:10px;right:10px;
        width:32px;height:32px;
        font-size:13px;
    }
}

/* ──────────────────────────────────────────────────────────────
   GamifEYE detail modal (#gfeDetailModal)
   --------------------------------------------------------------
   Shown in place of the intro modal after a successful import or
   when the user clicks one of their existing GamifEYEs. Follows
   the #spinIntroModal's glass-backdrop + magenta-ringed card
   language, but enters from the TOP with a bouncy spring curve
   so the swap reads as "old modal flies up, new modal drops in",
   not just a second modal appearing in the same slot.

   Motion sequencing lives in public/js/ping-landing.js
   (pingLandingFocusOnGamifeye → hideIntro → setTimeout → show
   #gfeDetailModal). The delay matches .5s FLY_OUT_MS so the card
   below starts its descent as the intro card is still clearing
   the top of the viewport.
   ────────────────────────────────────────────────────────────── */
#gfeDetailModal{
    position:fixed;inset:0;
    z-index:41; /* one above #spinIntroModal (40) so the two can
                   co-exist momentarily during the handoff */
    display:none;
    align-items:center;justify-content:center;
    background:rgba(5,8,18,0.58);
    -webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
    opacity:0;
    transition:opacity .3s ease;
}
#gfeDetailModal.visible{display:flex;opacity:1;}

/* Same card shell as #spinIntroModal / #spinModal — matching
   9:16 story-card aspect, 420px wide, 92vh cap — so the
   intro-modal fly-out and the detail-modal bounce-in read as
   two shots of the same visual object rather than two
   differently-shaped cards. Values below are copied verbatim
   from the .spin-card rule (public/css/ping-landing.css ~L92)
   so future card-size tweaks only need to be made in one
   place if they're kept in sync. */
#gfeDetailModal .gfe-detail-card{
    position:relative;
    width:420px;max-width:90vw;
    aspect-ratio:9/16;
    max-height:92vh;
    border-radius:20px;
    overflow:hidden;
    background:#14182a;
    box-shadow:
        0 30px 80px rgba(0,0,0,0.6),
        0 0 0 2px #ff2bd6,
        0 0 28px rgba(255,43,214,0.38);
    font-family:'Fredoka',sans-serif;
    display:flex;
    flex-direction:column;
    color:#fff;
    /* Starting offscreen state — gfeDetailBounceIn overrides this
       once .visible + the animation run. */
    transform:translateY(-120vh) scale(0.94);
    opacity:0;
}
#gfeDetailModal.visible .gfe-detail-card{
    animation:gfeDetailBounceIn .8s cubic-bezier(.34,1.56,.64,1) forwards;
}
/* Matching exit motion (fly back up). Not currently wired but
   ready for a future "X swaps back to intro" path. */
#gfeDetailModal.is-flying-out.visible .gfe-detail-card{
    animation:gfeDetailFlyOut .45s cubic-bezier(.4,0,.2,1) forwards;
}
#gfeDetailModal.is-flying-out.visible{opacity:0;}

@keyframes gfeDetailBounceIn{
    /* Spring-loaded descent: card drops past its resting position,
       overshoots downward, then settles back up. The 60%/80%/100%
       triplet creates the visible bounce. */
    0%   {transform:translateY(-120vh) scale(0.94); opacity:0;}
    55%  {transform:translateY(18px)   scale(1.02); opacity:1;}
    75%  {transform:translateY(-8px)   scale(0.995);opacity:1;}
    90%  {transform:translateY(3px)    scale(1);    opacity:1;}
    100% {transform:translateY(0)      scale(1);    opacity:1;}
}
@keyframes gfeDetailFlyOut{
    0%   {transform:translateY(0)      scale(1);    opacity:1;}
    100% {transform:translateY(-120vh) scale(0.94); opacity:0;}
}

/* Close button: top-right glass chip. Positioned over the hero
   so it always has enough contrast against the darkened image. */
#gfeDetailModal .gfe-detail-close{
    position:absolute;
    top:12px;right:12px;
    z-index:3;
    width:36px;height:36px;
    display:inline-flex;align-items:center;justify-content:center;
    border-radius:50%;
    border:1px solid rgba(255,255,255,0.2);
    background:rgba(15,20,40,0.55);
    -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
    color:#fff;
    font-size:14px;
    cursor:pointer;
    transition:background .15s,border-color .15s,transform .15s;
}
#gfeDetailModal .gfe-detail-close:hover{
    background:rgba(255,43,214,0.25);
    border-color:rgba(255,43,214,0.55);
    transform:scale(1.06);
}

/* Hero: 16/9 image band at the top of the card. The actual photo
   is set as a background-image on .gfe-detail-hero-img via JS
   (so we can fade it in after image preload), and the fallback
   gradient sits underneath — when no image loads, the fallback
   remains visible and reads as intentional instead of "broken". */
#gfeDetailModal .gfe-detail-hero{
    position:relative;
    width:100%;
    aspect-ratio:16/9;
    flex:0 0 auto;
    overflow:hidden;
    background:linear-gradient(135deg,#ff2bd6 0%,#7a0e60 100%);
}
#gfeDetailModal .gfe-detail-hero-img{
    position:absolute;inset:0;
    background-size:cover;
    background-position:center;
    opacity:0;
    transition:opacity .35s ease;
}
#gfeDetailModal .gfe-detail-hero-img.is-loaded{opacity:1;}
#gfeDetailModal .gfe-detail-hero-fallback{
    position:absolute;inset:0;
    display:flex;align-items:center;justify-content:center;
    color:rgba(255,255,255,0.4);
    font-size:48px;
}
/* Feather the image into the body instead of a hard seam. Fades
   from transparent → card background (#14182a, the exact colour
   of .gfe-detail-card's `background`) over the bottom ~45% of
   the hero, so the image reads as "evaporating" into the modal
   rather than darkening into a black vignette. Keep the rgba
   values below in sync with the .gfe-detail-card background if
   it ever changes.

   Earlier iterations stacked a second rgba(0,0,0,0.55) gradient
   here to buff the rating pill's contrast, but that darkened
   the feather to black and broke the blend. The rating pill
   has its own blurred rgba(15,20,40,0.75) background — it's
   self-sufficient against bright imagery without help here. */
#gfeDetailModal .gfe-detail-hero-shade{
    position:absolute;inset:0;
    background:linear-gradient(
        180deg,
        rgba(20,24,42,0)    55%,
        rgba(20,24,42,0.85) 85%,
        #14182a             100%
    );
    pointer-events:none;
}

/* Rating pill — overlays the bottom-left of the hero when
   present. */
#gfeDetailModal .gfe-detail-rating{
    position:absolute;
    left:14px;bottom:12px;
    z-index:2;
    display:inline-flex;
    align-items:center;
    gap:6px;
    padding:5px 10px;
    border-radius:999px;
    background:rgba(15,20,40,0.75);
    -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
    border:1px solid rgba(255,255,255,0.15);
    color:#fff;
    font-size:12px;
    font-weight:600;
}
#gfeDetailModal .gfe-detail-rating .bi-star-fill{color:#ffd54f;font-size:11px;}
#gfeDetailModal .gfe-detail-rating-count{
    color:rgba(255,255,255,0.6);
    font-weight:500;
    font-size:11px;
}

/* Body: scrollable info block below the hero. flex:1 + min-height:0
   makes the body consume the remaining card real estate and forces
   the overflow:auto to kick in at the body level (not the card
   level) — critical for preserving the card's 9:16 aspect ratio
   regardless of how much copy the Places API returns. The hero
   above has flex:0 0 auto so it holds its own aspect-ratio:16/9
   height and never fights the body for space. */
#gfeDetailModal .gfe-detail-body{
    flex:1 1 auto;
    min-height:0;
    padding:18px 20px 22px;
    display:flex;
    flex-direction:column;
    gap:12px;
    overflow-y:auto;
    scrollbar-width:thin;
    scrollbar-color:rgba(255,255,255,0.18) transparent;
}
#gfeDetailModal .gfe-detail-body::-webkit-scrollbar{width:6px;}
#gfeDetailModal .gfe-detail-body::-webkit-scrollbar-thumb{
    background:rgba(255,255,255,0.18);
    border-radius:3px;
}

#gfeDetailModal .gfe-detail-title{
    margin:0;
    font-size:22px;
    font-weight:700;
    line-height:1.2;
    color:#fff;
    letter-spacing:0.1px;
}

#gfeDetailModal .gfe-detail-subhead{
    display:flex;
    flex-wrap:wrap;
    align-items:center;
    gap:8px 12px;
    font-size:12px;
}
#gfeDetailModal .gfe-detail-category{
    display:inline-flex;
    align-items:center;
    padding:3px 9px;
    border-radius:999px;
    background:rgba(255,255,255,0.06);
    border:1px solid rgba(255,255,255,0.1);
    color:rgba(255,255,255,0.75);
    font-weight:500;
    text-transform:capitalize;
    letter-spacing:0.2px;
}
#gfeDetailModal .gfe-detail-slug{
    display:inline-flex;
    align-items:center;
    gap:5px;
    color:#ff2bd6;
    text-decoration:none;
    font-weight:600;
}
#gfeDetailModal .gfe-detail-slug:hover{text-decoration:underline;}

#gfeDetailModal .gfe-detail-description{
    margin:2px 0 0;
    font-size:13.5px;
    line-height:1.5;
    color:rgba(255,255,255,0.75);
}

/* "Add Your Own Domain" badge. Replaces the previous info block
   (address / phone / website / Google Maps). A single pill-shaped
   card with a globe glyph on the left and a title + description
   stack on the right. Magenta accent matches the rest of the
   detail modal (slug link, rating star) so it reads as part of
   the same visual system rather than a bolt-on. */
#gfeDetailModal .gfe-detail-add-domain{
    display:flex;
    align-items:flex-start;
    gap:12px;
    margin-top:4px;
    padding:14px 14px;
    border-radius:14px;
    background:linear-gradient(135deg, rgba(255,43,214,0.12) 0%, rgba(255,43,214,0.04) 100%);
    border:1px solid rgba(255,43,214,0.28);
}
#gfeDetailModal .gfe-detail-add-domain-icon{
    flex:0 0 36px;
    width:36px;height:36px;
    display:inline-flex;
    align-items:center;
    justify-content:center;
    border-radius:10px;
    background:rgba(255,43,214,0.18);
    color:#ff2bd6;
    font-size:17px;
}
#gfeDetailModal .gfe-detail-add-domain-body{
    flex:1 1 auto;
    min-width:0;
    display:flex;
    flex-direction:column;
    gap:3px;
}
#gfeDetailModal .gfe-detail-add-domain-title{
    font-size:14.5px;
    font-weight:700;
    color:#ffffff;
    letter-spacing:0.1px;
}
#gfeDetailModal .gfe-detail-add-domain-desc{
    font-size:12.5px;
    line-height:1.45;
    color:rgba(255,255,255,0.7);
}

@media(max-width:500px){
    #gfeDetailModal{padding:14px;}
    #gfeDetailModal .gfe-detail-card{border-radius:16px;}
    #gfeDetailModal .gfe-detail-title{font-size:19px;}
    #gfeDetailModal .gfe-detail-body{padding:14px 16px 18px;}
}

/* ────────────────────────────────────────────────────────────────
   Town detail modal (#townDetailModal)
   --------------------------------------------------------------
   Sibling of #gfeDetailModal — same card shell + bounce-in / fly-
   out keyframes — shown when the user clicks one of the cards in
   the intro modal's "GamifEYED Towns" list. Rules below mirror
   #gfeDetailModal's so the two paths feel identical in motion and
   visual weight; keep them in sync when either changes.

   Extra town-specific bits live at the bottom of this block:
     .town-detail-pills      (level + reward chips over the hero)
     .town-detail-stats      (6-tile play-stats grid in the body)
     .town-detail-overpass   (Overpass POI selector + preview panel)
     .gfe-detail-wiki-link   (Wikipedia deep-link variant of the extract)
   ──────────────────────────────────────────────────────────── */
#townDetailModal{
    position:fixed;inset:0;
    z-index:41;
    display:none;
    align-items:center;justify-content:center;
    background:rgba(5,8,18,0.58);
    -webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
    opacity:0;
    transition:opacity .3s ease;
}
#townDetailModal.visible{display:flex;opacity:1;}

#townDetailModal .gfe-detail-card{
    position:relative;
    width:420px;max-width:90vw;
    aspect-ratio:9/16;
    max-height:92vh;
    border-radius:20px;
    overflow:hidden;
    background:#14182a;
    box-shadow:
        0 30px 80px rgba(0,0,0,0.6),
        0 0 0 2px #ff2bd6,
        0 0 28px rgba(255,43,214,0.38);
    font-family:'Fredoka',sans-serif;
    display:flex;
    flex-direction:column;
    color:#fff;
    transform:translateY(-120vh) scale(0.94);
    opacity:0;
}
#townDetailModal.visible .gfe-detail-card{
    animation:gfeDetailBounceIn .8s cubic-bezier(.34,1.56,.64,1) forwards;
}
#townDetailModal.is-flying-out.visible .gfe-detail-card{
    animation:gfeDetailFlyOut .45s cubic-bezier(.4,0,.2,1) forwards;
}
#townDetailModal.is-flying-out.visible{opacity:0;}

#townDetailModal .gfe-detail-close{
    position:absolute;
    top:12px;right:12px;
    z-index:3;
    width:36px;height:36px;
    display:inline-flex;align-items:center;justify-content:center;
    border-radius:50%;
    border:1px solid rgba(255,255,255,0.2);
    background:rgba(15,20,40,0.55);
    -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
    color:#fff;
    font-size:14px;
    cursor:pointer;
    transition:background .15s,border-color .15s,transform .15s;
}
#townDetailModal .gfe-detail-close:hover{
    background:rgba(255,43,214,0.25);
    border-color:rgba(255,43,214,0.55);
    transform:scale(1.06);
}

#townDetailModal .gfe-detail-hero{
    position:relative;
    width:100%;
    aspect-ratio:16/9;
    flex:0 0 auto;
    overflow:hidden;
    background:linear-gradient(135deg,#ff2bd6 0%,#7a0e60 100%);
}
#townDetailModal .gfe-detail-hero-img{
    position:absolute;inset:0;
    background-size:cover;
    background-position:center;
    opacity:0;
    transition:opacity .35s ease;
}
#townDetailModal .gfe-detail-hero-img.is-loaded{opacity:1;}
#townDetailModal .gfe-detail-hero-fallback{
    position:absolute;inset:0;
    display:flex;align-items:center;justify-content:center;
    color:rgba(255,255,255,0.4);
    font-size:48px;
}
#townDetailModal .gfe-detail-hero-shade{
    position:absolute;inset:0;
    background:linear-gradient(
        180deg,
        rgba(20,24,42,0)    55%,
        rgba(20,24,42,0.85) 85%,
        #14182a             100%
    );
    pointer-events:none;
}

/* Level + reward pills overlay the bottom-left of the hero. They
   mirror the in-list card chips 1:1 so the modal reads as a
   zoomed-in version of the card the user just clicked. */
#townDetailModal .town-detail-pills{
    position:absolute;
    left:14px;bottom:12px;
    z-index:2;
    display:inline-flex;
    gap:6px;
}
#townDetailModal .town-detail-pill{
    display:inline-flex;
    align-items:center;
    padding:4px 9px;
    border-radius:999px;
    font-size:11px;
    font-weight:700;
    letter-spacing:0.2px;
    -webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
    border:1px solid rgba(255,255,255,0.12);
}
#townDetailModal .town-detail-pill-level{
    background:rgba(96,165,250,0.85);
    color:#fff;
}
#townDetailModal .town-detail-pill-reward{
    background:rgba(244,114,182,0.22);
    color:#f472b6;
    border-color:rgba(244,114,182,0.4);
}

#townDetailModal .gfe-detail-body{
    flex:1 1 auto;
    min-height:0;
    padding:18px 20px 22px;
    display:flex;
    flex-direction:column;
    gap:12px;
    overflow-y:auto;
    scrollbar-width:thin;
    scrollbar-color:rgba(255,255,255,0.18) transparent;
}
#townDetailModal .gfe-detail-body::-webkit-scrollbar{width:6px;}
#townDetailModal .gfe-detail-body::-webkit-scrollbar-thumb{
    background:rgba(255,255,255,0.18);
    border-radius:3px;
}

#townDetailModal .gfe-detail-title{
    margin:0;
    font-size:22px;
    font-weight:700;
    line-height:1.2;
    color:#fff;
    letter-spacing:0.1px;
}

#townDetailModal .gfe-detail-subhead{
    display:flex;
    flex-wrap:wrap;
    align-items:center;
    gap:8px 12px;
    font-size:12px;
}
#townDetailModal .gfe-detail-category{
    display:inline-flex;
    align-items:center;
    padding:3px 9px;
    border-radius:999px;
    background:rgba(255,255,255,0.06);
    border:1px solid rgba(255,255,255,0.1);
    color:rgba(255,255,255,0.75);
    font-weight:500;
    letter-spacing:0.2px;
}

#townDetailModal .gfe-detail-description{
    margin:2px 0 0;
    font-size:13.5px;
    line-height:1.5;
    color:rgba(255,255,255,0.75);
}

/* Wikipedia deep-link variant of the description paragraph. When
   the server ships a wiki_url the JS wraps the extract text in an
   <a.gfe-detail-wiki-link> (and tacks on a caret icon after it);
   without wiki_url the text falls through to the bare styling
   above, unchanged. We keep the anchor visually identical to the
   paragraph body so the copy reads first, and only broadcast its
   linkness on hover via a soft magenta underline + brighter text. */
#townDetailModal .gfe-detail-wiki-link{
    color:inherit;
    text-decoration:none;
    transition:color .15s ease, text-shadow .15s ease;
    background-image:linear-gradient(transparent calc(100% - 1px), rgba(255,43,214,0.35) 1px);
    background-size:0 100%;
    background-repeat:no-repeat;
    background-position:0 100%;
    transition:background-size .25s ease, color .15s ease;
}
#townDetailModal .gfe-detail-wiki-link:hover,
#townDetailModal .gfe-detail-wiki-link:focus{
    color:#fff;
    background-size:100% 100%;
    outline:none;
}
#townDetailModal .gfe-detail-wiki-caret{
    font-size:10px;
    color:rgba(255,43,214,0.75);
    vertical-align:baseline;
    margin-left:2px;
}

/* Play-stats grid — 3 tiles × 2 rows. Each tile is a vertical
   stack of icon / value / label and sits on the same translucent
   chip surface the category pill uses, so the grid reads as a
   coherent group of chips rather than a freestanding table. */
#townDetailModal .town-detail-stats{
    display:grid;
    grid-template-columns:repeat(6, minmax(0, 1fr));
    gap:6px;
    margin-top:4px;
}
#townDetailModal .town-detail-stat{
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:1px;
    padding:8px 4px;
    border-radius:10px;
    background:rgba(255,255,255,0.04);
    border:1px solid rgba(255,255,255,0.08);
    text-align:center;
    min-width:0;
}
#townDetailModal .town-detail-stat i{
    color:rgba(255,43,214,0.9);
    font-size:14px;
    margin-bottom:1px;
}
#townDetailModal .town-detail-stat-val{
    font-size:13px;
    font-weight:700;
    color:#fff;
    line-height:1.1;
    max-width:100%;
    overflow:hidden;
    text-overflow:ellipsis;
    white-space:nowrap;
}
#townDetailModal .town-detail-stat-lbl{
    font-size:10px;
    font-weight:500;
    color:rgba(255,255,255,0.55);
    letter-spacing:0.2px;
    max-width:100%;
    overflow:hidden;
    text-overflow:ellipsis;
    white-space:nowrap;
}

/* Dashboard CTA. Magenta accent matches the slug link + rating
   star on the gfe detail modal so this modal reads as part of
   the same visual system. */
/* ────────────────────────────────────────────────────────────────
   Overpass POI selector (#townDetailOverpass)
   --------------------------------------------------------------
   Replaces the old "Manage on dashboard" CTA. Structure:
     section.town-detail-overpass
       header.town-detail-overpass-head      (title + subtitle)
       div.town-detail-overpass-actions      (preview button + hint)
       button.town-detail-overpass-result    (count/cost summary, hidden)
   The Preview button always scans every category in
   config/town_poi_categories.php — there is no per-category
   chip selector on this modal. A previous revision rendered
   a chip grid here; those styles have been removed.
   ──────────────────────────────────────────────────────────── */
#townDetailModal .town-detail-overpass{
    display:flex;
    flex-direction:column;
    gap:12px;
    margin-top:4px;
    padding:12px 12px 14px;
    border-radius:14px;
    background:rgba(255,255,255,0.03);
    border:1px solid rgba(255,255,255,0.06);
}

#townDetailModal .town-detail-overpass-head{
    display:flex;
    flex-direction:column;
    gap:3px;
}
#townDetailModal .town-detail-overpass-title{
    margin:0;
    display:flex;
    align-items:center;
    gap:7px;
    font-size:13.5px;
    font-weight:600;
    letter-spacing:0.2px;
    color:#fff;
    text-transform:uppercase;
}
#townDetailModal .town-detail-overpass-title i{
    color:#ff2bd6;
    font-size:15px;
}
#townDetailModal .town-detail-overpass-sub{
    margin:0;
    font-size:12px;
    line-height:1.45;
    color:rgba(255,255,255,0.6);
}

/* Action row — button on the left, hint text on the right. The
   hint doubles as the error line by toggling .is-error (red). */
#townDetailModal .town-detail-overpass-actions{
    display:flex;
    align-items:center;
    gap:10px;
}
#townDetailModal .town-detail-overpass-preview{
    display:inline-flex;
    align-items:center;
    gap:7px;
    padding:9px 14px;
    border-radius:10px;
    background:linear-gradient(135deg, rgba(255,43,214,0.22) 0%, rgba(255,43,214,0.1) 100%);
    border:1px solid rgba(255,43,214,0.5);
    color:#fff;
    font-size:12.5px;
    font-weight:600;
    letter-spacing:0.1px;
    cursor:pointer;
    transition:background .15s, border-color .15s, transform .12s, opacity .15s;
}
#townDetailModal .town-detail-overpass-preview:hover:not(:disabled){
    background:linear-gradient(135deg, rgba(255,43,214,0.34) 0%, rgba(255,43,214,0.16) 100%);
    border-color:rgba(255,43,214,0.8);
    transform:translateY(-1px);
}
#townDetailModal .town-detail-overpass-preview:disabled{
    opacity:0.45;
    cursor:not-allowed;
    transform:none;
}
#townDetailModal .town-detail-overpass-preview.is-loading{
    opacity:0.7;
    cursor:progress;
}
#townDetailModal .town-detail-overpass-preview i{
    color:#ff2bd6;
    font-size:14px;
}
#townDetailModal .town-detail-overpass-hint{
    flex:1 1 auto;
    min-width:0;
    font-size:11.5px;
    line-height:1.35;
    color:rgba(255,255,255,0.55);
}
#townDetailModal .town-detail-overpass-hint.is-error{
    color:#fca5a5;
}

/* Result card — shown once a preview response arrives. It's a
   real <button> element: the whole panel is the "Show on map"
   CTA for the fetch step. Two stat chips (count + cost) share
   a row with the CTA arrow, with an optional message line
   below for errors. The .is-error modifier flips the panel
   to red and disables its interactivity. */
#townDetailModal .town-detail-overpass-result{
    display:flex;
    flex-direction:column;
    gap:6px;
    width:100%;
    padding:10px 12px;
    border-radius:10px;
    background:rgba(34,197,94,0.08);
    border:1px solid rgba(34,197,94,0.3);
    color:rgba(255,255,255,0.85);
    text-align:left;
    font:inherit;
    cursor:pointer;
    transition:background .15s ease, border-color .15s ease, transform .12s ease, box-shadow .15s ease;
}
#townDetailModal .town-detail-overpass-result:hover:not(:disabled){
    background:rgba(34,197,94,0.14);
    border-color:rgba(34,197,94,0.55);
    transform:translateY(-1px);
    box-shadow:0 6px 18px -10px rgba(34,197,94,0.5);
}
#townDetailModal .town-detail-overpass-result:focus-visible{
    outline:2px solid rgba(34,197,94,0.7);
    outline-offset:2px;
}
#townDetailModal .town-detail-overpass-result:disabled{
    cursor:default;
    transform:none;
}
#townDetailModal .town-detail-overpass-result.is-loading{
    cursor:progress;
    opacity:0.75;
}
#townDetailModal .town-detail-overpass-result.is-error{
    background:rgba(239,68,68,0.08);
    border-color:rgba(239,68,68,0.32);
    cursor:default;
}
#townDetailModal .town-detail-overpass-result.is-error:hover{
    transform:none;
    box-shadow:none;
    background:rgba(239,68,68,0.08);
    border-color:rgba(239,68,68,0.32);
}
#townDetailModal .town-detail-overpass-result-row{
    display:flex;
    align-items:center;
    flex-wrap:wrap;
    gap:12px;
    font-size:12px;
    color:rgba(255,255,255,0.8);
}
#townDetailModal .town-detail-overpass-result-count,
#townDetailModal .town-detail-overpass-result-cost{
    display:inline-flex;
    align-items:center;
    gap:6px;
}
#townDetailModal .town-detail-overpass-result-count i{color:#22c55e;}
#townDetailModal .town-detail-overpass-result-cost i{color:#fbbf24;}
#townDetailModal .town-detail-overpass-result-row strong{
    font-weight:700;
    color:#fff;
    font-variant-numeric:tabular-nums;
}

/* The "Show on map" CTA inside the result button — pushed to
   the far right by margin-left:auto, hidden in the error state
   since the button is non-interactive then. */
#townDetailModal .town-detail-overpass-result-cta{
    display:inline-flex;
    align-items:center;
    gap:6px;
    margin-left:auto;
    padding:3px 8px;
    border-radius:999px;
    background:rgba(34,197,94,0.2);
    color:#bbf7d0;
    font-size:11px;
    font-weight:600;
    letter-spacing:0.2px;
    transition:background .15s ease, color .15s ease;
}
#townDetailModal .town-detail-overpass-result:hover:not(:disabled) .town-detail-overpass-result-cta{
    background:rgba(34,197,94,0.32);
    color:#ecfdf5;
}
#townDetailModal .town-detail-overpass-result-cta i{
    color:#bbf7d0;
    font-size:13px;
}
#townDetailModal .town-detail-overpass-result.is-error .town-detail-overpass-result-cta{
    display:none;
}
#townDetailModal .town-detail-overpass-result.is-loading .town-detail-overpass-result-cta{
    opacity:0.7;
}

#townDetailModal .town-detail-overpass-result-message{
    font-size:11.5px;
    color:#fca5a5;
    line-height:1.4;
}
#townDetailModal .town-detail-overpass-result-message:empty{display:none;}

/* ────────────────────────────────────────────────────────────────
   POI heatmap legend (top-left IControl on the landing map)
   --------------------------------------------------------------
   Injected by buildTownPoiLegendControl() in ping-landing.js
   right after the per-category heatmap layers are added.
   It's a maplibregl.IControl, so MapLibre wraps it in its
   own .maplibregl-ctrl-top-left slot and we just need to
   style the inner panel.

   The panel starts hidden (opacity:0) and fades in by having
   the JS flip .is-visible on the next animation frame after
   mount — that frame happens while the town detail modal is
   bouncing off-screen, so the legend appears as the card
   lifts, which reads as "the legend flew in with the data".

   Each row is a real <button> so it's keyboard-focusable.
   Clicking toggles .is-off and swaps the matching heatmap
   layer's 'visibility' layout property via setLayoutProperty.
   ──────────────────────────────────────────────────────────── */
.town-poi-heatmap-key{
    min-width:168px;
    max-width:220px;
    padding:10px 12px 10px 10px;
    border-radius:12px;
    background:rgba(11,15,26,0.78);
    -webkit-backdrop-filter:blur(10px);
    backdrop-filter:blur(10px);
    border:1px solid rgba(255,255,255,0.08);
    color:rgba(255,255,255,0.92);
    font-family:'Fredoka', sans-serif;
    font-size:12px;
    box-shadow:0 10px 28px rgba(0,0,0,0.45);
    opacity:0;
    transform:translateY(-4px);
    transition:opacity .5s ease, transform .5s ease;
    pointer-events:auto;
}
.town-poi-heatmap-key.is-visible{
    opacity:1;
    transform:translateY(0);
}

.town-poi-heatmap-key-head{
    display:flex;
    align-items:center;
    gap:6px;
    font-size:10.5px;
    font-weight:700;
    letter-spacing:0.4px;
    text-transform:uppercase;
    color:rgba(255,255,255,0.6);
    margin-bottom:8px;
}
.town-poi-heatmap-key-head i{
    color:#ff2bd6;
    font-size:12px;
}

.town-poi-heatmap-key-list{
    display:flex;
    flex-direction:column;
    gap:2px;
}

.town-poi-heatmap-key-row{
    display:flex;
    align-items:center;
    gap:9px;
    padding:5px 6px;
    border:0;
    border-radius:8px;
    background:transparent;
    color:inherit;
    font:inherit;
    text-align:left;
    cursor:pointer;
    transition:background .15s ease, opacity .15s ease;
}
.town-poi-heatmap-key-row:hover{
    background:rgba(255,255,255,0.06);
}
.town-poi-heatmap-key-row:focus-visible{
    outline:2px solid rgba(255,255,255,0.35);
    outline-offset:1px;
}
.town-poi-heatmap-key-row.is-off{
    opacity:0.45;
}
.town-poi-heatmap-key-row.is-off .town-poi-heatmap-key-swatch{
    filter:grayscale(0.7);
}

.town-poi-heatmap-key-swatch{
    --swatch-color:#ff2bd6;
    display:inline-flex;
    align-items:center;
    justify-content:center;
    width:22px;
    height:22px;
    flex-shrink:0;
    border-radius:50%;
    background:var(--swatch-color);
    border:2px solid #fff;
    color:#fff;
    font-size:11px;
    box-shadow:0 2px 6px rgba(0,0,0,0.35);
}
.town-poi-heatmap-key-swatch i{
    line-height:1;
}

.town-poi-heatmap-key-label{
    line-height:1.25;
    color:#fff;
    font-weight:500;
    white-space:nowrap;
    overflow:hidden;
    text-overflow:ellipsis;
    min-width:0;
}

@media(max-width:500px){
    #townDetailModal{padding:14px;}
    #townDetailModal .gfe-detail-card{border-radius:16px;}
    #townDetailModal .gfe-detail-title{font-size:19px;}
    #townDetailModal .gfe-detail-body{padding:14px 16px 18px;}
    /* Six-across would crush the values on a phone, so reflow
       back to a 3×2 grid (matches the pre-single-row layout). */
    #townDetailModal .town-detail-stats{
        grid-template-columns:repeat(3, minmax(0, 1fr));
        gap:6px;
    }
    #townDetailModal .town-detail-stat{padding:9px 5px;}
    #townDetailModal .town-detail-stat i{font-size:15px;}
    #townDetailModal .town-detail-stat-val{font-size:13.5px;}
    #townDetailModal .town-detail-stat-lbl{font-size:10.5px;}

    /* On narrow screens let the preview button + hint line wrap
       so the hint never gets clipped next to the CTA. */
    #townDetailModal .town-detail-overpass-actions{flex-wrap:wrap;}
}
