
        /* 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;
        }

        /* Bug fix: the base rule's own max-height:100vh (a legitimate
           safety cap on the live frontend - see its own comment above -
           preventing an unusually WIDE full-bleed section from making a
           square globe grow absurdly TALL too) was also capping the
           admin Map Editor's own Live Preview, where it has nothing to
           do with the actual page layout at all: 100vh there means "the
           height of the browser's current viewport", regardless of how
           far down the wp-admin screen the Live Preview metabox happens
           to be scrolled - on a normal laptop window this is routinely
           well UNDER what the preview's own width (often 900-1000px)
           needs for a proper square/8:9 (Timeline) shape, so the cap
           kicked in and silently squashed the globe's flex-column layout
           down to whatever vertical room the browser window had at that
           moment, instead of the width-driven size aspect-ratio actually
           asks for. Confirmed empirically: on a 993.2px-wide preview
           wrapper, removing just this one constraint (everything else
           unchanged) took the wrapper from 737.6px tall (clipped by an
           ~equal 737.6px browser-viewport max-height) to the CORRECT
           1117.35px (= 993.2 * 9/8, matching the Timeline wrapper's own
           aspect-ratio:8/9 exactly) - the globe itself went from a small,
           visibly undersized sphere to filling its stage properly, with
           zero other change needed. Scoped to #gp-map-preview-mount only
           (the Live Preview metabox's own, single, reused mount element -
           see admin-map-preview.js) so the live frontend's real safety
           cap is completely untouched; the WP-admin content column has
           its own natural width ceiling regardless (nothing analogous to
           a theme's arbitrarily-wide full-bleed section can happen
           inside a metabox), so an unbounded height here carries none of
           the runaway-tall risk the frontend cap was written to prevent. */
        #gp-map-preview-mount .granpai-globe-wrapper {
            max-height: none;
        }
        /* Follow-up requested by Tho right after the max-height:none fix
           above shipped: with the cap removed, an admin preview that's
           genuinely wide (~1000px, typical) now renders a correctly-
           proportioned but VERY large globe (~1000px tall, or ~1125px
           for Timeline's 8:9) in its INITIAL, no-modal-open state -
           technically correct, but "quá khủng" (too huge), forcing a lot
           of scrolling just to see the whole thing. Deliberately capping
           via WIDTH (not max-height) here: aspect-ratio:1 on the base
           rule then derives height from this already-capped width, so
           the box stays a true square (or 8:9 for Timeline, see the
           override just below) instead of getting squashed out of shape
           the way the ORIGINAL max-height:100vh bug did. min(100%, 80vh)
           only ever binds when the container is wide enough that a
           square box would exceed ~80% of the browser's actual viewport
           height - never on a narrow/mobile-width preview, where 100% is
           already well under 80vh. (Originally shipped as 90vh; lowered
           to 80vh in a same-day follow-up round per Tho's direct request,
           to leave a bit more visible headroom above/below the preview on
           a normal laptop window - same mechanism throughout, only the
           one number changed.)
           Also floored at min(100%, 780px): pro-frontend.js's
           applyAutoBalanceShift() only engages Auto-Balance once
           wrapperEl.offsetWidth >= its own MIN_SPLIT_WRAPPER_WIDTH
           (780px) - a bare 80vh cap alone can shrink the wrapper under
           that gate on a normal-height laptop window, silently stopping
           Auto-Balance from ever triggering. max(...) with a min(100%,
           780px) floor keeps the wrapper at least 780px wide whenever
           the container actually has that much room (a no-op on a
           genuinely narrower/mobile preview).
           **:not(.granpai-auto-balance-split) is the critical, load-
           bearing part of this selector** - a first version of this fix
           left that off and applied the SAME cap unconditionally,
           including once Auto-Balance had already split the layout. That
           was a real, screenshot-confirmed regression Tho caught: since
           this #gp-map-preview-mount rule is ID-scoped, its specificity
           beats the split rule below even though the split rule comes
           later in the file (the split rule never sets its own `width`
           to contest it) - so the wrapper stayed capped at ~780-990px
           EVEN IN THE SPLIT STATE, leaving dead gray space to the right
           of the modal while the globe itself got squeezed down to
           roughly 780 - 420 (modal) - gap, well under the ~517-524px it
           correctly measured at before this whole cap existed. Tho's own
           diagnosis was exactly right: only the INITIAL (pre-modal) size
           needed capping - "khi modal xuất hiện nó đã hoạt động đúng."
           With this guard, the cap applies only while
           .granpai-auto-balance-split is NOT present; the instant that
           class is added (a pin's modal opens), this whole rule stops
           matching and width falls back to the base rule's plain 100% -
           the split layout gets the FULL container width again, exactly
           as it did (and was already confirmed correct) before this
           initial-size cap was ever added. */
        #gp-map-preview-mount .granpai-globe-wrapper:not(.granpai-auto-balance-split) {
            width: max(min(100%, 80vh), min(100%, 780px));
            /* Centers the capped box horizontally instead of leaving it
               flush against the mount's left edge - the mount
               (.gp-map-preview-mount, admin-map-editor.css) is a plain
               block container (position:relative only, no flex/grid), so
               once this rule drops the wrapper's width below the mount's
               own 100%, nothing else centers the leftover space by
               default; a block-level box with no margin just starts at
               its parent's left edge. Requested directly by Tho after
               seeing the capped preview sitting off to one side instead
               of centered ("tạo nó nằm giữa khung chứa, thay vì nằm sát 1
               bên"). Declared once here rather than repeated on the
               Timeline-specific rule below: that rule is MORE specific
               but only ever overrides `width`, so this rule's
               margin-inline still applies to Timeline wrappers too -
               CSS cascades property-by-property, not rule-by-rule. */
            margin-inline: auto;
        }
        /* Timeline's wrapper is 8:9 (height = width * 9/8), so capping
           the SAME way needs a smaller width ceiling to land on the same
           ~80vh total height: 80vh * 8/9. Computed via calc() rather than
           a rounded literal (the old 90vh/80vh pair was written as two
           independent literals - deliberately switched to a derived
           calc() this round so a future change to the 80vh base above
           can never silently drift out of sync with this value the way
           two hand-picked literals could). Same 780px Auto-Balance floor
           and same :not(.granpai-auto-balance-split) guard as the rule
           above; inherits margin-inline:auto from that same rule above
           (this one only overrides width). Scoped narrowly (same
           #gp-map-preview-mount prefix) so this has zero effect on the
           live frontend, exactly like the rule above. */
        #gp-map-preview-mount .granpai-globe-wrapper[data-mode='globe'][data-timeline-enabled='1']:not(.granpai-auto-balance-split) {
            width: max(min(100%, calc(80vh * 8 / 9)), min(100%, 780px));
        }

        /* A world map's own natural proportions are roughly 2:1 (see
           computeProjection() in globe-init.js) - the base wrapper rule
           above forces a square (1:1) box, which fits an actual 3D
           sphere but left Flat Map's projection centered inside a lot
           of empty letterboxed space above/below it instead of filling
           its own frame properly. The projection math itself was
           already correct either way (centered, undistorted) - this
           just makes the wrapper's own shape match what it's actually
           framing. Desktop only: the existing mobile block below
           (max-width:640px) already sets its own fixed height with
           aspect-ratio:unset for unrelated reasons (a fixed 50/50
           split with the modal), which this doesn't need to touch. */
        @media (min-width: 641px) {
            .granpai-globe-wrapper[data-mode='flat'] {
                aspect-ratio: 2 / 1;
            }
        }

        /* 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. */
        /* 2026-09-29: Tho asked directly to raise --gp-accent-soft,
           --gp-grid-line, --gp-land-fill and --gp-land-stroke in both
           theme blocks, for Dot Matrix/Low-Poly clarity. Worth being
           precise about what these 4 variables actually touch, since
           it isn't quite what it might look like from the names alone:
           Globe mode (the only place Dot Matrix/Low-Poly are currently
           selectable - the Style field is gated to mode=globe in
           settings-schema.json) computes its own landFill/landStroke
           straight from config.landColor whenever no custom Land
           Opacity is set (the normal case), and never reads
           --gp-land-fill/--gp-land-stroke for those two styles at all -
           see the isDotMatrix/isLowPoly boost block in globe-init.js,
           bumped the same day to 0.88/0.92. So raising --gp-land-fill/
           --gp-land-stroke here has NO effect on what Tho is actually
           looking at in the admin Live Preview; the real fix for that
           lives in the JS boost value above. --gp-accent-soft is only
           ever used for the pin pulse-glow box-shadow, and --gp-grid-
           line only for flat UI dividers (timeline ruler, zoom-button
           border, panel borders) - neither one touches the globe/map
           rendering at all, on any style. Raised all 4 anyway, since
           they're real (if secondary) levers: --gp-land-fill/--gp-land-
           stroke still set Minimal/Photoreal's own default flat-mode
           land color and Low-Poly's flat-mode facet stroke (the
           Flat-mode legacy code path, still reachable if a map's style
           was set before its mode was switched to flat), and their old
           values (0.10/0.14) were actually BELOW globe-init.js's own
           JS-side fallback default for the same variable (0.18/0.25),
           so this also fixes that latent mismatch for Minimal. */
        .granpai-theme-dark {
            --gp-bg: #03030a;
            --gp-surface: #0a0c18;
            --gp-accent: #00ffcc;
            --gp-accent-soft: rgba(0, 255, 204, 0.45);
            --gp-text: #ffffff;
            --gp-text-muted: #d1d1de;
            --gp-grid-line: rgba(255, 255, 255, 0.10);
            --gp-land-fill: rgba(0, 255, 204, 0.22);
            --gp-land-stroke: rgba(0, 255, 204, 0.70);
        }
        .granpai-theme-light {
            --gp-bg: transparent;
            --gp-surface: #ffffff;
            --gp-accent: #0d9488;
            --gp-accent-soft: rgba(13, 148, 136, 0.45);
            --gp-text: #14151f;
            --gp-text-muted: #565971;
            --gp-grid-line: rgba(20, 21, 31, 0.12);
            --gp-land-fill: rgba(13, 148, 136, 0.30);
            --gp-land-stroke: rgba(13, 148, 136, 0.78);
        }

        .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). Still drives
               the Auto-Balance layout-shift mechanism (the globe's own
               space growing/shrinking around the modal), which a
               transform-only approach on this element would break -
               the actual scale-in/scale-out visual effect instead lives
               on .granpai-modal-box below, layered independently. */
            display: grid;
            grid-template-rows: 0fr;
            background: transparent;
            opacity: 0;
            visibility: visible;
            overflow: hidden;
            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;
            /* Bug fix: this element has horizontal padding (40px) and a
               1px border on each side, but no box-sizing was ever set
               here, so it fell back to the browser default (content-box).
               With width:100% resolving against the parent overlay's own
               fixed width (var(--gp-modal-dock-width), 420px), content-box
               ADDS the padding+border on top of that 420px content width
               instead of including them in it - the box rendered at
               420 + 40 (padding) + 2 (border) = 462px, ~42px wider than
               its own parent (.granpai-modal-overlay, fixed at exactly
               420px via flex:0 0 auto). Confirmed empirically live: the
               box measured 461.6px against a 420px parent before this
               fix, and exactly 420px after adding border-box. */
            box-sizing: border-box;
            width: 100%;
            max-width: none;
            max-height: 60vh;
            min-height: 0;
            overflow: hidden;
            padding: 22px 20px 20px;
            color: var(--gp-text, #ffffff);
            /* Genuine scale-in/scale-out, even on both axes - not just
               the overlay's own height (grid-template-rows above),
               which previously left the box's width unshrunk while its
               height collapsed, briefly showing as a thin strip with
               the close button still sitting in it rather than
               shrinking as one cohesive shape. A slight overshoot
               (cubic-bezier dipping past 1) reads as a light, springy
               pop rather than a flat mechanical resize. */
            transform: scale(0.86);
            transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
            box-shadow: 0 -8px 24px rgba(0,0,0,0.25);
        }
        .granpai-modal-overlay.is-open .granpai-modal-box {
            transform: scale(1);
            opacity: 1;
        }

        /* ---------- Modal presets (Pro) ----------
           2026-09-29 (Tho): "modal hoạt động đúng nhưng giao diện chưa
           tương xứng với các trang web cao cấp" - asked for a few
           ready-made visual presets on top of the modal's existing
           color/border customization (--gp-modal-tag-color/--gp-modal-
           border-color, set inline per-map by appearance-pro.php).
           Deliberately implemented as pure visual overrides on
           .granpai-modal-box only (background/border/shadow/blur) - NOT
           layout, sizing, or positioning, none of which any preset
           touches. That's what makes this safe to layer on top of every
           existing docking mode (base/mobile, outside-docked, flush,
           Auto-Balance split) with a single rule set instead of needing
           a separate override per breakpoint block: every one of those
           modes already renders the same .granpai-modal-box element,
           this only changes how that box paints, never where/how big it
           is. Selector specificity (.granpai-globe-wrapper[data-modal-
           preset="x"] .granpai-modal-box, 2 classes + 1 attribute) beats
           the base rule's plain .granpai-modal-box (1 class) on every
           property it redeclares, so no !important needed anywhere
           here, and it still loses cleanly to the more specific opacity-
           on-close overrides inside the docking breakpoints further
           below (those never touch background/border/box-shadow, so
           there's no actual conflict to begin with).
           data-modal-preset is written by shortcode.php from
           $settings['modal_preset'] (Pro-only meta, see
           granpai_pro_attach_map_appearance_settings() in
           appearance-pro.php) - empty/absent on Free or when unset,
           which matches no attribute selector below and falls through
           to the existing base look untouched. color-mix() (broadly
           supported in every evergreen browser since 2023) is used
           throughout instead of a hard-coded rgba() so each preset's
           border/glow tint automatically follows whatever custom
           --gp-modal-border-color a site owner has already set (or the
           theme's own --gp-accent when they haven't), rather than
           fighting that existing customization with an unrelated fixed
           color. */

        /* Glass: frosted, translucent card - the globe/page behind it
           stays faintly visible through the blur, a modern "glassmorphism"
           look. backdrop-filter has no effect in a browser that doesn't
           support it (older Firefox, some in-app webviews) - the
           semi-transparent background-color underneath is still a
           reasonable, readable fallback on its own even with zero blur. */
        .granpai-globe-wrapper[data-modal-preset="glass"] .granpai-modal-box {
            background: color-mix(in srgb, var(--gp-surface, #0a0c18) 55%, transparent);
            -webkit-backdrop-filter: blur(20px) saturate(160%);
            backdrop-filter: blur(20px) saturate(160%);
            border: 1px solid color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 35%, transparent);
            box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.08);
        }
        .granpai-globe-wrapper[data-modal-preset="glass"] .granpai-modal-title {
            border-bottom-color: color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 25%, transparent);
        }
        .granpai-globe-wrapper[data-modal-preset="glass"] .granpai-modal-nav-btn {
            background: color-mix(in srgb, var(--gp-surface, #0a0c18) 40%, transparent);
            -webkit-backdrop-filter: blur(8px);
            backdrop-filter: blur(8px);
        }

        /* Bento: bolder, opaque card - larger radius, heavier shadow, and
           a thin gradient accent bar across the top edge (the "bento
           grid" tile look many SaaS marketing/dashboard sites use for a
           card's header). Solid background (no blur) so it reads as a
           distinct, opaque block sitting ON the page rather than part of
           it, the opposite intent from Glass above. */
        .granpai-globe-wrapper[data-modal-preset="bento"] .granpai-modal-box {
            background: var(--gp-surface, #0a0c18);
            border: 1px solid color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 45%, transparent);
            border-radius: 22px;
            box-shadow: 0 28px 70px -12px rgba(0, 0, 0, 0.5);
            overflow: hidden; /* clips the top accent bar below to the box's own rounded corners */
        }
        .granpai-globe-wrapper[data-modal-preset="bento"] .granpai-modal-box::before {
            content: '';
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            height: 4px;
            background: linear-gradient(90deg, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)), color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 40%, transparent));
        }
        .granpai-globe-wrapper[data-modal-preset="bento"] .granpai-modal-tag {
            background: color-mix(in srgb, var(--gp-modal-tag-color, var(--gp-accent, #00ffcc)) 16%, transparent);
            display: inline-block;
            padding: 4px 10px;
            border-radius: 999px;
        }
        .granpai-globe-wrapper[data-modal-preset="bento"] .granpai-modal-nav-btn {
            border-radius: 999px;
            border-color: color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 45%, transparent);
        }

        /* Neon/Glow: dark card with a glowing accent-colored ring - fits
           the globe's own "space/tech" visual theme most directly of the
           3 presets. Layered box-shadow (tight ring + two soft outward
           blurs at increasing spread/decreasing opacity) reads as a real
           glow rather than a flat colored border; the title's own
           text-shadow echoes the same glow at a much smaller scale so
           the whole card feels lit from the same light source. */
        .granpai-globe-wrapper[data-modal-preset="neon"] .granpai-modal-box {
            background: var(--gp-bg, #03030a);
            border: 1px solid var(--gp-modal-border-color, var(--gp-accent, #00ffcc));
            box-shadow:
                0 0 0 1px color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 60%, transparent),
                0 0 24px color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 55%, transparent),
                0 0 64px color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 30%, transparent);
        }
        .granpai-globe-wrapper[data-modal-preset="neon"] .granpai-modal-title {
            text-shadow: 0 0 18px color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 45%, transparent);
        }
        .granpai-globe-wrapper[data-modal-preset="neon"] .granpai-modal-tag {
            text-shadow: 0 0 10px color-mix(in srgb, var(--gp-modal-tag-color, var(--gp-accent, #00ffcc)) 70%, transparent);
        }
        .granpai-globe-wrapper[data-modal-preset="neon"] .granpai-modal-nav-btn {
            border-color: color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 55%, transparent);
        }
        .granpai-globe-wrapper[data-modal-preset="neon"] .granpai-modal-nav-btn:hover {
            box-shadow: 0 0 12px color-mix(in srgb, var(--gp-modal-border-color, var(--gp-accent, #00ffcc)) 50%, transparent);
        }

        .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;
        }
        @keyframes granpai-modal-reveal {
            from { opacity: 0; transform: translateY(10px); }
            to { opacity: 1; transform: translateY(0); }
        }
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-media,
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-tag,
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-title,
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-body,
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-contacts,
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-footer {
            animation: granpai-modal-reveal 0.4s ease-out both;
        }
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-media    { animation-delay: 0.02s; }
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-tag     { animation-delay: 0.08s; }
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-title  { animation-delay: 0.14s; }
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-body   { animation-delay: 0.20s; }
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-contacts { animation-delay: 0.26s; }
        .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-footer { animation-delay: 0.32s; }
        @media (prefers-reduced-motion: reduce) {
            .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-media,
            .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-tag,
            .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-title,
            .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-body,
            .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-contacts,
            .granpai-globe-wrapper[data-modal-reveal-effect="1"] .granpai-modal-overlay.is-revealing .granpai-modal-footer {
                animation: none;
            }
        }
        .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) * 0.5 - 64px);
            }
        }

        /* ---------- 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;
                }
                /* 2026-09-29 bugfix (Tho): flush mode above docks against
                   the WRAPPER's own edge, but inside the admin Live
                   Preview the wrapper is deliberately capped narrower
                   than, and centered within, the preview mount (see the
                   #gp-map-preview-mount width-cap rule near the top of
                   this file) - leaving a real gap between where "flush"
                   lands and the mount's own true visible edge, which is
                   what actually reads as "the edge of the map" to Tho.
                   updateModalDockMode() (globe-init.js) measures that
                   exact gap and writes it to --gp-preview-edge-gap on the
                   wrapper; pulling the modal outward by that same amount
                   here closes it, so the modal's outer edge lands on the
                   mount's real edge instead of the wrapper's. ID-scoped
                   so it only ever applies inside the preview - the live
                   frontend's own flush mode (a real, narrower embed where
                   the wrapper's own edge genuinely IS the visible edge)
                   is untouched, and --gp-preview-edge-gap is never set
                   outside the preview branch of updateModalDockMode() in
                   the first place. */
                #gp-map-preview-mount .granpai-globe-wrapper.granpai-modal-flush .granpai-modal-overlay {
                    margin-right: calc(-1 * var(--gp-preview-edge-gap, 0px));
                }
                #gp-map-preview-mount .granpai-globe-wrapper.granpai-modal-flush[data-modal-side='left'] .granpai-modal-overlay {
                    margin-right: 0;
                    margin-left: calc(-1 * var(--gp-preview-edge-gap, 0px));
                }

                /* Bug fix (Tho's own diagnosis, confirmed correct):
                   .granpai-modal-box's BASE rule (further up this file)
                   never declares its own `opacity` at all - the only
                   place opacity is ever set on the box itself is
                   `.granpai-modal-overlay.is-open .granpai-modal-box {
                   opacity: 1; }` (unconditional, not scoped to this
                   breakpoint), which is redundant while open (opacity's
                   own browser-default initial value is already 1) but
                   crucially never gets UNDONE on close - there was no
                   rule anywhere that ever set the box back to opacity:0.
                   Meanwhile closePinModal() (globe-init.js) clears the
                   pin's title/body/tag content synchronously, the
                   instant closing starts - well before the overlay's own
                   ~0.28-0.32s opacity/transform close transition
                   finishes. Since the box itself was always fully opaque
                   throughout that whole window (only its own `transform:
                   scale(...)` was ever animated - opacity isn't even in
                   its `transition` list), the result was an empty
                   card - still showing its border/background/close
                   button, content already gone - visibly fading out over
                   a quarter-second instead of vanishing with the rest of
                   the close animation. Adding `opacity: 0` here (scoped
                   to this breakpoint's box override, so it can never
                   fight the base rule's own !important-free specificity
                   elsewhere) undoes exactly that gap: the moment
                   `.is-open` is removed, the higher-specificity `.is-open
                   .granpai-modal-box { opacity: 1 }` rule stops matching
                   and this rule's opacity:0 becomes the winning value for
                   the box - and since opacity was never in this element's
                   `transition` list, that change applies INSTANTLY, not
                   over time. The box disappears the same instant closing
                   starts, so the emptied-out content is never visible at
                   all, regardless of how long the overlay's own separate
                   fade/scale-down animation continues to run around it.
                   The open side is unaffected: `.is-open .granpai-modal-
                   box`'s existing opacity:1 still wins there (higher
                   specificity, 3 classes vs. this rule's 1), so opening
                   still shows the box immediately while its `transform:
                   scale(0.86 -> 1)` bounce-in animates exactly as before -
                   only the CLOSE side's behavior changes. */
                .granpai-modal-box {
                    width: 100%;
                    height: auto;
                    max-height: 100%;
                    opacity: 0;
                    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;
                }
                /* Same 2026-09-29 preview-edge-gap fix as the
                   @container branch above - see its own comment there
                   for the full reasoning. Duplicated here for the
                   @supports-not fallback (this whole block only applies
                   in browsers without container-query support). */
                #gp-map-preview-mount .granpai-globe-wrapper.granpai-modal-flush .granpai-modal-overlay {
                    margin-right: calc(-1 * var(--gp-preview-edge-gap, 0px));
                }
                #gp-map-preview-mount .granpai-globe-wrapper.granpai-modal-flush[data-modal-side='left'] .granpai-modal-overlay {
                    margin-right: 0;
                    margin-left: calc(-1 * var(--gp-preview-edge-gap, 0px));
                }
                /* Bug fix (Tho's own diagnosis, confirmed correct):
                   .granpai-modal-box's BASE rule (further up this file)
                   never declares its own `opacity` at all - the only
                   place opacity is ever set on the box itself is
                   `.granpai-modal-overlay.is-open .granpai-modal-box {
                   opacity: 1; }` (unconditional, not scoped to this
                   breakpoint), which is redundant while open (opacity's
                   own browser-default initial value is already 1) but
                   crucially never gets UNDONE on close - there was no
                   rule anywhere that ever set the box back to opacity:0.
                   Meanwhile closePinModal() (globe-init.js) clears the
                   pin's title/body/tag content synchronously, the
                   instant closing starts - well before the overlay's own
                   ~0.28-0.32s opacity/transform close transition
                   finishes. Since the box itself was always fully opaque
                   throughout that whole window (only its own `transform:
                   scale(...)` was ever animated - opacity isn't even in
                   its `transition` list), the result was an empty
                   card - still showing its border/background/close
                   button, content already gone - visibly fading out over
                   a quarter-second instead of vanishing with the rest of
                   the close animation. Adding `opacity: 0` here (scoped
                   to this breakpoint's box override, so it can never
                   fight the base rule's own !important-free specificity
                   elsewhere) undoes exactly that gap: the moment
                   `.is-open` is removed, the higher-specificity `.is-open
                   .granpai-modal-box { opacity: 1 }` rule stops matching
                   and this rule's opacity:0 becomes the winning value for
                   the box - and since opacity was never in this element's
                   `transition` list, that change applies INSTANTLY, not
                   over time. The box disappears the same instant closing
                   starts, so the emptied-out content is never visible at
                   all, regardless of how long the overlay's own separate
                   fade/scale-down animation continues to run around it.
                   The open side is unaffected: `.is-open .granpai-modal-
                   box`'s existing opacity:1 still wins there (higher
                   specificity, 3 classes vs. this rule's 1), so opening
                   still shows the box immediately while its `transform:
                   scale(0.86 -> 1)` bounce-in animates exactly as before -
                   only the CLOSE side's behavior changes. */
                .granpai-modal-box {
                    width: 100%;
                    height: auto;
                    max-height: 100%;
                    opacity: 0;
                    box-shadow: 0 20px 60px rgba(0,0,0,0.35);
                }
            }
        }

        /* Auto-Balance split layout (Pro) - Case 1-2 (flex) and Case 3
           (Timeline, grid) combined into ONE unconditional rule set,
           deliberately NOT wrapped in any @container/@media/@supports.
           This used to live inside "@container granpai-globe (min-width:
           520px)" / its @media fallback, gated on the wrapper's own
           width - but .granpai-globe-wrapper is what DECLARES that
           container (container: granpai-globe / inline-size, above), and
           per the CSS Containment spec an element can never be styled by
           a query on the container it itself establishes - only its
           descendants can react to it. Confirmed live: with
           .granpai-auto-balance-split correctly toggled on by JS and the
           modal open, getComputedStyle(wrapper).display stayed "flex"
           (Case 3's "display: grid" and every other wrapper-level
           property never applied), even though the same rule's
           .granpai-modal-overlay descendant selector DID win and apply
           correctly - exactly the self-container-query limitation, not a
           specificity problem. Same root cause, and same fix pattern
           (JS-measured class + plain selector, no query), as the
           .granpai-desktop-roomy precedent further below in this file.
           No size condition is needed here at all: pro-frontend.js's
           applyAutoBalanceShift() already gates on
           wrapperEl.offsetWidth >= 780 (stricter than this rule's old
           520px) before ever toggling .granpai-auto-balance-split, so by
           the time this class is present the width requirement is
           already satisfied. */
        /* :not([data-timeline-enabled='1']) on every selector in this
           Case 1-2 block is deliberate, not decorative: without it, these
           rules still MATCH a Timeline wrapper too (nothing here ever
           excluded it), and even though Case 3 below has higher
           specificity and wins for the properties it DOES declare, any
           property Case 3 leaves undeclared falls back to whatever a
           matching Case 1-2 rule says instead - which is exactly what
           happened with .granpai-stage-clip's max-width (see Case 3's own
           comment below): Case 3 deliberately stopped declaring it, but
           this rule was still silently supplying "max-width: min(640px,
           55%)" underneath, resurrecting the same compounding-percentage
           collapse in the grid layout that removing it from Case 3 was
           supposed to fix. Confirmed live: without this exclusion, a
           1000px-wide Timeline wrapper's globe still rendered at 288px
           (55% of the already-524px grid column) instead of the correct
           524px, DESPITE Case 3's own rule no longer containing that
           max-width anywhere. Higher specificity only wins property-by-
           property, it does not fence off a whole element from a lower-
           specificity rule that still matches it - true isolation between
           the two cases needs an explicit :not() on both sides. */
        .granpai-globe-wrapper.granpai-auto-balance-split:not([data-timeline-enabled='1']):has(.granpai-modal-overlay.is-open) {
            display: flex;
            /* The base .granpai-globe-wrapper rule (top of this file) sets
               flex-direction: column (for the normal stacked globe-then-
               timeline-bar layout) - this split rule never overrode it
               back to a row for the default/right modal-side case, only
               the [data-modal-side='left'] variant below sets row-reverse.
               Without this, the DEFAULT (right) modal-side case silently
               stayed stacked vertically instead of side-by-side whenever
               Auto-Balance activated - found live while checking every
               Auto-Balance case/side combination, not just the one
               (modal-side='left') this whole debugging chain happened to
               use as its running example. */
            flex-direction: row;
            align-items: center;
            justify-content: center;
            /* Was clamp(24px, 4vw, 56px) - tightened at Tho's request so
               more of the row's width goes to the globe (flex:1 1 auto,
               see .granpai-stage-clip below) instead of the gap between
               it and the fixed-width modal. Still fluid/responsive
               (4vw -> 2.5vw), just a smaller floor/ceiling. */
            gap: clamp(16px, 2.5vw, 32px);
            /* The base .granpai-globe-wrapper rule (top of this file) sets
               aspect-ratio:1 + height:100%!important, which locks the
               WRAPPER itself to a square - correct for the normal single-
               column case, but directly fighting a wide side-by-side pair
               here: it forced the wrapper's height to match its own
               (now much wider) width, i.e. tried to make the whole pair a
               giant square too. Unsetting it lets the wrapper's height
               come from its own content (the row below) instead - the
               globe itself gets its shape back via aspect-ratio on
               .granpai-stage-clip specifically, not the outer wrapper. */
            aspect-ratio: unset;
            height: auto;
        }
        .granpai-globe-wrapper.granpai-auto-balance-split:not([data-timeline-enabled='1']):has(.granpai-modal-overlay.is-open) .granpai-stage-clip {
            position: relative;
            flex: 1 1 auto;
            width: auto;
            /* No max-width here (used to be min(640px, 55%)) - the globe
               is meant to be exactly "100% of the wrapper minus the
               modal's own fixed width minus the gap", per Tho's explicit
               spec, and flex-grow already does exactly that on its own:
               the modal is flex: 0 0 auto (a fixed var(--gp-modal-dock-
               width), 420px, never grows/shrinks), so this flex:1 1 auto
               item naturally receives 100% of whatever space is left in
               the row once the modal and the gap are subtracted - that
               IS "container - modal - gap", with no extra cap needed.
               The old min(640px, 55%) ceiling was fighting this: past
               roughly a 1164px-wide wrapper (640 / 0.55), 640px always
               won, so the globe stopped growing at all on wide screens
               and the whole pair just centered with dead space on both
               sides instead of the globe actually getting bigger - not a
               compounding-percentage bug like Case 3 had (flexbox
               doesn't have that grid-only issue), just an artificial
               ceiling that no longer matches the intended behavior.
               Note: since .granpai-stage-clip keeps aspect-ratio:1 below,
               removing the cap means the globe's HEIGHT grows right along
               with its width on very wide screens - worth a visual check
               on an ultrawide display; add a max-width back here (a much
               larger one, not 640px) if it ever looks too large. */
            /* Was relying on the wrapper's own square aspect-ratio (now
               unset just above) to give this element a real height via
               flex-grow filling an already-square parent - with that gone,
               .granpai-stage-clip needs its own intrinsic shape so the
               globe still renders round/square instead of collapsing to
               whatever tiny height a canvas defaults to with no sizing
               hint at all (confirmed via direct read of this file: no
               other rule ever gave this element a height in split mode). */
            aspect-ratio: 1;
            height: auto;
            margin: 0;
        }
        .granpai-globe-wrapper.granpai-auto-balance-split:not([data-timeline-enabled='1'])[data-modal-side='left']:has(.granpai-modal-overlay.is-open) {
            flex-direction: row-reverse;
        }
        .granpai-globe-wrapper.granpai-auto-balance-split:not([data-timeline-enabled='1']):has(.granpai-modal-overlay.is-open) .granpai-modal-overlay {
            position: relative;
            flex: 0 0 auto;
            left: auto;
            right: auto;
            top: auto;
            margin: 0;
            transform: none;
            opacity: 1;
            pointer-events: auto;
            width: var(--gp-modal-dock-width);
            max-width: calc(100vw - 32px);
        }

        /* Case 3 (Timeline) only - separate, isolated rule set, scoped
           via [data-timeline-enabled='1'] so it can never affect or be
           affected by Case 1-2 above (Case 1-2's own selectors now also
           carry :not([data-timeline-enabled='1']) for the same reason,
           see the comment on that block - isolation has to be mutual). */
        .granpai-globe-wrapper.granpai-auto-balance-split[data-timeline-enabled='1']:has(.granpai-modal-overlay.is-open) {
            display: grid;
            /* Globe column is 1fr (used to be minmax(0, min(640px, 55%))
               - see the matching comment on Case 1-2's stage-clip rule
               above for the full reasoning): the modal column is "auto",
               but the modal item itself has a fixed width
               (var(--gp-modal-dock-width), 420px) and nothing else in
               this row has an intrinsic size, so "auto" resolves to
               exactly that ~420px and never grows further. 1fr on the
               globe column claims ALL the grid's remaining free space
               after that fixed column and the gap are subtracted - i.e.
               exactly "container - modal - gap", per Tho's explicit
               spec. The old 640px ceiling meant that past roughly a
               1164px-wide wrapper (640 / 0.55) the globe column simply
               stopped growing and the whole pair just centered with
               empty space on both sides instead of the globe actually
               getting bigger on wide screens. */
            grid-template-columns: minmax(0, 1fr) auto;
            grid-template-areas: "globe modal" "timeline timeline";
            justify-content: center;
            align-items: center;
            /* column-gap tightened (was clamp(24px, 4vw, 56px)) at Tho's
               request, same reasoning as Case 1-2's identical change
               above - more of the "globe" column's 1fr goes to the globe
               itself instead of the gap between it and the modal.
               row-gap (the space above the timeline bar row) is
               deliberately left unchanged - Tho asked specifically for
               the COLUMN gap, and the row gap serves an unrelated visual
               purpose (breathing room before the timeline bar). */
            column-gap: clamp(16px, 2.5vw, 32px);
            row-gap: clamp(24px, 4vw, 56px);
            /* Same reasoning as Case 1-2's identical override above: the
               base wrapper rule's aspect-ratio:1 + height:100%!important
               locks this element to a square, which fights a wide grid
               pair - unset it so the wrapper's height comes from its own
               grid rows (globe/modal row + timeline row) instead. */
            aspect-ratio: unset;
            height: auto;
        }
        .granpai-globe-wrapper.granpai-auto-balance-split[data-timeline-enabled='1']:has(.granpai-modal-overlay.is-open) .granpai-stage-clip {
            grid-area: globe;
            position: relative;
            /* Deliberately NOT giving this item its own width/max-width
               (e.g. a percentage) - the grid-template-columns declaration
               above (now minmax(0, 1fr)) already sizes the COLUMN itself
               to exactly what's wanted. A grid item's percentage width
               resolves against its own column's already-resolved size,
               not the outer container - so putting a percentage cap here
               too would compound on top of the column's own sizing
               instead of no-op'ing like it would in a normal block
               context (this bit an earlier version of this rule, back
               when the column itself was capped at min(640px, 55%): a
               duplicate max-width: min(640px, 55%) here computed 55% of
               an already-capped column, ~30% of the real container,
               crushing the globe far smaller than intended - confirmed
               empirically at the time: 288px instead of ~524-550px on a
               1000px wrapper). width:100% (of the column) is the
               correct, non-compounding way to fill exactly what the
               column track already sized, whatever that track sizing is. */
            width: 100%;
            /* align-items:center above means this grid cell does NOT
               stretch to fill its row's height by default - without its
               own intrinsic shape, the "globe" row's auto-sizing has
               nothing but an empty/near-empty div to size against and
               collapses toward whatever a bare canvas defaults to. Same
               fix as Case 1-2: give the globe its shape back directly,
               derived from the column width the grid already computed,
               instead of depending on the wrapper's own square ratio
               (unset above) or on stretch-to-fill (overridden above). */
            aspect-ratio: 1;
            height: auto;
            margin: 0;
            justify-self: end;
        }
        /* Always its own row spanning both columns, below the pair, on
           both modal-side settings. */
        .granpai-globe-wrapper.granpai-auto-balance-split[data-timeline-enabled='1']:has(.granpai-modal-overlay.is-open) .granpai-timeline-bar {
            grid-area: timeline;
            width: 100%;
            margin-top: 0;
        }
        .granpai-globe-wrapper.granpai-auto-balance-split[data-timeline-enabled='1'][data-modal-side='left']:has(.granpai-modal-overlay.is-open) {
            /* Modal column first now (mirrors the default rule above,
               just reversed) so it's still the one that resolves to its
               own fixed ~420px content size, leaving the globe's 1fr
               column free to claim the rest - see the comment on the
               default (non-modal-side-specific) rule above. */
            grid-template-columns: auto minmax(0, 1fr);
            grid-template-areas: "modal globe" "timeline timeline";
        }
        .granpai-globe-wrapper.granpai-auto-balance-split[data-timeline-enabled='1'][data-modal-side='left']:has(.granpai-modal-overlay.is-open) .granpai-stage-clip {
            justify-self: start;
        }
        .granpai-globe-wrapper.granpai-auto-balance-split[data-timeline-enabled='1']:has(.granpai-modal-overlay.is-open) .granpai-modal-overlay {
            grid-area: modal;
            position: relative;
            left: auto;
            right: auto;
            top: auto;
            margin: 0;
            transform: none;
            opacity: 1;
            pointer-events: auto;
            width: var(--gp-modal-dock-width);
            max-width: calc(100vw - 32px);
        }

        /* 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);
            }
            /* Same bug, same fix, same reasoning as the desktop-docked
               .granpai-modal-box override above (min-width:520px block) -
               this tablet-range (641-1024px) override duplicates the
               same width/height/max-height/box-shadow set and had the
               exact same missing-opacity gap, so it shows the identical
               empty-box-before-vanishing flash on close at this
               breakpoint too. Same fix: opacity:0 here, still beaten by
               the higher-specificity `.is-open .granpai-modal-box {
               opacity: 1 }` rule while open, winning (and applying
               instantly - opacity isn't in this element's `transition`
               list) the moment `.is-open` is removed on close. */
            .granpai-modal-box {
                width: 100%;
                height: auto;
                max-height: 100%;
                opacity: 0;
                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;
        }
        /* Minimal 3D Globe cinematic pin entrance. This nested visual
           wrapper is only emitted for Minimal globe markers. The outer marker
           remains fully controlled by globe.gl and .granpai-pulse-marker-inner
           remains fully controlled by clustering/fan-out logic. */
        .granpai-pin-entrance {
            position: absolute;
            inset: 0;
            display: block;
            opacity: 0;
            transform: scale(0);
            transform-origin: 50% 50%;
            transition: transform 680ms cubic-bezier(0.16, 1, 0.3, 1), opacity 120ms ease-out;
            will-change: transform, opacity;
        }
        .granpai-pin-entrance--visible {
            opacity: 1;
            transform: scale(1);
        }
        /* Minimal-only "location activation": the solid location dot lands
           first, then its radar rings wake up a fraction later. Because this
           is inside .granpai-pin-entrance it never touches globe.gl's marker
           transform or the clustering transform on marker-inner. */
        .granpai-pin-entrance:not(.granpai-pin-entrance--visible) .granpai-pulse-ring {
            animation-play-state: paused;
        }
        .granpai-pin-entrance--visible .granpai-pulse-dot {
            animation: granpai-location-dot-activate 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
        }
        .granpai-pin-entrance--visible .granpai-pulse-ring {
            animation-name: granpai-pulse-anim;
            animation-duration: 2.2s;
            animation-timing-function: ease-out;
            animation-iteration-count: infinite;
            animation-delay: 220ms;
        }
        .granpai-pin-entrance--visible .granpai-pulse-ring.granpai-pulse-delay {
            animation-delay: 1.32s;
        }
        @keyframes granpai-location-dot-activate {
            0%   { transform: scale(0);   box-shadow: 0 0 0 0 var(--gp-pin-color-soft, var(--gp-accent-soft)); }
            58%  { transform: scale(1.42); box-shadow: 0 0 22px 8px 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)); }
        }
        /* Minimal hover polish: a small physical lift/zoom and brighter dot.
           Only the dedicated entrance wrapper moves, so cluster fan-out and
           globe positioning remain untouched. */
        .granpai-pulse-marker:hover .granpai-pin-entrance--visible {
            transform: translateY(-2px) scale(1.14);
            transition-duration: 240ms;
        }
        .granpai-pulse-marker:hover .granpai-pin-entrance--visible .granpai-pulse-dot {
            box-shadow: 0 0 18px 6px var(--gp-pin-color-soft, var(--gp-accent-soft));
        }
        .granpai-pulse-marker--flash .granpai-pin-entrance--visible {
            animation: granpai-location-click-pop 520ms cubic-bezier(0.34, 1.56, 0.64, 1);
        }
        @keyframes granpai-location-click-pop {
            0%   { transform: scale(1); }
            42%  { transform: scale(1.28); }
            100% { transform: scale(1); }
        }

        .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.22s ease, transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
            /* 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;
            /* Bug fix: same box-sizing bug as .granpai-modal-box above.
               In the Auto-Balance Timeline split (Case 3, grid layout),
               .granpai-timeline-bar gets width:100% forced against the
               wrapper's grid track (see the [data-timeline-enabled='1']
               override further down: grid-area: timeline; width: 100%;).
               With no box-sizing declared here, the browser default
               (content-box) adds this rule's own horizontal padding
               (16px + 16px = 32px) on top of that 100% width instead of
               inside it, so the bar overflows the wrapper's right edge
               by exactly that 32px. Confirmed empirically live on the
               Timeline map: the bar measured 1025.2px against a 993.2px
               wrapper width before this fix (32.0px overflow), and
               exactly 993.2px - zero overflow - after adding
               border-box. */
            box-sizing: border-box;
            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;
        }
        /* 2026-09-28 bugfix: this rule never declared its own `fill`, so
           whenever the JS doesn't set an inline fill (no country
           highlight color and no gradient - i.e. the default look) a
           facet fell through to inheriting .granpai-land's own `fill`
           (SVG `fill` is inherited) - the same LOW-alpha --gp-land-fill
           (0.10 dark / 0.14 light) the comment above already explains is
           too weak for a small, discrete shape. Every triangle is exactly
           that - small and discrete - so Low-Poly ended up reading as
           much fainter than Dot Matrix (which got this same fix already,
           via --gp-accent on .granpai-land--dots circle above) despite
           being the same class of shape. Fixed by giving facets the
           identical full-strength --gp-accent fallback fill. Each facet
           still reads its own --facet-shade custom property (set
           per-element in JS, deterministic per facet) via
           filter:brightness() on top of that - the classic low-poly
           faceted-shading look, every triangle a slightly different tone
           of the same base land color. */
        .granpai-lowpoly-facet {
            fill: var(--gp-accent);
            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;
        }

/* ==========================================================================\n   3D globe first-paint placeholder — Slow Layered Pulse\n   --------------------------------------------------------------------------\n   Six lightweight CSS rings. Deliberately slow cadence: each wave has a soft\n   attack, a long expansion and a long opacity tail. Only transform + opacity.\n   ========================================================================== */
.granpai-stage-clip:has(> .granpai-globe-placeholder)::before,
.granpai-stage-clip:has(> .granpai-globe-placeholder)::after { display: none; }

.granpai-globe-placeholder {
    position: absolute;
    inset: 0;
    z-index: 3;
    display: grid;
    place-items: center;
    pointer-events: none;
    opacity: 1;
    visibility: visible;
    color: var(--gp-pulse-color, #ffffff);
    transition: opacity 680ms cubic-bezier(.22,.61,.36,1), visibility 0s linear 680ms;
    will-change: opacity;
}
.granpai-stage-clip--ready > .granpai-globe-placeholder {
    opacity: 0;
    visibility: hidden;
}
/* Pulse contrast is based on the page background, never the globe theme. */
.granpai-globe-wrapper[data-pulse-contrast="light"] { --gp-pulse-color: #ffffff; --gp-pulse-outline: rgba(0,0,0,.16); }
.granpai-globe-wrapper[data-pulse-contrast="dark"] { --gp-pulse-color: #17212b; --gp-pulse-outline: rgba(255,255,255,.22); }
.granpai-globe-wrapper[data-pulse-contrast="dual"] { --gp-pulse-color: #ffffff; --gp-pulse-outline: rgba(0,0,0,.42); }
.granpai-globe-wrapper[data-pulse-contrast="custom"] { --gp-pulse-outline: rgba(0,0,0,.18); }
.granpai-pulse-loader-core {
    position: relative;
    width: min(48%, 288px);
    aspect-ratio: 1;
    border-radius: 50%;
    contain: layout paint style;
    transform: translateZ(0);
}
.granpai-pulse-loader-ring {
    --gp-ring-step: calc(var(--gp-ring-i) * .46s);
    position: absolute;
    left: 50%;
    top: 50%;
    width: 31%;
    aspect-ratio: 1;
    border: 1px solid color-mix(in srgb, currentColor 64%, transparent);
    box-shadow: 0 0 0 .55px var(--gp-pulse-outline, rgba(0,0,0,.18));
    border-radius: 50%;
    opacity: 0;
    transform: translate3d(-50%, -50%, 0) scale(.34);
    transform-origin: 50% 50%;
    animation: granpai-orbital-pulse 3.6s linear infinite;
    animation-delay: var(--gp-ring-step);
    will-change: transform, opacity;
}
.granpai-pulse-loader-core::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: 4px;
    height: 4px;
    border-radius: 50%;
    background: currentColor;
    opacity: .34;
    transform: translate3d(-50%, -50%, 0) scale(.86);
    animation: granpai-pulse-core 3.6s ease-in-out infinite;
}
@keyframes granpai-orbital-pulse {
    0%   { opacity: 0;    transform: translate3d(-50%,-50%,0) scale(.34); }
    8%   { opacity: .08; }
    16%  { opacity: .25; }
    30%  { opacity: .23; }
    48%  { opacity: .17; }
    68%  { opacity: .095; }
    84%  { opacity: .035; }
    100% { opacity: 0;    transform: translate3d(-50%,-50%,0) scale(3.12); }
}
@keyframes granpai-pulse-core {
    0%,100% { opacity: .22; transform: translate3d(-50%,-50%,0) scale(.82); }
    45%     { opacity: .42; transform: translate3d(-50%,-50%,0) scale(1); }
    72%     { opacity: .29; transform: translate3d(-50%,-50%,0) scale(.91); }
}
@media (prefers-reduced-motion: reduce) {
    .granpai-pulse-loader-ring { animation: none; opacity: .11; }
    .granpai-pulse-loader-ring:nth-child(1) { transform: translate3d(-50%,-50%,0) scale(.7); }
    .granpai-pulse-loader-ring:nth-child(2) { transform: translate3d(-50%,-50%,0) scale(1.08); }
    .granpai-pulse-loader-ring:nth-child(3) { transform: translate3d(-50%,-50%,0) scale(1.46); }
    .granpai-pulse-loader-ring:nth-child(4) { transform: translate3d(-50%,-50%,0) scale(1.84); }
    .granpai-pulse-loader-ring:nth-child(5) { transform: translate3d(-50%,-50%,0) scale(2.22); }
    .granpai-pulse-loader-ring:nth-child(6) { transform: translate3d(-50%,-50%,0) scale(2.60); }
    .granpai-pulse-loader-core::after { animation: none; }
    .granpai-globe-placeholder { transition-duration: 180ms; }
}

