      :root {
        --primary: #2f6f9f;
        --secondary: #c93b52;
        --panel-bg: rgba(255, 255, 255, 0.94);
        --ink: #1f2937;
        --muted: #6b7280;
        --sea: #dfe8ee;
        --land: #f4f1ea;
        --coast: #a9b4bd;
      }
      * { box-sizing: border-box; }
      html, body {
        margin: 0;
        height: 100%;
        overflow: hidden;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        color: var(--ink);
        background: var(--sea);
      }


      /* --- page shell ---
         Three stacked full-width rows at every screen size:

             topbar   everything the app knows and every control
             stage    the map, which takes all the space left over
             adbar    one ad, and nothing else

         One column, so there is no second layout to reason about — desktop and
         mobile differ only in how tightly the bars pack.

         min-height:0 on #stage is load-bearing: a grid item's default
         min-size:auto refuses to shrink below its content, and the SVG would
         push both bars off-screen without it. */
      #shell {
        position: fixed; inset: 0;
        display: grid;
        grid-template-columns: 1fr;
        grid-template-rows: auto 1fr auto;
        grid-template-areas: "topbar" "stage" "adbar";
      }
      #stage { grid-area: stage; position: relative; min-width: 0; min-height: 0; overflow: hidden; }

      /* --- map --- */
      #map { position: absolute; inset: 0; width: 100%; height: 100%; display: block;
             touch-action: none; user-select: none; }
      .land { fill: var(--land); stroke: var(--coast); stroke-width: 1; }
      .land.context { fill: #ece9e2; stroke: #c3ccd3; }
      .half-primary { fill: #3d7ea6; opacity: 0.18; }
      .half-secondary { fill: #d1495b; opacity: 0.18; }
      .pt-primary { fill: #2f6f9f; }
      .pt-secondary { fill: #c93b52; }
      #pts-primary, #pts-secondary { opacity: 0.75; }
      #divider-ext { stroke: #111827; stroke-width: 1.5; stroke-dasharray: 6 4; opacity: 0.55; fill: none; }
      #divider-seg { stroke: #111827; stroke-width: 3; fill: none; }
      #hit-seg { stroke: transparent; stroke-width: 22; fill: none; cursor: move; }
      /* Each handle lives in a group scaled by unitsPerPx, so everything inside
         is authored in screen pixels — the effective scale is exactly 1. */
      .handle-group { cursor: grab; }
      body.dragging .handle-group { cursor: grabbing; }
      .handle { fill: #fff; stroke: #111827; stroke-width: 2; }
      .handle-group:hover .handle { fill: #fde68a; }
      /* Curved bidirectional arrow over each handle: the "you can rotate this"
         affordance. Subtle by default, emphasised while hovering the handle. */
      .rot-arc { fill: none; stroke: #111827; stroke-width: 1.5; opacity: 0.5; }
      .rot-head { fill: #111827; opacity: 0.5; }
      .handle-group:hover .rot-arc,
      .handle-group:hover .rot-head { opacity: 0.9; }
      /* Strokes stay in screen pixels despite the tiny world-unit scale. */
      .land, #divider-ext, #divider-seg, #hit-seg { vector-effect: non-scaling-stroke; }

      /* --- bar controls ---
         These all used to live in the floating panel. The panel is gone; the
         status bar is the app's only chrome, so its widgets are laid out in a
         wrapping row rather than a column. */
      .bar-data select {
        min-width: 0; padding: 6px 8px; font-size: 12px; font-weight: 600;
        font-family: inherit; color: var(--ink); background: #fff;
        border: 1px solid #d1d5db; border-radius: 8px; cursor: pointer;
      }
      .bar-data select:hover { background: #f3f4f6; border-color: #9ca3af; }
      /* After a file upload no catalogue entry is current, so the picker is
         deselected and greyed — still active, just not claiming a selection. */
      .bar-data select.deselected { color: var(--muted); font-weight: 400; }

      .bar-snaps button {
        padding: 6px 9px; font-size: 12px; font-weight: 600; white-space: nowrap;
        color: var(--ink); background: #fff; border: 1px solid #d1d5db;
        border-radius: 8px; cursor: pointer; transition: background 0.12s, border-color 0.12s;
      }
      .bar-snaps button:hover { background: #f3f4f6; border-color: #9ca3af; }
      .bar-snaps button:active { background: #e5e7eb; }

      .link-info {
        border: none; background: none; padding: 0; font: inherit; font-size: 11px;
        cursor: pointer; color: var(--accent, #2563eb); text-decoration: underline;
        white-space: nowrap;
      }
      .link-info:hover { color: var(--ink); }
      .lbl-short { display: none; }
      .animate-toggle {
        display: flex; align-items: center; gap: 5px; margin: 0;
        font-size: 11px; color: var(--muted); cursor: pointer; white-space: nowrap;
      }
      .animate-toggle input { cursor: pointer; }
      #btn-info {
        flex: none; width: 20px; height: 20px; line-height: 1;
        font-size: 12px; font-weight: 700; font-style: italic;
        font-family: Georgia, 'Times New Roman', serif;
        color: var(--muted); background: #fff;
        border: 1.5px solid #d1d5db; border-radius: 50%;
        cursor: pointer; padding: 0;
      }
      #btn-info:hover { background: #f3f4f6; border-color: #9ca3af; color: var(--ink); }
      /* Provenance now lives in #sources-modal. Every dataset has an entry —
         including the synthetic one and an uploaded file — so the list is
         never empty; see the data-source contract. */
      #sources-list { margin: 0; padding-left: 16px; }
      #sources-list li { margin: 3px 0; overflow-wrap: anywhere; }

      /* Picker and load button share one line; the select shrinks to fit it. */
      .load-row { display: flex; gap: 6px; align-items: stretch; }
      .load-row select { flex: 1 1 auto; }
      #btn-load {
        flex: none; width: 36px; height: 34px; padding: 0;
        display: inline-flex; align-items: center; justify-content: center;
        color: var(--muted); background: #fff; border: 1px solid #d1d5db;
        border-radius: 8px; cursor: pointer;
      }
      #btn-load:hover { background: #f3f4f6; border-color: #9ca3af; color: var(--ink); }
      #btn-load svg { display: block; }

      /* --- column-mapping modal --- */
      .modal {
        position: fixed; inset: 0; z-index: 50; display: flex;
        align-items: center; justify-content: center;
        background: rgba(17, 24, 39, 0.45); padding: 16px;
      }
      .modal[hidden] { display: none; }
      .modal-card {
        background: #fff; border-radius: 14px; padding: 20px 22px;
        width: min(420px, 100%); box-shadow: 0 18px 50px rgba(0, 0, 0, 0.3);
        max-height: 90vh; overflow-y: auto;
      }
      .modal-card h2 { margin: 0 0 4px; font-size: 17px; }
      .modal-sub { margin: 0 0 16px; font-size: 12px; color: var(--muted); }
      .modal-field { margin-bottom: 12px; }
      .modal-field label {
        display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;
      }
      .modal-field select {
        width: 100%; padding: 8px 10px; font-size: 13px; font-family: inherit;
        color: var(--ink); background: #fff; border: 1px solid #d1d5db;
        border-radius: 8px;
      }
      .modal-req { color: var(--muted); font-weight: 400; }
      .modal-actions { display: flex; gap: 8px; margin-top: 18px; }
      .modal-actions button {
        flex: 1; padding: 9px 12px; font-size: 13px; font-weight: 600;
        font-family: inherit; border-radius: 8px; cursor: pointer;
        border: 1px solid #d1d5db; background: #fff; color: var(--ink);
      }
      #modal-confirm {
        background: #2f6f9f; border-color: #2f6f9f; color: #fff;
      }
      #modal-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
      .modal-actions button:hover { filter: brightness(0.97); }

      /* --- About / info dialog body --- */
      #info-modal .modal-card { width: min(520px, 100%); }
      .info-body { font-size: 13px; line-height: 1.5; color: var(--ink); }
      .info-body h3 {
        margin: 16px 0 4px; font-size: 13px; font-weight: 700;
      }
      .info-body h3:first-child { margin-top: 4px; }
      .info-body p { margin: 0 0 8px; }
      .info-body ul { margin: 0 0 8px; padding-left: 18px; }
      .info-body li { margin-bottom: 6px; }
      .info-body a { color: #2f6f9f; }
      .info-body .info-src { color: var(--muted); font-size: 12px; }

      /* --- hover tooltip --- */
      #point-tip {
        position: fixed; z-index: 40; pointer-events: none;
        background: rgba(17, 24, 39, 0.92); color: #fff;
        padding: 5px 9px; border-radius: 7px; font-size: 12px; line-height: 1.35;
        max-width: 240px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
        white-space: nowrap;
      }
      #point-tip[hidden] { display: none; }
      #point-tip .tip-name { font-weight: 700; }
      #point-tip .tip-val { opacity: 0.85; }
      /* Notes and errors from the ingest layer. Guessing a weight column
         silently is how a plausible wrong answer gets believed, so this is
         never hidden while a source has something to say. */
      .notice {
        margin: 0 0 12px; font-size: 11px; line-height: 1.45; color: var(--muted);
        border-left: 3px solid #d1d5db; padding: 4px 0 4px 8px;
      }
      .notice.error { color: #9f1239; border-left-color: var(--secondary); }
      body.drop-active::after {
        content: 'Drop a CSV, GeoJSON or grid file';
        position: fixed; inset: 12px; z-index: 20; pointer-events: none;
        display: flex; align-items: center; justify-content: center;
        font-size: 18px; font-weight: 700; color: var(--ink);
        background: rgba(255, 255, 255, 0.72);
        border: 3px dashed var(--primary); border-radius: 16px;
      }
      /* --- breakdown (now a dialog, was a <details> in the panel) --- */
      .bd-total { margin: 0 0 8px; font-size: 11px; color: var(--muted);
                  font-variant-numeric: tabular-nums; }
      #breakdown-modal .bd-cols { gap: 18px; }
      .bd-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
      .bd-col h2 {
        margin: 0 0 4px; font-size: 10px; font-weight: 700;
        text-transform: uppercase; letter-spacing: 0.04em;
      }
      .bd-primary h2 { color: var(--primary); }
      .bd-secondary h2 { color: var(--secondary); }
      .bd-col ul { list-style: none; margin: 0; padding: 0; }
      .bd-col li {
        display: flex; justify-content: space-between; gap: 6px; align-items: flex-start;
        font-size: 11px; line-height: 1.4; font-variant-numeric: tabular-nums;
      }
      /* The number never shrinks and pins to the top-right; the name takes the
         rest and, when it is too long for one line, wraps to a second and then
         ellipsises. min-width:0 is the load-bearing bit — without it a flex item
         refuses to shrink below its content and overflows the panel. */
      .bd-col li .bd-w { color: var(--muted); flex: none; }
      .bd-col li .bd-n {
        min-width: 0; overflow: hidden; text-overflow: ellipsis;
        display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
      }
      .bd-col li.bd-more { color: var(--muted); font-style: italic; }
      .bd-split::after { content: '†'; color: var(--muted); margin-left: 2px; }
      .bd-note { margin: 8px 0 0; font-size: 10px; color: var(--muted); line-height: 1.4; }


      /* --- advert ---
         Square (300x250) floats in the free bottom-right corner on desktop; the
         leaderboard (728x90) takes over as a liquid-scaled bottom bar on narrow
         screens (see the media query). Both link out in a new tab. */
      /* --- ad bar ---
         A fixed-height row of its own. The height is on the CONTAINER, not on
         the <ins>: adsbygoogle.js rewrites the unit's inline style once it
         picks a creative, so anything we set there is overwritten — but it
         cannot escape a container that clips. That is what stops a creative
         growing into the map, which is the whole reason the ad is not
         responsive in height. */
      #adbar {
        grid-area: adbar; min-width: 0;
        display: flex; flex-direction: column; align-items: center; gap: 1px;
        padding: 4px 0 6px;
        background: var(--panel-bg); border-top: 1px solid #cfdae2;
      }
      #ad-slot {
        width: 100%; height: 100px; overflow: hidden;
        /* A flex item's default min-height:auto refuses to shrink below its
           content, so without this the box would grow around a tall creative
           instead of capping it. Same trap as #stage. */
        min-height: 0; flex: none;
        display: flex; align-items: center; justify-content: center;
      }
      /* THE size of the ad, and the only place it is declared.
         adsbygoogle.js reads the unit's COMPUTED size when it requests the ad,
         so these rules are what is actually asked for — 320x100 on a phone,
         728x90 once there is room. !important because the script rewrites the
         <ins> inline style once a creative lands, and an inline style beats an
         ordinary rule; the numbers match what was requested, so this pins the
         result rather than distorting it. */
      #ad-slot .adsbygoogle {
        display: inline-block !important;
        width: 320px !important; height: 100px !important;
      }
      @media (min-width: 800px) {
        #ad-slot .adsbygoogle { width: 728px !important; height: 90px !important; }
      }
      /* Shown wherever the AdSense tag is not emitted — locally, and on the
         live host until the slot id is filled in. Same height as the real
         unit, so the layout it is standing in for is the layout you get. */
      .ad-placeholder {
        margin: 0; display: flex; align-items: center; justify-content: center;
        width: min(728px, 100%); height: 100%;
        font-size: 12px; color: var(--muted);
        border: 1px dashed #cbd5e1; border-radius: 6px; background: #f8fafc;
      }

      /* --- status bar ---
         With the floating panel gone this is the app's only chrome: the answer
         on top, the controls under it, the ingest notice below both. Fed by
         renderSummary() on every drag. */
      /* The counters are the answer the whole app exists to give, so they are
         sized to be read from across the room. Everything inside scales off
         this one font-size in em, so changing it here rescales the lot. */
      #summary {
        margin: 0; font-size: 24px; font-weight: 600;
        font-variant-numeric: tabular-nums;
        display: flex; flex-wrap: wrap; align-items: baseline;
        justify-content: center; gap: 2px 16px; line-height: 1.15;
      }
      #summary .s-primary { color: var(--primary); }
      #summary .s-secondary { color: var(--secondary); }
      #summary .s-sep { color: var(--muted); font-weight: 400; }
      /* A colour swatch, so the bar reads against the map's blue/red halves
         without relying on the label wording alone. */
      #summary .s-dot {
        display: inline-block; width: 0.42em; height: 0.42em; margin-right: 0.3em;
        border-radius: 2px; background: currentColor;
      }
      #summary .s-val { font-weight: 800; }
      #summary .s-pct { font-weight: 800; margin-left: 0.12em; }
      /* Unit and count are supporting detail — kept, but dropped back so they
         do not compete with the number and the percentage. */
      #summary .s-unit { font-weight: 400; font-size: 0.58em; color: var(--muted); }
      #summary .s-count { font-weight: 400; font-size: 0.5em; color: var(--muted); }
      #topbar {
        grid-area: topbar; min-width: 0;
        display: flex; align-items: center; gap: 16px;
        padding: 8px 14px 9px;
        background: var(--panel-bg); border-bottom: 1px solid #cfdae2;
        /* Never let the chrome eat the map: past this the controls scroll
           inside the bar instead of pushing the coastline off-screen. */
        max-height: 45vh; overflow-y: auto;
      }
      .bar-controls {
        display: flex; flex-wrap: wrap; align-items: center;
        justify-content: center; gap: 6px 14px; width: 100%;
      }
      .bar-group { display: flex; align-items: center; gap: 6px; min-width: 0; }
      /* The wordmark. Sized and weighted to read as a logo rather than as one
         more label in the bar, and pinned left so the bar has a fixed anchor
         whatever the controls beside it wrap to. */
      .bar-brand {
        flex: none; display: flex; align-items: center; gap: 7px;
        align-self: center;
      }
      .bar-brand h1 {
        margin: 0; font-size: 23px; font-weight: 800; letter-spacing: -0.03em;
        white-space: nowrap; color: var(--ink);
      }
      /* Everything that is not the logo, stacked and centred in the space left. */
      #topbar-main {
        flex: 1; min-width: 0;
        display: flex; flex-direction: column; align-items: center; gap: 6px;
      }
      .bar-data select { max-width: 210px; }
      .bar-links { flex-wrap: wrap; gap: 6px 12px; }
      .bar-stat { font-size: 11px; color: var(--muted); white-space: nowrap; }
      .bar-stat span { color: var(--ink); font-weight: 600; }
      #topbar .notice {
        margin: 0; max-width: 640px; text-align: center;
        border-left: 0; border-radius: 999px; padding: 3px 12px;
        background: #f1f5f9;
      }
      #topbar .notice.error { background: #fee2e2; color: #9f1239; }
      /* --- Google consent UI ---
         Deliberately EMPTY. There used to be a rule here:

             body > [class*="fc-"]:not(.fc-consent-root) { position:relative; z-index:30 }

         Its purpose was real — Google injects some consent elements as STATIC
         body children, and #shell is position:fixed, so those would paint under
         the map (positioned elements paint above non-positioned ones whatever
         the z-index). Promoting them fixed that.

         But it also hit the elements Google positions ITSELF. The floating
         "Privacy and cookie settings" toolbar is anchored bottom-left; forced
         to position:relative it fell out of viewport anchoring and landed
         mid-page, over the map. Measured on this page: a badge asking for
         fixed/bottom:12px/left:12px computed to relative and sat 1025px from
         the bottom of a 1069px viewport.

         CSS cannot ask "are you already positioned?", so the promotion moved to
         a script at the end of the page which checks the computed position and
         only touches the static ones. Setting z-index from here is no good
         either: it is inert on static elements, and on positioned ones it would
         CAP Google's own stacking value rather than raise it. */

      .ad-tag {
        display: block; font-size: 9px; line-height: 1.4; text-align: center;
        letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted);
      }

      /* --- narrow screens ---
         No layout swap to make: the three rows are the same shape at every
         width. Only the packing changes. */
      @media (max-width: 640px) {
        /* 44px min touch target */
        .bar-snaps button { padding: 11px 9px; }
        #btn-load { padding: 9px; }
        /* Beside the content the logo squeezed #topbar-main narrow enough that
           the controls wrapped into an extra row apiece — it cost more map
           than it saved. On a phone it takes its own line instead. */
        #topbar { flex-direction: column; align-items: stretch; gap: 5px; }
        .bar-brand { align-self: flex-start; }
        .bar-brand h1 { font-size: 18px; }
        #topbar-main { width: 100%; }
        /* Paying for the bigger counters: the glyph carries the meaning, so
           the words come off the snap buttons and the breakdown gets its
           short name. Both labels stay in the DOM for screen readers. */
        .btn-word { display: none; }
        .bar-snaps button { padding: 10px 12px; font-size: 14px; }
        .lbl-long { display: none; }
        .lbl-short { display: inline; }
        .bar-controls { gap: 6px 10px; }
        /* Never let the chrome eat the map: past this the top bar scrolls
           inside itself rather than pushing the coastline off-screen. */
        #topbar { max-height: 42vh; overflow-y: auto; padding: 7px 10px 8px; }
        /* The two halves wrap one per line at phone widths, which reads well
           and leaves room for the counts — so they stay. */
        #summary { font-size: 17px; gap: 1px 10px; }
        /* The picker right above it already names the dataset, and on a phone
           a whole extra row of chrome costs more than the repetition is worth.
           It stays in the DOM — setSource() still writes to it. */
        .bar-stat { display: none; }
        /* The slot is 100px at every width, so only the stand-in changes. */
        .ad-placeholder { width: 320px; }
      }
