/* Map system — SpyZ Feed
 *
 * Layout: toolbar across the top, tool rail on the left, canvas in the middle, object panel on the
 * right, thin status bar at the bottom. Everything is sized in one grid so the canvas can never
 * collapse to zero height (a classic map-widget bug on short screens).
 */

.map-page-root {
    width: 100%;
    max-width: 1600px;
    margin: 0 auto;
    /* No horizontal or bottom padding: this page is a full-bleed map, so shell padding beneath it is
       dead space. Measured live, the three wrappers between the map and `#main-content` spent 64px
       (16 + 16 padding + 32 margin) below the app, and the map stopped 106px short of the window with
       nothing in the gap. The toolbar already carries its own spacing, so the top edge needs none. */
    padding: 0;
    box-sizing: border-box;
}

/* The shell's own trailing margin under the map page is dead space too. Verified live after zeroing
   the wrapper padding: all three wrappers measured 0px chrome, and the app still stopped 42px short -
   `FIT_BOTTOM_GAP` (10) plus `#main-content`'s 32px padding-bottom. A full-bleed map has no content
   for that padding to separate, so it is reclaimed here rather than sized around in JS. */
.content-container:has(> .map-page-root),
.content-container:has(> #map-root .map-app) {
    margin-bottom: 0;
}

body:has(.map-page-root) #main-content {
    padding-bottom: 0;
}

/* The map page deliberately sets no `min-height` here. `#main-content` already floors the document at
   `100vh` plus a navbar-height top padding, so a floor on this wrapper would fight the measured
   `--map-fit-height` and win - the map would stop shrinking with the shell and push a scrollbar.
   Height on this page comes from `--map-fit-height` alone, measured in `js/map/page.js`. */

.map-root {
    width: 100%;
}

.map-app {
    --map-panel-bg: var(--surface-bg, #fff);
    --map-panel-alt: var(--surface-bg-alt, #eef1f5);
    --map-border: var(--border-color, #e1e8ed);
    --map-text: var(--text-color, #2c3e50);
    --map-muted: var(--text-secondary, #55637a);
    /* Floating glass used by the tool rail and both panels, so the map shows through them. */
    --map-glass: rgba(14, 26, 38, 0.9);
    --map-glass-border: rgba(148, 197, 255, 0.22);
    --map-glass-text: #e2e8f0;
    --map-glass-muted: #93a7bd;
    /* The one accent the map's own controls share. Declared at the root because the top-bar buttons sit
       beside the toolbar and read it here rather than inheriting a value from somewhere else. */
    --map-accent: #38bdf8;
    --map-overlay-inset: 0.6rem;
    --map-overlay-top: calc(var(--map-overlay-inset) + 34px);
    display: grid;
    /* ONE row: the toolbar is an overlay now, so the stage owns the whole card and the map starts at the very
       top edge. The old `auto minmax(0,1fr) auto` reserved a permanent strip above the canvas, which is the
       opposite of "the toolbar appears on top of the map". */
    grid-template-rows: minmax(0, 1fr) auto;
    /* `--map-fit-height` is measured in js/map/page.js (viewport minus navbar minus a small gap), so
       the default layout ends above the fold and the page itself never scrolls. The vh expression is
       only the pre-measurement fallback for the first frame. */
    height: var(--map-fit-height, calc(100dvh - var(--nav-height, 6.5rem) - 2rem));
    position: relative;
    /* No min-height once a measurement exists. Any CSS floor larger than `--map-fit-height` wins over
       the `height` declared above and pushes a scrollbar - a flat 420px on a 390px-tall landscape phone
       forced 147px of overflow (measured live). The measured value is the only authority; this floor
       survives solely as the pre-measurement first-frame guard, so it is dropped the moment the
       variable is published. */
    min-height: min(420px, 100dvh - var(--nav-height, 6.5rem));
    min-height: var(--map-fit-height, min(420px, 100dvh - var(--nav-height, 6.5rem)));
    border: 1px solid var(--map-border);
    border-radius: 12px;
    overflow: hidden;
    background: var(--map-panel-bg);
    box-shadow: var(--shadow, 0 2px 4px rgba(0, 0, 0, 0.1));
    color: var(--map-text);
}

.map-app.is-embedded {
    height: min(78vh, 900px);
    border-radius: 10px;
}

/* -- toolbar ------------------------------------------------------------------------------ */

.map-toolbar {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.5rem 0.65rem;
    /* The border is the glass border, not `--map-border`: that token resolves to the light `#e1e8ed` line the
       panels use, which rings a dark glass bar in a white strip. */
    border-bottom: 1px solid var(--map-glass-border);
    /* See-through, on the same glass as the objects panel, so the bar reads as part of the map rather than a
       solid strip above it. The solid `--map-panel-alt` that lived here lost to the absolutely-positioned bar
       declared further down this file, so it was describing a colour the page never painted. Do not "improve"
       this to `transparent`: the glass is what keeps white text legible over bright terrain. The relative
       position and the stacking level stay — the bar is still the anchor the overlays measure from. */
    background: var(--map-glass);
    color: var(--map-glass-text);
    flex-wrap: wrap;
    /* The overlay panels offset themselves from the measured height of this bar, so the bar
       keeps a stable position however many rows it wraps to. */
    position: relative;
    z-index: 16;
}

.map-tabs {
    display: flex;
    gap: 0.25rem;
    flex-wrap: wrap;
}

.map-tab {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.05rem;
    padding: 0.3rem 0.7rem;
    border: 1px solid transparent;
    border-radius: 8px;
    background: transparent;
    color: var(--map-muted);
    cursor: pointer;
    line-height: 1.15;
    font: inherit;
}

.map-tab:hover {
    background: var(--surface-bg-hover, #e2e6ec);
    color: var(--map-text);
}

.map-tab.is-active {
    background: var(--primary-color, #2471a3);
    border-color: var(--primary-color, #2471a3);
    color: var(--on-primary, #fff);
}

.map-tab-name {
    font-weight: 600;
    font-size: 0.88rem;
}

.map-tab-size {
    font-size: 0.68rem;
    opacity: 0.8;
}

.map-search {
    position: relative;
    /* Centred on the BAR, not merely "floating in the middle of the row". `margin: auto` only centres
       within the space the flex items leave over, and the left end (the map chooser, up to 240px) is far
       wider than the right end, so the field measured 62px right of centre. Taken out of the flow and
       pinned at `left: 50%` with a transform, it centres on the bar itself whatever the ends weigh. */
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: min(420px, calc(100% - 520px));
}

/* Below the width where a centred field and the two ends would collide, the field gives up the centre and
   takes the free space in the row instead. 520px is the two ends plus the field's own minimum. */
@media (max-width: 900px) {
    .map-search {
        position: relative;
        left: auto;
        top: auto;
        transform: none;
        margin-left: auto;
        margin-right: auto;
        width: auto;
        flex: 1 1 200px;
        max-width: 420px;
    }
}

.map-search-input {
    width: 100%;
    box-sizing: border-box;
    padding: 0.45rem 0.7rem;
    border: 1px solid var(--input-border, rgba(0, 0, 0, 0.15));
    border-radius: 8px;
    background: var(--input-bg, #fff);
    color: var(--map-text);
    font: inherit;
    font-size: 0.86rem;
}

.map-search-results {
    position: absolute;
    z-index: 30;
    top: calc(100% + 4px);
    left: 0;
    right: 0;
    max-height: 320px;
    overflow-y: auto;
    background: var(--map-glass);
    border: 1px solid var(--map-glass-border);
    border-radius: 8px;
    color: var(--map-glass-text);
    box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
}

.map-search-result {
    display: flex;
    justify-content: space-between;
    gap: 0.5rem;
    width: 100%;
    padding: 0.45rem 0.7rem;
    border: 0;
    background: transparent;
    color: var(--map-glass-text);
    font: inherit;
    font-size: 0.85rem;
    text-align: left;
    cursor: pointer;
}

.map-search-result:hover {
    background: rgba(148, 197, 255, 0.14);
}

.map-search-result small {
    color: var(--map-glass-muted);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.map-search-empty {
    margin: 0;
    padding: 0.6rem 0.7rem;
    color: var(--map-glass-muted);
    font-size: 0.82rem;
}

.map-toolbar-actions {
    display: flex;
    align-items: center;
    gap: 6px;
    /* Pushed to the right end of the bar: the action cluster is the bar's own right-hand edge, whatever the
       map chooser and the centred search field weigh. */
    margin-left: auto;
}

.map-icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border: 1px solid var(--map-border);
    border-radius: 8px;
    background: var(--map-panel-bg);
    color: var(--map-text);
    font: inherit;
    font-size: 1rem;
    line-height: 1;
    cursor: pointer;
}

.map-icon-btn:hover {
    background: var(--surface-bg-hover, #e2e6ec);
}

/* -- body grid ---------------------------------------------------------------------------- */

/* ONE row, ONE column: the canvas stage IS the body. The tool rail, the layers panel and the objects
   panel are absolutely positioned overlays on top of it, so opening any of them never shrinks the map
   — which is the whole reason this file no longer uses a three-column grid. */
.map-body {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr);
    min-height: 0;
    position: relative;
}

/* `.map-tool` survives the top-left rail it was named for: the admin sheet still builds its action
   buttons with `map-tool map-tool-wide` (see actionButton in js/map/page.js), so these are the shared
   button styles, not rail-only ones. The rail's own container, group, icon and label rules are gone
   with the rail. */
.map-tool {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.3rem 0.45rem;
    border: 1px solid transparent;
    border-radius: 7px;
    background: transparent;
    color: var(--map-glass-text);
    font: inherit;
    font-size: 0.78rem;
    cursor: pointer;
    text-align: left;
}

.map-tool:hover {
    background: rgba(148, 197, 255, 0.14);
}

.map-tool.is-active {
    background: var(--primary-color, #2471a3);
    border-color: var(--primary-color, #2471a3);
    color: var(--on-primary, #fff);
}

.map-icon-btn svg {
    display: block;
}

.map-tool-wide {
    width: 100%;
    justify-content: center;
    border-color: var(--map-glass-border);
    background: rgba(148, 197, 255, 0.1);
    color: var(--map-glass-text);
    font-size: 0.74rem;
}

.map-advanced {
    border-top: 1px solid var(--map-glass-border);
    padding-top: 0.34rem;
    margin-top: 0.05rem;
}

/* Layer toggles stay in one wide-enough column, with labels that never wrap mid-word. A two-column
   layout was tried and reverted: in a 176px rail the columns were ~80px each, so "Base map" broke
   across two lines and the "Buildings (11,866)" count collided with "Event spawns (1,532)" in the
   next column. One column in a 190px rail fits every label and count on a single line. */
.map-layer-toggle {
    white-space: nowrap;
}

.map-group-title {
    font-size: 0.64rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--map-glass-muted);
    padding: 0 0.2rem 0.2rem;
}

.map-layer-toggle {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.1rem 0.2rem;
    font-size: 0.76rem;
    color: var(--map-glass-text);
    cursor: pointer;
}

.map-layer-toggle input {
    margin: 0;
    accent-color: var(--primary-color, #2471a3);
}

/* -- zone layer ---------------------------------------------------------------------------- */

/* A small count beside the Zones toggle so the operator can tell at a glance whether the server has any
   zones to show before hunting for them on the map. */
.map-layer-count {
    margin-left: auto;
    font-size: 0.68rem;
    font-variant-numeric: tabular-nums;
    color: var(--map-muted);
}

.map-zone-filters {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    padding: 0.35rem 0 0.15rem;
}

.map-zone-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
}

.map-zone-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.16rem 0.42rem;
    font-size: 0.7rem;
    line-height: 1.3;
    color: var(--map-glass-text);
    background: rgba(148, 197, 255, 0.1);
    border: 1px solid var(--map-glass-border);
    border-radius: 999px;
    cursor: pointer;
}

/* A switched-off style keeps its swatch but loses its weight, so the legend still shows the full set. */
.map-zone-chip.is-off {
    opacity: 0.45;
    text-decoration: line-through;
}

.map-zone-swatch {
    width: 0.6rem;
    height: 0.6rem;
    border-radius: 2px;
    flex: 0 0 auto;
}

.map-zone-chip-count {
    font-variant-numeric: tabular-nums;
    color: #c0d0e2;
}

.map-zone-search {
    width: 100%;
    padding: 0.3rem 0.4rem;
    font-size: 0.74rem;
    color: var(--map-glass-text);
    background: rgba(4, 12, 20, 0.55);
    border: 1px solid var(--map-glass-border);
    border-radius: 4px;
}

.map-zone-search::placeholder {
    color: var(--map-glass-muted);
}

/* -- stage -------------------------------------------------------------------------------- */

.map-stage {
    position: relative;
    min-width: 0;
    min-height: 0;
    /* No padding-top. The stage carried the bar's measured height as padding, which pushed the canvas down
       and left a strip of card background above it — the opposite of "the bar floats on the terrain". The
       canvas host is `inset: 0`, so the terrain now fills the card edge to edge and the translucent bar reads
       as an overlay on it. Every panel that must clear the bar offsets itself by `--map-toolbar-h` instead;
       js/map/page.js still publishes that variable for exactly that reason. */
    /* The stage spans every column and every row the body may declare. The `max-width` breakpoints
       below still set a three-column `grid-template-columns` for the rail and sidebar - leftovers from
       when those were grid cells rather than overlays - and without this the stage was pinned into
       column 1: measured live at 1024px, a 96px stage and a 120px canvas inside a 980px body. The map
       was a sliver. The panels are absolutely positioned, so the stage has no neighbours to share with. */
    grid-column: 1 / -1;
    grid-row: 1 / -1;
    background: #0b1622;
    overflow: hidden;
}

.map-canvas-host {
    position: absolute;
    inset: 0;
}

.map-canvas {
    display: block;
    width: 100%;
    height: 100%;
    touch-action: none;
    cursor: grab;
}

.map-canvas:focus-visible {
    outline: 2px solid var(--accent-text, #2471a3);
    outline-offset: -2px;
}

.map-hint {
    position: absolute;
    left: 50%;
    bottom: 14px;
    transform: translateX(-50%);
    padding: 0.3rem 0.7rem;
    border-radius: 999px;
    background: rgba(8, 18, 28, 0.82);
    color: #dbeafe;
    font-size: 0.74rem;
    pointer-events: none;
    white-space: nowrap;
}

/* The hint pill and the readout share the bottom centre, so the hint steps up out of its way rather
   than sitting on top of it. Both float over the canvas, so neither costs the map any height. */
.map-coords {
    position: absolute;
    left: 50%;
    bottom: 10px;
    transform: translateX(-50%);
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    max-width: calc(100% - 24px);
    padding: 0.26rem 0.34rem 0.26rem 0.62rem;
    border: 1px solid rgba(148, 197, 255, 0.2);
    border-radius: 999px;
    background: rgba(8, 18, 28, 0.84);
    color: #e2e8f0;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.72rem;
    line-height: 1.2;
    cursor: pointer;
    white-space: nowrap;
    /* It is a control now (click to copy), so it takes the pointer — but only where it is drawn, never
       as a strip across the map. */
    user-select: none;
    transition: background 120ms ease, border-color 120ms ease;
}

.map-coords:hover,
.map-coords:focus-visible {
    background: rgba(12, 26, 40, 0.94);
    border-color: rgba(148, 197, 255, 0.42);
    outline: none;
}

.map-coords-value {
    overflow: hidden;
    text-overflow: ellipsis;
}

.map-coords-copy {
    padding: 0.12rem 0.5rem;
    border-radius: 999px;
    background: rgba(148, 197, 255, 0.14);
    color: #bcd2ea;
    font-family: inherit;
    font-size: 0.66rem;
    letter-spacing: 0.02em;
    text-transform: uppercase;
}

.map-coords.is-copied {
    border-color: rgba(74, 222, 128, 0.55);
}

.map-coords.is-copied .map-coords-copy {
    background: rgba(74, 222, 128, 0.2);
    color: #bbf7d0;
}

/* While the hint is showing it owns the bottom centre; lift it clear of the readout. */
.map-hint:not([hidden]) {
    bottom: 46px;
}

/* Touch: 44px is the smallest comfortable target, and the copy chip is the only affordance there is
   on a phone, since Ctrl-C does not exist. */
@media (pointer: coarse) {
    .map-coords {
        padding: 0.34rem 0.4rem 0.34rem 0.7rem;
        font-size: 0.78rem;
        min-height: 34px;
    }

    .map-coords-copy {
        padding: 0.22rem 0.62rem;
    }
}

.map-toast {
    position: absolute;
    right: 12px;
    top: 12px;
    max-width: min(340px, 70%);
    padding: 0.5rem 0.75rem;
    border-radius: 8px;
    background: rgba(12, 24, 36, 0.94);
    border: 1px solid rgba(148, 197, 255, 0.35);
    color: #e2e8f0;
    font-size: 0.8rem;
    box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
    z-index: 20;
}

.map-toast[data-tone="error"] {
    border-color: rgba(248, 113, 113, 0.6);
}

.map-toast[data-tone="warn"] {
    border-color: rgba(251, 191, 36, 0.6);
}

/* -- sidebar ------------------------------------------------------------------------------ */

.map-sidebar {
    position: absolute;
    z-index: 15;
    /* Below the floating toolbar, not under it. The bar overlays the top of the card, so this panel clears
       the bar's measured height before applying its own inset. */
    top: calc(var(--map-toolbar-h, 56px) + var(--map-overlay-inset));
    right: var(--map-overlay-inset);
    /* The panel stops short of the map's bottom-right corner instead of running to the floor, so the
       "+" and the ruler underneath it are never covered by a scrollable column of objects. The gap is
       the drawing cluster's own height plus two insets. The floor is the height a long object list
       actually needs before it should scroll rather than shrink. */
    /* `--map-draw-h` is MEASURED by `measureFitHeight` from the real drawing cluster: a 42px `.map-draw-btn`
       inside a flex row positioned from `--map-overlay-inset` (6px) = 48px, measured live rather than declared
       here. The cluster has no padding of its own, so the old "42 + 4 + 6 = 52px" note was wrong twice over and
       is corrected rather than carried. The fallback is a plain zero - the inset gap alone - which is where the
       cluster ends before any measurement lands. The old 84px fallback was 36px taller than
       the cluster, so every sheet positioned below it started 36px too high and its head was clipped
       (reported 2026-10-23: "I cannot see the top of the panel"). Two previous fixes moved the centre and
       left this constant alone, which is why the defect kept returning. */
    bottom: calc(var(--map-overlay-inset) + var(--map-draw-h, 0px) + var(--map-overlay-inset));
    max-height: calc(100% - var(--map-toolbar-h, 56px) - var(--map-overlay-inset) - var(--map-draw-h, 0px) - var(--map-overlay-inset));
    /* Named height/width, not bare numbers: `.map-app:not(.sidebar-collapsed) .map-adminbar` centres the Admin
       sheet on the map area this panel leaves free, and it must read the SAME width this box is laid out
       with rather than a second copy of 320px that could drift away from it. */
    width: var(--map-sidebar-w, min(320px, calc(100% - 2 * var(--map-overlay-inset))));
    display: grid;
    /* head | object list | inspector. The LIST stays a usable index and the INSPECTOR gets the rest of the
       panel, capped then scrolled internally. Previously the inspector was the `auto` row, so a selected
       shape's long settings form pushed the list down to a sliver (measured 11px tall when a circle was
       selected — you could not see the shapes you were trying to pick). */
    /* Two things changed here, both because "the editor should span as tall as possible without covering the
       buttons at the bottom" had never actually been delivered:
        1. The inspector's share is a variable (`--map-inspector-share`, 55%, 85% while editing) so the editing
           state raises it from ONE row definition instead of a duplicated one drifting away.
        2. The LIST row is now `minmax(96px, min(34%, 1fr))`: its share of the panel is capped at a third, so
           the inspector keeps the remaining two thirds — 85% of 100% would have left the list a 15% sliver,
           which is the 11px defect again under a bigger number. `min(34%, 1fr)` still grows to a third on a
           tall panel and still floors at 96px, so above ~285px of panel the list cap binds and the inspector
           gets everything else.
       The bottom buttons are NOT in this grid: `.map-draw-controls` is a separate absolute overlay and the
       panel clears it with its own measured `bottom`, so a taller inspector can never reach them.
       The row collapses while nothing is selected (`.map-inspector[hidden]`), so no share is reserved for a
       blank editor. */
    grid-template-rows: auto minmax(96px, min(34%, 1fr)) minmax(0, var(--map-inspector-share, 55%));
    min-width: 0;
    min-height: 0;
    border: 1px solid var(--map-glass-border);
    border-radius: 12px;
    background: var(--map-glass);
    color: var(--map-glass-text);
    box-shadow: 0 16px 36px rgba(0, 0, 0, 0.38);
    overflow: hidden;
    transition: opacity 140ms ease, transform 140ms ease;
}

.map-app.sidebar-collapsed .map-sidebar {
    opacity: 0;
    transform: translateX(8px);
    pointer-events: none;
    visibility: hidden;
}

.map-sidebar-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.45rem 0.6rem;
    border-bottom: 1px solid var(--map-glass-border);
    font-size: 0.78rem;
    color: var(--map-glass-text);
}

.map-sidebar-close {
    width: 26px;
    height: 26px;
    font-size: 0.9rem;
}

.map-object-list {
    overflow-y: auto;
    padding: 0.35rem;
    min-height: 0;
}

.map-object {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    padding: 0.4rem 0.45rem;
    border: 1px solid transparent;
    border-radius: 7px;
    background: transparent;
    color: var(--map-glass-text);
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.map-object:hover {
    background: rgba(148, 197, 255, 0.14);
}

.map-object.is-selected {
    background: rgba(56, 189, 248, 0.22);
    border-color: var(--primary-color, #2471a3);
}

.map-object-swatch {
    flex: 0 0 auto;
    width: 12px;
    height: 12px;
    border-radius: 3px;
    border: 1px solid rgba(0, 0, 0, 0.25);
}

.map-object-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.map-object-name {
    font-size: 0.84rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.map-object-meta {
    font-size: 0.68rem;
    color: var(--map-muted);
}

.map-object-eyebrow {
    margin-left: auto;
    font-size: 0.62rem;
    color: var(--map-muted);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

/* The audience word carries the same three values the store does, so it is readable at a glance rather than
   being one more grey column: public is the published state, withdrawn is the state the switch left behind, and
   private is the default that needs no emphasis. Selector is scoped to the eyebrow so it cannot rename or
   restyle the object row, and no new `.map-sidebar` rule is introduced here (the sidebar's stacking check reads
   the FIRST such rule in this file). */
.map-object-eyebrow[data-object-audience="public"] {
    color: #38bdf8;
}

.map-object-eyebrow[data-object-audience="withdrawn"] {
    color: #fbbf24;
}

.map-empty {
    margin: 0;
    padding: 0.75rem 0.6rem;
    color: var(--map-muted);
    font-size: 0.8rem;
    line-height: 1.4;
}

/* -- inspector ---------------------------------------------------------------------------- */

.map-inspector {
    /* The glass border, not `--map-border`: the panel is dark glass, so the light `#e1e8ed` rule read as a
       white scratch between the object list and the editor. */
    border-top: 1px solid var(--map-glass-border);
    /* No `max-height: 60%` here. The sidebar is a three-row grid (`head | list | inspector`), and a
       percentage max-height on a grid ITEM resolves against the whole sidebar box, not its own row —
       so the inspector could claim most of the panel and starve the object list down to a sliver
       (measured: list 11px, inspector 178px). The grid rows now settle it: the list is capped at
       `minmax(96px, min(34%, 1fr))` and the inspector takes `minmax(0, var(--map-inspector-share))`
       (55%, 85% while editing), so here it only needs to be able to shrink and scroll inside the share
       it gets. That is the whole contract: the row supplies the height, this box supplies the scroller. */
    min-height: 0;
    overflow-y: auto;
    background: var(--map-panel-alt);
}

/* The row collapses while nothing is selected. The markup carries `hidden` on the inspector already, and with
   `minmax(0, …)` the row would be 0 tall regardless — this only states it, so a future `display` on this box
   cannot reserve a third of the panel for an empty editor. */
.map-inspector[hidden] {
    display: none;
}

/* -- the object editor, taller ------------------------------------------------------------- */
/*
 * The Commander asked for the shape editor to span as tall as it needs without making the object panel any
 * larger. The panel is a fixed-height overlay anchored between the toolbar and the drawing cluster (see its
 * `top`/`bottom`), so "as tall as it needs" cannot mean the panel growing — that would push the terrain off
 * the bottom of a phone. It means the inspector gets the panel's remaining height and scrolls inside itself:
 * the share rises from 55% to 85%, and the list is now capped at a third of the panel
 * (`minmax(96px, min(34%, 1fr))`), so it can never be squeezed out of reach again (the 11px list measured
 * earlier in this file). At an 85% share the list has its 15% plus the leftover the `min()` frees, which is
 * the 96px floor on a normal panel and a real scroll window on a tall one.
 *
 * Two spellings, because two things switch it: `.map-sidebar.is-editing` for the panel that owns the
 * selection, and `.map-inspector.is-expanded` for the inspector itself, so the page can raise the share by
 * putting one class on either node. Both write the same variable the grid row reads.
 *
 * WHICH ONE THE PAGE ACTUALLY USES: `renderInspector` puts `is-expanded` on the inspector whenever a shape
 * or a server zone is selected, and removes it when the inspector is cleared. Before that, NOTHING set either
 * class — `is-editing` only ever landed on `.map-edit-block` (the point-editing sub-panel) — so this rule was
 * dead and the inspector sat at 55% forever. `is-editing` on the sidebar stays supported for a caller that
 * wants the whole panel expanded, but it is not what the shape editor relies on.
 */
.map-sidebar.is-editing,
.map-inspector.is-editing,
.map-inspector.is-expanded {
    --map-inspector-share: 85%;
}

/* The scroller is the inspector, not the panel, so a long settings form never grows it: it takes the row it is
   given and scrolls inside that. `overscroll-behavior` stops a flick at the end of the form from scrolling a
   page behind the panel on the engine where the panel's own overflow is not the next scroller. */
.map-sidebar.is-editing .map-inspector,
.map-inspector.is-editing,
.map-inspector.is-expanded {
    /* Stated here as well as on `.map-inspector`, because a box-scoped scroll region is exactly what the
       expanded share above is asking for: the inspector, never the panel. */
    overflow-y: auto;
    overscroll-behavior: contain;
}

/* EXCEPTION: when the full zone editor is mounted, the inspector stops scrolling and the editor takes over.
   The editor is a scroll island (see the zone editor block at the end of this file), and two nested scrollers
   on the same axis is how a trackpad ends up fighting itself for the wheel. `.map-inspector:has(.map-zone-editor)`
   is (0,2,0) and would LOSE to the (0,3,0) `.map-sidebar.is-editing .map-inspector` above, so the sidebar
   variant is repeated here at matching specificity - `:has()` + `.map-sidebar.is-editing` + `.map-inspector` is
   (0,4,0) - one higher than the sidebar rule above. No force flag is involved: the ordering is honest, the
   selector just has to outrank the rule it overrides.
   `overflow` is the SHORTHAND here, both axes: `clip` is a one-axis-only value, and mixing it with `hidden`
   (`overflow-y: clip; overflow-x: hidden`) makes the whole declaration invalid per CSS Overflow - the browser
   drops it and the inspector keeps scrolling under the editor, which is the double-scroll this exists to
   prevent. The longhands above are `overflow-y`/`overflow-x`; this shorthand is later in the file, so it wins
   on both axes correctly. `clip` rather than `hidden` because clip does not create a scroll container, so the
   sticky head inside the editor is unaffected. */
.map-sidebar.is-editing .map-inspector:has(.map-zone-editor),
.map-inspector.is-editing:has(.map-zone-editor),
.map-inspector.is-expanded:has(.map-zone-editor) {
    overflow: clip;
}

/* The head stays glued to the top of the inspector while the form scrolls under it. The deselect (×) lives in
   that head, and in an expanded editor it is the only way out — it must never scroll out of view. The
   background is repainted here because a sticky box lets the rows beneath it show through. */
.map-inspector-head {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--map-panel-alt);
    display: flex;
    align-items: center;
    gap: 0.45rem;
    padding: 0.45rem 0.6rem;
    border-bottom: 1px solid var(--map-border);
    font-size: 0.84rem;
}

.map-inspector-head strong {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.map-inspector-body {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    padding: 0.6rem;
    /* The chain link that lets a long zone editor shrink instead of overflowing the inspector. A flex item
       defaults to `min-height: auto` (its content height); without this the body refuses to be shorter than the
       settings list and the panel appears to grow. See the zone editor block at the end of this file for the
       full chain - this line is the one that gets "tidied away" and breaks it. */
    min-height: 0;
}

.map-inspector-body label {
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    font-size: 0.72rem;
    color: var(--map-muted);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

.map-inspector-body input[type="text"],
.map-inspector-body input[type="number"],
.map-inspector-body select,
.map-inspector-body textarea {
    padding: 0.35rem 0.45rem;
    border: 1px solid var(--input-border, rgba(0, 0, 0, 0.15));
    border-radius: 6px;
    background: var(--input-bg, #fff);
    color: var(--map-text);
    font: inherit;
    font-size: 0.82rem;
    text-transform: none;
    letter-spacing: normal;
}

.map-field-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0.5rem;
}

.map-field-row input[type="color"] {
    width: 100%;
    height: 30px;
    padding: 0;
    border: 1px solid var(--input-border, rgba(0, 0, 0, 0.15));
    border-radius: 6px;
    background: transparent;
}

.map-facts {
    display: flex;
    flex-direction: column;
    gap: 0.18rem;
    margin: 0;
    padding: 0.4rem 0;
    border-top: 1px solid var(--map-border);
    font-size: 0.76rem;
}

.map-facts div {
    display: flex;
    justify-content: space-between;
    gap: 0.5rem;
}

.map-facts dt {
    color: var(--map-muted);
    text-transform: uppercase;
    font-size: 0.64rem;
    letter-spacing: 0.05em;
}

.map-facts dd {
    margin: 0;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.74rem;
}

/* "Updated by" names a person, not a stored value, so it leaves the mono face the rest of `.map-facts` uses:
   a username in monospace reads as another id. Colour is inherited rather than set, so the link takes the
   map's own link colour and the honest "Unknown account" fallback never looks like a link. */
.map-facts dd .map-owner-link {
    font-family: inherit;
    color: var(--map-accent, #38bdf8);
    text-decoration: underline;
    text-underline-offset: 2px;
    overflow-wrap: anywhere;
}

.map-facts dd .map-owner-link:hover,
.map-facts dd .map-owner-link:focus-visible {
    text-decoration-thickness: 2px;
}

.map-facts dd .map-owner-unknown {
    font-family: inherit;
    color: var(--map-muted);
    font-style: italic;
}

.map-inspector-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
}

.map-btn {
    flex: 1 1 auto;
    padding: 0.35rem 0.5rem;
    border: 1px solid var(--map-border);
    border-radius: 6px;
    background: var(--map-panel-bg);
    color: var(--map-text);
    font: inherit;
    font-size: 0.76rem;
    cursor: pointer;
}

.map-btn:hover {
    background: var(--surface-bg-hover, #e2e6ec);
}

.map-btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

.map-btn-danger {
    border-color: rgba(220, 38, 38, 0.5);
    color: #dc2626;
}

.map-btn-danger:hover {
    background: rgba(220, 38, 38, 0.1);
}

/* -- status bar --------------------------------------------------------------------------- */

.map-statusbar {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.3rem 0.7rem;
    border-top: 1px solid var(--map-border);
    background: var(--map-panel-alt);
    color: var(--map-muted);
    font-size: 0.72rem;
}

.map-statusbar [data-status-map] {
    margin-left: auto;
}

.map-statusbar [data-status-save][data-tone="error"] {
    color: #dc2626;
}

/* -- server tab embedding ----------------------------------------------------------------- */

.server-map-panel {
    padding: 0.25rem 0;
}

.server-map-panel .map-app {
    height: min(74vh, 880px);
}

.server-map-actions {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    flex-wrap: wrap;
    padding: 0.5rem 0.25rem 0.6rem;
}

.server-map-actions h3 {
    margin: 0;
    font-size: 1rem;
}

.server-map-actions p {
    margin: 0.15rem 0 0;
    color: var(--muted-text, #5c6b7a);
    font-size: 0.8rem;
}

/* -- responsive --------------------------------------------------------------------------- */

@media (max-width: 1100px) {
    /* No column redefinition: the sidebar is an overlay, not a grid cell. This used to set
       `96px minmax(0, 1fr) 260px`, which left `.map-stage` pinned to 96px. */
    .map-body {
        grid-template-columns: minmax(0, 1fr);
    }

    .map-tool {
        justify-content: center;
    }
}

@media (max-width: 820px) {
    /* Keep the full-bleed zero padding from the base rule. The old mobile override re-added
       0.5rem/0.75rem here, which silently reintroduced the dead band the base rule removes. */
    .map-page-root {
        padding: 0;
    }

    .map-app {
        /* This rule used to hard-set `height: calc(100dvh - 120px)` and `min-height: 380px`, which
           overrode the measurement `js/map/page.js` performs and cannot know about the real navbar.
           On a 390px-tall landscape phone it forced 380 against a 273px budget and produced 147px of
           page overflow (measured live). The measured value wins when present; the old expression is
           kept only as the pre-measurement first-frame fallback, floored the same yielding way. */
        height: var(--map-fit-height, calc(100dvh - 120px));
        min-height: var(--map-fit-height, min(380px, 100dvh - var(--nav-height, 6.5rem)));
        border-radius: 10px;
    }

    /* Same reason as the 1100px rule: overlays need no grid cells. The sidebar is a bottom sheet
       (see `.map-sidebar` below), still absolutely positioned over the stage. */
    .map-body {
        grid-template-columns: minmax(0, 1fr);
        grid-template-rows: minmax(0, 1fr);
    }

    .map-sidebar {
        grid-column: 1 / -1;
        grid-row: 2;
        max-height: 42%;
        border-left: 0;
        border-top: 1px solid var(--map-border);
        /* The sheet's height is the budget: the grid row's `minmax(0, var(--map-inspector-share))` is measured
           against 42% of the app here rather than against a full-height column on the desktop, and the object
           list keeps its own 96px floor underneath either way. Same variable, one row definition, both layouts. */
    }

    .map-app.sidebar-collapsed .map-body {
        grid-template-columns: minmax(0, 1fr);
    }

    /* This breakpoint used to give `.map-inspector` a `max-height: 70%` of its own. It is gone on purpose: a
       percentage max-height on a grid ITEM resolves against the whole sidebar box, not the inspector's own row,
       and it fought the row's `minmax(0, var(--map-inspector-share))` — on the bottom sheet the two disagreed
       and the shorter one won, so the editor could not use the height it was given. The row owns the cap now;
       the inspector owns the scrolling (see `.map-inspector.is-editing`). */

    .map-tool {
        padding: 0.42rem 0.2rem;
    }

    .map-search {
        order: 3;
        flex-basis: 100%;
        max-width: none;
        margin-left: 0;
    }

    .map-toolbar {
        gap: 0.4rem;
    }

    .map-hint {
        display: none;
    }
}

@media (prefers-reduced-motion: reduce) {
    .map-app * {
        transition: none !important;
        animation: none !important;
    }
}

/* -- building / event popout -------------------------------------------------------------- */

/*
 * The card is placed by JS (`positionFeaturePop`) beside the tap that opened it: Commander: "Clicking to
 * open information on buildings should open the pop out near where you clicked the building instead of at
 * the bottom left." The bottom-left home below is therefore only the FALLBACK, used when there is no click
 * point to anchor to - a card opened from a restored link or programmatically. It stays in the stylesheet
 * so that case still lands somewhere sensible instead of at the top-left corner under the toolbar.
 * Pointer events are enabled only on the panel itself, so the map beneath stays usable.
 */
.map-feature-pop {
    position: absolute;
    left: 12px;
    bottom: 12px;
    width: min(280px, 46%);
    max-height: min(60%, 380px);
    overflow-y: auto;
    padding: 0.6rem 0.7rem;
    border-radius: 10px;
    background: rgba(12, 24, 36, 0.95);
    border: 1px solid rgba(148, 197, 255, 0.35);
    color: #e2e8f0;
    font-size: 0.8rem;
    box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
    z-index: 18;
}

.map-feature-pop[hidden] {
    display: none;
}

.map-pop-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}

.map-pop-title {
    font-weight: 600;
    font-size: 0.9rem;
    line-height: 1.2;
    color: #f1f5f9;
}

.map-pop-kind {
    margin-top: 0.1rem;
    font-size: 0.68rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: #7dd3fc;
}

.map-pop-section {
    padding-top: 0.45rem;
    border-top: 1px solid rgba(148, 197, 255, 0.16);
}

.map-pop-section + .map-pop-section {
    margin-top: 0.45rem;
}

.map-pop-label {
    font-size: 0.66rem;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: #94a3b8;
    margin-bottom: 0.25rem;
}

.map-pop-coords {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.78rem;
    color: #cbd5e1;
}

.map-pop-usage {
    color: #cbd5e1;
    margin-bottom: 0.3rem;
}

.map-pop-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
}

.map-pop-chip {
    padding: 0.1rem 0.4rem;
    border-radius: 999px;
    background: rgba(56, 189, 248, 0.14);
    border: 1px solid rgba(56, 189, 248, 0.35);
    color: #bae6fd;
    font-size: 0.7rem;
    white-space: nowrap;
}

.map-pop-meta {
    margin-top: 0.3rem;
    font-size: 0.72rem;
    color: #94a3b8;
    line-height: 1.35;
}

.map-pop-raw {
    margin-top: 0.45rem;
    padding-top: 0.4rem;
    border-top: 1px solid rgba(148, 197, 255, 0.16);
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.68rem;
    color: #64748b;
    word-break: break-all;
}

/* -- fill the screen ---------------------------------------------------------------------- */

/* Maximized, the app is pinned under the fixed navbar and runs to the bottom of the viewport. The
   shell's own chrome stays in place behind it, so nothing reflows and nothing is lost on restore. */
.map-app.is-maximized {
    /* A limit, not a fixed length: the map always sits inside the shell, so a real body scrollbar would shrink
       the usable width and `vw` would push the right edge underneath it. */
    position: fixed;
    z-index: 900;
    /* Flush to the top of the viewport. This used to reserve the fixed navbar's height, which is exactly the
       strip "span the entire screen" asked to reclaim; `setMaximized` publishes `--nav-height: 0px` and
       hides the bar while maximized, and `syncNavHeight()` restores the real measurement on exit. */
    top: 0;
    right: 0;
    left: 0;
    bottom: 0;
    width: auto;
    height: auto;
    max-width: 100%;
    margin: 0;
    min-height: 0;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    /* Keep any transform put on an ancestor by the page transition from becoming the containing block. */
    transform: none;
    /* THE SIZE HAS TO WIN, NOT JUST THE POSITION, AND IT CANNOT COME FROM THE ANCESTORS.
       Inside the SERVER map tab the app is nested in `.server-map-mount` > `.server-map-panel` >
       `.server-info-tab-panel` > `.server-detail-layout` > `.server-card` > `.content-container`. Measured live
       in that nesting, the maximized app was 0px wide and 8px tall (a 26x26 box) where /map gave 1280x800 - the
       collapsed strip on a server's Map tab that the Commander photographed.
       Two findings from that measurement decide this block. First, `min-height` was not the culprit: forcing it
       to 0 changed the computed value and left the box at 0x8. `height` IS authoritative - pinning 100vh made
       the app 800px tall again - so both size declarations of the base `.map-app` rule are overridden here.
       Second, and more important: filling the box with `width: 100vw` STILL measured 0px wide, because a fixed
       element is only viewport-sized relative to its containing block, and inside that server card the
       containing block has no width of its own to inherit. No amount of `!important` inside this nesting fixes
       that, which is why `setMaximized` MOVES the app to `<body>` while it is maximized and puts it back on
       restore. This rule then describes a viewport-child: full insets, no base height, nothing to shrink by.
       `!important` is deliberate - the declarations being beaten here are same-specificity ones later in the
       cascade, and a full-screen map must not depend on declaration order. */
    height: auto !important;
    min-height: 0 !important;
    max-height: none !important;
}

/* While maximized the page behind must not scroll, or the fixed panel would drift against it. */
body.map-maximized {
    overflow: hidden;
}

/* While the map is full screen the ROOT must stop being a scroll container, and it has to happen in CSS
   (before layout) rather than as an inline style set after the class lands. A `position: fixed` app sizes
   itself from the initial containing block, which is the root minus any scrollbar gutter it is still
   accepting: measured live, the app came out 10px narrow because the root kept that gutter for a frame.
   With the rule below it spans the true viewport width. */
html:has(body.map-maximized) {
    overflow: hidden;
    scrollbar-gutter: auto;
}

/* The navbar floats above the full-screen map, so its own stacking context is preserved; the map
   sits one layer under it and the panels one layer above the canvas. */
.map-app.is-maximized .map-toolbar {
    border-radius: 0;
}

/* -- overlays on small screens ------------------------------------------------------------- */

@media (max-width: 820px) {
    .map-app {
        --map-overlay-inset: 0.4rem;
        min-height: 360px;
    }

    .map-sidebar {
        top: auto;
        height: min(58%, 420px);
        width: var(--map-sidebar-w, min(320px, calc(100% - 2 * var(--map-overlay-inset))));
    }
}

@media (max-width: 560px) {
    .map-toolbar {
        flex-wrap: wrap;
        gap: 0.4rem;
    }

    .map-toolbar-actions {
        flex-wrap: wrap;
    }
}

/* Checkboxes and small counts inside the glass rail: the default control colours are tuned for a light
   panel and go nearly invisible once the rail sits over map imagery. */
.map-layer-toggle input[type="checkbox"] {
    accent-color: var(--primary-color, #2471a3);
    width: 0.85rem;
    height: 0.85rem;
}

.map-app .map-layer-count {
    color: #c0d0e2;
}

/* -- the live draft's Finish / Cancel / audience, on the map --------------------------------- */
/*
 * LEGACY, and still referenced: `data-draft-actions` and `data-draft-audience` sit in the tool rail, and the
 * rail keeps drawing them until the markup moves them out. Nothing below this block may assume they are gone.
 * The rail is the wrong place for them, though — the buttons that commit the shape you are drawing are 400px
 * from the point you just clicked, and on a phone the rail can be scrolled away entirely. The floating cluster
 * is where they belong: a compact pill pinned to the last point, so Finish is under the hand that drew it.
 *
 * The page positions `.map-draft-float` by writing `left`/`top` in pixels at the last clicked point, and the
 * translate is what makes that point the pill's anchor rather than its corner: half the width left, its whole
 * height up, so the pill hangs above the vertex with its tail on it.
 *
 * The one case the translate cannot serve is a vertex within a pill's height of the top of the map: the pill
 * would be lifted off the top edge. The page's answer to that is a class — add `is-below` and the pill flips
 * under its point, tail upward — which is why this block declares it even though nothing writes it yet.
 */
.map-draft-float {
    position: absolute;
    z-index: 21;
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 4px;
    border: 1px solid var(--map-glass-border);
    border-radius: 999px;
    background: var(--map-glass);
    color: var(--map-glass-text);
    box-shadow: 0 10px 26px rgba(2, 10, 20, 0.42);
    /* Hung above and centred on the last point. `pointer-events` stays auto: every pixel of this pill is a
       control, unlike the caption that shares the canvas. */
    transform: translate(-50%, -100%);
    /* The pill is positioned against the stage, so it must never be the thing that stretches it. */
    max-width: calc(100% - 2 * var(--map-overlay-inset, 0.6rem));
}

/* Flipped: the point is near the top of the map, so the pill sits below it and the tail points up. */
.map-draft-float.is-below {
    transform: translate(-50%, 0);
}

/*
 * DOCKED: the pill no longer hangs off a vertex. Commander: "Move the finish/cancel/audience buttons the
 * bottom center above the coordinates area." A fixed home on the bottom edge is predictable - the shape
 * stays fully visible while Finish sits in one place - where a pill that chased the last click jumped
 * around during a long polygon and slid away from the hand whenever a corner hit the clamp.
 *
 * The point-to-a-pixel tail is meaningless once there is no vertex under it, so it is removed rather than
 * left pointing at empty terrain. The pill keeps its pill shape and simply floats.
 */
.map-draft-float.is-docked::after {
    display: none;
}

/* A one-way tail down to the vertex, drawn as a rotated square on the pill's bottom edge so it inherits the
   glass and the border without a second surface. It is decorative only: no text, no hit target. */
.map-draft-float::after {
    content: "";
    position: absolute;
    left: 50%;
    bottom: -5px;
    width: 9px;
    height: 9px;
    margin-left: -4.5px;
    border-right: 1px solid var(--map-glass-border);
    border-bottom: 1px solid var(--map-glass-border);
    border-bottom-right-radius: 2px;
    background: var(--map-glass);
    transform: rotate(45deg);
    pointer-events: none;
}

.map-draft-float.is-below::after {
    top: -5px;
    bottom: auto;
    border-top: 1px solid var(--map-glass-border);
    border-left: 1px solid var(--map-glass-border);
    border-right: 0;
    border-bottom: 0;
    border-bottom-right-radius: 0;
    border-top-left-radius: 2px;
}

/* Its buttons keep the pill's own sizing rather than the panel's full-width `.map-btn`: a button that is
   `flex: 1 1 auto` inside a shrink-to-fit pill would grow the pill to the width of its longest label. */
.map-draft-float .map-btn {
    flex: 0 0 auto;
    min-width: 32px;
    /* 32px is the smallest target that still reads as a button under a thumb; the pill's own padding and the
       gap between the two are what keep Finish and Cancel from being one hit box. */
    min-height: 32px;
    padding: 0.2rem 0.7rem;
    border-color: var(--map-glass-border);
    border-radius: 999px;
    background: transparent;
    color: var(--map-glass-text);
    font-size: 0.76rem;
}

.map-draft-float .map-btn:hover {
    background: rgba(148, 197, 255, 0.14);
}

/* Finish is the one affirmative control on the pill, so it alone takes the accent — the same blue/black pair
   the map's other "this does the thing" buttons wear. */
.map-draft-float .map-draft-finish {
    border-color: transparent;
    background: var(--map-accent, #38bdf8);
    color: #04131f;
    font-weight: 600;
}

.map-draft-float .map-draft-finish:hover {
    background: color-mix(in srgb, var(--map-accent, #38bdf8) 86%, #ffffff);
}

/* The audience control arrives from the same builder the inspector uses, which emits a full-width switch row.
   Inside the pill it is a compact chip: the label rides beside its checkbox instead of under it, and the
   explanatory line stays, because "who sees this" is exactly the thing not to leave ambiguous mid-draw. */
.map-draft-float .map-audience-field {
    display: flex;
    align-items: center;
    gap: 6px;
    min-height: 32px;
    margin: 0;
    padding: 0 0.55rem 0 0.2rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 999px;
    font-size: 0.74rem;
    line-height: 1.2;
    white-space: nowrap;
}

.map-draft-float .map-audience-field input[type="checkbox"] {
    margin: 0;
    accent-color: var(--map-accent, #38bdf8);
}

.map-draft-float .map-switch-text small {
    display: none;
}

/* A pill with Finish, Cancel and the audience chip is wide on a 390px screen. It wraps onto a second row rather
   than running off the map, and the rounded ends are eased so the wrapped shape does not read as a mistake.
   The tail stays centred on the pill's bottom edge, and the bottom edge is still under the point. */
@media (max-width: 560px) {
    .map-draft-float {
        flex-wrap: wrap;
        justify-content: center;
        border-radius: 14px;
    }
}

/* -- shape editing ---------------------------------------------------------------------- */
/* The engine has always supported dragging handles, double-clicking to add or remove a point and arrow
   keys to nudge. None of that was discoverable, so the inspector names the gestures and offers the ones
   that are not guessable. These styles deliberately reuse the inspector's own tokens rather than
   introducing a second visual language inside one panel. */
.map-edit-block {
    margin-top: 0.6rem;
    padding: 0.55rem 0.6rem 0.6rem;
    border: 1px solid var(--map-border);
    border-radius: 8px;
    background: var(--map-panel-bg);
}

.map-edit-block.is-editing {
    border-color: var(--map-accent, #38bdf8);
    box-shadow: inset 0 0 0 1px var(--map-accent, #38bdf8);
}

.map-edit-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    font-size: 0.76rem;
    font-weight: 600;
}

.map-edit-block.is-editing .map-edit-head span::after {
    content: " \2022 editing";
    color: var(--map-accent, #38bdf8);
    font-weight: 500;
}

.map-edit-hint {
    margin: 0.4rem 0 0;
    font-size: 0.72rem;
    line-height: 1.45;
    color: var(--map-text-muted, #556);
}

.map-edit-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin-top: 0.5rem;
}

.map-btn-small {
    flex: 0 0 auto;
    min-width: 2.1rem;
    padding: 0.24rem 0.45rem;
    font-size: 0.72rem;
    line-height: 1.2;
}

.map-btn-small[aria-pressed="true"] {
    background: var(--map-accent, #38bdf8);
    border-color: var(--map-accent, #38bdf8);
    color: #06121d;
}

/* -- manual point coordinates ------------------------------------------------------------
   One row per vertex: label, X, Z, and a button that selects that point on the map. Values are set
   by typing because dragging cannot land on a specific number at map scale. The row highlight is the
   only cue that a canvas handle and a table row are the same point, so it has to survive on both. */
.map-point-coords {
    margin-top: 0.6rem;
    padding-top: 0.55rem;
    border-top: 1px solid var(--map-border, rgba(148, 163, 184, 0.22));
}

.map-point-coords-head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.25rem 0.5rem;
    margin-bottom: 0.35rem;
    font-size: 0.72rem;
    font-weight: 600;
    color: var(--map-text, #dbe4f0);
}

.map-point-coords-hint {
    font-weight: 400;
    font-size: 0.68rem;
    color: var(--map-text-muted, #556);
}

.map-point-rows {
    display: flex;
    flex-direction: column;
    gap: 0.22rem;
    max-height: 11rem;
    overflow-y: auto;
    /* The rail must stay scrollable on its own rather than pushing the edit block past the panel. */
    scrollbar-gutter: stable;
}

.map-point-row {
    display: grid;
    grid-template-columns: 3.6rem minmax(0, 1fr) minmax(0, 1fr) 1.5rem;
    align-items: center;
    gap: 0.28rem;
    padding: 0.12rem 0.2rem;
    border-radius: 6px;
    border: 1px solid transparent;
}

.map-point-row.is-active {
    border-color: var(--map-accent, #38bdf8);
    background: rgba(56, 189, 248, 0.1);
}

/* The row you are ABOUT to edit is marked before you commit to it. Focusing a coordinate field used to give the
   row nothing but the browser's input ring, so the table said nothing about which vertex the number belonged
   to. `:focus-within` is the CSS-only half of that, and it costs no script: the row lights the moment the
   field takes focus, which is one gesture ahead of the value being committed. The canvas handle is marked by
   the page through `setActiveVertex` — that is the other half, and it lives in `js/map/page.js`. */
.map-point-row:focus-within {
    border-color: var(--map-accent, #38bdf8);
    background: rgba(56, 189, 248, 0.06);
}

/* A focused field is the one thing the operator is looking at: give it the accent border as well, so the row
   highlight and the active point read as one control group rather than two unrelated cues. */
.map-point-row:focus-within .map-point-input {
    border-color: var(--map-accent, #38bdf8);
}

.map-point-row-label {
    font-size: 0.68rem;
    color: var(--map-text-muted, #556);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.map-point-input {
    width: 100%;
    min-width: 0;
    padding: 0.16rem 0.3rem;
    font-size: 0.7rem;
    font-variant-numeric: tabular-nums;
    color: var(--map-text, #dbe4f0);
    background: rgba(15, 23, 42, 0.55);
    border: 1px solid var(--map-border, rgba(148, 163, 184, 0.28));
    border-radius: 5px;
}

.map-point-input:focus-visible {
    outline: 2px solid var(--map-accent, #38bdf8);
    outline-offset: 1px;
}

.map-point-jump {
    width: 1.5rem;
    height: 1.5rem;
    padding: 0;
    font-size: 0.66rem;
    font-variant-numeric: tabular-nums;
    color: var(--map-text-muted, #556);
    background: transparent;
    border: 1px solid var(--map-border, rgba(148, 163, 184, 0.28));
    border-radius: 5px;
    cursor: pointer;
}

.map-point-jump:hover,
.map-point-row.is-active .map-point-jump {
    color: var(--map-text, #dbe4f0);
    border-color: var(--map-accent, #38bdf8);
}

/* -- server zone inspector -------------------------------------------------------------- */
/* These classes were emitted by renderZoneInspector but never styled, so the whole per-zone settings
   panel — presets, field controls, hint — rendered as raw unstyled HTML inside a finished-looking
   inspector. Styled here against the inspector's existing tokens. */
.map-zone-meta {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    font-size: 0.7rem;
}

.map-zone-meta span {
    padding: 0.14rem 0.4rem;
    border-radius: 999px;
    background: rgba(148, 197, 255, 0.1);
    border: 1px solid var(--map-glass-border, var(--map-border));
    color: var(--map-glass-text, var(--map-text));
}

/* -- what the selected zone type controls ------------------------------------------------- */
/*
 * The description is derived from the preset's own settings (describeZonePreset), so an operator reads
 * what the game will actually do instead of inferring it from a colour. It sits directly under the type
 * line and above the preset picker: the sentence explains the current type, the picker changes it.
 */
.map-zone-description {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    padding: 0.55rem 0.6rem;
    border-radius: var(--map-radius, 0.5rem);
    background: rgba(148, 197, 255, 0.07);
    border: 1px solid var(--map-glass-border, var(--map-border));
}

.map-zone-description-lead {
    margin: 0;
    font-size: 0.74rem;
    font-weight: 600;
    color: var(--map-glass-text, var(--map-text));
}

.map-zone-description-body {
    margin: 0;
    font-size: 0.7rem;
    line-height: 1.45;
    color: var(--map-glass-muted, var(--map-muted));
}

.map-zone-description-controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.3rem;
    margin: 0;
}

.map-zone-description-caption {
    flex: 0 0 100%;
    font-size: 0.62rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--map-glass-muted, var(--map-muted));
}

.map-zone-chip {
    display: inline-flex;
    align-items: baseline;
    gap: 0.3rem;
    padding: 0.12rem 0.4rem;
    border-radius: 999px;
    font-size: 0.66rem;
    background: rgba(148, 197, 255, 0.12);
    border: 1px solid var(--map-glass-border, var(--map-border));
    color: var(--map-glass-text, var(--map-text));
}

.map-zone-chip-value {
    font-weight: 600;
    color: #bfe0ff;
}

.map-zone-presets {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    padding: 0.45rem 0.5rem;
    border: 1px solid var(--map-border);
    border-radius: 8px;
    background: var(--map-panel-bg);
}

.map-zone-presets-label {
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--map-muted);
}

/* Grid, not `flex-wrap` (the original): every chip gets the same width and a long label truncates instead of
   leaving one chip stranded at an odd size. `auto-fill` + a 7.5rem minimum keeps the row tidy at any panel
   width without a media query. */
.map-zone-preset-row {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
    gap: 0.3rem;
}

/* The colour dot is drawn by `::before` from an inline `--zone-swatch` custom property the page sets per chip;
   it falls back to the map accent so a chip with no swatch still reads. */
.map-zone-preset {
    flex: 0 1 auto;
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    width: 100%;
    min-width: 0;
    text-align: left;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    padding: 0.22rem 0.48rem;
    border: 1px solid var(--map-border);
    border-radius: 999px;
    background: transparent;
    color: var(--map-text);
    font: inherit;
    font-size: 0.71rem;
    line-height: 1.3;
    cursor: pointer;
}

.map-zone-preset::before {
    content: "";
    flex: 0 0 auto;
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: var(--zone-swatch, var(--map-accent, #38bdf8));
    /* A dark ring so a pale zone colour is still visible on a light preset chip. */
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.28);
}

/* A halo on the active chip, so the current preset is obvious at a glance on a phone. It repeats the base
   ring and adds one, so it must stay AFTER `.map-zone-preset::before` or the halo would be dropped. */
.map-zone-preset.is-current::before {
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.28), 0 0 0 3px rgba(56, 189, 248, 0.35);
}

.map-zone-preset.is-current {
    border-color: var(--map-accent, #38bdf8);
    background: rgba(56, 189, 248, 0.16);
    color: var(--map-text);
    font-weight: 600;
}

/* Hover last, so a hovered current chip keeps its selected background (`.is-current` above wins on source
   order for the shared `background` property). */
.map-zone-preset:hover {
    background: var(--surface-bg-hover, rgba(148, 197, 255, 0.16));
}

/* Tightened from 0.4rem: with the editor's rows carrying their own divider and padding, the original gap
   double-spaced against them. */
.map-zone-settings {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

.map-zone-settings label {
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    font-size: 0.72rem;
    color: var(--map-muted);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

.map-zone-settings input[type="text"],
.map-zone-settings input[type="number"],
.map-zone-settings select,
.map-zone-settings textarea {
    font: inherit;
    font-size: 0.78rem;
    text-transform: none;
    letter-spacing: normal;
    padding: 0.3rem 0.4rem;
    border: 1px solid var(--map-border);
    border-radius: 6px;
    background: var(--map-panel-bg);
    color: var(--map-text);
}

/* A switch row reads as a line of prose, not a stacked field: label and control side by side.
   The selector is two-class deliberately. `.map-zone-settings label` (0,1,1) sets `flex-direction: column`
   for the stacked fields, and this row used to beat it with a force flag. Specificity is the honest fix:
   (0,2,0) wins on its own, so a later rule is no longer fighting a force flag. */
.map-zone-settings .map-zone-switch {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    gap: 0.6rem;
    cursor: pointer;
}

.map-zone-switch input[type="checkbox"] {
    flex: 0 0 auto;
    width: 1rem;
    height: 1rem;
    margin: 0;
    cursor: pointer;
}

.map-zone-hint {
    margin: 0.15rem 0 0;
    padding-top: 0.4rem;
    border-top: 1px solid var(--map-border);
    font-size: 0.71rem;
    line-height: 1.45;
    color: var(--map-muted);
}

.map-zone-hint:empty {
    display: none;
}

.map-zone-readonly {
    margin: 0;
    font-size: 0.72rem;
    line-height: 1.45;
    color: var(--map-muted);
}

/* -- map lock badge and audience options ------------------------------------------------ */
/* Pre-existing gap found while auditing the inspector: these were emitted but never styled, so the
   "this server only" badge and the audience choice rendered as bare inline text. */
.map-locked {
    display: inline-flex;
    align-items: baseline;
    gap: 0.4rem;
    padding: 0.22rem 0.55rem;
    border: 1px solid var(--map-glass-border, var(--map-border));
    border-radius: 999px;
    background: rgba(148, 197, 255, 0.1);
    cursor: default;
}

.map-locked-label {
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--map-text);
}

.map-locked-meta {
    font-size: 0.68rem;
    color: var(--map-muted);
}

.map-audience-opt {
    display: flex;
    align-items: flex-start;
    gap: 0.45rem;
    padding: 0.4rem 0.5rem;
    border: 1px solid var(--map-border);
    border-radius: 7px;
    background: var(--map-panel-bg);
    font-size: 0.74rem;
    line-height: 1.4;
    text-transform: none;
    letter-spacing: normal;
    cursor: pointer;
}

.map-audience-opt:hover {
    border-color: var(--map-accent, #38bdf8);
}

.map-audience-opt input[type="radio"] {
    flex: 0 0 auto;
    margin: 0.1rem 0 0;
    cursor: pointer;
}

.map-audience-opt:has(input:checked) {
    border-color: var(--map-accent, #38bdf8);
    background: rgba(56, 189, 248, 0.12);
}

/* The label and its description are two lines. Without this the <span> is an inline box and the
   <small> runs straight on from the <strong>, reading "PrivateOnly this map's editors". */
.map-audience-opt > span {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}

.map-audience-opt strong {
    font-weight: 600;
    line-height: 1.25;
}

.map-audience-opt small {
    display: block;
    font-size: 0.68rem;
    line-height: 1.35;
    color: var(--map-muted);
}

/* A refused edit is reported beside the control that caused it, not in a toast that scrolls away. */
.map-edit-error {
    margin: 0.4rem 0 0;
    padding: 0.35rem 0.5rem;
    border-left: 2px solid rgba(220, 38, 38, 0.6);
    background: rgba(220, 38, 38, 0.1);
    border-radius: 0 4px 4px 0;
    color: var(--map-text);
    font-size: 0.72rem;
    line-height: 1.4;
}

.map-edit-error[hidden] {
    display: none;
}

/* ---------------------------------------------------------------------------------------------
   Map chooser — the personal map plus the server maps this account may open.
   ---------------------------------------------------------------------------------------------
   The chooser replaced four map tabs. A map is no longer a background you switch; it is a scope with its
   own shapes and its own permissions, so the control names the scope you are on and lists the ones you
   can move to. It is a popover over the toolbar, never a column: nothing here may take width from the
   canvas. */
.map-scope-picker {
    position: relative;
    display: flex;
    align-items: center;
}

.map-scope-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    max-width: 260px;
    padding: 0.4rem 0.7rem;
    border: 1px solid var(--map-border, rgba(255, 255, 255, 0.12));
    border-radius: 8px;
    background: var(--map-surface-2, rgba(18, 20, 26, 0.72));
    color: var(--map-text, #e8ebf2);
    font: inherit;
    font-size: 0.82rem;
    line-height: 1.1;
    cursor: pointer;
    transition: background 140ms ease, border-color 140ms ease;
}

.map-scope-btn:hover,
.map-scope-btn[aria-expanded="true"] {
    background: var(--map-surface-3, rgba(30, 34, 43, 0.9));
    border-color: var(--map-accent, #5b8cff);
}

/* Locked: the Servers page's Map tab shows the server chip as a label, not a menu. Every affordance that
   says "this opens something" is removed rather than merely muted - the pointer, the hover lift and the
   caret (which the markup also stops emitting) - because a disabled-looking button that still responds is
   worse than no button at all. It keeps the chip's own colours, since it is still telling the reader which
   server and which terrain they are looking at. */
.map-scope-btn.is-locked,
.map-scope-btn.is-locked:hover {
    cursor: default;
    background: var(--map-surface-2, rgba(18, 20, 26, 0.72));
    border-color: var(--map-border, rgba(255, 255, 255, 0.12));
}

.map-scope-btn.is-locked .map-scope-caret,
.map-scope-btn.is-locked::after {
    display: none;
}

.map-scope-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-weight: 600;
}

.map-scope-caret {
    font-size: 0.7rem;
    opacity: 0.7;
}

/* The menu floats above the canvas and must not be clipped by the toolbar, so it is positioned against
   the picker and given its own stacking context rather than living inside the toolbar's flow. */
.map-scope-menu {
    position: absolute;
    z-index: 40;
    top: calc(100% + 6px);
    left: 0;
    min-width: 268px;
    max-width: min(360px, calc(100vw - 32px));
    max-height: min(60vh, 420px);
    overflow-y: auto;
    padding: 0.35rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 10px;
    background: var(--map-glass);
    color: var(--map-glass-text);
    box-shadow: 0 18px 38px rgba(0, 0, 0, 0.45);
}

.map-scope-menu[hidden] {
    display: none;
}

.map-scope-group-title {
    padding: 0.5rem 0.55rem 0.3rem;
    font-size: 0.66rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--map-glass-muted);
}

.map-scope-opt {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    width: 100%;
    padding: 0.5rem 0.55rem;
    border: 0;
    border-radius: 7px;
    background: transparent;
    color: var(--map-glass-text);
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.map-scope-opt:hover,
.map-scope-opt:focus-visible {
    background: rgba(148, 197, 255, 0.14);
    outline: none;
}

.map-scope-opt[aria-selected="true"] {
    background: color-mix(in srgb, var(--map-accent, #5b8cff) 18%, transparent);
}

.map-scope-opt-name {
    font-size: 0.84rem;
    font-weight: 600;
}

.map-scope-opt-meta {
    font-size: 0.7rem;
    color: var(--map-glass-muted);
}

.map-scope-empty {
    padding: 0.55rem;
    font-size: 0.75rem;
    line-height: 1.5;
    color: var(--map-glass-muted);
}

/* The personal map cannot hold shapes. This stands in the tool rail where the draw tools would be, so the
   reason is visible rather than the tools simply being absent. */
.map-tool.map-need-server {
    border-style: dashed;
    color: var(--map-text-muted, #8b93a7);
}

/* ============================================================================
   Toolbar popovers: map filters, admin settings, and the create menu
   ============================================================================
   The layer toggles used to be permanent furniture in a 190px rail — a column of checkboxes standing
   between the operator and the map they were reading. They are popovers now: closed by default, opened
   from one toolbar icon, and laid over the canvas so no state of them can change the map's size.

   `.map-filterbar` lives in `.map-body`, which is now the whole card because the toolbar floats over it, so
   its `top` must clear that bar: the inset plus the measured toolbar height. This is the trap the rail fell
   into (counting --map-toolbar-h twice pushed it past its own row and sliced the last toggle); here the body
   carries no padding of its own, so the bar height belongs here and only here. */

.map-filterbar {
    position: absolute;
    /* Above EVERY other layer on the map: the objects panel (15), the tool rail (14), the drawing cluster
       (19), the reopen tab (19) and the corner's own popovers (22). The menu is opened from the bar and must
       cover whatever happens to be under it, not be sliced by it. 40 ties with the Admin sheet on purpose —
       the two are never open together, because each closes the other — and still sits under the world chooser
       (60), which is a modal and must win. This was 20, which lost to the panel's shrink-to-fit level and
       painted the menu behind a 320px column of objects. Absolute, not fixed: the page positions it by
       setting left/top at the filter button, and only an in-flow-of-the-app box keeps that maths in the
       app's own coordinates. */
    z-index: 40;
    top: calc(var(--map-toolbar-h, 56px) + 8px);
    /* Anchored under the toolbar's right-hand icon cluster. The JS clamps it back inside the app box
       when the buttons are pushed left by a narrow viewport. */
    right: calc(var(--map-overlay-inset) + 96px);
    /* The `top`/`right` pair above is the resting position and the JS's own clamp is expressed against it:
       `positionFilterbar()` clears and then shrinks `left` only, to pull a wide menu back inside the app box,
       so it never moves the menu above or below where this rule puts it. That is why "open slightly below,
       centred on the filter button" is a CSS job here — an inline top would outrank this box silently. */
    width: 320px;
    max-width: calc(100% - 32px);
    max-height: calc(100% - 24px);
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 0.55rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 12px;
    background: var(--map-glass);
    color: var(--map-glass-text);
    box-shadow: 0 22px 48px rgba(0, 0, 0, 0.46);
    font-size: 0.78rem;
}

.map-filterbar[hidden] {
    display: none;
}

/* A file input inside the Publish tab is driven by its own labelled button, so the native control - a
   default "Choose File" chip with a button beside it - is never drawn. */
.map-import-input {
    display: none;
}

/* -- the Admin sheet ---------------------------------------------------------------------- */
/*
 * The Admin sheet is a sheet, not a dropdown: it centres over the map and over every other panel on it,
 * because what it changes is the map everyone sees rather than a corner of the reader's own view. It is
 * positioned in CSS from the root's `admin-sheet-open` class instead of being measured in JS, so a save -
 * which re-renders every tab - never re-measures or drifts. The scrim below it takes the clicks that used
 * to land on the terrain, which is also what makes "click outside to dismiss" unambiguous.
 *
 * `.map-adminbar` also carries the glass surface every other map popover has, which used to come in with
 * the general popover rule this selector was split out of - without it the sheet had layout but no
 * background and the map showed straight through it.
 *
 * Its POSITION is this file's `.map-adminbar` rule and nothing else: JavaScript never measures or writes
 * a `top`/`left` for it (makeMenu branches the Admin sheet away from the dropdown positioner). The
 * `admin-sheet-open` class on the root is chrome only - it re-asserts the scrim and gives tests a state
 * to read - it does not position anything.
 */
.map-app > .map-adminbar,
.map-app .map-body > .map-adminbar {
    background: var(--map-glass-bg, rgba(9, 17, 28, 0.94));
    border: 1px solid var(--map-glass-border, rgba(120, 170, 230, 0.22));
    border-radius: 14px;
    color: var(--map-glass-text);
    box-shadow: 0 26px 64px rgba(0, 0, 0, 0.58);
}
/* The scrim is inserted as a sibling of the admin panel, and the panel is rendered inside `.map-body`.
   Matching only `.map-app > ...` left the scrim unstyled (no absolute inset, no z-index) there. */
.map-app > .map-adminbar-scrim,
.map-app .map-body > .map-adminbar-scrim {
    position: absolute;
    inset: 0;
    z-index: 39;
    background: rgba(4, 10, 18, 0.42);
    border: 0;
    padding: 0;
    cursor: default;
}

.map-adminbar[hidden],
.map-app > .map-adminbar-scrim[hidden],
.map-app .map-body > .map-adminbar-scrim[hidden] {
    display: none;
}

.map-adminbar {
    position: absolute;
    z-index: 40;
    /* Centre on the MAP the reader is looking at, not on the whole body box.
     *
     * The toolbar (`position: absolute` at the app's top edge) and the drawing cluster at the bottom both
     * float OVER `.map-body`, so the body's own middle is not the middle of the visible terrain. The free
     * band runs from the toolbar's bottom edge plus one inset down to the drawing cluster's top edge minus
     * one inset, and the sheet's centre is that band's middle:
     *
     *     top = 50% + (toolbar height - draw band) / 2
     *
     * The two insets cancel out of that expression, which is why none appears here. A PREVIOUS version
     * wrote the same idea in the symmetric-looking form
     * `(toolbar + draw)/2 + inset + (100% - toolbar - draw - 2*inset)/2`, and every term of it cancels:
     * it evaluates to exactly `50%`, so it centred on the whole body whatever the overlays measured, and
     * on a short body (the Servers page Map tab) the head and tab strip could still reach under the
     * toolbar whenever `--map-draw-h` went unmeasured. The honest form keeps the band DIFFERENCE, which
     * moves the centre down by half the toolbar height when the draw band is missing. Both values are the
     * same named variables the sidebar uses for its own top and bottom, so the sheet and the objects panel
     * can never disagree about where the map begins. `tools/check-map-admin-sheet.mjs` pins this shape. */
    top: calc(50% + (var(--map-toolbar-h, 56px) - var(--map-draw-h, 0px)) / 2);
    left: 50%;
    right: auto;
    transform: translate(-50%, -50%);
    width: min(520px, calc(100% - 2 * var(--map-overlay-inset)));
    /* A centred sheet must leave the map visible around it, so it never grows into a full-screen column:
       it takes what its three tabs need and scrolls inside that. */
    max-height: min(
        calc(100% - var(--map-toolbar-h, 56px) - var(--map-draw-h, 0px) - 2 * var(--map-overlay-inset)),
        640px
    );
    display: grid;
    grid-template-rows: auto auto minmax(0, 1fr);
    padding: 0;
    overflow: hidden;
}

/*
 * The head and the tabs are FIXED rows, and they have to say so.
 *
 * `grid-template-rows: auto auto minmax(0, 1fr)` only sizes rows for items that are auto-placed. The
 * three children are appended positionless, so placement is correct here - but the sheet is rebuilt from
 * scratch on every save and every tab press (`renderAdminSheet`), and any render that gives the body an
 * explicit `grid-row` (or a stray `order`) would silently push it into the first cell instead, leaving a
 * head and a tab strip nowhere in the sheet and a body that fills it top to bottom. Naming the rows makes
 * that impossible: the body can only ever be in row 3, so rows 1 and 2 are always the head and the tabs.
 */
.map-adminbar > .map-admin-head {
    grid-row: 1;
}

.map-adminbar > .map-admin-tabs {
    grid-row: 2;
}

.map-adminbar > .map-admin-body {
    grid-row: 3;
    /* Rows 1 and 2 are `auto`; without this a long settings list could still fight for height in the
       shared row and shrink them toward nothing. The body is the only row allowed to scroll. */
    min-height: 0;
}

/*
 * Commander: "the admin panel should open properly centered on the left of the object panel."
 *
 * The sheet is centred on the STAGE by default, which is right when the objects panel is closed - the map
 * is the whole room. With the panel open it sits on the right, so a sheet centred on the stage reads as
 * centred on the map and left of nothing in particular, and on a narrow card it can land partly underneath
 * the panel. While the panel is open the sheet is centred on the map area the panel leaves free: the same
 * `--map-sidebar-w` the panel itself is sized from, so the two can never disagree about where the panel
 * begins, and the sheet's right edge stops short of the panel's left edge with one inset of breathing room.
 *
 * `.sidebar-collapsed` is the panel's closed state (it is hidden rather than unmounted), so pairing the two
 * selectors is what keeps a closed panel from reserving space it is not using.
 *
 * The sheet is a DESCENDANT (`root > .map-body > .map-adminbar`) while the toggle lives on the root, so the
 * selector is deliberately not a child chain: written as `.map-app:not(.sidebar-collapsed) > .map-adminbar`
 * it matched nothing at all and the sheet silently kept the stage-centred default.
 */
.map-app:not(.sidebar-collapsed) .map-body > .map-adminbar {
    left: calc((100% - var(--map-sidebar-w, 320px) - var(--map-overlay-inset)) / 2);
    width: min(
        520px,
        calc(100% - var(--map-sidebar-w, 320px) - 3 * var(--map-overlay-inset))
    );
}

.map-admin-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.6rem 0.75rem 0.35rem;
}

.map-admin-head h2 {
    margin: 0;
    font-size: 0.86rem;
    font-weight: 700;
    letter-spacing: 0.01em;
    color: var(--map-glass-text);
}

.map-admin-tabs {
    display: flex;
    gap: 0.25rem;
    padding: 0 0.6rem;
    border-bottom: 1px solid var(--map-glass-border);
}

.map-admin-tab {
    appearance: none;
    border: 0;
    border-bottom: 2px solid transparent;
    background: none;
    padding: 0.4rem 0.55rem 0.45rem;
    font: inherit;
    font-size: 0.78rem;
    font-weight: 600;
    color: color-mix(in srgb, var(--map-glass-text) 70%, transparent);
    cursor: pointer;
}

.map-admin-tab:hover {
    color: var(--map-glass-text);
}

.map-admin-tab.is-active {
    color: var(--map-glass-text);
    border-bottom-color: var(--primary-color, #f0a020);
}

/*
 * Three tabs do not share a phone's width. With the strip scrolling sideways the last tab stays reachable
 * by swipe, and `scrollbar-width: none` keeps a desktop scrollbar from cutting the underline in half.
 */
.map-admin-tabs {
    overflow-x: auto;
    scrollbar-width: none;
}

.map-admin-tabs::-webkit-scrollbar {
    display: none;
}

.map-admin-tab {
    white-space: nowrap;
    flex: 0 0 auto;
}

.map-admin-body {
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 0.2rem 0.35rem 0.5rem;
}

/* A switch row: the control, then what it does, with the reason it is disabled beneath. */
.map-perm-list {
    display: flex;
    flex-direction: column;
}

.map-perm-row {
    display: flex;
    align-items: flex-start;
    gap: 0.55rem;
    padding: 0.42rem 0.4rem;
    border-radius: 7px;
    cursor: pointer;
}

.map-perm-row:hover {
    background: color-mix(in srgb, var(--map-glass-text) 6%, transparent);
}

.map-perm-row input {
    margin-top: 0.18rem;
    flex: 0 0 auto;
}

.map-perm-row input:disabled {
    cursor: not-allowed;
}

.map-perm-text {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}

.map-perm-text strong {
    font-size: 0.78rem;
    font-weight: 600;
}

.map-perm-text small {
    font-size: 0.71rem;
    line-height: 1.3;
    color: color-mix(in srgb, var(--map-glass-text) 62%, transparent);
}

/* A proper switch for every map-admin control: the same house pattern as the notification toggle
   (notifications.css), sized for the sheet and drawn from the map's own glass tokens. The checkbox stays
   in the DOM - focusable, labelled by the row - and only the track is painted. */
.map-toggle {
    position: relative;
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
    margin-top: 0.12rem;
    cursor: pointer;
}

.map-toggle input {
    position: absolute;
    opacity: 0;
    width: 1px;
    height: 1px;
}

.map-toggle-track {
    width: 2.4rem;
    height: 1.25rem;
    border-radius: 999px;
    background: color-mix(in srgb, var(--map-glass-text) 16%, transparent);
    border: 1px solid var(--map-glass-border);
    position: relative;
    transition: background 0.15s ease, border-color 0.15s ease;
    display: inline-block;
}

.map-toggle-track::after {
    content: '';
    position: absolute;
    top: 2px;
    left: 2px;
    width: 0.95rem;
    height: 0.95rem;
    border-radius: 50%;
    background: var(--map-glass-text);
    transition: transform 0.15s ease, background 0.15s ease;
}

.map-toggle input:checked + .map-toggle-track {
    background: var(--map-accent, #38bdf8);
    border-color: var(--map-accent, #38bdf8);
}

.map-toggle input:checked + .map-toggle-track::after {
    transform: translateX(1.15rem);
    background: #04131f;
}

.map-toggle input:focus-visible + .map-toggle-track {
    outline: 2px solid var(--map-accent, #38bdf8);
    outline-offset: 2px;
}

.map-toggle input:disabled + .map-toggle-track {
    opacity: 0.45;
    cursor: not-allowed;
}

/* A read-only fact (the loot-zone rules), not a control: no checkbox, no hover, no pointer. */
.map-perm-read {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.6rem;
    margin: 0;
    padding: 0.34rem 0.4rem;
    border-radius: 7px;
    font-size: 0.78rem;
}

.map-perm-read span {
    font-size: 0.73rem;
    color: color-mix(in srgb, var(--map-glass-text) 62%, transparent);
}

.map-filter-section {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.45rem 0.3rem;
}

.map-filter-section + .map-filter-section {
    border-top: 1px solid var(--map-glass-border);
}

.map-filter-section .map-group-title {
    padding: 0 0.15rem 0.3rem;
}

/* The shared row shape: one checkbox, its label, its count pushed right. */
.map-filterbar .map-layer-toggle {
    display: flex;
    align-items: center;
    gap: 0.45rem;
    padding: 0.34rem 0.35rem;
    border-radius: 7px;
    cursor: pointer;
}

.map-filterbar .map-layer-toggle:hover {
    background: rgba(148, 197, 255, 0.12);
}

.map-filterbar .map-layer-toggle input {
    flex: 0 0 auto;
    margin: 0;
    accent-color: var(--map-accent, #38bdf8);
}

.map-filterbar .map-layer-count {
    margin-left: auto;
}

.map-filter-note {
    margin: 0.15rem 0.35rem 0;
    color: var(--map-glass-muted);
    font-size: 0.72rem;
    line-height: 1.35;
}

/* A write the server ACCEPTED but could not store (`write-failed`). Drawn in the error tone rather than the muted
   note tone, because it is the one line in this sheet that means "your change is NOT on the server" — the exact
   message that used to be a "Saved" toast. Same box metrics as `.map-filter-note`, which it is appended beside. */
.map-filter-note.map-save-error {
    /* No `--map-danger` token exists in this theme, so this is the literal, not a variable with a fallback that
       would never be overridden - a token nobody defines reads as if the theme owns the colour when it does not. */
    color: #f87171;
    font-weight: 600;
}

.map-filterbar .map-zone-chips {
    padding: 0 0.15rem 0.25rem;
}

.map-filterbar .map-zone-search {
    margin-top: 0.25rem;
    width: 100%;
    padding: 0.32rem 0.5rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 7px;
    background: rgba(8, 18, 28, 0.6);
    color: var(--map-glass-text);
    font: inherit;
    font-size: 0.76rem;
}

.map-filterbar .map-zone-search::placeholder {
    color: var(--map-glass-muted);
}

.map-filterbar .map-tool-wide {
    margin-top: 0.2rem;
}

/* The admin menu is deliberately tinted differently from the filters: one reads the map, the other
   changes what everyone else sees of it, and confusing the two is expensive. */
.map-adminbar .map-public-switch {
    align-items: flex-start;
    gap: 0.5rem;
}

.map-adminbar .map-public-switch span {
    line-height: 1.3;
}

.map-icon-btn-admin.is-open,
.map-icon-btn-admin:hover {
    color: #fcd34d;
    border-color: rgba(252, 211, 77, 0.45);
}

/* --- the "+" create menu -------------------------------------------------- */

.map-create {
    position: relative;
}

.map-create-btn {
    border-color: var(--map-glass-border);
    background: rgba(148, 197, 255, 0.1);
}

.map-create-btn.is-open {
    border-color: var(--map-accent, #38bdf8);
    color: #ffffff;
}

/* Drops from the "+" button, inside the rail's column, never over the button itself. */
.map-create-menu {
    /* The "+" lives in the map's bottom-right corner now, so its menu opens UPWARD above it: a menu that
       dropped down from the corner would fall off the bottom of the map. `bottom: 100%` anchors its
       bottom edge to the button's top edge, and `right: 0` keeps it inside the map's edge. */
    position: absolute;
    right: 0;
    bottom: calc(100% + 6px);
    left: auto;
    top: auto;
    z-index: 22;
    /* A map is a viewport onto terrain: a menu may never be taller than the room it opens into, or
       its last option (and the keyboard hints on it) sit below the fold with no way to scroll. */
    max-height: min(60vh, calc(var(--map-overlay-inset) * 2 + 60vh));
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    width: 232px;
    padding: 0.35rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 11px;
    background: var(--map-glass);
    color: var(--map-glass-text);
    box-shadow: 0 18px 40px rgba(0, 0, 0, 0.44);
}

.map-create-menu[hidden] {
    display: none;
}

.map-create-opt {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.36rem 0.4rem;
    border: 1px solid transparent;
    border-radius: 8px;
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.map-create-opt:hover,
.map-create-opt:focus-visible {
    background: rgba(148, 197, 255, 0.14);
    border-color: var(--map-glass-border);
    outline: none;
}

.map-create-opt-icon {
    display: inline-flex;
    color: var(--map-glass-text);
}

.map-create-opt-text {
    display: flex;
    flex-direction: column;
    gap: 0.05rem;
    min-width: 0;
}

.map-create-opt-label {
    font-size: 0.78rem;
    font-weight: 600;
}

.map-create-opt-hint {
    color: var(--map-glass-muted);
    font-size: 0.68rem;
    line-height: 1.25;
}

.map-create-opt-key {
    margin-left: auto;
    padding: 0.05rem 0.3rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 5px;
    color: var(--map-glass-muted);
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.66rem;
}

/* --- the drawing cluster, on the map's bottom-right corner ----------------- *
 * New and Measure live on the terrain, not in the rail: drawing happens on the map, so the instrument
 * that starts it sits where the hand already is. The cluster is a single glass capsule, and the objects
 * panel stops above it (see `.map-sidebar`).
 */
.map-draw-controls {
    position: absolute;
    z-index: 19;
    right: var(--map-overlay-inset, 0.6rem);
    bottom: var(--map-overlay-inset, 0.6rem);
    display: flex;
    align-items: flex-end;
    gap: 6px;
}

.map-draw-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 42px;
    height: 42px;
    border: 1px solid var(--map-glass-border);
    border-radius: 10px;
    /* Gray/white like the rest of the map's controls, on the same glass as the objects panel. The one
       instrument the Commander singled out (Measure) opts into the blue/black pair below. */
    background: var(--map-glass);
    color: var(--map-glass-text);
    font: inherit;
    cursor: pointer;
    box-shadow: 0 8px 20px rgba(2, 10, 20, 0.28);
}

.map-draw-btn svg {
    width: 19px;
    height: 19px;
}

.map-draw-btn:hover {
    background: rgba(148, 197, 255, 0.14);
}

.map-draw-btn:focus-visible,
.map-draw-btn.is-active {
    outline: 2px solid var(--map-accent, #38bdf8);
    outline-offset: 1px;
}

/* The blue/black treatment: Measure is an instrument that switches a mode on, not an action that fires once,
   so it is drawn in the accent and its pressed state is stated in colour rather than an outline. */
.map-draw-btn.is-blue {
    background: var(--map-accent, #38bdf8);
    border-color: transparent;
    color: #04131f;
}

.map-draw-btn.is-blue:hover {
    background: color-mix(in srgb, var(--map-accent, #38bdf8) 86%, #ffffff);
}

.map-draw-btn.is-blue.is-active,
.map-draw-btn.is-blue[aria-pressed="true"] {
    background: var(--map-accent, #38bdf8);
    color: #04131f;
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--map-accent, #38bdf8) 45%, transparent);
}

/* The "+" is the primary action of the two, so it carries the accent and the other stays quiet. */
.map-create-btn {
    border-color: transparent;
    background: var(--map-accent, #38bdf8);
    color: #04131f;
}

.map-create-btn:hover {
    background: color-mix(in srgb, var(--map-accent, #38bdf8) 86%, #ffffff);
}

.map-create-btn svg {
    width: 21px;
    height: 21px;
    stroke-width: 2.4;
}

.map-draw-caption {
    align-self: center;
    margin-right: -2px;
    color: var(--map-glass-text);
    font-size: 0.66rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
    pointer-events: none;
}

@media (pointer: coarse) {
    .map-draw-btn {
        width: 46px;
        height: 46px;
    }
}

/* Measure is its own button, visually apart from the creation menu it is not part of: it makes nothing. */
.map-ruler-btn {
    border-color: var(--map-glass-border);
    /* Measure reads as an instrument, so it takes the blue/black pair whether or not the JS adds the class. */
    background: var(--map-accent, #38bdf8);
    color: #04131f;
}

/* --- the per-shape public switch in the inspector ------------------------- */

.map-switch-row {
    display: flex;
    align-items: flex-start;
    gap: 0.55rem;
    padding: 0.45rem 0.55rem;
    border: 1px solid var(--map-border);
    border-radius: 9px;
    cursor: pointer;
}

.map-switch-row input {
    margin-top: 0.15rem;
    accent-color: var(--map-accent, #38bdf8);
}

.map-switch-text {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    line-height: 1.3;
}

.map-switch-text small {
    color: var(--map-text-muted, #667);
    font-size: 0.72rem;
}

/* Narrow viewports: the popovers span the app instead of hanging off a toolbar button that has been
   pushed off-screen. */
@media (max-width: 720px) {
    .map-filterbar,
    .map-adminbar {
        left: 8px;
        right: 8px;
        width: auto;
        max-width: none;
    }

    /*
     * The rule above replaces `left: 50%` with `left: 8px` and stretches the sheet edge to edge, but the base
     * `.map-adminbar` rule's `transform: translate(-50%, -50%)` still applied. On any viewport 720px or
     * narrower that dragged the sheet half its own width left and half its own height UP - far enough to push
     * the header and the tab strip off the top of the screen, leaving only the body visible. That is the
     * "the top of the admin panel is missing" report. Neutralise the transform and keep the sheet vertically
     * centred on its own top edge instead.
     *
     * This block must NOT restate `top`. It used to, as a bare `top: 50%`, and because it is the later rule
     * it silently replaced the base rule's centring on the free map area with centring on the whole body box
     * on every phone - reintroducing, at <=720px, the very offset that pushed the head under the toolbar.
     * The base rule's `top` already lands the sheet's centre on the map's centre and subtracts the toolbar
     * and drawing-cluster bands, so the only job left here is to drop the horizontal half of the transform
     * and keep the vertical half.
     */
    .map-adminbar {
        transform: translateY(-50%);
    }

    .map-create-menu {
        left: 8px;
        right: 8px;
        width: auto;
        max-width: calc(100vw - 32px);
    }
}
/* -- fill the screen: no navbar ------------------------------------------------------------- */
/*
 * Maximizing used to pin the app at `top: var(--nav-height)`, which reserved the fixed navbar's height
 * along the top of the screen. The map asked to span the entire screen, so that reservation is gone and
 * `setMaximized` publishes `--nav-height: 0px` on the root while the map is maximized (restoring the real
 * measurement through `syncNavHeight()` on exit). The bar itself is hidden rather than merely covered:
 * with the map at z-index 900 and the navbar below it, a sticky bar could still win on some scroll
 * positions, and a hairline of it peeking over the terrain would contradict "entire screen".
 */
.map-app.is-maximized {
    top: 0;
}

body.map-maximized .nav-bar,
body.map-maximized [data-nav-bar] {
    visibility: hidden;
}

/* The app is moved to `<body>` while maximized (`setMaximized`, js/map/page.js), so it is a child of a flex
   or grid shell only when it is NOT full screen. As a body child it must not inherit any layout the page
   applied to map-app-shaped children, and it must not leave a gap behind in the slot it came from - the
   mount point keeps its own box because the app is put back into it on restore. */
body.map-maximized > .map-app.is-maximized {
    margin: 0;
    flex: none;
    grid-area: auto;
}

/* The escape hatch while the navbar is hidden: maximizing hides the only way out that some users reach
   for first, so the exit lives on the map itself (bottom-right cluster) and Escape still works. */
.map-app.is-maximized .map-zoom-controls {
    bottom: calc(var(--map-overlay-inset, 0.6rem) + env(safe-area-inset-bottom, 0px));
}

/*
 * "The entire span of the browser/mobile screen" includes the strip the status bar sits in.
 *
 * The app is a two-row grid - the map body, then the status bar - so at full height the terrain still
 * stopped 29px short of the bottom edge and left a bare band under the map (measured: app 485px tall,
 * canvas 456px, a 29px `.map-statusbar` row). Maximized, the body takes every row it can and the status
 * bar is laid over the terrain's bottom edge instead of owning a row, so the canvas itself is
 * viewport-high while the readout stays available. The zoom cluster is lifted above the bar (it is taller
 * than the bar) so the two never overlap.
 */
.map-app.is-maximized .map-body {
    grid-row: 1 / -1;
    /* The sidebar is `position: absolute`, so it contributes no track of its own - but an auto-placed
       grid item still leaves a bare column behind it, and maximized that showed up as a 257px-wide map
       beside 507px of empty grid (the row span made the app's own second column count). One column. */
    grid-column: 1 / -1;
    grid-template-columns: 1fr;
}

.map-app.is-maximized .map-statusbar {
    grid-row: 2;
    /* Same column as the body. Pinning only the ROW left the column auto, and a grid item placed into
       row 2 of a one-column grid does not inherit row 1's column - it opened a second track, which split
       the app into 257px of map beside 507px of empty grid. Both items span the single column. */
    grid-column: 1 / -1;
    z-index: 2;
    pointer-events: none;
}

.map-app.is-maximized .map-zoom-controls {
    bottom: calc(var(--map-overlay-inset, 0.6rem) + env(safe-area-inset-bottom, 0px) + 1.9rem);
}

/*
 * Commander: "we should move the coordinates panel up a little so that it is on screen when maximized."
 *
 * Maximized, the status bar stops owning a row and is laid over the terrain's bottom edge instead (see the
 * body rule above), and the coordinate pill sits at `bottom: 10px` — the same strip. The pill is taller
 * than the bar, so the bar covered its lower half and the readout was clipped exactly when the map had the
 * most room. It is lifted clear of the bar's own height, plus the safe-area inset and the same inset every
 * other overlay uses, so it reads as docked above the bar rather than floating in the middle of the map.
 * The draft cluster already measures this pill's live box (positionDraftControls), so it steps up with it
 * instead of landing on top of it.
 */
.map-app.is-maximized .map-coords {
    bottom: calc(1.9rem + var(--map-overlay-inset, 0.6rem) + env(safe-area-inset-bottom, 0px));
}

/* ...and the hint pill, which normally sits just above the readout, keeps its clearance above it. */
.map-app.is-maximized .map-hint:not([hidden]) {
    bottom: calc(1.9rem + var(--map-overlay-inset, 0.6rem) + env(safe-area-inset-bottom, 0px) + 36px);
}

/* The stage owns the whole column once maximized, so nothing can squeeze the canvas horizontally. */
.map-app.is-maximized .map-body .map-stage {
    grid-column: 1 / -1;
}

/* -- the toolbar is the map's own surface -------------------------------------------------- */
/*
 * The toolbar is a row of the app, not a floating strip, and the app is a rounded card: keeping the
 * bottom border only meant the map read as "toolbar, then map" with a seam between them. It now sits
 * flush on the terrain with a soft shadow instead of a rule, so the controls look like they belong to the
 * map they act on. The row is transparent over the canvas colour and the app's own radius clips the
 * corners, which is what makes it read as one surface at every width.
 */
.map-toolbar {
    border-bottom: 0;
    /* See-through, on exactly the same glass as the objects panel: the terrain under the bar stays visible
       and legible, with no blur and no opaque strip. The Commander asked for one translucency across every
       overlay, and this is that value. */
    background: var(--map-glass);
    color: var(--map-glass-text);
    border-color: var(--map-glass-border);
    box-shadow: 0 6px 16px rgba(2, 10, 20, 0.22);
    /* The canvas begins directly under this row; a hairline of it showing through the shadow keeps the two
       joined rather than separated by a gap. */
    margin-bottom: -1px;
    /* ...and then it leaves the flow entirely. The Commander asked for the toolbar ON TOP OF the map, not a
       strip above it: the app is now one row, so the canvas starts at the very top of the card and this bar
       floats over that first band of terrain. The app's own radius clips the glass, and `.map-stage` carries
       matching top padding so the "+" cluster and search are never hidden under it. */
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    z-index: 30;
    border-radius: 12px 12px 0 0;
}

.map-app.is-maximized .map-toolbar {
    box-shadow: 0 6px 18px rgba(2, 10, 20, 0.28);
    margin-bottom: 0;
}

/* -- filters locked to the right of the search field --------------------------------------- */
/*
 * `.map-search` is a relative flex item that grows; the filter button is its sibling, so `order` alone
 * could not pin it — the button is looked up by class in the markup and placed against the field's right
 * edge here. A ring separates the two tap targets so a thumb aimed at the field does not open a menu.
 */
.map-search + .map-search-filter {
    flex: 0 0 auto;
    margin-left: -0.55rem;
    position: relative;
    z-index: 1;
    box-shadow: -1px 0 0 var(--map-border) inset;
}

.map-search {
    /* Leaves room for the filter button that is drawn immediately after the field. */
    padding-right: 0;
}

.map-search-input {
    padding-right: 2.4rem;
}

.map-search .map-search-filter {
    position: absolute;
    right: 3px;
    top: 50%;
    transform: translateY(-50%);
    width: 28px;
    height: 28px;
    border: 0;
    background: transparent;
    color: var(--map-muted);
}

.map-search .map-search-filter:hover,
.map-search .map-search-filter[aria-expanded="true"] {
    background: var(--surface-bg-hover, #e2e6ec);
    color: var(--map-text);
}

/* The filter menu hangs off the field, not off the toolbar: it must open under the button that owns it. */
.map-search [data-filterbar] {
    left: auto;
    /* Centred under the BUTTON, not under the field. The button is the last 28px inside the field's right edge,
       so its centre sits `3px + 14px` in from that edge: pin the menu's right edge there, translate half the
       menu back out, and the two centres line up at any width without measuring anything. `right`, not `left`,
       because `left: auto` here is what lets the box follow the field when the toolbar is narrow. */
    right: calc(3px + 14px);
    /* `top: 100%` measures from the bottom of the field, which already sits a few pixels inside the bar, so the
       menu clears the bar itself and opens just below it rather than hanging off the toolbar's edge. */
    top: calc(100% + 8px);
    transform: translateX(50%);
}

/* -- zoom and size cluster, bottom-right of the map ---------------------------------------- */
/*
 * Zoom out, zoom in, fit, fill-screen. They were in the toolbar, which mixed a per-map control into a strip
 * that also holds scope choice and search; on a phone they scrolled out of reach. Pinned to the corner of
 * the terrain they are always where the map is, and they never compete with the coordinate readout, which
 * is centred on the bottom edge.
 */
.map-zoom-controls {
    position: absolute;
    z-index: 18;
    /* Zoom, fit and maximize sit at the map's BOTTOM-LEFT corner; the bottom-right corner belongs to the
       drawing cluster ("+" and the ruler). The two clusters never compete for the same thumb. */
    left: var(--map-overlay-inset, 0.6rem);
    right: auto;
    bottom: var(--map-overlay-inset, 0.6rem);
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 4px;
    border: 1px solid var(--map-glass-border);
    border-radius: 10px;
    background: var(--map-glass);
    color: var(--map-glass-text);
    box-shadow: 0 8px 20px rgba(2, 10, 20, 0.28);
}

.map-zoom-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border: 0;
    border-radius: 7px;
    background: transparent;
    color: var(--map-glass-text);
    font: inherit;
    cursor: pointer;
}

.map-zoom-btn svg {
    width: 18px;
    height: 18px;
}

.map-zoom-btn:hover {
    background: rgba(148, 197, 255, 0.14);
}

.map-zoom-btn:focus-visible {
    outline: 2px solid var(--primary-color, #2471a3);
    outline-offset: 1px;
}

/* Touch targets grow without growing the cluster's footprint on a desktop. */
@media (pointer: coarse) {
    .map-zoom-btn {
        width: 40px;
        height: 40px;
    }
}

/*
 * Pan leads the cluster, and it is the one button here that is a MODE rather than a one-shot action, so it
 * is the one that has to show a pressed state.
 *
 * Commander: "The pan button at the top left should be removed and placed with the map zoom features at the
 * bottom left." An earlier pass DELETED it rather than moving it, and deleted these rules with it - which
 * left the map with no visible way back to the pan tool, while the delegated `[data-tool]` listener in
 * page.js still expected a button in this cluster to exist. It is drawn again and wears the same blue/black
 * pair as the map's other active-state controls, so "pan is armed" is unmistakable in a column of four
 * otherwise identical grey buttons.
 */
.map-zoom-btn-pan {
    margin-bottom: 4px;
}

.map-zoom-btn-pan.is-active {
    background: var(--map-accent, #38bdf8);
    color: #04131f;
}

.map-zoom-btn-pan.is-active:hover {
    background: color-mix(in srgb, var(--map-accent, #38bdf8) 86%, #ffffff);
}

/* A landscape phone is 390px tall: a four-button column would eat a quarter of the terrain, so the cluster
   turns into a row along the bottom edge. */
@media (max-height: 520px) and (orientation: landscape) {
    .map-zoom-controls {
        flex-direction: row;
    }
}

/* -- the objects panel has exactly one way in and one way out -------------------------------- */
/*
 * A corner tab used to hang on the map's top-right as a second way back into the objects panel, alongside
 * the top bar's toggle. Two controls for one panel meant the reader had to learn which of them would honour a
 * press, and the tab's count badge duplicated the count already in the panel head. The tab and every rule
 * that styled it are gone; the top bar toggle is the only control, and it carries the pressed state. Do not
 * reintroduce a reopen selector here or in page.js.
 */

/* With the panel open the corner tab would sit under the panel's own header; the panel wins. */
.map-sidebar {
    z-index: 20;
}

/* -- the Admin shield --------------------------------------------------------------------- */
/*
 * The shield was drawn in the same outline weight as the filter and panel icons, so the one control that
 * opens every map setting was indistinguishable from the one that opens a checkbox list. It is now filled,
 * amber, and slightly larger — a deliberate colour used nowhere else in the toolbar, so "the amber shield"
 * is what an operator will say.
 */
.map-icon-btn-admin {
    width: 34px;
    height: 34px;
    border-color: #b45309;
    background: linear-gradient(180deg, #fbbf24, #f59e0b);
    color: #451a03;
    box-shadow: 0 2px 8px rgba(180, 83, 9, 0.36);
}

.map-icon-btn-admin svg {
    width: 19px;
    height: 19px;
    stroke-width: 2;
}

.map-icon-btn-admin:hover,
.map-icon-btn-admin.is-open {
    background: linear-gradient(180deg, #fcd34d, #fbbf24);
    border-color: #92400e;
    color: #3b1403;
}

.map-icon-btn-admin[aria-expanded="true"] {
    box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.5), 0 2px 8px rgba(180, 83, 9, 0.4);
}

/* -- the scope chooser's "new personal map" row ------------------------------------------- */
.map-scope-new {
    border-top: 1px dashed var(--map-glass-border);
    color: var(--map-glass-text);
}

.map-scope-new .map-scope-opt-name {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-weight: 600;
}

.map-scope-new .map-scope-opt-name svg {
    width: 13px;
    height: 13px;
}

/* -- a private map row with its delete control -------------------------------------------- */
/* The option and its delete button share one row: the option keeps its full click target, and the delete
   sits beside it rather than on top, so selecting a map and deleting one are never the same tap. */
.map-scope-row {
    display: flex;
    align-items: stretch;
    gap: 4px;
}

.map-scope-row .map-scope-opt {
    flex: 1 1 auto;
    min-width: 0;
}

.map-scope-del {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: 8px;
    background: transparent;
    color: var(--map-glass-muted);
    cursor: pointer;
}

.map-scope-del svg {
    width: 13px;
    height: 13px;
}

.map-scope-del:hover,
.map-scope-del:focus-visible {
    border-color: rgba(220, 70, 70, 0.45);
    background: rgba(220, 70, 70, 0.1);
    color: #c0392b;
}

/* -- the pencil on a private map row ------------------------------------------------------- */
/*
 * Rename and re-map belong to the map, not to its objects, so they hang off the map's own row beside the
 * delete rather than living in the object list. Same row, same 26px hit box as the delete, but quiet until it
 * is wanted: the delete is the one button on this row that deserves to draw the eye, and two loud icons
 * side by side would make an accidental tap likelier. The icon is drawn with `currentColor`, so the colour
 * transitions below are the only thing that has to change to change the glyph.
 */
.map-scope-edit {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 26px;
    min-width: 26px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: 8px;
    background: transparent;
    /* The same muted grey as the delete's resting state, so the row reads as one group of secondary controls. */
    color: var(--map-glass-muted);
    cursor: pointer;
}

.map-scope-edit svg {
    width: 13px;
    height: 13px;
}

/* Accent blue on hover and on keyboard focus: `focus-visible` rather than `focus`, so a mouse click does not
   leave the pencil lit up the way a tap on a touch screen would. */
.map-scope-edit:hover,
.map-scope-edit:focus-visible {
    border-color: color-mix(in srgb, var(--map-accent, #38bdf8) 45%, transparent);
    background: color-mix(in srgb, var(--map-accent, #38bdf8) 12%, transparent);
    color: var(--map-accent, #38bdf8);
}


/* ============================================================================
   top-bar action buttons
   ============================================================================
   The bar's right-hand cluster. Object creation used to be one "+" that opened a menu of ":icon: Marker"
   text rows; it is now one button per object type, each showing that type's own icon, and Measure sits
   beside them. Gray/white by default so the bar reads as furniture, blue/black when a mode is on, so the
   one pressed control is unmistakable. No backdrop filter: the bar is already the panel's glass, and a
   blur behind it would turn the terrain into a smear. */

.map-topbar-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    padding: 0;
    border: 1px solid var(--map-glass-border);
    border-radius: 8px;
    background: var(--map-accent, #38bdf8);
    color: #04131f;
    font: inherit;
    line-height: 1;
    cursor: pointer;
}

.map-topbar-btn:hover {
    background: color-mix(in srgb, var(--map-accent, #38bdf8) 86%, #ffffff);
}

.map-topbar-btn:focus-visible {
    outline: 2px solid var(--map-accent, #38bdf8);
    outline-offset: 1px;
}

.map-topbar-btn svg {
    width: 18px;
    height: 18px;
}

/* The quiet state, and the ONLY one for most buttons: gray/white on the panel's glass. Stated as a
   `:not()` pair so an active button never has to out-specify a later default. */
.map-topbar-btn:not(.is-active):not([aria-pressed="true"]) {
    background: var(--map-glass);
    color: var(--map-glass-text);
    border-color: var(--map-glass-border);
}

.map-topbar-btn:not(.is-active):not([aria-pressed="true"]):hover {
    background: rgba(148, 197, 255, 0.14);
}

/* ...and the highlighted state: the Commander asked for the toggles to stay blue while they are on. */
.map-topbar-btn.is-active,
.map-topbar-btn[aria-pressed="true"] {
    background: var(--map-accent, #38bdf8);
    border-color: transparent;
    color: #04131f;
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--map-accent, #38bdf8) 45%, transparent);
}

/* The object-type buttons sit as one tight run, apart from the mode toggles beside them. */
.map-toolbar-objects {
    display: flex;
    align-items: center;
    gap: 6px;
}

/* A zone button is only emitted for a user allowed to draw zones, so it carries no hidden state of its own. */
.map-topbar-btn-zone {
    border-color: var(--map-glass-border);
}

/* -- the GeoJSON file menu (Export / Import) ------------------------------------------------ */
/*
 * Same glass as every other overlay, no blur, same border and radius. It is positioned by the shared toolbar
 * popover code (positionFilterbar), which measures the button it belongs to and drops the panel just below
 * and horizontally centred on it — so no top/left is stated here beyond the layer's initial origin.
 */
.map-data-menu {
    position: absolute;
    z-index: 41;
    display: grid;
    gap: 2px;
    min-width: 186px;
    padding: 5px;
    border: 1px solid var(--map-glass-border);
    border-radius: 12px;
    background: var(--map-glass);
    color: var(--map-glass-text);
    box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48);
}

.map-data-menu[hidden] {
    display: none;
}

.map-data-menu-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    padding: 0.42rem 0.55rem;
    border: 0;
    border-radius: 8px;
    background: none;
    color: inherit;
    font: inherit;
    font-size: 0.78rem;
    text-align: left;
    cursor: pointer;
}

.map-data-menu-item:hover,
.map-data-menu-item:focus-visible {
    background: rgba(148, 197, 255, 0.16);
    outline: none;
}

.map-data-menu-item svg {
    width: 16px;
    height: 16px;
    flex: 0 0 auto;
}

/* ============================================================================
   World chooser for a new private map
   ============================================================================
   Shown once, when a reader creates a private map, because a map belongs to one of the four worlds and the
   same coordinate is a different place on each. Built as its own sheet rather than reusing the shared auth
   dialog because it needs a grid of world choices, not a text field. It wears the same floating glass as the
   object panel and the bottom controls, with no blur, so the terrain stays visible behind it. */
.map-world-sheet {
    position: absolute;
    inset: 0;
    z-index: 60;
    display: grid;
    place-items: center;
    padding: 1rem;
    background: rgba(4, 12, 20, 0.45);
}

.map-world-card {
    width: min(520px, 100%);
    max-height: calc(100% - 2rem);
    overflow-y: auto;
    padding: 1.15rem 1.25rem 1rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 14px;
    background: var(--map-glass);
    color: var(--map-glass-text);
    box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
}

.map-world-title {
    margin: 0 0 0.4rem;
    font-size: 1.02rem;
    font-weight: 600;
    line-height: 1.3;
}

.map-world-hint {
    margin: 0 0 0.9rem;
    font-size: 0.8rem;
    line-height: 1.45;
    color: var(--map-glass-muted);
}

.map-world-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
    gap: 8px;
    margin-bottom: 1rem;
}

/* Gray/white at rest, the one accent when chosen - the same two states the object buttons use, so the
   chooser reads as part of the map rather than a different app inside it. */
.map-world-opt {
    display: flex;
    flex-direction: column;
    gap: 2px;
    padding: 0.6rem 0.7rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 10px;
    background: rgba(255, 255, 255, 0.06);
    color: var(--map-glass-text);
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.map-world-opt:hover {
    background: rgba(255, 255, 255, 0.12);
}

.map-world-opt.is-selected {
    border-color: var(--map-accent);
    background: var(--map-accent);
    color: #04131f;
}

.map-world-name {
    font-size: 0.88rem;
    font-weight: 600;
}

.map-world-meta {
    font-size: 0.72rem;
    opacity: 0.72;
}

.map-world-actions {
    display: flex;
    justify-content: flex-end;
    gap: 8px;
}

.map-dialog-btn {
    padding: 0.45rem 0.9rem;
    border: 1px solid var(--map-glass-border);
    border-radius: 9px;
    background: rgba(255, 255, 255, 0.06);
    color: var(--map-glass-text);
    font: inherit;
    font-size: 0.82rem;
    cursor: pointer;
}

.map-dialog-btn:hover {
    background: rgba(255, 255, 255, 0.12);
}

.map-dialog-btn.is-blue {
    border-color: var(--map-accent);
    background: var(--map-accent);
    color: #04131f;
    font-weight: 600;
}

@media (prefers-reduced-motion: no-preference) {
    .map-world-card {
        animation: map-world-in 140ms ease-out;
    }
}

@keyframes map-world-in {
    from {
        opacity: 0;
        transform: translateY(6px);
    }
    to {
        opacity: 1;
        transform: none;
    }
}

/* -- zone editor --------------------------------------------------------------------------- */
/*
 * The full zone editor: a preset picker, a colour control, one row per setting and a role section, all inside
 * the object inspector. This is the section that can get long, and the Commander's rule is that the OBJECT
 * PANEL NEVER GROWS because of it. So the editor is not a taller panel - it is a scroll island inside a
 * fixed-height one.
 *
 * THE MIN-HEIGHT CHAIN, top to bottom, and why each link is there:
 *
 *   .map-sidebar          grid row 3 = minmax(0, var(--map-inspector-share, 55%))       -> supplies the cap
 *   .map-inspector        min-height: 0   (line 780, pre-existing)                      -> can shrink into it
 *   .map-inspector-body   display: flex; flex-direction: column; min-height: 0          -> the links ADDED here
 *   .map-zone-editor      flex: 1 1 auto; min-height: 0; overflow-y: auto               -> the scroller
 *
 * A flex item defaults to `min-height: auto`, which is its CONTENT height. Without `min-height: 0` on
 * `.map-inspector-body`, that body refuses to be shorter than the whole settings list, overflows the inspector
 * row, and the panel appears to grow / clip instead of scrolling. `min-height: 0` therefore has to sit on the
 * body (the flex PARENT of the scroller) and again on the editor, or the editor passes its content height back
 * up the chain and the island never forms. This is the exact line that gets "cleaned up" by someone adding a
 * nice flex layout later - do not remove either one.
 *
 * The mechanism is the CSS spec's own `min-height: auto` behaviour, reasoned from the rules in this file rather
 * than measured in a live browser: no one has yet loaded a zone with a settings list long enough to overflow
 * the inspector row and watched it clip. Treat the chain as REQUIRED BY CONSTRUCTION. If a future session can
 * run the app, that is the moment to confirm it and tighten this note.
 *
 * `overscroll-behavior: contain` keeps a flick at the end of the settings from scrolling the page behind the
 * map on engines where the panel's own overflow is not the next scroller - the same reason the inspector
 * declares it.
 */
/* `.map-inspector-body` is declared once, up in the inspector section: its `min-height: 0` is folded into the
   original rule there rather than restated here (see the chain note above). */

/* The inspector's overflow is NOT set here on purpose. When the editor mounts, the inspector stops being the
   scroller and the editor takes over, and that handover is declared once, up in the inspector section next to
   the `overflow-y: auto` it overrides (`.map-inspector:has(.map-zone-editor)`). Restating it down here would be
   a second selector for the same box in a different part of the file - the exact drift this block exists to
   avoid - and source order would decide it instead of specificity. */
.map-zone-editor {
    /* Takes the height the body has left after the meta row, and scrolls inside it. `flex: 1 1 auto` makes this
       the flex line's only grower, so it absorbs the free space in the body's column; `min-height: 0` lets it
       shrink BELOW its content, which is what turns `overflow-y: auto` into a real scrollbar instead of an
       overflowing box. Both halves are needed: with `auto` alone the item's automatic minimum is its content
       height and it never shrinks. */
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
    overscroll-behavior: contain;
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
    /* Negative side margins pull the editor out to the body's padding edge (the body is `padding: 0.6rem`), so
       the sticky head and the dirty strip span the full panel width instead of floating inside it. The side
       padding is restated at the same value because I cannot use the body's padding from here. */
    margin: 0 -0.6rem;
    padding: 0 0.6rem 0.5rem;
}

/* Sticky head: swatch, title, and the editor's own deselect button pinned right. It must never scroll out of
   view, because in the long editor it is the only way out. Repainted here because a sticky box lets the rows
   beneath it show through. No blur: this file carries none on purpose - blur forces a full repaint of the canvas
   behind every floating panel and made the map stutter while panning, and the test suite guards against it
   being re-added. Translucency here is background alpha only. */
.map-zone-editor-head {
    position: sticky;
    top: 0;
    z-index: 2;
    display: flex;
    align-items: center;
    gap: 0.45rem;
    margin: 0 -0.6rem 0.1rem;
    padding: 0.4rem 0.6rem;
    border-bottom: 1px solid var(--map-border);
    background: var(--map-panel-alt);
    font-size: 0.82rem;
    font-weight: 600;
    color: var(--map-text);
}

.map-zone-editor-head strong,
.map-zone-editor-head .map-zone-editor-title {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The deselect/close button sits at the editor's TOP RIGHT. `.map-icon-btn` is 32px square, which is right
   for the toolbar but heavy at the end of a settings head; 26px matches `.map-sidebar-close`. */
.map-zone-editor-head .map-icon-btn {
    flex: 0 0 auto;
    margin-left: auto;
    width: 26px;
    height: 26px;
    font-size: 0.9rem;
}

/* One titled block: Preset, Colour, Settings, Roles.
   `flex-shrink: 0` is NOT optional. These blocks are children of the editor's flex COLUMN, and a flex item
   defaults to `flex-shrink: 1` - so without this the sections are compressed to fit the viewport instead of
   overflowing it, their own `min-height: auto` floor keeps the content at full height, and the rows bleed over
   each other inside a scroller that has nothing to scroll. Shrinking never happens because the editor's height
   is decided by `.map-sidebar`'s fixed grid row, so the shrink would be pure damage. */
.map-zone-section {
    display: flex;
    flex-direction: column;
    flex-shrink: 0;
    gap: 0.4rem;
}

.map-zone-section + .map-zone-section {
    padding-top: 0.55rem;
    border-top: 1px solid var(--map-border);
}

/* Small caps label - the same type treatment `.map-inspector-body label` and `.map-zone-presets-label` use, so
   the section titles read as a third level of the same hierarchy rather than a new one. */
.map-zone-section-title {
    margin: 0;
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--map-muted);
}

/* -- preset chips ---------------------------------------------------------------------------
   `.map-zone-preset-row` / `.map-zone-preset` / `.is-current` are DECLARED ONCE, up in the server zone
   inspector section (lines 1674 / 1682), with the colour dot and grid wrapping folded into the original rules.
   They are deliberately not restated here: two selectors for the same chip in two parts of a 3700-line file is
   how these panels become unpredictable, and source order would silently decide the winner. */

/* -- colour section ------------------------------------------------------------------------- */

.map-zone-colour-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

/* The native colour picker, restyled down to a 24px swatch. The `::-webkit-color-swatch` pseudo elements are
   what make a real <input type="color"> stop looking like a browser default: without them the control keeps its
   own padding and 30px+ box and cannot sit on one line with the reset button. */
.map-zone-colour-row input[type="color"] {
    flex: 0 0 auto;
    width: 24px;
    height: 24px;
    padding: 0;
    border: 1px solid var(--map-border);
    border-radius: 6px;
    background: transparent;
    cursor: pointer;
}

.map-zone-colour-row input[type="color"]::-webkit-color-swatch-wrapper {
    padding: 2px;
}

.map-zone-colour-row input[type="color"]::-webkit-color-swatch {
    border: 0;
    border-radius: 4px;
}

.map-zone-colour-row input[type="color"]::-moz-color-swatch {
    border: 0;
    border-radius: 4px;
}

.map-zone-colour-row input[type="color"]:focus-visible {
    outline: 2px solid var(--map-accent, #38bdf8);
    outline-offset: 2px;
}

/* The value beside the swatch, in the same mono face `.map-facts dd` uses for a literal value. */
.map-zone-colour-value {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.72rem;
    color: var(--map-muted);
    text-transform: uppercase;
}

/* A text button, not a `.map-btn`: it is a quiet affordance next to a control, not a panel action. */
.map-zone-colour-reset {
    margin-left: auto;
    padding: 0.14rem 0;
    border: 0;
    background: none;
    color: var(--map-accent, #38bdf8);
    font: inherit;
    font-size: 0.72rem;
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}

.map-zone-colour-reset:hover {
    color: var(--map-text);
}

.map-zone-colour-reset:disabled {
    color: var(--map-muted);
    text-decoration: none;
    cursor: not-allowed;
}

/* -- setting rows -------------------------------------------------------------------------- */
/*
 * One row per setting: label, control, and (optionally) a cascade note underneath. The label/control pair is a
 * two-column grid at wide widths so the controls line up down the column and the eye can scan them; it stacks
 * to one column at 900px. `.map-zone-settings label` above still owns the stacked field pattern for the older
 * markup, and `.map-zone-setting` is the row that replaces it going forward.
 */
.map-zone-setting {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 10rem);
    align-items: center;
    gap: 0.2rem 0.5rem;
    padding: 0.24rem 0;
    border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}

.map-zone-setting:last-child {
    border-bottom: 0;
}

/* The label cell. `.map-inspector-body label` would make this a flex column and uppercase; a row inside a grid
   is neither, so reset both here rather than fighting them from the row. */
.map-zone-setting > label,
.map-zone-setting-label {
    display: block;
    min-width: 0;
    font-size: 0.76rem;
    color: var(--map-text);
    text-transform: none;
    letter-spacing: normal;
    line-height: 1.35;
}

/* The control cell: the row's second column, and full width inside it. A `<select>`/`<input>` dropped directly
   in the row (no wrapper) still lands in the right column by grid auto-placement. */
.map-zone-setting > input[type="text"],
.map-zone-setting > input[type="number"],
.map-zone-setting > select,
.map-zone-setting-control {
    width: 100%;
    min-width: 0;
    font: inherit;
    font-size: 0.78rem;
    padding: 0.26rem 0.4rem;
    border: 1px solid var(--map-border);
    border-radius: 6px;
    background: var(--map-panel-bg);
    color: var(--map-text);
}

/* A switch inside a row: the control is a 1rem checkbox, not a stretched input, and it aligns right. */
.map-zone-setting > input[type="checkbox"] {
    justify-self: end;
    width: 1rem;
    height: 1rem;
    margin: 0;
    cursor: pointer;
    accent-color: var(--map-accent, #38bdf8);
}

.map-zone-setting.is-disabled,
.map-zone-setting:has(input:disabled, select:disabled) {
    opacity: 0.6;
}

/* The cascade note and the applied note share a row-spanning full width under the label+control pair. */
.map-zone-cascade {
    grid-column: 1 / -1;
    display: flex;
    align-items: flex-start;
    gap: 0.3rem;
    margin: 0 0 0.1rem;
    font-size: 0.69rem;
    line-height: 1.4;
    /* Calm: the theme's muted ink with a thin accent rule on the left, not a coloured banner. */
    color: var(--map-muted);
    padding-left: 0.4rem;
    border-left: 2px solid rgba(148, 197, 255, 0.35);
}

/* A leading arrow glyph, so the note reads as a consequence ("also changes") rather than a description. */
.map-zone-cascade::before {
    content: "\2192";
    flex: 0 0 auto;
    color: var(--map-accent, #38bdf8);
    opacity: 0.85;
}

/* After a write: the same line, tinted from the theme's own accent so "this is what changed" is visible but
   still quiet. No colour of its own - a hard-coded green here would fight the light and dark themes. */
.map-zone-cascade.is-applied {
    color: var(--map-text);
    border-left-color: var(--map-accent, #38bdf8);
}

.map-zone-cascade.is-applied::before {
    content: "\2713";
    opacity: 1;
}

/* An inline badge for a setting that is currently neutralised by another one (a ban-killer rule while PvP is
   allowed, for example). Small, bordered, and unmistakably not a button. */
.map-zone-derived {
    display: inline-flex;
    align-items: center;
    /* A LEFT margin only: the badge sits inline beside the control it modifies, not on its own line, and it must
       not add height to the setting row. */
    margin: 0 0 0 0.35rem;
    padding: 0.02rem 0.32rem;
    border: 1px dashed var(--map-glass-border, var(--map-border));
    border-radius: 999px;
    font-size: 0.62rem;
    font-weight: 500;
    letter-spacing: 0.02em;
    line-height: 1.5;
    vertical-align: middle;
    color: var(--map-muted);
    text-transform: none;
    white-space: nowrap;
    cursor: default;
}

/* -- roles --------------------------------------------------------------------------------- */

.map-zone-roles {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}

.map-zone-role-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 9rem);
    align-items: center;
    gap: 0.2rem 0.5rem;
    padding: 0.22rem 0;
}

.map-zone-role-row > label,
.map-zone-role-row .map-zone-role-label {
    display: block;
    min-width: 0;
    font-size: 0.74rem;
    line-height: 1.35;
    color: var(--map-text);
    text-transform: none;
    letter-spacing: normal;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.map-zone-role-row select,
.map-zone-role-row input[type="text"],
.map-zone-role-row .map-zone-role-control {
    width: 100%;
    min-width: 0;
    font: inherit;
    font-size: 0.76rem;
    padding: 0.24rem 0.38rem;
    border: 1px solid var(--map-border);
    border-radius: 6px;
    background: var(--map-panel-bg);
    color: var(--map-text);
}

/* Where the role list came from. The distinction matters - a personal map has no server, so its list is the
   generic default and a role name picked here may not exist on the server it is later used against. Italic and
   muted, so it is readable but never mistaken for a warning. */
.map-zone-role-source {
    margin: 0;
    font-size: 0.7rem;
    font-style: italic;
    line-height: 1.4;
    color: var(--map-muted);
}

/* The server-specific case is the trustworthy one, so it drops the italics: the page adds `is-server` when a
   server is selected. */
.map-zone-role-source.is-server {
    font-style: normal;
}

/* -- unsaved-change strip ------------------------------------------------------------------- */
/* Pinned to the bottom of the editor's scroll island so it is visible without scrolling to the end of a long
   settings list. Sticky inside the scroller, like the head at the top. */
.map-zone-dirty {
    position: sticky;
    bottom: 0;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin: 0 -0.6rem -0.5rem;
    padding: 0.36rem 0.6rem;
    border-top: 1px solid var(--map-glass-border, var(--map-border));
    /* Alpha only - no blur. See the head note above on why this file has none. */
    background: rgba(251, 191, 36, 0.14);
    color: var(--map-text);
    font-size: 0.72rem;
}

.map-zone-dirty::before {
    content: "";
    flex: 0 0 auto;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: #fbbf24;
}

.map-zone-dirty[hidden] {
    display: none;
}

/* Re-stated for the new markup: a read-only zone shows its note in the editor body rather than the presets, and
   it must not inherit the sticky/scroll behaviour of the sections around it. */
.map-zone-editor .map-zone-readonly {
    margin: 0;
    padding: 0.4rem 0.5rem;
    border: 1px solid var(--map-border);
    border-radius: 7px;
    background: var(--map-panel-bg);
    font-size: 0.72rem;
    line-height: 1.45;
    color: var(--map-muted);
}

/* -- zone editor on narrow screens ----------------------------------------------------------
 * The inspector is a 320px column on desktop and a bottom sheet on a phone, and the object panel is at most
 * ~300px wide in the desktop overlay too. Below 900px every two-column row collapses: controls go full width,
 * labels sit above them. `grid-template-columns: minmax(0, 1fr)` (not `1fr`) is deliberate - `1fr` still has an
 * `auto` minimum, so a long role name or a native colour input would push the row wider than the panel and the
 * editor would scroll sideways. `minmax(0, 1fr)` lets the cell shrink, which is the whole point. */
@media (max-width: 900px) {
    .map-zone-editor {
        gap: 0.5rem;
    }

    .map-zone-setting,
    .map-zone-role-row {
        grid-template-columns: minmax(0, 1fr);
        gap: 0.16rem;
    }

    /* The stack above must NOT reach the switch rows. `.map-zone-setting > label` (0,1,1) still outranks a bare
       `.map-zone-setting-label` (0,1,0) and this media query comes later in the file, so left alone the switch's
       label would turn into a block above its checkbox and the row would stop reading as one line. Two classes
       re-beat it without a force flag. Required, not cosmetic: the switch row's own
       `.map-zone-settings .map-zone-switch { flex-direction: row }` (line ~1766) only has any effect while its
       label is still a flex box, so this keeps that display mode at narrow widths. */
    .map-zone-setting > input[type="checkbox"] {
        /* A lone checkbox on its own row reads as an afterthought; right-align it under its label. */
        justify-self: start;
        width: 1.15rem;
        height: 1.15rem;
    }

    .map-zone-setting-label,
    .map-zone-setting > label,
    .map-zone-role-row .map-zone-role-label,
    .map-zone-role-row > label {
        font-size: 0.72rem;
        color: var(--map-muted);
        text-transform: uppercase;
        letter-spacing: 0.04em;
        white-space: normal;
    }

    /* Full-width controls, taller so they stay a comfortable touch target. */
    .map-zone-setting > input[type="text"],
    .map-zone-setting > input[type="number"],
    .map-zone-setting > select,
    .map-zone-role-row select,
    .map-zone-role-row input[type="text"] {
        padding: 0.4rem 0.5rem;
        font-size: 0.82rem;
    }

    .map-zone-preset-row {
        /* `minmax(0, 1fr)`, not a pixel minimum, and this is the whole horizontal-scroll fix. The inspector is
           `width: min(320px, ...)` all the way down to 560px, so the editor's content box is only about 268px:
           a `6.5rem` (104px) minimum forces two tracks plus the gap = 215px and a third would not fit, and on a
           320px phone the sidebar is ~310px with a 0.4rem inset, leaving even less. A zero minimum makes
           `auto-fill` fall back to ONE full-width column rather than overflow, so the row simply stacks. */
        grid-template-columns: repeat(auto-fill, minmax(0, 1fr));
    }

    .map-zone-colour-reset {
        /* On a phone the reset drops under the swatch instead of squeezing the row. */
        margin-left: 0;
        padding: 0.3rem 0;
        font-size: 0.76rem;
    }

    /* Switch rows: declared ONCE, and LAST in this media block on purpose.
       The stack rule at the top of this block (`grid-template-columns: minmax(0, 1fr)`) is inherited by the
       switch rows too, and would turn a one-line switch into label-above-checkbox. Re-beating it needs two
       classes on the label, because `.map-zone-setting > label` (0,1,1) outranks a bare `.map-zone-setting-label`
       (0,1,0). The type reset has to beat the uppercase label rule ABOVE, which also sets `text-transform` at
       (0,1,1)/(0,2,0) - an equal tie, so source order decides - hence this block sitting after it with no force
       flag involved. Required, not cosmetic: `.map-zone-settings .map-zone-switch { flex-direction: row }`
       (line ~1766) only has an effect while its label is still a flex box. One rule, one place: do not split the
       box properties back up beside the checkbox rule. */
    .map-zone-setting .map-zone-switch,
    .map-zone-setting .map-zone-setting-label {
        display: flex;
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        gap: 0.5rem;
        text-transform: none;
        letter-spacing: normal;
        white-space: normal;
    }
}

/* The narrowest real case: the sidebar is `width: min(320px, calc(100% - 2 * inset))`, so below 560px the sheet
   is the panel edge itself and the settings rows (single-column since 900px) only need their padding tightened.
   The `-0.45rem` margins track `.map-app`'s `--map-overlay-inset: 0.4rem` at the 820px breakpoint. */
@media (max-width: 560px) {
    .map-zone-editor,
    .map-zone-editor-head,
    .map-zone-dirty {
        margin-left: -0.45rem;
        margin-right: -0.45rem;
        padding-left: 0.45rem;
        padding-right: 0.45rem;
    }

    .map-zone-section-title {
        font-size: 0.64rem;
    }

    .map-zone-derived {
        /* The badge wraps under its control at this width rather than forcing the row wider than the sheet. */
        margin-left: 0;
        white-space: normal;
    }
}

/* The zone's two independent states, stated in words under the zone's own title line: "Enforced by the game" and
   "hidden from the map" / "visible on the map". They are separate chips because they are separate facts — the
   `active` switch decides enforcement and the "Hide from the map" switch decides whether anyone sees the zone,
   and a reader must not have to infer either from a checkbox further down the form. */
.map-zone-visibility {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin: 0 0 0.4rem;
}

.map-zone-visibility-chip {
    padding: 0.12rem 0.45rem;
    border: 1px solid var(--map-border);
    border-radius: 999px;
    background: rgba(148, 197, 255, 0.1);
    color: var(--map-glass-text);
    font-size: 0.62rem;
    letter-spacing: 0.03em;
}

/* `data-zone-visibility` is `active|inactive` + `hidden|shown`, so the four combinations each get their own
   emphasis without any JavaScript deciding which one is "bad". An inactive zone stays muted (it is drawn, in
   grey); a hidden one is marked so the operator never mistakes the editor for the map's own state. */
.map-zone-visibility[data-zone-visibility$="-hidden"] .map-zone-visibility-chip:last-child {
    border-color: rgba(251, 191, 36, 0.5);
    color: #fbbf24;
}

.map-zone-visibility[data-zone-visibility^="inactive"] .map-zone-visibility-chip:first-child {
    border-color: rgba(148, 163, 184, 0.45);
    color: var(--map-muted);
}
