
        /* Registers --gp-modal-shift-x as an actual <length> instead of
           a generic, opaque custom property. Without this, a change to
           this variable's value (a different pin needing a different
           amount of edge-correction - see fitDockedModal() in
           globe-init.js) is NOT something the browser can smoothly
           interpolate, even though transform itself has a transition -
           unregistered custom properties are treated as discrete text
           substitutions, so the modal SNAPPED straight to its new
           position on every Next/Prev instead of sliding, which is what
           actually read as it jumping around. */
        @property --gp-modal-shift-x {
            syntax: '<length>';
            inherits: true;
            initial-value: 0px;
        }
        /* Same reasoning as --gp-modal-shift-x above, for the modal's
           width cap (see fitDockedModal() in globe-init.js) - without
           this, shrinking the modal to fit a tighter layout would also
           snap instead of resizing smoothly. */
        @property --gp-modal-max-w {
            syntax: '<length>';
            inherits: true;
            initial-value: 640px;
        }
        .granpai-globe-wrapper {
            position: relative;
            width: 100%;
            /* Single source of truth for the modal's dock width, used
               by every docking mode below (outside/flush/auto-balance-
               split) via var(--gp-modal-dock-width) instead of each
               repeating the literal value separately - change this one
               line and every mode stays in sync automatically, rather
               than needing to find and update every occurrence by hand
               (the actual root cause behind several "fixed it here but
               it's still wrong elsewhere" bugs this session). */
            --gp-modal-dock-width: 420px;
            /* height:100% (forced with !important - see below) fills a
               parent that already has an explicit height (a hero
               section, a page-builder column with a fixed height, etc).
               aspect-ratio:1 is the actual sizing mechanism for every
               other case (parent gives no explicit height, or a height
               that's inconsistent across breakpoints) - it derives a
               real, always-responsive height directly from whatever
               width the wrapper ends up with at any given breakpoint,
               instead of a fixed pixel fallback that would stay locked
               once set and crop the globe on narrow widths. !important
               is needed because the JS near-zero-height fallback in
               globe-init.js sets an inline height style directly on
               this element in some edge cases, and inline styles
               otherwise beat this rule. */
            height: 100% !important;
            aspect-ratio: 1;
            /* Without this, aspect-ratio:1 means height always equals
               width with no upper bound - fine in a normal content
               column, but a wide/full-width section (the shortcode
               placed in its own dedicated section is a common layout)
               can make the globe grow far taller than the viewport
               itself, since nothing else was capping it. */
            max-height: 100vh;
            /* Safety floor: even with the JS-side fix (waiting for
               document.fonts.ready before the globe's first real
               resize), a genuinely pathological page could still hand
               the globe an initial container size well below its final
               one for a moment. A reasonable minimum keeps that moment,
               if it ever happens, from looking like a cramped single
               dot-cluster instead of a recognizable globe. */
            min-width: 220px;
            min-height: 220px;
            /* Transparent by default so the map sits cleanly on top of
               any section background (image, gradient, solid color)
               instead of punching an opaque box into the page. The globe
               canvas itself already renders with alpha:true + a
               transparent backgroundColor (see globe-init.js) for the
               Light and Minimal styles; Photoreal Dark intentionally
               keeps its own starfield backdrop image for that specific
               look. Give a wrapper its own backdrop by overriding
               --gp-wrapper-bg (or --gp-bg to also affect the flat map),
               e.g. inline: style='--gp-wrapper-bg:#03030a'. */
            background-color: var(--gp-wrapper-bg, transparent);
            container-type: inline-size;
            container-name: granpai-globe;
            /* Column flex: on narrow/mobile screens the modal (2nd child,
               see below) simply stacks below the map in normal flow. On
               wider screens the modal switches to position:absolute (see
               the docked override further down) and escapes this layout
               entirely, so flex-direction stops mattering there - the
               stage-clip ends up filling the whole wrapper either way. */
            display: flex;
            flex-direction: column;
        }

        /* Holds the actual canvas/SVG and clips it - kept separate from
           the wrapper itself (which is NOT overflow:hidden) so the
           desktop-docked modal can be positioned outside the wrapper's
           box (left/right, per data-modal-side) without being clipped
           back off by the same overflow rule that keeps the globe
           canvas tidy. */
        .granpai-stage-clip {
            position: relative;
            width: 100%;
            flex: 1 1 auto;
            min-height: 0;
            overflow: hidden;
            border-radius: var(--gp-radius, 10px);
            /* Auto-Balance Layout (Pro) - no rule here ever sets a
               transform on this element by default, so this transition
               is a harmless no-op unless that feature is both enabled
               and active, at which point pro-frontend.js sets
               transform:translateX(...) via inline style when a pin's
               modal (or Pin List Sidebar) opens - this is what makes
               that shift slide smoothly instead of jumping straight to
               its new position. Timing roughly matches the modal's own
               opacity/transform transition (see .granpai-modal-overlay
               further down) so both move as one connected motion. Lives
               here (not on the outer wrapper) since that's what the JS
               actually transforms - the wrapper itself also contains
               the timeline bar and modal as siblings, which should
               never move.
               */
            transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), width 0.4s cubic-bezier(0.16, 1, 0.3, 1);
        }
        /* Loading state: a soft pulsing circle standing in for the globe
           about to appear, rather than a generic shimmer rectangle -
           two concentric rings breathing slightly out of phase for a
           layered feel instead of one flat pulse. Pure CSS, no JS
           needed to run it - only to remove it (see --ready below) once
           there's something real to show instead. */
        .granpai-stage-clip::before,
        .granpai-stage-clip::after {
            content: '';
            position: absolute;
            top: 50%;
            left: 50%;
            border-radius: 50%;
            border: 1px solid rgba(255, 255, 255, 0.18);
            transform: translate(-50%, -50%) scale(0.85);
            opacity: 0;
        }
        .granpai-stage-clip::before {
            width: min(34%, 200px);
            aspect-ratio: 1;
            background: radial-gradient(circle, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.01) 72%);
            animation: granpai-globe-pulse 2.2s ease-in-out infinite;
        }
        .granpai-stage-clip::after {
            width: min(50%, 300px);
            aspect-ratio: 1;
            border-color: rgba(255, 255, 255, 0.08);
            animation: granpai-globe-pulse 2.2s ease-in-out infinite 0.5s;
        }
        /* Added by JS (see the requestAnimationFrame/draw() calls in
           globe-init.js) the moment the globe/flat map has actually
           rendered - stops both rings so they don't linger behind the
           real canvas/SVG content once there's something to show. */
        .granpai-stage-clip--ready::before,
        .granpai-stage-clip--ready::after {
            display: none;
        }
        @keyframes granpai-globe-pulse {
            0%   { transform: translate(-50%, -50%) scale(0.85); opacity: 0; }
            35%  { opacity: 1; }
            100% { transform: translate(-50%, -50%) scale(1.15); opacity: 0; }
        }
        /* Toggled by JS on marker mouseenter/mouseleave (see
           buildPulseMarkerEl in globe-init.js) - lets the hover-preview
           tag (.granpai-pin-tag, further down) pop out past this
           container's own rounded-corner clip instead of being cut off,
           which it otherwise would be whenever a pin sits close enough
           to the globe's own edge. Reverted the instant hover ends, so
           the canvas/SVG itself stays properly clipped to its rounded
           box the rest of the time - this isn't a permanent change to
           the container, just a brief exception for the moment a tag
           actually needs the room. */
        .granpai-stage-clip--hover-overflow {
            overflow: visible;
        }
        /* Applied by JS (see openPinModal/closePinModal in globe-init.js)
           to whatever sits OUTSIDE the map/modal's own branch of the DOM,
           within the nearest section-like ancestor - draws focus toward
           the open pin modal without touching the map/modal themselves. */
        .granpai-blur-sibling {
            filter: blur(6px);
            transition: filter 0.3s ease;
            pointer-events: none;
        }

        /* Plain class selectors (not scoped to a specific wrapper ID) so
           these custom properties also resolve on the modal overlay,
           which is a sibling of the wrapper, not a descendant. */
        .granpai-theme-dark {
            --gp-bg: #03030a;
            --gp-surface: #0a0c18;
            --gp-accent: #00ffcc;
            --gp-accent-soft: rgba(0, 255, 204, 0.35);
            --gp-text: #ffffff;
            --gp-text-muted: #d1d1de;
            --gp-grid-line: rgba(255, 255, 255, 0.06);
            --gp-land-fill: rgba(0, 255, 204, 0.10);
            --gp-land-stroke: rgba(0, 255, 204, 0.55);
        }
        .granpai-theme-light {
            --gp-bg: transparent;
            --gp-surface: #ffffff;
            --gp-accent: #0d9488;
            --gp-accent-soft: rgba(13, 148, 136, 0.35);
            --gp-text: #14151f;
            --gp-text-muted: #565971;
            --gp-grid-line: rgba(20, 21, 31, 0.08);
            --gp-land-fill: rgba(13, 148, 136, 0.14);
            --gp-land-stroke: rgba(13, 148, 136, 0.65);
        }

        .granpai-globe-wrapper [data-role='render-target'] {
            width: 100%;
            height: 100%;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .granpai-modal-overlay {
            position: static;
            width: 100%;
            flex: 0 0 auto;
            /* CSS Grid 0fr/1fr technique: animates to the content's
               TRUE height instead of a fixed max-height target (the
               previous max-height:0->60vh approach spent most of its
               transition time not visually changing anything, since
               real content is usually shorter than 60vh, then snapped
               shut abruptly right at the end when closing - felt like
               a jarring bounce instead of a smooth close). */
            display: grid;
            grid-template-rows: 0fr;
            background: transparent;
            opacity: 0;
            visibility: visible;
            transition: grid-template-rows 0.38s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.28s ease;
        }
        .granpai-modal-overlay.is-open {
            grid-template-rows: 1fr;
            opacity: 1;
        }
        .granpai-modal-box {
            position: relative;
            display: flex;
            flex-direction: column;
            background: var(--gp-surface, #0a0c18);
            border: 1px solid var(--gp-modal-border-color, var(--gp-accent, #00ffcc));
            border-radius: 10px;
            width: 100%;
            max-width: none;
            max-height: 60vh;
            min-height: 0;
            overflow: hidden;
            padding: 22px 20px 20px;
            color: var(--gp-text, #ffffff);
            box-shadow: 0 -8px 24px rgba(0,0,0,0.25);
        }
        .granpai-modal-scroll {
            flex: 1 1 auto;
            min-height: 0;
            overflow-y: auto;
            overflow-x: hidden;
            /* Firefox */
            scrollbar-width: thin;
            scrollbar-color: var(--gp-accent, #00ffcc) transparent;
        }
        /* Image position override: "Below title" (Pro). Deliberately
           scoped to ONLY apply display:flex when this specific class is
           present - not unconditionally on .granpai-modal-scroll itself
           - because that would break the "Left of title" position's
           float:left entirely (flex items don't respect a sibling's
           float at all, so the image would stop having text wrap
           beside it). Safe here since 'below_title' always sets
           isLogo=false in the JS (pro-frontend.js) - float is never
           used in this state, so the two never overlap. */
        .granpai-modal-scroll.granpai-image-below-title {
            display: flex;
            flex-direction: column;
        }
        .granpai-modal-scroll.granpai-image-below-title .granpai-modal-media {
            order: 3;
            /* No longer flush against the modal's own top edge (that's
               what the top-only border-radius/no-top-margin above
               assumed) - needs breathing room from the title above it,
               and all 4 corners rounded instead of just the top 2. */
            margin: 12px 0 16px;
        }
        .granpai-modal-scroll.granpai-image-below-title .granpai-modal-media img {
            border-radius: 10px;
        }
        .granpai-modal-scroll.granpai-image-below-title .granpai-modal-tag {
            order: 1;
        }
        .granpai-modal-scroll.granpai-image-below-title .granpai-modal-title {
            order: 2;
        }
        .granpai-modal-scroll.granpai-image-below-title .granpai-modal-body {
            order: 4;
        }
        .granpai-modal-scroll.granpai-image-below-title .granpai-modal-contacts {
            order: 5;
        }
        .granpai-modal-scroll::-webkit-scrollbar {
            width: 6px;
        }
        .granpai-modal-scroll::-webkit-scrollbar-track {
            background: transparent;
        }
        .granpai-modal-scroll::-webkit-scrollbar-thumb {
            background: var(--gp-accent, #00ffcc);
            border-radius: 3px;
        }
        .granpai-modal-footer {
            flex: 0 0 auto;
        }
        .granpai-modal-credit {
            display: block;
            text-align: center;
            font-size: 10.5px;
            color: var(--gp-text-muted);
            opacity: 0.6;
            text-decoration: none;
            margin-top: 8px;
            padding-top: 8px;
            border-top: 1px solid var(--gp-grid-line);
        }
        .granpai-modal-credit:hover {
            opacity: 1;
            text-decoration: underline;
        }
        .granpai-modal-close {
            position: absolute;
            top: 12px;
            right: 14px;
            width: 30px;
            height: 30px;
            display: flex;
            align-items: center;
            justify-content: center;
            /* Fixed dark, semi-opaque backdrop + white icon regardless of
               theme or what's underneath (a pin image especially - a
               light/translucent circle disappeared against light photos;
               a dark circle with a white glyph stays readable against
               any image or background color). */
            background: rgba(0, 0, 0, 0.55);
            border: 1px solid rgba(255, 255, 255, 0.25);
            border-radius: 50%;
            box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
            color: #fff;
            font-size: 20px;
            line-height: 1;
            cursor: pointer;
            padding: 4px;
            z-index: 2;
        }
        .granpai-modal-close:hover {
            background: rgba(0, 0, 0, 0.8);
        }
        .granpai-modal-tag {
            color: var(--gp-modal-tag-color, var(--gp-accent));
            text-transform: uppercase;
            font-size: 11px;
            font-weight: 800;
            letter-spacing: 2px;
            display: block;
            margin-bottom: 10px;
        }
        .granpai-modal-title {
            margin: 0 0 14px 0;
            font-size: 20px;
            line-height: 1.35;
            font-weight: 700;
            color: var(--gp-text);
            border-bottom: 1px solid var(--gp-grid-line);
            padding-bottom: 12px;
        }
        .granpai-modal-body {
            margin: 0;
            font-size: 14px;
            line-height: 1.7;
            color: var(--gp-text-muted);
            white-space: pre-line;
        }
        .granpai-modal-media {
            margin: 0 0 16px;
        }
        .granpai-modal-media img {
            display: block;
            width: 100%;
            height: auto;
            border-radius: 10px 10px 0 0;
        }
        /* Logo mode (Pro Pin/modal image setting = Logo) - small,
           contained square instead of the full-width banner Photo mode
           uses. Pulled out of normal document flow and placed next to
           the title instead of above it, since a small logo sitting
           alone at the top like a banner would look like an accidental
           gap, not a deliberate compact mark. */
        .granpai-modal-media--logo {
            float: left;
            height: var(--gp-logo-height, 56px);
            width: auto;
            /* Generous enough for a fairly wide horizontal wordmark
               without letting an extreme case overrun the modal's own
               text column - a fixed multiple of the height (not a flat
               px value) so it still scales sensibly if --gp-logo-height
               is set much larger or smaller than the 56px default. */
            max-width: calc(var(--gp-logo-height, 56px) * 3);
            margin: 0 14px 10px 0;
        }
        .granpai-modal-media--logo img {
            display: block;
            height: 100%;
            width: auto;
            max-width: 100%;
            object-fit: contain;
            border-radius: 8px;
            background: var(--gp-grid-line);
            padding: 6px;
            box-sizing: border-box;
        }
        /* Only the tag+title sit beside the floated logo - body text
           clears below it instead of also wrapping around, which would
           look cramped for anything longer than a couple of words. */
        .granpai-modal-media--logo ~ .granpai-modal-body {
            clear: both;
        }
        .granpai-modal-contacts {
            margin-top: 16px;
            padding-top: 16px;
            border-top: 1px solid var(--gp-grid-line);
        }
        /* email / phone - readable rows, icon + actual value */
        .granpai-modal-contacts__info {
            display: flex;
            flex-direction: column;
            gap: 10px;
        }
        .granpai-modal-contacts__info a {
            display: inline-flex;
            align-items: center;
            gap: 12px;
            color: var(--gp-text, #fff);
            text-decoration: none;
            font-size: 0.92rem;
            transition: color 0.15s ease;
        }
        .granpai-modal-contacts__info a:hover {
            color: var(--gp-modal-tag-color, var(--gp-accent));
        }
        .granpai-modal-contacts__info svg {
            width: 17px;
            height: 17px;
            fill: currentColor;
            flex-shrink: 0;
            opacity: 0.75;
        }
        .granpai-modal-contacts__info a:hover svg { opacity: 1; }
        .granpai-modal-contacts__label {
            overflow-wrap: anywhere;
        }
        /* website / social - original compact icon-only row */
        .granpai-modal-contacts__social {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
        }
        .granpai-modal-contacts__info + .granpai-modal-contacts__social {
            margin-top: 14px;
        }
        .granpai-modal-contacts__social a {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 32px;
            height: 32px;
            border-radius: 50%;
            background: var(--gp-grid-line);
            border: 1px solid var(--gp-grid-line);
            box-sizing: border-box;
            color: var(--gp-text, #fff);
            text-decoration: none;
            transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
        }
        .granpai-modal-contacts__social a:hover {
            background: var(--gp-modal-tag-color, var(--gp-accent));
            border-color: var(--gp-modal-tag-color, var(--gp-accent));
            color: #ffffff;
        }
        .granpai-modal-contacts__social svg {
            width: 15px;
            height: 15px;
            fill: currentColor;
        }
        .granpai-modal-nav {
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            justify-content: flex-start;
            gap: 10px;
            row-gap: 8px;
            margin-top: 16px;
            padding-top: 16px;
            border-top: 1px solid var(--gp-grid-line);
        }
        /* Pushes Prev (and, right after it, Next) all the way to the
           right edge - Get Directions (inserted before Prev, see
           openPinModal() in globe-init.js) is left wherever it
           naturally sits at the start of the row, so the two actions
           read as clearly separate groups instead of three buttons
           evenly spaced across the row. */
        .granpai-modal-nav-prev {
            margin-left: auto;
        }
        /* Was previously only defined in Pro's own pro-frontend.css -
           never migrated here when the button's rendering logic moved
           from Pro to Free (openPinModal() in globe-init.js). On a
           Free-only site (Pro's CSS never loads), this left the button
           rendering as unstyled plain text. */
        .granpai-modal-directions {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            flex: 0 0 auto;
            padding: 10px 16px;
            border-radius: 999px;
            /* Plain accent as a fallback for browsers without color-mix()
               support - browsers that don't understand the color-mix()
               line below simply ignore it and keep this one. */
            background: var(--gp-accent, #00ffcc);
            /* Blends in some dark regardless of how bright/neon the
               site's own accent color is, so white text stays reliably
               readable either way. */
            background: color-mix(in srgb, var(--gp-accent, #00ffcc) 70%, #04121a 30%);
            color: #ffffff;
            box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
            font-weight: 700;
            font-size: 14px;
            text-align: center;
            text-decoration: none;
            line-height: 1;
            transition: filter 0.15s ease;
        }
        .granpai-modal-directions:hover,
        .granpai-modal-directions:focus-visible {
            filter: brightness(0.94);
        }
        .granpai-modal-directions svg {
            width: 16px;
            height: 16px;
            fill: currentColor;
            flex-shrink: 0;
        }
        .granpai-modal-nav-btn {
            background: transparent;
            border: 1px solid var(--gp-grid-line);
            color: var(--gp-text, #fff);
            border-radius: 6px;
            padding: 7px 14px;
            font-size: 13px;
            cursor: pointer;
            transition: border-color 0.15s ease, color 0.15s ease;
        }
        .granpai-modal-nav-btn:hover {
            border-color: var(--gp-accent);
            color: var(--gp-accent);
        }

        /* ---------- Mobile: fixed 50/50 split (globe always visible, modal as a bottom sheet) ----------
           Below the desktop-docked breakpoint (see the two blocks after
           this one), the globe/map wrapper is pinned to exactly half
           the viewport height and stays there whether or not a pin's
           modal is open - previously the wrapper's height came purely
           from aspect-ratio against its own width, which on a narrow
           phone screen could render smaller than the space actually
           available, and opening a modal pushed the globe out of view
           entirely (normal document flow, modal expanding inline above
           it). Now the modal is a fixed bottom sheet instead, sliding up
           over the bottom half while the globe keeps rendering/rotating
           in the top half the whole time - matches how the desktop
           docked panel already keeps the map interactive alongside an
           open modal, just stacked instead of side-by-side. */
        @media (max-width: 640px) {
            /* Actual available height is (100vh - sticky header), not
               100vh outright - see the modal-open rule further down for
               why. Default (no modal open) gives the globe a roomy 85%
               of that instead of splitting evenly with a modal that
               isn't even visible yet - it only needs to give up room
               once a pin's modal is actually open, not permanently sized
               for that case. */
            .granpai-globe-wrapper {
                height: calc((100vh - 76px) * 0.65) !important;
                aspect-ratio: unset;
                transition: height 0.3s ease;
                /* Matches the site's sticky header height - without
                   this, scrollIntoView() (see openPinModal() in
                   globe-init.js, triggered when a pin is tapped) would
                   align the wrapper's top edge exactly with the
                   viewport's top, which is where the sticky header
                   sits, tucking the globe's own top portion behind it. */
                scroll-margin-top: 76px;
            }
            /* Shrinks to the 50/50 split only while a pin's modal is
               actually open (:has() reacts live to the .is-open class
               JS toggles on the modal below) - and grows back to the
               roomy 85% default the instant it closes, rather than
               staying shrunk permanently. The previous 50vh+38px /
               50vh-38px split (before this) summed to exactly 100vh on
               its own, which never left room for the header sitting on
               top of that same viewport space, so wrapper+modal
               together always overflowed by the header's own height
               regardless of Timeline - this and the timeline-bar rule
               below are both computed from (100vh - header) instead. */
            .granpai-globe-wrapper:has(.granpai-modal-overlay.is-open) {
                height: calc((100vh - 76px) * 0.5) !important;
            }
            .granpai-modal-overlay {
                position: fixed;
                left: 0;
                right: 0;
                bottom: 0;
                width: 100%;
                height: calc((100vh - 76px) * 0.5);
                display: block;
                grid-template-rows: unset;
                background: transparent;
                opacity: 0;
                visibility: hidden;
                pointer-events: none;
                transform: translateY(16px);
                transition: opacity 0.3s ease, transform 0.3s ease, visibility 0s linear 0.3s;
                /* Above the parent theme's back-to-top button
                   (z-index 999980) so an open modal is never partially
                   covered by it, and below GranpaiEdit's own editing
                   fab (999990) so that stays reachable while editing. */
                z-index: 999985;
            }
            .granpai-modal-overlay.is-open {
                opacity: 1;
                visibility: visible;
                pointer-events: auto;
                transform: translateY(0);
                transition: opacity 0.3s ease, transform 0.3s ease;
            }
            .granpai-modal-box {
                height: 100%;
                max-height: 100%;
                border-radius: 16px 16px 0 0;
                box-shadow: 0 -8px 32px rgba(0,0,0,0.35);
            }
            .granpai-timeline-bar {
                padding: 8px 12px;
                gap: 10px;
            }
            .granpai-timeline-play {
                width: 30px;
                height: 30px;
                margin: 0;
            }
            .granpai-timeline-play::before {
                border-width: 5px 0 5px 8px;
                margin-left: 2px;
            }
            .granpai-timeline-play.is-playing::before {
                width: 3px;
                height: 10px;
                box-shadow: 6px 0 0 #ffffff;
                margin-left: -6px;
            }
            .granpai-timeline-nodes {
                height: 40px;
                gap: 26px;
            }
            /* Timeline bar's real height on mobile is ~64px (40px nodes
               + 16px vertical padding + 8px margin-top, from the rules
               just above). Pulled out of the wrapper's normal flow
               entirely (position:fixed) instead of sizing the wrapper
               to "leave room" for it as a flex sibling - that approach
               depended on 2 independently-animating elements (the
               wrapper's own height transition, and the modal's
               separate opacity/transform transition) landing on
               complementary values only once BOTH fully settled,
               visually overlapping mid-animation in between. Fixed
               positioning means this never depends on the wrapper's
               height/transition at all - its own bottom offset tracks
               modal.is-open directly, always landing exactly above the
               modal regardless of what animation state anything else is
               in. */
            .granpai-globe-wrapper:has(.granpai-timeline-bar:not([hidden])) .granpai-timeline-bar {
                position: fixed;
                left: 0;
                right: 0;
                bottom: 0;
                margin-top: 0;
                z-index: 999984; /* just under the modal (999985) - purely defensive, since position no longer overlaps it either way */
                transition: bottom 0.3s ease;
            }
            .granpai-globe-wrapper:has(.granpai-timeline-bar:not([hidden])):has(.granpai-modal-overlay.is-open) .granpai-timeline-bar {
                bottom: calc((100vh - 76px) * 0.5 - 64px);
            }
            .granpai-globe-wrapper:has(.granpai-timeline-bar:not([hidden])) .granpai-modal-overlay {
                height: calc((100vh - 76px - 64px) * 0.5);
            }
        }

        /* ---------- Responsive modal: overlay (mobile) vs docked panel (wider maps) ----------
           Base rule above (.granpai-modal-overlay / .granpai-modal-box)
           is the full-screen, must-close-to-continue overlay - correct
           for small screens. The two @supports blocks below layer a
           docked, non-blocking side-panel treatment on top of it once
           the map itself is wide enough: clicking a different pin then
           just swaps the panel's content in place, and the rest of the
           map stays clickable underneath.

           The two blocks are mutually exclusive (container-type support
           vs not), so exactly one applies in any given browser - no
           override war between them. Preferred: a CSS container query,
           keyed to THIS map's own rendered width rather than the whole
           viewport, so a map embedded in a narrow sidebar still correctly
           gets the mobile-style overlay even on a wide desktop screen.
           Fallback: a plain viewport media query for browsers without
           container query support. */
        @supports (container-type: inline-size) {
            @container granpai-globe (min-width: 520px) {
                .granpai-modal-overlay {
                    position: absolute;
                    top: 50%;
                    /* Closed state: slightly scaled down + fully
                       transparent for a soft pop-in entrance instead
                       of appearing instantly - .is-open below animates
                       both opacity and this transform together. */
                    transform: translateY(calc(-50% - 10px)) scale(0.94);
                    /* Default: docks OUTSIDE the wrapper's own box, to
                       the side - the original design, preferred whenever
                       there's genuinely enough viewport room for it.
                       Whether there IS enough room is decided by JS
                       exactly ONCE (updateModalDockMode() in
                       globe-init.js) - at initial render, and again on
                       window resize (genuinely changes available space).
                       It is deliberately NOT re-checked on every pin
                       open/Next/Prev, since those never change how much
                       room exists - re-measuring on every open was the
                       actual root cause of the modal visibly jumping/
                       jittering, not the outside-docking approach
                       itself. See .granpai-modal-flush below for the
                       fallback JS switches to instead when there isn't
                       enough room. */
                    left: 100%;
                    right: auto;
                    margin-left: 16px;
                    /* Same fixed width as every other docking mode
                       below (flush / auto-balance-split) - was
                       previously min(640px, 65%), the one mode with a
                       different width than the rest. calc(100vw - 32px)
                       is the safety net for a wrapper embedded in a
                       genuinely narrow column, where 420px would
                       otherwise force it past the real viewport edge. */
                    width: var(--gp-modal-dock-width);
                    max-width: calc(100vw - 32px);
                    /* Viewport-relative, NOT tied to the globe container's
                       own height - a square/short hero embed shouldn't
                       cap how tall the modal can be on a roomy laptop
                       screen. This is a definite height (needed so the
                       box's height:100% below has something real to
                       resolve against) rather than 100% of the wrapper. */
                    /* Content-driven height, capped rather than
                       forced - short pin descriptions used to be
                       stretched into a fixed 85vh/700px box with a lot
                       of dead space at the bottom. Long content still
                       caps here and scrolls internally (see
                       .granpai-modal-scroll). */
                    height: auto;
                    max-height: min(85vh, 700px);
                    overflow: visible;
                    background: transparent;
                    opacity: 0;
                    pointer-events: none;
                    transition: opacity 0.28s ease, transform 0.38s cubic-bezier(0.16, 1, 0.3, 1);
                    z-index: var(--gp-modal-z-index, 999999);
                }
                .granpai-modal-overlay.is-open {
                    opacity: 1;
                    pointer-events: auto;
                    transform: translateY(-50%) scale(1);
                    transition: opacity 0.32s ease, transform 0.42s cubic-bezier(0.16, 1, 0.3, 1);
                }
                .granpai-globe-wrapper[data-modal-side='left'] .granpai-modal-overlay {
                    left: auto;
                    right: 100%;
                    margin-left: 0;
                    margin-right: 16px;
                }
                /* Fallback mode: JS-toggled class, set once (init +
                   resize only, never per pin-open) by
                   updateModalDockMode() when the viewport genuinely
                   doesn't have room for the outside-docked width above.
                   position:absolute (relative to the wrapper), NOT
                   position:fixed (relative to the viewport) - fixed
                   used to be the fix for a real earlier bug (docking
                   INSIDE the wrapper's own box, which is exactly the
                   globe's own box, meant "flush against ITS edge"
                   landed right on top of the globe) - but position:fixed
                   creates a worse problem: the modal stays visibly
                   pinned in place while the page scrolls past it,
                   instead of scrolling away with the rest of the page's
                   content like every other absolutely/relatively
                   positioned element on it. The actual fix for the
                   original overlap bug is having the globe (stage-clip)
                   shrink out of the modal's way (below) - identical to
                   how Auto-Balance's own split layout already solves
                   this same problem - not escaping into fixed
                   positioning. The globe itself is deliberately NOT
                   shrunk in this mode (unlike Auto-Balance's own split
                   layout just below) - that's reserved for when
                   Auto-Balance is the site owner's own deliberate
                   choice. Automatically shrinking the globe for any
                   narrow wrapper made it render far too small/broken-
                   looking on a genuinely small embed - some overlap
                   between the modal and a small globe is the accepted
                   tradeoff here instead. */
                .granpai-globe-wrapper.granpai-modal-flush .granpai-modal-overlay {
                    left: auto;
                    right: 0;
                    margin: 0;
                    width: var(--gp-modal-dock-width);
                    max-width: calc(100vw - 32px);
                }
                .granpai-globe-wrapper.granpai-modal-flush[data-modal-side='left'] .granpai-modal-overlay {
                    left: 0;
                    right: auto;
                }

                /* Auto-Balance split layout (Pro) - a fundamentally
                   different docking model from the plain "outside"
                   default above: instead of the globe staying full-
                   width and the modal floating beside or over it (which
                   either escapes the viewport entirely on a genuinely
                   full-width section, or ends up stacked awkwardly far
                   from the modal), BOTH the globe (stage-clip) and the
                   modal resize/reposition to sit side-by-side within
                   the wrapper's own original width - identical CSS
                   behavior to .granpai-modal-flush directly above
                   (same 420px width, same shrink-the-globe mechanism),
                   only the JS condition that TURNS THIS ON differs
                   (Auto-Balance setting + wide-enough wrapper, vs.
                   flush's "not enough room for outside-docking" check).
                   Deliberately kept as its own separate class rather
                   than merged into one, since Pro's own JS
                   (applyAutoBalanceShift() in pro-frontend.js) needs to
                   toggle it independently of Free's updateModalDockMode()
                   - but giving both classes THE SAME resulting CSS
                   means that even if both end up active on the wrapper
                   at once, the result is still correct either way. */
                .granpai-globe-wrapper.granpai-auto-balance-split:has(.granpai-modal-overlay.is-open) .granpai-stage-clip {
                    width: calc(100% - var(--gp-modal-dock-width));
                }
                .granpai-globe-wrapper.granpai-auto-balance-split[data-modal-side='left']:has(.granpai-modal-overlay.is-open) .granpai-stage-clip {
                    margin-left: var(--gp-modal-dock-width);
                }
                .granpai-globe-wrapper.granpai-auto-balance-split .granpai-modal-overlay {
                    left: auto;
                    right: 0;
                    margin: 0;
                    width: var(--gp-modal-dock-width);
                    max-width: calc(100vw - 32px);
                }
                .granpai-globe-wrapper.granpai-auto-balance-split[data-modal-side='left'] .granpai-modal-overlay {
                    left: 0;
                    right: auto;
                }

                .granpai-modal-box {
                    width: 100%;
                    height: auto;
                    max-height: 100%;
                    box-shadow: 0 20px 60px rgba(0,0,0,0.35);
                }
            }
        }
        @supports not (container-type: inline-size) {
            @media (min-width: 520px) {
                .granpai-modal-overlay {
                    position: absolute;
                    top: 50%;
                    transform: translateY(calc(-50% - 10px)) scale(0.94);
                    left: 100%;
                    right: auto;
                    margin-left: 16px;
                    width: var(--gp-modal-dock-width);
                    max-width: calc(100vw - 32px);
                    height: auto;
                    max-height: min(85vh, 700px);
                    overflow: visible;
                    background: transparent;
                    opacity: 0;
                    pointer-events: none;
                    transition: opacity 0.28s ease, transform 0.38s cubic-bezier(0.16, 1, 0.3, 1);
                    z-index: var(--gp-modal-z-index, 999999);
                }
                .granpai-modal-overlay.is-open {
                    opacity: 1;
                    pointer-events: auto;
                    transform: translateY(-50%) scale(1);
                    transition: opacity 0.32s ease, transform 0.42s cubic-bezier(0.16, 1, 0.3, 1);
                }
                .granpai-globe-wrapper[data-modal-side='left'] .granpai-modal-overlay {
                    left: auto;
                    right: 100%;
                    margin-left: 0;
                    margin-right: 16px;
                }
                .granpai-globe-wrapper.granpai-modal-flush .granpai-modal-overlay {
                    left: auto;
                    right: 0;
                    margin: 0;
                    width: var(--gp-modal-dock-width);
                    max-width: calc(100vw - 32px);
                }
                .granpai-globe-wrapper.granpai-modal-flush[data-modal-side='left'] .granpai-modal-overlay {
                    left: 0;
                    right: auto;
                }
                .granpai-globe-wrapper.granpai-auto-balance-split:has(.granpai-modal-overlay.is-open) .granpai-stage-clip {
                    width: calc(100% - var(--gp-modal-dock-width));
                }
                .granpai-globe-wrapper.granpai-auto-balance-split[data-modal-side='left']:has(.granpai-modal-overlay.is-open) .granpai-stage-clip {
                    margin-left: var(--gp-modal-dock-width);
                }
                .granpai-globe-wrapper.granpai-auto-balance-split .granpai-modal-overlay {
                    left: auto;
                    right: 0;
                    margin: 0;
                    width: var(--gp-modal-dock-width);
                    max-width: calc(100vw - 32px);
                }
                .granpai-globe-wrapper.granpai-auto-balance-split[data-modal-side='left'] .granpai-modal-overlay {
                    left: 0;
                    right: auto;
                }
                .granpai-modal-box {
                    width: 100%;
                    height: auto;
                    max-height: 100%;
                    box-shadow: 0 20px 60px rgba(0,0,0,0.35);
                }
            }
        }

        /* Tablet range (641px-1024px): overrides BOTH docked-beside
           blocks above with a plain fixed overlay pinned to one edge -
           no dynamic width-capping or shift-correction math at all. The
           dock-beside-the-globe, measure-overflow, nudge-back approach
           above kept oscillating on this specific range even after
           several rounds of fixing fitDockedModal() itself - there just
           isn't reliably enough room beside the globe at these widths for
           a beside treatment to make sense in the first place. An
           overlay avoids the problem instead of continuing to chase it:
           fixed size and position, nothing to recalculate, nothing to
           get wrong. Comes after both blocks above in source order so it
           wins regardless of which one the browser matched. */
        @media (min-width: 641px) and (max-width: 1024px) {
            .granpai-modal-overlay {
                position: absolute;
                top: 50%;
                bottom: auto;
                left: auto;
                right: 16px;
                margin: 0;
                width: min(380px, 90%);
                /* Content-driven, not forced to fill the wrapper - a
                   short pin description in a box stretched to the full
                   tablet height left a lot of dead space at the bottom,
                   which read as broken rather than intentional. */
                height: auto;
                max-height: min(75vh, 640px);
                overflow: visible;
                transform: translateY(-50%) translateX(16px) scale(0.96);
                display: block;
                grid-template-rows: unset;
                background: transparent;
                opacity: 0;
                visibility: hidden;
                pointer-events: none;
                transition: opacity 0.28s ease, transform 0.34s cubic-bezier(0.16, 1, 0.3, 1);
            }
            .granpai-modal-overlay.is-open {
                opacity: 1;
                visibility: visible;
                pointer-events: auto;
                transform: translateY(-50%) translateX(0) scale(1);
            }
            .granpai-globe-wrapper[data-modal-side='left'] .granpai-modal-overlay {
                right: auto;
                left: 16px;
                transform: translateY(-50%) translateX(-16px) scale(0.96);
            }
            .granpai-globe-wrapper[data-modal-side='left'] .granpai-modal-overlay.is-open {
                transform: translateY(-50%) translateX(0) scale(1);
            }
            .granpai-modal-box {
                width: 100%;
                height: auto;
                max-height: 100%;
                border-radius: 14px;
                box-shadow: 0 20px 60px rgba(0,0,0,0.35);
            }
            /* Leaves a bit more headroom above the Timeline bar - only
               applies when a visible timeline bar actually exists as a
               sibling, so maps without Timeline enabled keep the full
               75vh/640px cap (nothing below to avoid there). Adjusts the
               CAP rather than pinning a hard bottom offset, so the card
               stays a natural floating shape either way instead of
               getting clipped. */
            .granpai-globe-wrapper:has(.granpai-timeline-bar:not([hidden])) .granpai-modal-overlay {
                max-height: min(64vh, 560px);
            }
        }

        /* Desktop only (measured, not viewport-based - see
           updateDesktopRoomyClass() in globe-init.js). A container-
           query equivalent isn't possible here: CSS Container Queries
           can't style the container element itself, only its
           descendants - and .granpai-globe-wrapper (below) IS the
           container (see container-type/container-name above). A plain
           @media(min-width:1025px) used to sit here instead, but that's
           viewport width, not the wrapper's own real width - wrongly
           matching "desktop roomy" in a wide admin browser window even
           when the actual preview panel (inside a narrow admin metabox
           column) was much narrower, and equally wrong the other
           direction for a map genuinely embedded in a narrow sidebar on
           an otherwise-wide page. */
        .granpai-globe-wrapper.granpai-desktop-roomy[data-mode='globe']:not(.granpai-auto-balance-split):not([data-timeline-enabled='1']) .granpai-stage-clip {
            /* Gives the globe a small margin within its own square
               container - without this, the WebGL canvas fills the
               ENTIRE container (stage-clip has overflow:hidden),
               leaving zero room for the globe's own atmosphere/glow
               effect to render into before hitting the clip
               boundary, visibly cutting it off and looking clipped/
               flat instead of soft-edged. Padding on stage-clip
               (the parent), not render-target itself - globe.gl
               measures render-target's own clientWidth/clientHeight
               to size its canvas, and clientWidth already includes
               an element's own padding, so padding directly on
               render-target wouldn't shrink anything; it has to be
               the PARENT's content box that's smaller instead.
               Only in Globe mode (Flat map has no sphere/glow to
               clip), only outside auto-balance-split mode (that's
               the one mode that's genuinely rectangular now, so
               there's naturally some extra room on one axis
               already), and only OUTSIDE Timeline mode - Timeline
               already gets its own aspect-ratio compensation just
               below for the exact same shadow-margin reasoning, and
               stacking both was over-shrinking the globe
               specifically whenever Timeline was enabled. */
            padding: 3%;
        }
        /* Timeline's bar (~76px: 48px play button + 10px*2 padding
           + 8px margin-top - see .granpai-timeline-bar/.granpai-
           timeline-play) eats into the wrapper's own fixed height
           (aspect-ratio:1 above) without the wrapper growing to
           compensate, since it's a flex sibling of stage-clip
           within the same fixed-height column - stage-clip renders
           shorter than it is wide, and globe.gl fits the sphere to
           the SMALLER of width/height, so the globe rendered
           noticeably smaller specifically whenever Timeline was
           enabled. Widening the ratio here (height slightly more
           than width) compensates for roughly that same amount -
           not pixel-perfect at every possible wrapper width (aspect
           -ratio can't add a flat pixel amount, only a fixed
           ratio), but a clear, deliberate improvement over not
           compensating for the bar's height at all.
           [data-timeline-enabled='1'] (a static, PHP-known setting
           value present from the very first byte of HTML) is used
           here rather than :has(.granpai-timeline-bar:not([hidden]))
           (a JS-dependent state that only becomes true once enough
           dated pins are validated to exist) - the wrapper is this
           shape from initial load through Timeline actually
           "activating" this way, instead of genuinely changing
           shape mid-load (square at first, then this ratio once
           the bar's [hidden] is removed), which was the real root
           cause of the globe rendering at two different sizes
           before vs. after - not a resize-timing issue. This one
           stays gated on the timeline-enabled data attribute alone
           (not .granpai-desktop-roomy) - Timeline's own bar-height
           compensation matters at any width, not just roomy desktop
           ones. */
        .granpai-globe-wrapper[data-mode='globe'][data-timeline-enabled='1'] {
            aspect-ratio: 8 / 9;
        }

        /* Docked-panel overflow (map embedded in a wide/full-width
           section, close enough to the section's own edge that the
           panel spills past the real browser viewport) is handled in
           JS - see fitDockedModal() in globe-init.js. It measures the
           exact overflow in pixels and nudges the panel back by only
           that much via transform: translateX(), rather than a fixed
           viewport-width guess or an all-or-nothing snap to the
           wrapper's edge. No CSS fallback is needed here: the map
           itself requires JS (WebGL/Canvas) to render at all. */

        .granpai-pulse-marker {
            position: relative;
            width: 24px;
            height: 24px;
            cursor: pointer;
            transform: translate(-12px, -12px);
            pointer-events: auto;
            z-index: 1;
        }
        /* z-index only resolves within a shared stacking context - the
           label's own z-index:3 (below) only wins against elements
           inside the SAME marker. When pins sit close together (fanned
           cluster), a neighboring marker's dot could still paint over
           this marker's label unless the whole marker itself is also
           raised on hover. !important is needed here specifically:
           globe.gl (three-globe/CSS2DRenderer) sets each marker's
           z-index via inline style every render frame based on camera
           depth, which otherwise always wins over a plain CSS rule. */
        .granpai-pulse-marker:hover {
            z-index: 10 !important;
        }
        /* Everything visual lives in here, so applyClusters() (see
           globe-init.js) can offset it with its own transform when
           fanning out a cluster, without touching the outer
           .granpai-pulse-marker - that's the element three-globe/Flat
           mode position at the pin's true anchor point, and re-set on
           every one of their own updates, which would otherwise
           overwrite anything added here directly on it. */
        .granpai-pulse-marker-inner {
            position: absolute;
            top: 0; left: 0;
            width: 24px; height: 24px;
            transition: transform 0.3s ease;
        }
        .granpai-pulse-dot {
            position: absolute;
            top: 6px; left: 6px;
            width: 12px; height: 12px;
            background: var(--gp-pin-color, var(--gp-accent));
            border-radius: 50%;
            box-shadow: 0 0 12px 3px var(--gp-pin-color-soft, var(--gp-accent-soft));
            z-index: 2;
        }
        .granpai-pulse-ring {
            position: absolute;
            top: 6px; left: 6px;
            width: 12px; height: 12px;
            box-sizing: border-box;
            border-radius: 50%;
            border: 2.5px solid var(--gp-pin-color, var(--gp-accent));
            animation: granpai-pulse-anim 2.2s ease-out infinite;
            z-index: 1;
        }
        .granpai-pulse-ring.granpai-pulse-delay {
            animation-delay: 1.1s;
        }
        @keyframes granpai-pulse-anim {
            0%   { transform: scale(1);   opacity: 0.75; }
            100% { transform: scale(4.5); opacity: 0; }
        }
        .granpai-pulse-marker--flash .granpai-pulse-dot {
            animation: granpai-pulse-flash 0.7s ease-out;
        }
        @keyframes granpai-pulse-flash {
            0%   { transform: scale(1);   box-shadow: 0 0 12px 3px var(--gp-pin-color-soft, var(--gp-accent-soft)); }
            35%  { transform: scale(2.1); box-shadow: 0 0 26px 12px var(--gp-pin-color-soft, var(--gp-accent-soft)); }
            100% { transform: scale(1);   box-shadow: 0 0 12px 3px var(--gp-pin-color-soft, var(--gp-accent-soft)); }
        }

        /* Two or more pins sharing (roughly) the same screen position
           (see recomputeClusters()/applyFanOut() in globe-init.js) are
           never hidden from each other - every one of them stays fully
           visible and independently clickable, each nudged out to its
           own spot in a small fan above the shared anchor and connected
           back to it with a thin leg line, drawn from that true anchor
           point (this element's own top:12px/left:12px - the center of
           the 24x24 marker box) out to wherever the JS-set width/rotate
           transform points it. */
        .granpai-pulse-marker-leg {
            position: absolute;
            top: 12px; left: 12px;
            height: 1px;
            width: 0;
            /* Dashed via a repeating gradient rather than
               border-style:dashed - a 1px dashed BORDER render
               inconsistently (sometimes invisible) across browsers at
               that thickness; a gradient background doesn't have that
               problem and still reads as a clean dashed guide line. */
            background-image: repeating-linear-gradient(
                to right,
                var(--gp-text-muted, rgba(234, 242, 255, 0.55)) 0,
                var(--gp-text-muted, rgba(234, 242, 255, 0.55)) 3px,
                transparent 3px,
                transparent 6px
            );
            opacity: 0;
            transform-origin: 0 50%;
            transition: width 0.3s ease, opacity 0.3s ease;
            pointer-events: none;
        }
        .granpai-pulse-marker--fanned .granpai-pulse-marker-leg {
            opacity: 0.4;
        }

        /* Timeline (Pro) - toggled in initTimelineControls() in
           globe-init.js based on each marker's own data-pin-date
           attribute (set in buildPulseMarkerEl). Fades rather than a
           hard display:none, so a pin doesn't just abruptly pop into
           existence the instant the slider passes its date. */
        .granpai-pulse-marker--timeline-hidden {
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.4s ease;
        }


        /* Hover preview bubble - shows the pin's short 'location' label
           right above it, speech-bubble style. Pointer-events:none so it
           never steals the click/hover from the marker itself; visible
           purely via opacity+transform so it never affects layout/
           marker positioning math (that's all done in JS against the
           marker element's own untouched size). */
        .granpai-pin-tag {
            position: absolute;
            left: 50%;
            bottom: 100%;
            margin-bottom: 14px;
            transform: translate(-50%, 4px) scale(0.92);
            /* Was pointer-events:none - now interactive so hovering the
               tag itself (image/label), not just the tiny marker dot,
               keeps it visible. Needed because the camera-nudge-toward-
               edge-pin behavior (see buildPulseMarkerEl's mouseenter in
               globe-init.js) can slide the marker+tag away from
               wherever the cursor originally was - without this, the
               tag would vanish the instant the marker itself moved out
               from under the cursor, even if the tag's own body still
               happened to be sitting right there. Since the tag is a
               DOM child of the marker, hovering it also counts as
               hovering the marker for CSS :hover purposes (hover state
               bubbles up the ancestor chain), so the existing
               .granpai-pulse-marker:hover rule below just keeps working
               with no other changes needed. */
            pointer-events: auto;
            opacity: 0;
            z-index: 3;
            transition: opacity 0.18s ease, transform 0.18s ease;
            /* One unified shadow under the whole shape (body + tail),
               rather than a box-shadow that would only hug the body's
               rounded rectangle and leave the tail looking flat/pasted
               on. */
            filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.3));
        }
        .granpai-pin-tag-img {
            display: block;
            width: 120px;
            height: 68px;
            object-fit: cover;
            border-radius: 8px 8px 0 0;
        }
        /* Logo mode (Pro Pin/modal image setting = Logo) - square and
           contained instead of a wide cropped photo, matching the text
           label's own 120px width but sized down since a logo doesn't
           need as much room as a location photo. */
        .granpai-pin-tag-img--logo {
            width: 120px;
            height: 60px;
            object-fit: contain;
            padding: 8px;
            box-sizing: border-box;
            background: var(--gp-grid-line);
        }
        .granpai-pin-tag-text {
            display: block;
            width: 120px;
            box-sizing: border-box;
            padding: 6px 10px;
            border-radius: 8px;
            position: relative;
            /* Blends in some dark regardless of how bright the site's pin
               color is - same reasoning as the Get Directions button:
               a raw neon color can wash out white text just as easily as
               it washes out black. */
            background: var(--gp-pin-color, var(--gp-accent));
            background: color-mix(in srgb, var(--gp-pin-color, var(--gp-accent)) 75%, #04121a 25%);
            color: #ffffff;
            font-size: 12px;
            font-weight: 700;
            line-height: 1.3;
            text-align: center;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .granpai-pin-tag-img + .granpai-pin-tag-text {
            border-radius: 0 0 8px 8px;
        }
        /* Speech-bubble tail: a small rotated square tucked just behind
           the body's bottom edge, same fill color, with a touch of its
           own corner rounding so the point isn't a razor-sharp corner -
           reads as a single continuous shape with the body instead of a
           separate triangle glued on. */
        .granpai-pin-tag::after {
            content: '';
            position: absolute;
            top: 100%;
            left: 50%;
            width: 11px;
            height: 11px;
            margin-top: -7px;
            transform: translateX(-50%) rotate(45deg);
            border-radius: 0 0 3px 0;
            background: color-mix(in srgb, var(--gp-pin-color, var(--gp-accent)) 75%, #04121a 25%);
        }
        .granpai-pulse-marker:hover .granpai-pin-tag {
            opacity: 1;
            transform: translate(-50%, 0) scale(1);
        }
        /* Triggered by JS on next/prev navigation (see navigatePin) -
           a slightly springier pop than the plain hover fade-in, so it
           reads as 'look here now' rather than a passive hover state. */
        .granpai-pin-tag.granpai-pin-tag-force-show {
            opacity: 1;
            animation: granpai-pin-tag-bounce 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
        }
        @keyframes granpai-pin-tag-bounce {
            0%   { transform: translate(-50%, 10px) scale(0.8); opacity: 0; }
            60%  { transform: translate(-50%, -4px) scale(1.05); opacity: 1; }
            100% { transform: translate(-50%, 0) scale(1); opacity: 1; }
        }

        .granpai-flatmap {
            position: absolute;
            inset: 0;
            /* --gp-surface, not --gp-bg: --gp-bg is intentionally
               transparent for the light theme (fine for Globe mode,
               where the sphere itself is the visual surface regardless
               of what is behind it) - but flat map fills its entire
               rectangular box with no shape of its own, so a
               transparent background there just showed whatever is
               behind the page for BOTH themes, making light and dark
               look nearly identical. --gp-surface is the theme's actual
               solid-color surface (#ffffff light / #0a0c18 dark). */
            background: var(--gp-wrapper-bg, var(--gp-surface, transparent));
            overflow: hidden;
            cursor: grab;
            touch-action: none;
        }
        .granpai-flatmap.is-dragging {
            cursor: grabbing;
        }
        .granpai-flatmap svg {
            display: block;
            width: 100%;
            height: 100%;
        }
        /* Flight line between two pins on Next/Prev/click, flat-map
           version (see drawArc() inside initFlatMode() in globe-init.js). */
        .granpai-arc-overlay {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
            pointer-events: none;
            overflow: visible;
            z-index: 2;
        }
        .granpai-arc-line {
            stroke-width: 2;
            stroke-dasharray: 5 4;
            fill: none;
            opacity: 0;
        }
        .granpai-arc-line.is-animating {
            animation:
                granpai-arc-flow 2.2s linear forwards,
                granpai-arc-fade 2.2s ease-out forwards;
        }
        @keyframes granpai-arc-flow {
            from { stroke-dashoffset: 0; }
            to   { stroke-dashoffset: -54; }
        }
        @keyframes granpai-arc-fade {
            0%   { opacity: 0; }
            15%  { opacity: 0.75; }
            75%  { opacity: 0.75; }
            100% { opacity: 0; }
        }

        /* Timeline (Pro) - see updateTimelinePins()/initTimelineControls()
           in globe-init.js. Sits as a floating bar at the bottom of the
           stage, identical positioning whether the map underneath is
           Globe or Flat Map - it's a sibling UI element, not tied to
           either mode's own rendering. */
        .granpai-timeline-bar {
            flex: 0 0 auto;
            display: flex;
            align-items: center;
            gap: 14px;
            padding: 10px 16px;
            margin-top: 8px;
            border-radius: 12px;
            background: var(--gp-surface, rgba(10, 12, 24, 0.65));
            color: var(--gp-text, #ffffff);
        }
        /* Makes the hidden attribute actually take effect - without
           this explicit rule, .granpai-timeline-bar's own display:flex
           above (an author style) always wins over the browser's
           default [hidden] { display: none } (a user-agent style),
           REGARDLESS of specificity or whether JS ever sets/clears the
           attribute at all - the bar was always visible on every map,
           Timeline enabled or not, purely from this CSS rule existing. */
        .granpai-timeline-bar[hidden] {
            display: none;
        }
        .granpai-timeline-play {
            position: relative;
            z-index: 1;
            flex: 0 0 auto;
            width: 48px;
            height: 48px;
            /* Negative margin lets the button genuinely protrude above
               and below the bar's own 10px vertical padding (see
               .granpai-timeline-bar above), instead of just sitting
               flush inside it at the same size as everything else -
               this is what actually makes it stand out as the bar's
               one prominent action, not merely "a bit bigger". Doesn't
               change the bar's own total height (align-items: center
               still centers it correctly either way), just how far it
               visually spills past the bar's edge. */
            margin: -12px 0;
            border-radius: 50%;
            border: none;
            /* Blends in some dark regardless of how bright the
               configured pin color is - same reasoning as the Get
               Directions button: a light/neon pin color (including the
               default cyan accent) can wash out the white icon just as
               easily as it would wash out a dark one, so this
               guarantees contrast instead of assuming the pin color is
               always dark enough on its own. */
            background: linear-gradient(135deg,
                color-mix(in srgb, var(--gp-pin-color, var(--gp-accent)) 80%, #04121a 20%),
                color-mix(in srgb, var(--gp-pin-color, var(--gp-accent)) 55%, #04121a 45%));
            box-shadow: 0 2px 10px -2px var(--gp-pin-color, var(--gp-accent));
            color: #ffffff;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            padding: 0;
            transition: transform 0.15s ease, box-shadow 0.15s ease;
        }
        /* Play/Pause icon - pure CSS shapes (border-trick triangle /
           box-shadow-doubled bar), NOT an inline SVG. An inline <svg>
           here previously kept vanishing from the actual HTML the
           browser received, even though the PHP source genuinely had
           it, with every server-side caching layer (LiteSpeed page
           cache, HTML Minify, Opcode Cache) ruled out one at a time -
           the root layer responsible was never conclusively found.
           Drawing the icon in CSS instead sidesteps the problem
           entirely: there's no HTML content for anything to strip, so
           it can't go missing regardless of what that layer turns out
           to be. ::after is already used for the pulse ring below, so
           this uses ::before instead - both states (play/pause) are
           the SAME pseudo-element, swapped via the existing
           .is-playing class, matching the toggle already in
           startPlaying()/stopPlaying() with no JS changes needed for
           the icon itself. */
        .granpai-timeline-play::before {
            content: '';
            display: block;
            position: relative;
            z-index: 1;
            /* Play (default): solid right-pointing triangle. The
               transparent top/bottom borders + solid left border is
               the classic CSS triangle technique - only the left
               border actually renders as visible color, at an angle
               carved out by the transparent sides. */
            width: 0;
            height: 0;
            border-style: solid;
            border-width: 8px 0 8px 13px;
            border-color: transparent transparent transparent #ffffff;
            /* Optical centering - a triangle's visual "weight" sits
               left of its own bounding box (the pointed tip is empty
               space on the right), so a couple pixels of left margin
               is what actually makes it look centered in the button,
               not just mathematically centered. */
            margin-left: 3px;
        }
        /* Pause: box-shadow draws a second identical bar 9px to the
           right of the pseudo-element itself, so 2 bars render from
           a single element without needing a second one. Negative
           margin-left is required here - box-shadow isn't counted in
           the flex box model used for centering (.granpai-timeline-play
           itself is display:flex + justify-content:center), so only
           this 4px element itself was being centered, leaving the
           shadow-drawn second bar (9px further right, entirely outside
           what flex "sees") to push the whole visual icon rightward. */
        .granpai-timeline-play.is-playing::before {
            width: 4px;
            height: 16px;
            border: none;
            background: #ffffff;
            box-shadow: 9px 0 0 #ffffff;
            margin-left: -9px;
        }
        .granpai-timeline-play:hover {
            transform: scale(1.08);
            box-shadow: 0 4px 16px -2px var(--gp-pin-color, var(--gp-accent));
        }
        .granpai-timeline-play:active {
            transform: scale(0.96);
        }
        /* Without this, clicking/tabbing to the button shows the
           browser's own default focus outline, which follows the
           element's rectangular bounding box regardless of
           border-radius - producing a square ring around this round
           button. This box-shadow ring is shaped correctly instead,
           since box-shadow (unlike outline in most browsers) respects
           the element's own border-radius. */
        .granpai-timeline-play:focus-visible {
            outline: none;
            box-shadow: 0 0 0 3px color-mix(in srgb, var(--gp-pin-color, var(--gp-accent)) 50%, transparent 50%);
        }
        /* A soft, slow pulse ring while idle - an invitation to press
           Play, not just a static icon waiting to be noticed. Stops the
           instant playback actually starts (.is-playing below), so it
           never looks like something's wrong once it's running. */
        .granpai-timeline-play::after {
            content: '';
            position: absolute;
            inset: -4px;
            border-radius: 50%;
            border: 1.5px solid var(--gp-pin-color, var(--gp-accent));
            opacity: 0;
            animation: granpai-timeline-play-pulse 2.4s ease-out infinite;
        }
        .granpai-timeline-play.is-playing::after {
            animation: none;
            opacity: 0;
        }
        @keyframes granpai-timeline-play-pulse {
            0%   { transform: scale(0.9); opacity: 0.6; }
            100% { transform: scale(1.35); opacity: 0; }
        }
        .granpai-timeline-nodes {
            position: relative;
            flex: 1 1 auto;
            min-width: 0;
            height: 46px;
            display: flex;
            align-items: center;
            gap: 34px;
            overflow-x: auto;
            cursor: grab;
            /* Drag/swipe with a natural snap-to-year settle, instead of
               free-scrolling to an arbitrary in-between position - the
               same interaction an iOS date wheel or Spotify's year
               picker uses. */
            scroll-snap-type: x mandatory;
            -webkit-overflow-scrolling: touch;
            scrollbar-width: none;
        }
        .granpai-timeline-nodes.is-dragging {
            cursor: grabbing;
            scroll-snap-type: none;
        }
        .granpai-timeline-nodes::-webkit-scrollbar {
            display: none;
        }
        /* The ruler baseline itself - a single line running the full
           width, behind every node, with each node's own tick mark (see
           .granpai-timeline-node::before) sitting on top of it. Reads as
           a real timeline axis rather than a loose row of floating
           labels. */
        .granpai-timeline-nodes {
            background-image: linear-gradient(var(--gp-grid-line, rgba(255,255,255,0.14)), var(--gp-grid-line, rgba(255,255,255,0.14)));
            background-repeat: no-repeat;
            background-position: center 6px;
            background-size: 100% 1px;
        }
        /* Lets the FIRST and LAST year also reach dead-center when
           scrolled all the way to either end - without this, the
           carousel could only center years somewhere in the middle of
           the list, never the ones at the very edges. */
        .granpai-timeline-nodes::before,
        .granpai-timeline-nodes::after {
            content: '';
            flex: 0 0 auto;
            width: calc(50% - 17px);
        }
        .granpai-timeline-node {
            position: relative;
            scroll-snap-align: center;
            flex: 0 0 auto;
            border: none;
            background: transparent;
            cursor: pointer;
            padding: 15px 3px 0;
            font-weight: 700;
            white-space: nowrap;
            color: var(--gp-text-muted, #8a8fa3);
            font-size: 12px;
            opacity: 0.4;
            transition: font-size 0.2s ease, opacity 0.2s ease, color 0.2s ease;
        }
        /* Each year's own tick mark on the ruler baseline - taller and
           brighter for the centered/active year, so its position on the
           axis is unmistakable even before reading the number itself. */
        .granpai-timeline-node::before {
            content: '';
            position: absolute;
            top: 0;
            left: 50%;
            transform: translateX(-50%);
            width: 1.5px;
            height: 7px;
            background: var(--gp-text-muted, #8a8fa3);
            opacity: 0.6;
            transition: height 0.2s ease, background 0.2s ease, opacity 0.2s ease;
        }
        .granpai-timeline-node:hover {
            opacity: 0.7;
        }
        /* The one centered year - larger and in the accent color, every
           other year stays small/dim (see .granpai-timeline-node's own
           base style above) so the current position is unmistakable at
           a glance. */
        .granpai-timeline-node.is-active {
            color: #ffffff;
            opacity: 1;
            font-size: 19px;
        }
        .granpai-timeline-node.is-active::before {
            height: 12px;
            background: var(--gp-pin-color, var(--gp-accent));
            opacity: 1;
        }
        .granpai-timeline-label {
            display: none;
        }
        /* Flat Map's own zoom controls sit in the bottom-right corner
           (see .granpai-flatmap-zoom-controls below) - narrow the bar
           on that side so the two never overlap. Globe mode has nothing
           in that corner, so it keeps the full-width bar. */
        .granpai-flatmap-zoom-controls {
            position: absolute;
            right: 14px;
            bottom: 14px;
            z-index: 3;
            display: flex;
            flex-direction: column;
            gap: 6px;
            pointer-events: auto;
        }
        .granpai-zoom-btn {
            width: 30px;
            height: 30px;
            border-radius: 6px;
            border: 1px solid var(--gp-grid-line);
            background: var(--gp-surface, #0a0c18);
            color: var(--gp-text, #fff);
            font-size: 16px;
            line-height: 1;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .granpai-zoom-btn:hover {
            border-color: var(--gp-accent);
            color: var(--gp-accent);
        }
        .granpai-zoom-reset {
            font-size: 14px;
        }
        .granpai-flatmap .granpai-land {
            fill: var(--gp-land-fill);
            stroke: var(--gp-land-stroke);
            stroke-width: 0.6;
        }
        /* --gp-accent (full-strength base color, no low alpha baked
           in) instead of inheriting --gp-land-fill from .granpai-land
           - that color's LOW baked-in alpha (0.10 dark / 0.14 light)
           is tuned for a large solid fill area, where it still reads
           fine over a big surface; a small discrete dot (r=1.4px, with
           background peeking through the gaps between dots) has far
           less ink coverage, so the exact same low-opacity color reads
           as much fainter overall on either theme - this isn't a
           light/dark-specific issue, the base color itself was simply
           too weak for a sparse dot pattern. No stroke (a filled
           circle, not an outlined shape). */
        .granpai-land--dots circle {
            fill: var(--gp-accent);
            stroke: none;
        }
        /* Each facet reads its own --facet-shade custom property (set
           per-element in JS, deterministic per facet) via
           filter:brightness() - the classic low-poly faceted-shading
           look, every triangle a slightly different tone of the same
           base land color. */
        .granpai-lowpoly-facet {
            stroke: var(--gp-land-stroke);
            stroke-width: 0.4;
            filter: brightness(var(--facet-shade, 1));
        }
        .granpai-flatmap-markers {
            position: absolute;
            inset: 0;
            pointer-events: none;
        }
        .granpai-flatmap-markers .granpai-pulse-marker {
            position: absolute;
        }
        .granpai-flatmap-loading {
            position: absolute;
            inset: 0;
            display: flex;
            align-items: center;
            justify-content: center;
            color: var(--gp-text-muted);
            font-size: 13px;
            letter-spacing: 0.5px;
            text-align: center;
            padding: 20px;
        }
