/* ═══════════════════════════════════════════════════════════════
   Terrain Explorer — chrome for site/js/terrain-viewer.js

   Tokens come from landing-v2.css, which this page loads first and
   which scopes them to body.landing-v2 (the page carries that class
   alongside .rml-explore). Every colour below still names a literal
   fallback: the viewer is a standalone component and a page that
   forgets the stylesheet should degrade to readable, not invisible.
   Same discipline js/sample-lightbox.js uses.

   DESIGN.md: no border-radius, 1px hairlines, no drop shadows, sage
   is the only chromatic accent, mono uppercase for the label primitive.
   ═══════════════════════════════════════════════════════════════ */

.rml-explore main { padding: 0; }

.tv-shell {
  position: relative;
  width: 100%;
  /* ONE VIEWPORT. The nav floats over the top of it rather than sitting
     above it in flow, so the map starts at y=0 and nothing is spent on
     chrome that is not the map.

     vh FIRST, svh second. `vh` is valid in every engine that has ever
     shipped and only overshoots (it is lvh by spec — the viewport with the
     browser's own bars retracted); `svh` is Chrome 108+ / Safari 15.4+ and
     merely refines it. Stating vh first means an older Android Chrome or
     iOS loses the refinement, not the height. Same pair, same ordering and
     the same reasoning as landing-v2.css's draw stage.

     NOT `dvh`: it tracks the collapsing URL bar continuously, so this
     container would resize on every frame of a scroll — and the
     ResizeObserver in terrain-viewer.js would call map.resize() on a WebGL
     canvas each time. The spec also lets a UA snap dvh rather than animate
     it, so it does not even buy smoothness. */
  height: 100vh;
  height: 100svh;
  /* No 420px floor. It existed to stop a percentage height collapsing, and
     a viewport height cannot collapse — while in landscape the floor made
     the shell TALLER than the screen it was measured against. */
  min-height: 0;
  background: var(--bg, #0b0c0a);
  border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08));
}

/* ── Containment ───────────────────────────────────────────────
   The map row and the layer sheet are FLEX SIBLINGS, not a map with a
   panel laid over it. The sheet used to be absolutely positioned and
   claimed the bottom 46% of the shell on a phone — a layer control that
   hides the layer. As siblings the two boxes cannot overlap at any size:
   the map row's bottom edge IS the sheet's top edge, so every
   relationship between them is a constant instead of a number measured
   in JS and republished. landing-v2.css:1914 makes the same argument for
   the draw dock, and records what the overlay version cost.

   MapLibre mounts into .tv-canvas, which terrain-viewer.js creates.
   ───────────────────────────────────────────────────────────── */

.tv-map {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
}

.tv-canvas {
  /* min-height: 0, or the flex item refuses to shrink below its content
     and the column overflows — taking the sheet off the bottom of the
     screen with it. */
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
}

/* ── Masthead ─────────────────────────────────────────────── */

/* Below the floating nav, in both orientations. `env()` resolves to 0 in
   ordinary Safari portrait — the browser's own chrome absorbs the notch
   there — so the literal is the floor and the inset only ever adds to it. */
.tv-head {
  position: absolute;
  top: calc(var(--nav-h, 54px) + 12px);
  top: calc(var(--nav-h, 54px) + max(12px, env(safe-area-inset-top, 0px)));
  left: 18px;
  z-index: 3;
  pointer-events: none;
  max-width: min(52ch, calc(100% - 36px));
}

.tv-eyebrow {
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 11px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--text-mute, rgba(232, 228, 217, .55));
  margin-bottom: 6px;
  text-shadow: 0 1px 3px rgba(0, 0, 0, .85);
}

.tv-place {
  font-family: var(--serif, "EB Garamond", Garamond, serif);
  font-size: clamp(22px, 3.4vw, 34px);
  font-weight: 500;
  line-height: 1.15;
  margin: 0;
  color: var(--text, #e8e4d9);
  text-shadow: 0 1px 6px rgba(0, 0, 0, .9);
}

.tv-res {
  margin-top: 6px;
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 11px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent, #a8b890);
  text-shadow: 0 1px 3px rgba(0, 0, 0, .85);
}

.tv-state {
  position: absolute;
  left: 18px;
  /* Above the corner badge, which is above the 3D/Orbit row: the row is
     `bottom: 12px` and 44px tall, the badge `bottom: 64px` and ~24px tall,
     both on this edge and both above this line's z-index. At `bottom: 18px`
     the line sat UNDER the buttons — survivable while it only ever carried
     boot statuses, and a defect once the checkout road wrote its refusals
     here: measured at 1440x900, a 409 "already unlocked" printed with the
     3D and Orbit buttons over its first words. The phone rule below lifts
     it over the phone's own, taller ladder. */
  bottom: calc(12px + 44px + 8px + 24px + 8px);
  z-index: 3;
  max-width: 46ch;
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-dim, rgba(232, 228, 217, .65));
  text-shadow: 0 1px 4px rgba(0, 0, 0, .9);
}
.tv-state:empty { display: none; }

/* ── Scroll cue ───────────────────────────────────────────────
   The map is 78svh, so on most screens it is the whole first view and
   the reading below it is invisible. This is the affordance that says
   otherwise. Centred to clear .tv-state (bottom-left) and MapLibre's
   controls + attribution (bottom-right); shown only once a report has
   been drawn, and retired by .is-gone as soon as the reader scrolls,
   because a cue that survives the scroll is just clutter. */

.tv-scroll-cue {
  position: absolute;
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 10px;
  max-width: min(92vw, 52ch);
  padding: 10px 18px;
  border: 1px solid var(--border, rgba(255, 255, 255, .12));
  border-radius: 999px;
  /* No blur, and .82 like the nav chips. A backdrop-filter here is a
     compositor readback and blur every frame over the live WebGL canvas —
     the exact cost the nav's own blur was switched off for, and this element
     sits over moving terrain the whole time rather than only at the edge. */
  background: rgba(11, 12, 10, .82);
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 11px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--text-dim, rgba(232, 228, 217, .8));
  text-align: left;
  cursor: pointer;
  opacity: 1;
  transition: opacity .35s ease, transform .35s ease, border-color .2s ease, color .2s ease;
}
.tv-scroll-cue:hover,
.tv-scroll-cue:focus-visible {
  color: var(--text, #e8e4d9);
  border-color: var(--accent, #8ba888);
}
.tv-scroll-cue[hidden] { display: none; }

/* Retired, not removed: it keeps its box while it fades so the fade has
   something to animate, then stops taking clicks. */
.tv-scroll-cue.is-gone {
  opacity: 0;
  transform: translateX(-50%) translateY(8px);
  pointer-events: none;
}

.tv-cue-arrow {
  font-size: 13px;
  line-height: 1;
  color: var(--accent, #8ba888);
  animation: tv-cue-bob 1.9s ease-in-out infinite;
}

@keyframes tv-cue-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(3px); }
}

@media (prefers-reduced-motion: reduce) {
  .tv-scroll-cue { transition: opacity .01ms; }
  .tv-cue-arrow { animation: none; }
}

/* Narrow screens: the full sentence would run three lines across a phone,
   so it tightens rather than wrapping into a block. */
@media (max-width: 560px) {
  .tv-scroll-cue {
    font-size: 10px;
    letter-spacing: .08em;
    padding: 9px 14px;
    gap: 8px;
    bottom: 14px;
  }
}

/* ── Layer panel ──────────────────────────────────────────── */

/* On a phone the sheet is row two of the shell's flex column, so it takes
   room OUT of the map rather than covering it. On a wide screen there is
   room beside the map and it goes back to being a panel in the corner —
   which is why the desktop rules live in the media query at the foot of
   this file rather than here. */

.tv-panel {
  flex: 0 0 auto;
  position: relative;
  z-index: 4;
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: var(--bg-panel, #111310);
  border-top: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
  /* Reserve the home indicator / Android chin ONCE. The plain value first,
     so an engine that rejects the nested env() below loses the safe area
     rather than the padding.

     `safe-area-max-inset-bottom` is the STATIC maximum and is what this
     wants: on Android the bottom inset is dynamic — it animates as the chin
     slides — and consuming it on a bottom bar is a documented anti-pattern
     that thrashes layout. Inside this flex column it would be worse, since
     it would relayout the MAP row on every frame of that animation. The
     nested fallback is deliberate rather than an @supports probe: env()
     with an unknown name parses fine, so a probe would always be true. */
  padding-bottom: 0;
  padding-bottom: env(safe-area-max-inset-bottom,
                      env(safe-area-inset-bottom, 0px));
}

/* ── The peek bar ──────────────────────────────────────────────
   ONE button, and the whole bar is it. This replaces a 17x17 caret sitting
   beside a label that did nothing — splitting "the thing you press" from
   "the thing that opens" is the most-repeated interaction complaint across
   the map products surveyed, and 17px is well under the 24px floor.

   Tap toggles the detent; there is no drag. Both design systems require a
   single-pointer way to change a sheet's height, and it is what makes the
   sheet operable under VoiceOver. A dragged sheet in a web view would also
   mean arbitrating touchmove between sheet-drag and inner-scroll by hand,
   against Safari's rubber-banding and its swipe-back edge gesture.
   ───────────────────────────────────────────────────────────── */

.tv-panel-head {
  /* `.tv-grip` is absolute and belongs to THIS element, not the panel: with
     the offer bar now the panel's first child on a cropped phone parcel, a
     grip positioned against the panel painted inside the bar, 60px above
     the head it is meant to mark. */
  position: relative;
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  /* 44, not 56. Every pixel of bar is a pixel of terrain, and 44 is the
     target floor — there is nothing above it to spend. */
  min-height: 44px;
  padding: 13px 14px 7px;
  background: none;
  border: 0;
  cursor: pointer;
  text-align: left;
  -webkit-tap-highlight-color: transparent;
}

/* The grabber. 34x3 is the house value from parcel.css; Material's own
   token is 32x4, so the two agree to within a pixel. It is decoration —
   the 56px button around it is the target. */
.tv-grip {
  position: absolute;
  top: 6px;
  left: 0;
  right: 0;
  width: 36px;
  height: 4px;
  margin: 0 auto;
  border-radius: 2px;
  /* Brighter than `--border-hi`, and the departure from the house value is
     the point. Every other grabber in this product sits on an opaque sheet;
     this one floats at .82 over live satellite imagery, where .18 white is
     invisible over sunlit limestone — the same reason the bar's own label was
     moved off `--text-mute` twenty lines up, measured against the same
     ground. 36x4 splits the difference between parcel.css's 34x3 and
     Material's 32x4 token, so it is still recognisably the same object. */
  background: rgba(232, 228, 217, .42);
  pointer-events: none;
}

.tv-panel-head b {
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--text-mute, rgba(232, 228, 217, .55));
}

/* "4 on". A closed layer control that will not say how many layers are
   drawn makes the reader open it to find out. */
.tv-panel-count {
  margin-left: auto;
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 11px;
  letter-spacing: .1em;
  color: var(--accent, #a8b890);
}

.tv-panel-close {
  display: none;
  position: absolute;
  top: 0;
  right: 0;
  width: 44px;
  height: 44px;
  z-index: 1;
  background: none;
  border: 0;
  color: var(--text-mute, rgba(232, 228, 217, .55));
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
}
.tv-panel.tv-open .tv-panel-close { display: block; }
/* Room for the 44x44 close control. This belonged to `.tv-panel-close`, not
   to the retired `.tv-cue`, and went out with it — the count then sat under
   the button and rendered as "4 o✕n" at every width. */
.tv-panel.tv-open .tv-panel-count { margin-right: 40px; }

.tv-groups {
  display: none;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  /* Stop a scroll at the end of the list chaining to the page behind, and
     with it Android's pull-to-refresh. */
  overscroll-behavior: contain;
}
.tv-panel.tv-open .tv-groups { display: block; }

/* ── Groups are accordions ─────────────────────────────────────
   The registry's full layer set at a 44px target is ~900px of content. Two
   levels is the maximum, and it is the shape every product carrying more than
   about fifteen layers converges on.
   ───────────────────────────────────────────────────────────── */

.tv-group { border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08)); }
.tv-group:last-child { border-bottom: 0; }

.tv-group-name {
  display: flex;
  align-items: center;
  width: 100%;
  min-height: 44px;
  padding: 10px 14px;
  background: none;
  border: 0;
  text-align: left;
  cursor: pointer;
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 10px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--text-mute, rgba(232, 228, 217, .55));
  -webkit-tap-highlight-color: transparent;
}
.tv-group-name::after {
  content: "\203a";
  margin-left: auto;
  font-size: 15px;
  line-height: 1;
  transform: rotate(90deg);
  transition: transform 160ms ease;
  color: var(--text-faint, rgba(232, 228, 217, .25));
}
.tv-group-open > .tv-group-name::after { transform: rotate(-90deg); }

.tv-group-body { display: none; padding-bottom: 6px; }
.tv-group-open > .tv-group-body { display: block; }

/* ── Rows ─────────────────────────────────────────────────── */

.tv-row { padding: 0 14px; }

/* flex-wrap, so the opacity control can take a full-width second line
   rather than squeezing the layer's name out of the row. */
.tv-row-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 10px;
  padding: 0;
}

.tv-row-head input[type="checkbox"] {
  width: 18px;
  height: 18px;
  flex: 0 0 auto;
  cursor: pointer;
}

/* THE WHOLE ROW IS THE SWITCH. A real <label for>, stretched to fill the
   row and given the 44px height, so the target is the layer's name and all
   the space after it — not an 18px tick. Splitting those two is the
   interaction complaint that recurs across every product surveyed. */
.tv-row-label {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  /* The lock glyph rides in its own span, and a flex item drops the leading
     whitespace inside it — locked rows read "Parcel imagery🔒" without this. */
  gap: 6px;
  min-height: 44px;
  font-size: 14px;
  color: var(--text, #e8e4d9);
  cursor: pointer;
  line-height: 1.3;
  -webkit-tap-highlight-color: transparent;
}

.tv-locked .tv-row-label { color: var(--text-faint, rgba(232, 228, 217, .35)); cursor: default; }
/* THE WINDOW — the finding, and the offer that follows it.
   The BAND rules (`.tv-keyhole-*`) are not scoped to .tv-host: the band is not
   inside the map, so a host-scoped selector would match nothing. It lives in
   `.tv-keyhole-mount`, markup each page owns, between the map and the
   verdict. The row tag, the panel offer and the lens label below are inside
   the host and stay scoped to it. */

/* The caption band moved BELOW the map (see .tv-keyhole-mount further down),
   so the ladder that used to lift the corner mark, the 3D/Orbit pair,
   MapLibre's chrome and the scroll cue clear of it is gone with it. Four
   collisions in two days were all one cause: an opaque strip pinned to the
   bottom edge of a canvas that already had five things pinned there. The
   fix was not a better ladder. */


/* The band carries the finding only. Its own buy button and guarantee went
   on 2026-09-26: they made four controls going to checkout on one screen,
   and the offer now lives once on the map (the panel block, the phone bar),
   with the guarantee under it. */

/* A keyhole row is being GIVEN something, so it carries the offer rather than
   the refusal. Text weight, not a sage button: DESIGN.md allows one dominant
   sage moment per view and that is the primary CTA, not nine row links. */
/* A state tag, not a link and not a price. "window only", set as a quiet
   mono label with a hollow dot, right-aligned in the row. It still presses
   (it goes where every other offer control goes) but it is not the thing
   selling — the lens pill and the panel offer are, once each. It carries the
   44px floor every other control in this file does: at 14px inside a 44px
   label a miss toggled the layer, which is the wrong action, not a no-op. */
.tv-host .tv-unlock {
  appearance: none; background: none; border: 0;
  display: inline-flex; align-items: center; min-height: 44px;
  margin-left: auto; padding: 0 8px;
  font: 500 10px/1 var(--mono, "JetBrains Mono", ui-monospace, monospace);
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-mute, rgba(232, 228, 217, .45));
  text-decoration: none; cursor: pointer; white-space: nowrap;
}
.tv-host .tv-unlock::before {
  content: ""; width: 6px; height: 6px; margin-right: 7px;
  border: 1px solid currentColor; border-radius: 50%;   /* the one round thing: it is a dot */
}
.tv-host .tv-unlock:hover,
.tv-host .tv-unlock:focus-visible { color: var(--accent, #a8b890); }

/* The compact legend under a cropped row: the ramp's own classes, from the
   manifest. Names and colours are never typed here. */
.tv-host .tv-row-key {
  display: flex; flex-wrap: wrap; gap: 5px 12px;
  margin: 0 0 6px 32px; padding: 0; list-style: none;
  font: 500 10px/1.3 var(--mono, "JetBrains Mono", ui-monospace, monospace);
  letter-spacing: .06em; text-transform: uppercase;
  color: var(--text-dim, rgba(232, 228, 217, .65));
}
.tv-host .tv-row-key li { display: inline-flex; align-items: center; gap: 6px; }
.tv-host .tv-row-key i { width: 9px; height: 9px; display: inline-block; flex: none; }

/* THE PANEL OFFER — once, above the first group with anything to open.
   One dominant sage moment in the panel (DESIGN.md section 3). */
.tv-host .tv-offer {
  padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 6px;
  border-bottom: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
}
.tv-host .tv-offer-buy,
.tv-host .tv-offer-bar-buy {
  appearance: none; border: 0; border-radius: 0;
  background: var(--accent, #a8b890); color: var(--bg, #0b0c0a);
  font: 600 12px/1 var(--sans, Inter, system-ui, sans-serif); letter-spacing: .01em;
  padding: 12px 14px; cursor: pointer; width: 100%; text-align: left;
}
.tv-host .tv-offer-buy:hover, .tv-host .tv-offer-bar-buy:hover { background: var(--accent-dim, #8c9c75); }
.tv-host .tv-offer-buy:focus-visible, .tv-host .tv-offer-bar-buy:focus-visible {
  outline: 2px solid #e8e4d9; outline-offset: 2px;
}
/* Every control that goes to checkout carries `data-tv-buy`; one rule for
   the disabled state of all of them, whatever their class. */
[data-tv-buy]:disabled { opacity: .45; cursor: not-allowed; }
.tv-host .tv-offer-buy, .tv-host .tv-offer-bar-buy { min-height: 44px; }
.tv-host .tv-offer-s {
  margin: 0; font: 400 11.5px/1.4 var(--serif, Georgia, serif);
  color: var(--text-dim, rgba(232, 228, 217, .65));
}
/* The guarantee under the map's offer. Its TYPE is landing-v2.css's
   .guarantee-foot, the one owner both hosts load; these carry alignment
   only, the rule the band's copy followed before it moved here. */
.tv-host .tv-offer-g { text-align: left; margin: 2px 0 0; }
.tv-host .tv-offer-bar-g { display: none; }
/* What the rest of the parcel holds, above the button it pays for: the finding
   at reading size, the same serif as the guarantee below it. */
.tv-host .tv-offer-facts {
  margin: 0 0 4px; font: 400 13.5px/1.45 var(--serif, Georgia, serif);
  color: var(--text, #e8e4d9);
}
.tv-host .tv-offer-bar-facts,
.tv-host .tv-offer-bar-more { display: none; }

/* THE PHONE BAR — off by default; the phone query turns it on above the
   panel head while the sheet is collapsed. See --tv-bar-extra. */
.tv-host .tv-offer-bar { display: none; }

/* THE LENS PILL — a Marker on the ring's edge. The marker element is a
   zero-size anchor; the pill hangs 22px off it with a hairline leader back
   to the ring, on whichever side has room (`tv-lens-flip`, set by the
   viewer from where the ring sits on screen). z-index 3: above the
   watermark at 2, below every control at 4. Sharp corners, one sage line,
   no shadow, no blur — it is a label on a map, not a card. Since 2026-09-26
   it IS only a label: it names the window and sells nothing, so it takes no
   pointer events and a drag that starts on it moves the map. */
.tv-host .tv-lens-anchor { width: 0; height: 0; z-index: 3; pointer-events: none; }
.tv-host .tv-lens {
  position: absolute; left: 22px; top: 0; transform: translateY(-50%);
  pointer-events: none; border-radius: 0;
  display: flex; align-items: center; gap: 10px; white-space: nowrap;
  min-height: 32px; padding: 0 12px;
  background: rgba(11, 12, 10, .9); border: 1px solid var(--accent, #a8b890);
  color: var(--text, #e8e4d9);
  font: 500 12px/1 var(--mono, "JetBrains Mono", ui-monospace, monospace);
  letter-spacing: .04em;
}
.tv-host .tv-lens::before {
  content: ""; position: absolute; top: 50%; left: -19px; width: 18px; height: 1px;
  background: var(--accent, #a8b890);
}
.tv-host .tv-lens-anchor.tv-lens-flip .tv-lens { left: auto; right: 22px; }
.tv-host .tv-lens-anchor.tv-lens-flip .tv-lens::before { left: auto; right: -19px; }
/* Below the ring, when it fits beside it on neither side. The viewer sets
   `left` inline to keep the pill inside the viewport and `--tv-lens-lead` to
   where the anchor sits along it, so the leader still points at the ring. */
.tv-host .tv-lens-anchor.tv-lens-below .tv-lens { top: 22px; transform: none; right: auto; }
.tv-host .tv-lens-anchor.tv-lens-below .tv-lens::before {
  top: -19px; left: var(--tv-lens-lead, 50%); right: auto; width: 1px; height: 18px;
}
/* Above the ring, when below would run into the control band. */
.tv-host .tv-lens-anchor.tv-lens-above .tv-lens { top: auto; bottom: 22px; transform: none; right: auto; }
.tv-host .tv-lens-anchor.tv-lens-above .tv-lens::before {
  top: auto; bottom: -19px; left: var(--tv-lens-lead, 50%); right: auto; width: 1px; height: 18px;
}
/* The row tag's hover and focus colour is the single sage rule beside its
   declaration above; a second `:hover` here once overrode it with an
   unnamed tint, so hover and focus disagreed. */
.tv-host .tv-unlock:focus-visible { outline: 1px solid var(--accent, #a8b890); outline-offset: 2px; }

/* IN FLOW, under the map. Sharp corners, no shadow — the system has exactly
   one and this is not it. On a solid ground because it is reading, not an
   overlay: the scrim-over-the-map version this replaced is the full-frame
   filter this viewer refuses on principle. No position, no z-index, no pointer-events games:
   it is a block of reading between the map and the verdict, so it cannot
   cover anything and nothing can cover it. The `pointer-events: none` this
   rule used to carry (so the map kept the drag under it) is gone with the
   overlay, and with it the bug where the whole 906px CTA row re-armed events
   across a guarantee that ended at 482. */
.tv-keyhole-mount:empty { display: none; }
.tv-keyhole-note {
  padding: 18px 24px 20px;
  background: var(--bg, #0b0c0a);
  border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08));
}
.tv-keyhole-scope {
  margin: 0 0 6px;
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--text-mute, rgba(232, 228, 217, .4));
}
.tv-keyhole-find {
  margin: 0;
  font-family: var(--serif, Georgia, serif);
  font-size: 16px; line-height: 1.4;
  color: var(--text, #e8e4d9);
}
/* The file's standard phone pair, not `max-width: 720px` alone. A rotated
   iPhone is 844-956px wide — the reason every other phone rule here uses
   both halves — so the single-width form left the band at desktop padding and
   16px serif on a 390px-tall viewport. */
@media (max-width: 720px), (max-height: 560px) and (orientation: landscape) {
  .tv-keyhole-note { padding: 14px 16px 16px; }
  .tv-keyhole-find { font-size: 15px; }
}

.tv-lock { font-size: 11px; }
/* Quiet, factual words beside a row, never a price. Two uses share this ink:
   the licence refusal inside the label (' · not ours to serve': the padlock
   means 'buy this', this means 'nobody can'), and the Satellite row's
   floor-state tag in the row head beside the label (' · beneath Parcel
   imagery', while the mosaic draws under the parcel photo with its own box
   unticked; terrain-viewer.js syncFloorTag). A state, not an offer or a
   refusal, so it takes the same muted ink and no rule of its own. */
.tv-unavail { font-size: 10px; letter-spacing: .04em; color: var(--text-mute, rgba(232, 228, 217, .45)); }

/* 44x44, of which the drawn ring is 22px. The box is the target; the ring
   is what the eye reads. It was 18x18 in both senses. */
.tv-row-info {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  background: none;
  border: 0;
  color: var(--text-mute, rgba(232, 228, 217, .55));
  /* The glyph is drawn by ::before; the button's own text node would double
     it. Zero here, real size on the pseudo-element. */
  font-size: 0;
  line-height: 1;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.tv-row-info::before {
  content: "?";
  font-size: 11px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
}
.tv-row-info:hover::before { border-color: var(--accent, #a8b890); color: var(--accent, #a8b890); }

/* The ring lands on the DRAWN 22px circle, not on the 44px hit box, or a
   keyboard user sees an outline floating in empty space. */
.tv-row-info:focus-visible { outline: none; }
.tv-row-info:focus-visible::before { outline: 2px solid var(--accent, #a8b890); outline-offset: 2px; }

.tv-panel-head:focus-visible,
.tv-group-name:focus-visible,
.tv-panel-close:focus-visible {
  outline: 2px solid var(--accent, #a8b890);
  outline-offset: -2px;
}


.tv-row-body { display: none; padding: 2px 0 14px; }
.tv-row-open .tv-row-body { display: block; }

.tv-kicker {
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 9px;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--text-faint, rgba(232, 228, 217, .25));
  margin-bottom: 5px;
}

.tv-explain {
  margin: 0 0 8px;
  font-size: 12px;
  line-height: 1.55;
  color: var(--text-dim, rgba(232, 228, 217, .65));
}

.tv-note {
  font-size: 11px;
  line-height: 1.45;
  color: var(--caution-amber, #d8a657);
  margin-bottom: 8px;
}

/* ── Legend ───────────────────────────────────────────────── */

.tv-legend { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; }
.tv-legend-item { display: flex; align-items: baseline; gap: 7px; font-size: 11px; }

.tv-swatch {
  flex: 0 0 auto;
  width: 11px;
  height: 11px;
  display: inline-block;
  border: 1px solid rgba(0, 0, 0, .35);
}

.tv-legend-name { color: var(--text, #e8e4d9); }
.tv-legend-tendency { color: var(--text-faint, rgba(232, 228, 217, .35)); font-size: 10px; }

/* ── Opacity: five stops, on a line of their own ───────────────
   This was a range input with a 9x9 thumb in a 52x14 box. It failed the
   24x24 floor (WCAG 2.5.8) by a wide margin, and it failed WCAG 2.2 SC
   2.5.7 as well — that criterion requires a single-pointer alternative to
   any dragging movement and names sliders explicitly, and the exemption a
   NATIVE range input would enjoy is forfeited the moment
   `-webkit-appearance: none` is set, which landing-v2.css does to every
   input on this site. So it failed on size and on drag.

   Five buttons across the row's full width are ~64px each on a 375px
   phone. Opacity needs about five useful values; the continuous range was
   precision no thumb could reach.

   Shown only for a layer that is DRAWN — an opacity for something
   invisible is a knob with nothing on the end of it, and hiding it is what
   keeps a long layer list scannable.
   ───────────────────────────────────────────────────────────── */
.tv-opacity { display: none; }
.tv-row-on .tv-opacity {
  display: flex;
  flex: 1 0 100%;
  /* LAST in visual order, whatever the DOM says. The stops are a DOM sibling
     of the checkbox and the "?" because tests/js/terrain-viewer.spec.js pins
     them as a direct child of .tv-row-head — and a full-width item in a
     wrapping flex row pushes everything after it onto a THIRD line, which put
     the "?" on a row of its own under the stops. `order` moves the wrap point
     without moving the element. */
  order: 1;
  margin: 0 0 8px;
  border: 1px solid var(--border, rgba(255, 255, 255, .08));
}

/* Name what the strip sets. A bare 0-100 row under a layer name reads as a
   claim ABOUT the layer on a page where percentages already mean scores, sun
   share and per-layer confidence — DESIGN.md §2, "a share names its
   denominator". Generated content, so the radiogroup keeps exactly five
   role="radio" children and its accessible name stays the aria-label. */
.tv-row-on .tv-opacity::before {
  content: "Opacity";
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  padding: 0 10px;
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 9px;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--text-faint, rgba(232, 228, 217, .25));
}

.tv-op-stop {
  flex: 1 1 0;
  min-height: 44px;
  padding: 0;
  background: none;
  border: 0;
  border-left: 1px solid var(--border, rgba(255, 255, 255, .08));
  color: var(--text-faint, rgba(232, 228, 217, .35));
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 11px;
  letter-spacing: .04em;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.tv-op-stop:first-child { border-left: 0; }
.tv-op-stop[aria-checked="true"] {
  background: var(--accent-bg, rgba(168, 184, 144, .06));
  color: var(--accent, #a8b890);
}
.tv-op-stop:focus-visible { outline: 2px solid var(--accent, #a8b890); outline-offset: -2px; }

/* ── Controls ─────────────────────────────────────────────── */

/* Inside .tv-canvas, so the sheet can never swallow these and the offset
   is a constant. It used to be `calc(var(--tv-sheet) + 22px)` — a second
   number describing the sheet, which is exactly the collision containment
   removes. */
.tv-controls {
  position: absolute;
  left: 18px;
  bottom: 12px;
  z-index: 4;
  display: flex;
  gap: 8px;
}

.tv-btn {
  min-width: 60px;
  min-height: 44px;
  padding: 9px 14px;
  background: var(--bg-panel, #111310);
  border: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
  color: var(--text, #e8e4d9);
  font-family: var(--sans, Inter, system-ui, sans-serif);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: .04em;
  cursor: pointer;
  transition: border-color 200ms ease, color 200ms ease;
}

.tv-btn:hover:not(:disabled) { border-color: var(--accent, #a8b890); }
.tv-btn.tv-on { background: var(--accent, #a8b890); color: var(--bg, #0b0c0a); border-color: var(--accent, #a8b890); }
.tv-btn.tv-off { color: var(--text-faint, rgba(232, 228, 217, .35)); }
.tv-btn:disabled { opacity: .45; cursor: not-allowed; }

/* ── Sources ──────────────────────────────────────────────── */

.tv-sources { padding: 40px 0 56px; }
.tv-sources-inner { max-width: 780px; margin: 0 auto; padding: 0 24px; }

.tv-src-table { width: 100%; border-collapse: collapse; margin: 14px 0 18px; }
.tv-src-table td {
  padding: 7px 0;
  border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08));
  vertical-align: top;
  font-size: 13px;
}

.tv-src-k {
  width: 108px;
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 11px;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--text-mute, rgba(232, 228, 217, .55));
}

.tv-src-v { color: var(--text-dim, rgba(232, 228, 217, .65)); }
.tv-src-table td:last-child { width: 128px; text-align: right; white-space: nowrap; }

.tv-limits {
  font-size: 12px;
  line-height: 1.6;
  /* --text-dim (6.82:1), not --text-faint. The faint token resolves to 0.25
     alpha, which measures 1.94:1 over --bg at this size — under AA and under
     even the 3:1 large-text floor. This is the on-site-inspection sentence
     every reading is required to end on, and DESIGN.md settled the same shape
     in 2026-08-13 when the guarantee came off 10px mute: a liability line set
     fainter than the copy around it reads as something we hope goes unread.
     The 12px is the blessed type scale, so the fix is the token, not the size. */
  color: var(--text-dim, rgba(232, 228, 217, .65));
  margin: 0;
}

/* ── Phone, and a landscape phone ──────────────────────────────
   The landscape half of the condition is not decoration. A rotated iPhone
   is 844-956px WIDE, so `max-width: 720px` alone stopped matching the
   moment the reader turned the device — landscape fell through to the
   desktop rules and, with the old 420px floor, produced a shell taller
   than the screen it was measured against. landing-v2.css's draw stage
   carries the same pair for the same reason.
   ───────────────────────────────────────────────────────────── */

@media (max-width: 720px), (max-height: 560px) and (orientation: landscape) {
  /* How tall the sheet may grow when open. It takes this out of the MAP
     ROW rather than laying itself over the map, so the number describes
     one thing and nothing else has to be kept in step with it — the
     camera padding in terrain-viewer.js used to carry a second, different
     value for the same sheet. */
  /* vh, refined to svh ONLY where svh exists.
     A custom property cannot take the `height: 48vh; height: 48svh;` pair the
     shell uses. Custom properties are parsed as arbitrary tokens, so nothing
     about `48svh` is invalid until it is substituted into `max-height` — the
     second declaration always wins, and on Chrome <108 / Safari <15.4 the
     substitution then fails at computed-value time, `max-height` falls back
     to its initial `none`, and an open sheet takes its FULL content height
     (~900px at the registry's 17 rows) inside a 100vh flex column. The
     canvas is `min-height: 0`, so it would be squeezed toward zero: the map
     disappears. @supports is the only fallback that works, and it is the
     shape parcel.css:45-51 already uses for exactly this reason. */
  .tv-shell, .tv-host { --tv-sheet: 48vh; }
  .tv-panel.tv-open { max-height: var(--tv-sheet); }
  .tv-state { display: none; }
  /* ...except while it holds a checkout message. A refused checkout — a 409
     "already unlocked", a 500 "price not configured", a dropped connection —
     re-enabled the buttons and said nothing on a phone, because the only
     element that speaks is hidden here. explore-surface.js sets the class
     while such a message stands; it sits above the bottom-edge ladder so the
     bar and the controls do not cover it. */
  .tv-state.tv-state-live {
    display: block;
    left: 12px; right: 70px; max-width: none;     /* clear of MapLibre's zoom column */
    /* Above the whole bottom-edge ladder — bar, 3D/Orbit row, scroll cue —
       each rung's own height and gap restated here because a lane is the
       one thing this edge has no token for. Measured at 375 px: at
       `--tv-bar + 12px` it landed under the corner badge. */
    bottom: calc(var(--tv-bar, 44px) + 12px + 44px + 8px + 44px + 12px);
    padding: 8px 10px;
    background: rgba(11, 12, 10, .9);
    border: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
    text-shadow: none;
  }
  /* The lens pill hangs off the ring wherever the ring is, and on a phone
     the ring's south vertex lands in this lane often enough: measured at
     375x812 on the finca, a 409 printed across the pill's lower half. The
     message IS the answer to a press on that pill, so while it stands the
     pill steps aside; the bar's own button and the band's stay. `:has()` on
     the parent because the status line is the HOST's markup, a sibling of
     the map container on both pages, and the viewer knows nothing of it. */
  :has(> .tv-state.tv-state-live) > .tv-host .tv-lens-anchor { visibility: hidden; }

  /* The masthead is the caller's markup, not the viewer's, so it is a sibling
     of `.tv-host` and no marker class can carry `--tv-top` to it. The fallback
     is the base rule's own offset, which is what a page whose stage already
     starts BELOW its chrome wants — /v3 among them, where this declaration
     otherwise won the cascade and computed to `top: auto`, dropping the
     masthead flush onto the top edge of the map and under the 3D button. */
  .tv-head { top: var(--tv-top, calc(var(--nav-h, 54px) + 12px)); }

  /* ── The masthead says its piece and gets out of the way ───────
     Name and resolution are an opening statement, not a permanent label:
     both are repeated in the reading below the map, and the block is
     `pointer-events: none`, so nothing is lost when it goes. It stays in the
     DOM and in the accessibility tree — this is the page's only <h1> — and
     it is CSS rather than a timer in JS so there is no state to get wrong.
     ───────────────────────────────────────────────────────────── */
  /* The EYEBROW and the resolution line retire; the place name does not.
     `location_label` is printed in exactly one place on this page — the <h1>
     these rules fade — and neither explore-report.js nor verdict-view.js
     prints it below the map. Fading the whole block left a reader arriving on
     a `?job_id=` link with no statement anywhere of which parcel they were
     looking at, and no way to get it back. The two lines that ARE repeated
     (the eyebrow is a constant, the resolution reappears in Sources) go. */
  .tv-eyebrow,
  .tv-res { animation: tv-head-retire 1.2s ease 6s both; }
  .tv-shell::before { animation: tv-head-retire 1.2s ease 6s both; }

  /* Clear of the notch when the phone is on its side, where the inset is
     real (in portrait Safari it resolves to 0 and the literal wins). */
  .tv-controls { left: max(12px, env(safe-area-inset-left, 0px)); }
  .tv-head { max-width: calc(100% - 24px - env(safe-area-inset-left, 0px)
                             - env(safe-area-inset-right, 0px));
             left: max(12px, env(safe-area-inset-left, 0px)); }
  .tv-group-name,
  .tv-row { padding-left: max(14px, env(safe-area-inset-left, 0px));
            padding-right: max(14px, env(safe-area-inset-right, 0px)); }
}

@supports (height: 100svh) {
  @media (max-width: 720px), (max-height: 560px) and (orientation: landscape) {
    .tv-shell, .tv-host { --tv-sheet: 48svh; }
  }
}

/* ── Collapsed, the bar FLOATS ─────────────────────────────────
   In flow, a 45px bar is 45px of solid chrome with no terrain behind it —
   the last full-width band left in portrait, and the one thing landscape
   does not have (there the dock costs WIDTH and the map keeps its height).
   Out of flow, `.tv-canvas { flex: 1 1 auto }` takes the whole shell, the
   map runs to the bottom edge, and the bar becomes a label ON the map
   rather than a wall under it — translucent for the same reason the nav
   chips are.

   OPEN, it goes back into flow and the map shrinks. That is where the
   containment promise actually earns its keep: a sheet big enough to hide
   the map must not be allowed to. A 45px bar cannot hide anything, which is
   why it is the one state allowed to float.
   ───────────────────────────────────────────────────────────── */
@media (max-width: 720px) and (min-height: 561px) {
  /* One number for the bar's height, because three things measure it: the bar
     itself, and the two control clusters that now have to clear it. Taking
     the bar out of flow made the canvas full-height, which pushed its
     bottom-anchored chrome straight onto the bar — the 3D/Orbit pair landed
     on "Layers" and the zoom cluster on "Verdict". */
  /* `.tv-host` is the class terrain-viewer.js puts on whatever element it was
     mounted into; `.tv-shell` is markup only site/explore.html has. BOTH are
     needed and neither is redundant: everything the viewer BUILDS (the sheet,
     its head, the 3D/Orbit pair) is a descendant of the host, while
     `.tv-scroll-cue` is a SIBLING of it inside the shell. Declaring these on
     the shell alone left site/landing-v3.html reading four undefined vars —
     see the comment at the `tv-host` line in terrain-viewer.js for what that
     measured. */
  .tv-shell, .tv-host {
    /* The bar's height in ONE place. `.tv-panel-head` reads it too, so
       changing it moves the bar and everything that clears the bar together
       — a second literal here is precisely the shape this change exists to
       retire. */
    --tv-bar-h: 44px;
    /* The phone offer bar's height, published by the viewer (0 where the
       bar is hidden). Folded in here, never into --tv-bar-h, which
       .tv-panel-head sizes itself off. */
    --tv-bar-extra: 0px;
    --tv-bar: calc(var(--tv-bar-h) + var(--tv-bar-extra, 0px));
    --tv-bar: calc(var(--tv-bar-h) + var(--tv-bar-extra, 0px) + env(safe-area-max-inset-bottom,
                                         env(safe-area-inset-bottom, 0px)));
    /* The 3D/Orbit row's width, for the one thing that has to sit beside it.
       Derived, not guessed: two `.tv-btn` and the 8px gap between them, where
       the wider label ("Orbit", 12px/600 Inter at .04em inside 14px padding)
       measures ~62 against the 60px floor its narrow twin takes. The 10px on
       the end is the gap to whatever follows, folded in so a caller adds
       nothing. Re-measure this if `.tv-btn` changes or a third button lands
       here; it is one number in one place precisely so that is possible. */
    --tv-ctrl-w: 140px;
  }
  .tv-panel-head { min-height: var(--tv-bar-h); }
  .tv-panel:not(.tv-open) {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 4;
    /* .82, the alpha the nav CHIPS use — not the nav bar's .72, which the
       first cut borrowed. The difference is measurable: at .72 the bar's
       `LAYERS` label computes 3.36:1 over bright sand, under the 4.5:1 floor,
       and `.tv-shell::before` cannot rescue it (that scrim is the top 200px,
       and it fades at 6s). */
    background: rgba(11, 12, 10, .82);
    border-top: 0;
  }
  /* --text-mute at 11px over live imagery is the weakest text on the screen.
     One step up clears AA with the .82 ground above. */
  .tv-panel:not(.tv-open) .tv-panel-head b { color: var(--text-dim, rgba(232, 228, 217, .65)); }

  /* The offer rides above the head, as part of the bottom bar, while the
     sheet is collapsed. Its height reaches everything stacked off --tv-bar
     through --tv-bar-extra, so the 3D/Orbit pair, the badge and the cue move
     up with it — the mechanism the token was built for. An open sheet is the
     reader choosing layers, not buying; the bar stands down there the way
     the controls do, and its height goes to 0 with it. */
  .tv-host .tv-panel:not(.tv-open) .tv-offer-bar {
    display: flex; align-items: center; gap: 8px 10px;
    padding: 8px 12px;
    border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08));
  }
  .tv-host .tv-offer-bar-k {
    flex: none; max-width: 38%;
    font: 500 10px/1.3 var(--mono, "JetBrains Mono", ui-monospace, monospace);
    letter-spacing: .1em; text-transform: uppercase;
    color: var(--text-dim, rgba(232, 228, 217, .65));
  }
  .tv-host .tv-offer-bar-buy { flex: 1; padding: 0 14px; }   /* 44px floor above; thumb-sized */
  .tv-host .tv-panel:not(.tv-open) .tv-offer-bar { flex-wrap: wrap; }
  .tv-host .tv-panel:not(.tv-open) .tv-offer-bar-g {
    display: block; flex: 0 0 100%; margin: 0; text-align: left;
  }
  /* The finding tops the phone bar, clamped to two lines so the bar stays
     under the 160 px the map can spare at 375 x 812. */
  .tv-host .tv-panel:not(.tv-open) .tv-offer-bar-facts {
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
    overflow: hidden; flex: 0 0 100%; margin: 0;
    font: 400 13px/1.35 var(--serif, Georgia, serif); color: var(--text, #e8e4d9);
  }
  /* The way down to the reading, in the kicker's slot (terrain-viewer.js
     builds it only beside a finding). Shown only while the host's cue is
     live — not [hidden] (no report drawn yet) and not .is-gone (the reader
     already scrolled) — and while it is, the floating pill stands down: one
     overlay fewer over the map. Keyed on the PARENT because the cue is a
     sibling of the host (`.tv-shell` on /explore, `.v3-stage` on /v4); a
     browser without :has keeps the pill and never shows the link. */
  .tv-host .tv-offer-bar-more {
    display: none; flex: none; max-width: 38%; min-height: 44px;
    align-items: center; gap: 6px; padding: 0; border: 0; background: none;
    font: 500 10px/1.3 var(--mono, "JetBrains Mono", ui-monospace, monospace);
    letter-spacing: .1em; text-transform: uppercase; text-align: left;
    color: var(--text-dim, rgba(232, 228, 217, .8)); cursor: pointer;
  }
  :has(> .tv-scroll-cue:not([hidden]):not(.is-gone)) > .tv-host .tv-panel:not(.tv-open) .tv-offer-bar-more {
    display: inline-flex;
  }
  :has(> .tv-host .tv-panel:not(.tv-open) .tv-offer-bar-more) > .tv-scroll-cue { display: none; }
  /* The panel's own offer block is redundant on a phone: the bar IS it. */
  .tv-host .tv-offer { display: none; }
  .tv-host .tv-lens { padding: 0 12px; font-size: 11px; gap: 8px; }
  /* Collapsed is the only state these render in — the block below takes them
     away while the sheet is open — so the offset describes exactly one
     situation. It read "offset unconditionally … they ride a little higher,
     which costs nothing" before that suppression existed; they did not ride
     higher, they landed dead centre. */
  .tv-controls { bottom: calc(var(--tv-bar) + 12px); }
  /* `.tv-host`, not `.rml-explore`. MapLibre's zoom column and attribution are
     inside the map, which is inside whatever element the viewer was mounted
     into — so this belongs to the COMPONENT, not to the page that happens to
     host it. Scoped to the page it missed site/landing-v3.html, where the
     cluster sat behind the floating layer bar (39x44 of the zoom stack, and
     the source credit with it, on a surface whose licence posture is about
     honouring attribution). */
  .tv-host .maplibregl-ctrl-bottom-right { bottom: calc(var(--tv-bar) + 4px); }

  /* An open sheet moves the canvas's bottom edge to the middle of the screen,
     and these are anchored to it — so they floated dead centre over the map,
     which is the same complaint the old `bottom: calc(var(--tv-sheet) + 22px)`
     earned when the panel was COLLAPSED. Anchoring cannot fix it in both
     states at once, and a reader with the layer list open is choosing layers,
     not orbiting. They come back on the next tap of a 44px bar. */
  /* `.maplibregl-ctrl-group`, NOT the whole bottom-right container: the
     AttributionControl lives in it too (the Map is constructed with
     `attributionControl: { compact: true }` and no position, so it defaults
     there). Hiding the container took the map's source credit off screen
     with the zoom buttons, on a page whose entire licence posture is about
     honouring source terms. */
  .tv-sheet-open .tv-controls,
  .tv-sheet-open .maplibregl-ctrl-bottom-right .maplibregl-ctrl-group { display: none; }

  /* ABOVE the 3D/Orbit row, not beside it. Both were given the same `bottom`,
     so the cue landed on the Orbit button — measured 43x44 of overlap at
     390x844 and the whole button at 320x568, and the cue is a <button> later
     in the DOM at the same z-index, so it took the taps. */
  .tv-scroll-cue { bottom: calc(var(--tv-bar) + 12px + 44px + 8px); }

  /* ── The badge joins the row it was left out of ──────────────
     `.tv-mark` is pinned at `bottom: 64px` in the base rules — a number
     computed against `.tv-controls { bottom: 12px }`. This block raises the
     controls to `calc(var(--tv-bar) + 12px)` and left the badge behind, which
     put it back exactly where the base rule's own comment says it must never
     be. It collided at BOTH ends of the safe-area range, against two
     different victims:

       inset 0   (Chrome on iOS with its toolbar up, most of Android)
                 controls [56,100] · badge [64,89] — badge inside both buttons
       inset 34  (the home indicator)
                 bar [0,78] · badge [64,89]        — badge on the LAYERS bar

     The first row is MEASURED, in real Chrome at 320, 360, 375, 390 and 430
     wide: `elementFromPoint` at each button's own centre returned `.tv-mark`
     at every one of them. The second is DERIVED — `env()` resolves to 0 in a
     desktop harness, so no probe here can reach a real home indicator. Both
     are fixed by the same move, and only the first is evidence.

     And it did not merely cover them. The badge wins on z-index (6 over 4)
     and is an <a> with no `pointer-events: none`, so it TOOK THEIR TAPS:
     pressing 3D, Orbit or the top of the bar navigated to the homepage.

     It moves BESIDE the controls rather than above them. A fourth rung would
     push the scroll cue up and cost ~33px of terrain on a page whose entire
     layout argument is how much of a phone the map gets. Room is made the way
     clean mode already makes it thirty lines further down: the tile watermark
     says `ReadMyLand.com` across the whole frame and survives any crop, so a
     corner repeating the domain is one claim stated twice. The corner keeps
     the half the tile cannot carry — this parcel's own measured resolution.

     The lane is bounded from BOTH sides rather than given a width: the
     control row on the left, MapLibre's zoom column on the right — its 44px
     button plus its own 10px margin is the 54px first term, and the second
     10px is the gap to the badge. At 390px the line fits with room to spare; at 320px
     it wraps to two, which is why the box is sized to the RUNG it sits in
     rather than to its own text — it can then never grow past the 44px band
     the buttons occupy, whatever the string or the font fallback. That also
     puts the referral link itself on the 44px target floor for the first
     time.

     KEYED OFF `.tv-host`, NOT `.rml-explore`. Every rule in this badge group
     describes the badge's relationship to `.tv-controls`, and the controls are
     built by terrain-viewer.js wherever it is mounted — so page-scoping them
     fixed the page this was found on and left the same collision standing on
     every other mount site. site/landing-v3.html mounts the same viewer twice
     and reproduced it exactly: 129x32 of overlap at both 390x844 and 320x568,
     with `elementFromPoint` at the 3D button's centre returning
     `.tv-mark-name`. The reel stage hides the controls, so it takes the corner
     back in landing-v3.css the same way `.tv-host.tv-sheet-open` does here. */
  .tv-host .tv-mark {
    left: calc(max(12px, env(safe-area-inset-left, 0px)) + var(--tv-ctrl-w));
    right: calc(54px + 10px + env(safe-area-inset-right, 0px));
    bottom: calc(var(--tv-bar) + 12px);
    display: flex;
    align-items: center;
    min-height: 44px;
    box-sizing: border-box;
    line-height: 1.25;
    /* The base rule's `backdrop-filter: blur(3px)` comes off here. That is a
       compositor readback and a blur every frame over the live WebGL canvas —
       the exact cost `.tv-hint` below is built to avoid and `.tv-clean
       .tv-mark` already switches off — and this rule makes the blurred region
       BIGGER, from a text-fitted pill to a 44px lane box. The .72 fill carries
       the legibility on its own. */
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  /* The name is the tile's job here. Its separator goes with it — baked into
     the span's own text it would survive its sibling being hidden, which is
     the dangling "· elevation read at 50 cm" the rule below records.
     
     `:not(.tv-mark-bare)` because `renderWatermark` appends `.tv-mark-res`
     only when the parcel HAS terrain, and its own docstring promises "when the
     terrain is unavailable it says only the domain" — so hiding the wordmark
     unconditionally left a licence-blocked parcel with an empty 44px box that
     still navigated to the homepage. The viewer sets the class; the bare badge
     keeps its name and shrink-wraps instead of holding the lane open. */
  .tv-host .tv-mark:not(.tv-mark-bare) .tv-mark-name { display: none; }
  .tv-host .tv-mark.tv-mark-bare { right: auto; min-height: 0; }
  .tv-host .tv-mark-res::before { content: ""; }
  .tv-host .tv-mark-res::first-letter { text-transform: capitalize; }

  /* An open sheet takes the controls away, so the badge takes the corner
     back — the lane it was given only exists while they are in it. This rule
     MUST live inside this query: a desk boots with `tv-sheet-open` set
     (`setSheet(deskLayout())`), where the controls are visible at
     `bottom: 12px`, and at file scope this would drop the badge straight onto
     them again. */
  .tv-host.tv-sheet-open .tv-mark {
    left: max(12px, env(safe-area-inset-left, 0px));
    right: auto;
    bottom: 12px;
    min-height: 0;
  }

  /* Clean mode is the one view with no Sources table under the map, so the
     corner carries the whole source line there and needs its full width and
     its own flow back. Specificity (0,3,0) over the (0,2,0) above; the
     `bottom` it wants is already set by `.tv-clean .tv-mark` further down. */
  .rml-explore .tv-clean .tv-mark { left: 12px; right: auto; display: block; min-height: 0; }

  /* ── The bar says it opens ────────────────────────────────────
     It is a full-width <button> carrying `aria-expanded`, and until now the
     only visible sign of that was `.tv-grip` over live imagery. The caret
     removed in #251 was removed for being a 17x17 TARGET beside an inert
     label; this is not that. It is a `::after` on the button itself —
     decoration inside the control, never a second one — using the same glyph
     and the same rotation as the group accordions four hundred lines up,
     because two disclosure vocabularies in one sheet is worse than none.

     The head is a flex container, so its `::after` is a flex item and
     therefore blockified, which is what makes `transform` apply at all; the
     10px flex `gap` already spaces it off the count, so it takes no margin.

     Collapsed only. When the sheet is open the 44x44 close control owns that
     corner and says the same thing, and a chevron there would sit underneath
     it. The landscape dock cannot conflict: it renders its own hamburger from
     the same pseudo-element, and its query (`max-height: 560px`) and this one
     (`min-height: 561px`) are mutually exclusive. */
  /* `.tv-host`, not `.rml-explore`. #266 scoped this to the page because /v3
     "carries neither `.rml-explore` nor `.tv-shell`, so `--tv-bar` is
     undefined there" — a premise that stopped being true when the tokens
     moved onto `.tv-host`, which every mount site carries. This rule reads no
     custom property at all (`content`, `font-size`, `color`, `transform`), so
     it was portable the whole time, and /v3 has been rendering the same 44px
     bar with nothing to say it opens. */
  .tv-host .tv-panel-head::after {
    content: "\203a";
    font-size: 15px;
    line-height: 1;
    color: var(--text-dim, rgba(232, 228, 217, .65));
    transform: rotate(-90deg);
  }
  .tv-host .tv-panel.tv-open .tv-panel-head::after { content: none; }

  /* ── The first-visit hint ─────────────────────────────────────
     Once per browser, six seconds, then gone. A chevron and a grabber say the
     bar is a control; they do not say what is behind it, and the reader who
     needs that sentence is the one who has never seen this page.

     It takes the CONTROLS' rung and takes them away while it shows, which is
     the move `.tv-sheet-open .tv-controls` above already makes for a
     transient state that needs this corner. The alternative was a rung of its
     own, and there is not one: at 320px, after `--tv-ctrl-w` (140px, which
     already folds in its trailing gap) and MapLibre's 54px zoom column, the
     free band is 320 - 12 - 140 - 54 = 114px — narrower than the sentence.
     Read the widths off the tokens, not off this prose: an earlier draft of
     this comment said 130px and disagreed with the declaration above.

     The controls step aside because the pill would otherwise LAND on them,
     not because the intro makes them redundant — the pill is centred and they
     are left-anchored, so at 390px they overlap from x89 to x141. That reason
     holds on every path, including a `?cam=` link or a reduced-motion reader
     where no intro runs at all.

     Not a peeking sheet: the sheet is a flex SIBLING of the map, so peeking
     it resizes the map, which fires the ResizeObserver and calls
     `map.resize()` on a live WebGL canvas mid-orbit.

     `pointer-events: none` because the pill is a label and the 44px bar
     underneath it is the target — a pill that swallowed the tap it is asking
     for would be the joke version of this feature.

     An earlier draft of this comment claimed the masthead and the pill run on
     one clock, so the resolution was "never stated twice or not at all". That
     is FALSE and worth recording as false, because it is the kind of thing the
     next reader would build on. The two clocks have different origins: the
     masthead's `tv-head-retire` starts at first render, while HINT_MS starts
     at buildPanel(), i.e. after the manifest — which this project measures at
     up to 6.9s cold and allows 20s. So on a warm load both are up together for
     a few seconds, and on a cold one the masthead has already faded before the
     badge appears. Neither is harmful; the resolution is also in the Sources
     table under the map. Do not re-derive the invariant. */
  .rml-explore .tv-hint {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    bottom: calc(var(--tv-bar) + 12px);
    z-index: 6;
    display: flex;
    align-items: center;
    gap: 8px;
    min-height: 44px;
    max-width: calc(100vw - 24px);
    box-sizing: border-box;
    padding: 0 16px;
    border: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
    border-radius: 999px;
    /* .82 and no blur, for the reason `.tv-scroll-cue` gives: a
       backdrop-filter here is a compositor readback and blur every frame over
       the live WebGL canvas, and this pill sits over moving terrain for its
       whole life. */
    background: rgba(11, 12, 10, .82);
    font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
    font-size: 11px;
    letter-spacing: .1em;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--text-dim, rgba(232, 228, 217, .8));
    pointer-events: none;
    opacity: 1;
    transition: opacity .4s ease;
  }
  .rml-explore .tv-hint-arrow { color: var(--accent, #a8b890); font-size: 13px; line-height: 1; }
  /* Retired, not removed: it keeps its box while it fades, the same contract
     `.tv-scroll-cue.is-gone` uses. */
  .rml-explore .tv-hint.is-gone { opacity: 0; }
  .rml-explore .tv-hint-on .tv-panel:not(.tv-open) .tv-offer-bar { display: none; }
  .rml-explore .tv-hint-on .tv-controls,
  .rml-explore .tv-hint-on .tv-mark { display: none; }
  .rml-explore .tv-sheet-open .tv-hint { display: none; }

}

/* The cue is a SIBLING of #tvMap inside .tv-shell, not a descendant, so the
   descendant form of this rule never matched and the pill stayed on screen
   over an open sheet — painting across the layer rows and taking their taps.
   Its two line-mates in the block above are inside .tv-canvas and did match,
   which is why the pair read as symmetric while only half of it fired.
   Not scoped to portrait: the retired `.tv-cue` carried a landscape
   suppression that the merge dropped without a decision, and a cue over a
   390px-tall map is the case that suppression existed for. */
.tv-sheet-open ~ .tv-scroll-cue { display: none; }

/* ...but it comes BACK on a desk, where the panel is not a sheet.
   The rule above was written for the phone SHEET, which takes the map's own
   space and would have the cue painting across the layer rows. On a desk the
   panel is a floating 268px card in the corner and covers nothing the cue
   needs — yet a desk BOOTS with `tv-sheet-open` set
   (`setSheet(deskLayout())` in terrain-viewer.js), so that one line has been
   hiding the cue on EVERY desktop since it was written. Measured at
   1440x900: `.tv-scroll-cue` computed `display: none` with a perfectly good
   `bottom` — and the map owns the whole first viewport here, so nothing on
   screen told a reader a written verdict existed under it at all.
   `:not([hidden])` is load-bearing: `wireScrollCue` holds the cue back with
   the hidden ATTRIBUTE until a report has actually been drawn, because on a
   job still running or whose scoring failed there is nothing below, and
   scrolling someone to an empty page is worse than no cue. A bare
   `display: flex` here would out-specify `.tv-scroll-cue[hidden]` and show
   the pill on both. */
@media (min-width: 721px) and (min-height: 561px) {
  .tv-sheet-open ~ .tv-scroll-cue:not([hidden]) { display: flex; }
}

/* A landscape phone is SHORT, and a bottom sheet is the wrong shape for a
   390px-tall viewport — 48svh of it is 187px. Dock to the side instead, as
   parcel.css already does for its own sheet, so the sheet costs width
   (which landscape has) rather than height (which it has not). */
@media (max-height: 560px) and (orientation: landscape) {
  .tv-map { flex-direction: row; }
  .tv-panel {
    border-top: 0;
    border-left: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
    /* The dock sits on the RIGHT (the canvas is row one), so it is the right
       inset that matters. Grow the box by the inset and pad it back, rather
       than padding the head: at 56px wide a ~44px landscape notch inset would
       leave the head a 0-width content box and push the hamburger into the
       display cutout. The 13-viewport sweep could not see this — env()
       resolves to 0 in a desktop harness. */
    width: calc(56px + env(safe-area-inset-right, 0px));
    padding-right: env(safe-area-inset-right, 0px);
    padding-bottom: 0;
  }
  .tv-panel.tv-open {
    width: calc(min(320px, 42vw) + env(safe-area-inset-right, 0px));
    max-height: none;
  }
  .tv-panel-head { flex-direction: column; align-items: flex-start; }
  .tv-panel-count { margin-left: 0; }
  .tv-grip { display: none; }
  .tv-panel:not(.tv-open) b,
  .tv-panel:not(.tv-open) .tv-panel-count { display: none; }
  .tv-panel:not(.tv-open) .tv-panel-head::after {
    content: "\2261";
    font-size: 18px;
    color: var(--text-mute, rgba(232, 228, 217, .55));
  }
}

/* ── Desk ──────────────────────────────────────────────────────
   There is room BESIDE the map here, so the sheet goes back to being a
   panel in the corner and the map keeps the whole shell. It floats
   because at this width it covers a margin, not the parcel.
   ───────────────────────────────────────────────────────────── */

@media (min-width: 721px) and (min-height: 561px) {
  .tv-panel {
    position: absolute;
    top: calc(var(--nav-h, 54px) + 12px);
    right: 18px;
    width: 268px;
    max-height: calc(100% - var(--nav-h, 54px) - 84px);
    border: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
    padding-bottom: 0;
  }
  .tv-panel.tv-open { max-height: calc(100% - var(--nav-h, 54px) - 84px); }

  .tv-grip { display: none; }
  .tv-panel-head { min-height: 44px; padding: 12px 14px; }
  /* The close control is pinned at top:6px with height:44px, so on a 44px
     head it overhangs 6px INTO .tv-groups and eats the top-right corner of
     the first accordion header — a click aimed there closes the panel. Sit
     it on the head instead of over it. */
  .tv-panel-close { top: 0; height: 44px; }
}

/* ── Density is a POINTER question, not a width one ────────────
   The block above is about LAYOUT: at this width there is room beside the
   map, so the sheet becomes a panel in the corner. Target size is a
   different question with a different answer, and folding the two together
   was wrong in a way the viewport sweep caught: an iPad in portrait is
   768px wide, so it took the desktop rules and its opacity stops came out
   at 26px under a finger. Width does not tell you what is pointing at the
   screen; `pointer: fine` does.
   ───────────────────────────────────────────────────────────── */

@media (min-width: 721px) and (min-height: 561px) and (hover: hover) and (pointer: fine) {
  .tv-row-label { min-height: 38px; font-size: 13px; }
  .tv-row-head input[type="checkbox"] { width: 14px; height: 14px; }
  .tv-row-info { width: 34px; height: 34px; }
  /* A mouse can hit a narrow control, so the stops go back inline beside
     the layer name rather than taking a line of their own. */
  .tv-row-on .tv-opacity { flex: 0 0 auto; margin: 0; }
  .tv-op-stop { min-height: 26px; padding: 0 6px; font-size: 10px; }
  .tv-row-on .tv-opacity::before { display: none; }
}

@keyframes tv-head-retire {
  from { opacity: 1; }
  to   { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .tv-btn { transition: none; }
  .tv-group-name::after { transition: none; }
  .rml-explore .tv-hint { transition: opacity .01ms; }
  /* No fade, and no disappearing act either — a reader who asked for less
     motion gets the masthead left where it is. */
  .tv-eyebrow,
  .tv-res,
  .tv-head,
  .tv-shell::before { animation: none; }
}

/* ── Touch ─────────────────────────────────────────────────────
   :hover sticks on a touch screen, so the FIRST tap paints the hover state
   and the second one acts. Neither of landing-v2.css's two hover-reset
   blocks names a .tv-* selector, so every control in this file had it.
   ───────────────────────────────────────────────────────────── */

@media (hover: none) {
  .tv-btn:hover:not(:disabled) { border-color: var(--border-hi, rgba(255, 255, 255, .18)); }
  /* The buy controls' hovers, for the same reason: a tap leaves the hover
     state stuck on the thing that was tapped. */
  .tv-host .tv-offer-buy:hover, .tv-host .tv-offer-bar-buy:hover { background: var(--accent, #a8b890); }
  .tv-row-info:hover::before { border-color: var(--border-hi, rgba(255, 255, 255, .18)); color: inherit; }
  .tv-row-head input[type="checkbox"]:hover:not(:disabled) { border-color: var(--border-hi, rgba(255, 255, 255, .18)); }
}

/* ── The nav floats over the map ───────────────────────────────
   Scoped to .rml-explore: every other page keeps its sticky bar. `fixed`
   rather than `sticky` so the bar leaves flow entirely and the map shell
   starts at y=0 — which is where the ~54px this used to spend goes.

   The bar carries its OWN ground rather than borrowing the masthead's.
   .tv-shell::before is absolutely positioned inside the shell, so it scrolls
   away while a fixed nav does not: past roughly 150px of scroll the bar would
   sit on bare satellite imagery until the shell clears the viewport, and a
   34% wash over bright sand is not a contrast ratio. .72 is the same alpha
   .tv-scroll-cue uses over the same imagery.

   AND THE BLUR IS OFF. `backdrop-filter` is inherited from the sticky rule in
   landing-v2.css, where the backdrop is a static page the compositor caches
   once. Here the backdrop is a WebGL canvas that repaints every frame of the
   intro orbit and every pan, so the compositor would re-read and re-blur a
   full-width strip per frame, for the whole session, on a phone. The scrim
   below plus an opaque-enough bar does the legibility job without it.
   ───────────────────────────────────────────────────────────── */

.rml-explore .nav {
  position: fixed;
  left: 0;
  right: 0;
  background: rgba(11, 12, 10, .72);
  border-bottom: 0;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  /* Standalone/PWA display mode puts the status bar behind the page; in an
     ordinary Safari tab this resolves to 0 and costs nothing. */
  padding-top: env(safe-area-inset-top, 0px);
}
.rml-explore .nav-menu:not([hidden]) { background: rgba(11, 12, 10, .97); }

/* The pill rules MUST come after the base rule above: same
   specificity, so source order decides, and a whole media block
   losing that way is silent — the rules parse, match, and are
   overwritten. */
@media (max-width: 720px), (max-height: 560px) and (orientation: landscape) {
  /* ── The nav stops being a bar ────────────────────────────────
     A full-width 54px bar is 54px of terrain on a screen whose whole point
     is terrain. Here it becomes two floating chips over the map — the
     Google Maps shape — so the map runs edge to edge behind them and the
     only thing between the reader and the land is a brand chip, a CTA chip
     and the sheet's 44px bar.

     The CTA keeps its own chip rather than being folded away: it is the
     conversion path off this page, and a control nobody can find converts
     nobody. It is smaller, not hidden.

     `--tv-top` is what the masthead measures itself against, so the two
     cannot drift: chip height plus the gap above and below it.
     ───────────────────────────────────────────────────────────── */
  .tv-shell { --tv-top: calc(44px + 16px + env(safe-area-inset-top, 0px)); }

  .rml-explore .nav { background: none; padding-top: 0; }
  .rml-explore .nav-inner {
    height: auto;
    padding: max(8px, env(safe-area-inset-top, 0px))
             max(10px, env(safe-area-inset-right, 0px)) 0
             max(10px, env(safe-area-inset-left, 0px));
    gap: 8px;
  }
  .rml-explore .nav-brand,
  .rml-explore .nav-links .nav-pill,
  .rml-explore .nav-menu-btn {
    display: inline-flex;
    align-items: center;
    /* 44, like every other control on this page. Shrinking chrome to buy map
       stops at the target floor — a chip nobody can hit is not a saving. */
    min-height: 44px;
    padding: 0 12px;
    background: rgba(11, 12, 10, .82);
    border: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
  }
  .rml-explore .nav-links { gap: 8px; }
  /* The dropdown hangs off the chip row, and it is the one piece of nav that
     has to be fully opaque — it carries real links over live imagery. */
  .rml-explore .nav-menu { top: calc(100% + 8px); }
}


/* Safari 26 tints its own chrome by SAMPLING the background of fixed and
   sticky elements near the viewport edges, and ignores theme-color. The nav
   is fixed and deliberately translucent, so without an opaque root behind it
   the browser samples through to white and paints white bars around a black
   page. `body.landing-v2` already carries --bg; `html` did not. */
html { background: var(--bg, #0b0c0a); }

/* ── MapLibre's own chrome ─────────────────────────────────────
   The zoom/compass control is the only single-pointer way to zoom OUT on
   this map, and it shipped at ~29px. It also used to sit UNDER the layer
   panel on a phone; that half is fixed by construction now, since the
   control lives inside .tv-canvas and the sheet is the canvas's sibling.
   ───────────────────────────────────────────────────────────── */

/* `.tv-host`, not `.rml-explore`: MapLibre's zoom column lives inside the
   map, which is inside whatever element the viewer was mounted into, so
   these belong to the COMPONENT. Scoped to the page they missed /v3, where
   the lens pill (z-index 3) painted over the zoom column's default z and
   took its taps. */
.tv-host .maplibregl-ctrl-group button {
  width: 44px;
  height: 44px;
}
.tv-host .maplibregl-ctrl-bottom-right { z-index: 4; }

/* ── Checkbox ─────────────────────────────────────────────────
   landing-v2.css sets `appearance: none` on form controls, so the
   native tick never draws and the row reads as a label with nothing
   to click. Drawing it here rather than un-setting appearance: the
   reset is deliberate elsewhere on the site, and a control that looks
   the same in Safari and Firefox is worth twelve lines.
   ───────────────────────────────────────────────────────────── */

.tv-row-head input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  border: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
  background: transparent;
  margin: 0;
}

.tv-row-head input[type="checkbox"]:hover:not(:disabled) {
  border-color: var(--accent, #a8b890);
}

.tv-row-head input[type="checkbox"]:checked {
  background: var(--accent, #a8b890);
  border-color: var(--accent, #a8b890);
}

/* The tick, drawn as a rotated L rather than a glyph so it does not
   depend on a font that may not have loaded yet. */
.tv-row-head input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 3px;
  height: 7px;
  border: solid var(--bg, #0b0c0a);
  border-width: 0 1.5px 1.5px 0;
  transform: rotate(45deg);
}

.tv-row-head input[type="checkbox"]:disabled {
  border-style: dashed;
  opacity: .5;
}

.tv-row-head input[type="checkbox"]:focus-visible {
  outline: 1px solid var(--accent, #a8b890);
  outline-offset: 2px;
}

/* ── Masthead legibility ──────────────────────────────────────
   The heading sits over live imagery, which on a bright parcel is
   pale sand. A text-shadow alone is not enough at 34px, so the
   masthead carries its own scrim: a soft top-down gradient that
   fades out before it reaches the map the reader is here to see.
   ───────────────────────────────────────────────────────────── */

.tv-shell::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 190px;
  z-index: 2;
  pointer-events: none;
  background: linear-gradient(to bottom,
              rgba(11, 12, 10, .82) 0%,
              rgba(11, 12, 10, .55) 45%,
              rgba(11, 12, 10, 0) 100%);
}

@media (max-width: 720px) {
  /* Taller than it was, not shorter: it now has the floating nav to carry
     as well as the masthead. */
  .tv-shell::before { height: 200px; }
}

/* A rotated phone is 844-956px WIDE, so the query above does not match it and
   it kept the 190px desktop scrim over a 390px-tall viewport — half the
   screen darkened. Same defect class the shell's own query was widened for. */
@media (max-height: 560px) and (orientation: landscape) {
  .tv-shell::before { height: 120px; }
}

/* Map labels for the sun path and wind rose.
   DOM markers rather than a MapLibre `symbol` layer: text there needs a
   `glyphs` URL the style does not have, and without one MapLibre drops the
   layer silently. This also lets the labels use the page's own type. */
/* A PLATE, NOT A GLOW. These sit on a photograph, and a blurred shadow behind
   10px amber does nothing over bright sand — the same 1.13:1 contrast problem
   the sun path's casing solves for the lines. An opaque chip is the only thing
   that reads over ground we do not control the colour of. */
.tv-maplabel {
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: .06em;
  white-space: nowrap;
  pointer-events: none;
  padding: 2px 6px;
  border-radius: 3px;
  background: rgba(9, 11, 8, .72);
  border: 1px solid rgba(255, 255, 255, .10);
  text-shadow: 0 1px 2px rgba(9, 11, 8, .9);
}

/* The report under the map. Same inner width as the sources table so the
   verdict, the scores and the provenance read as one column. */
.tv-report { padding-top: 34px; padding-bottom: 0; }
.tv-report-inner {
  max-width: 760px;
  margin: 0 auto;
  padding: 0 24px;
}

/* ── The unlocked verdict, under the map ──────────────────────────────
   The chaptered layout itself is styled by landing-v2.css (every rule is
   .landing-v2-scoped and this page's <body> carries that class, which is
   what lets one renderer serve both surfaces). What follows is only what
   the Explorer adds around it: the column width the chapters need, the
   free-tier teaser, the dossier buttons and a plate lightbox. */

/* The verdict view sets its own 860px measure; let it, rather than
   squeezing the chapter maps into the 760px sources column. */
.tv-report-inner:has(.verdict-view) { max-width: 900px; }

/* The downloads sit between the verdict and the first layer chapter, so
   they carry their own space above as well as below — they no longer
   inherit the gap that the retired "Full dossier" banner used to leave. */
.tv-dossier-row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 30px 0 8px;
}
.tv-dossier-row .cta { margin: 0; }

/* Free tier — the verdict teaser. The fade is applied ONLY when the
   server actually truncated the text (the renderer adds .is-cut on a
   trailing ellipsis), so a whole verdict never looks withheld. */
.tv-verdict { margin: 0 0 26px; }
.tv-verdict p {
  font-family: var(--serif);
  font-size: 17px;
  line-height: 1.62;
  color: var(--text-soft, #aebcb2);
  margin: 0 0 0.7em;
}
.tv-verdict.is-cut p:last-child {
  -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 55%, transparent 100%);
}

.tv-cta {
  margin: 34px 0 0;
  padding: 26px 24px;
  text-align: center;
  border: 1px solid var(--border);
  background: var(--accent-bg);
}
.tv-cta p {
  max-width: 44ch;
  margin: 10px auto 18px;
  color: var(--text-soft, #aebcb2);
  line-height: 1.55;
}
.tv-cta .cta { margin: 0; }

/* Plate lightbox. The chapter tiles carry data-map-src, the same contract
   the landing page's lightbox reads; this page has no lightbox of its own,
   so it brings one rather than shipping tiles that look clickable and are
   not. */
.tv-lightbox {
  position: fixed;
  inset: 0;
  z-index: 9000;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 32px;
  background: rgba(8, 9, 7, .94);
  cursor: zoom-out;
}
.tv-lightbox img {
  max-width: 100%;
  max-height: calc(100vh - 130px);
  object-fit: contain;
  cursor: default;
}
.tv-lightbox-cap {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--text-mute);
}
.tv-lightbox-close {
  position: absolute;
  top: 18px;
  right: 22px;
  width: 40px;
  height: 40px;
  font-size: 17px;
  color: var(--text);
  background: rgba(9, 11, 8, .72);
  border: 1px solid var(--border-hi, rgba(255, 255, 255, .18));
  border-radius: 50%;
  cursor: pointer;
}

@media (max-width: 720px) {
  .tv-lightbox { padding: 16px; }
  .tv-cta { padding: 22px 18px; }
}

/* ── Shareable view ──────────────────────────────────────────────────────
   `?clean=1` strips the chrome for a screen recording. ATTRIBUTION STAYS.
   The licences that make 91.6% of jobs redistributable — OGL, dl-de/by-2.0,
   CC-BY-4.0, ODbL — are ATTRIBUTION licences, so a mode that hides the source
   line and then invites the reader to publish the recording would break
   compliance on every job. The watermark stays for the same reason it exists. */
/* Two scopes, because the chrome lives in two places. `.tv-panel` and the
   controls are children of #tvMap; the masthead, the scroll cue, the report
   and the sources table are siblings of it inside `.tv-shell`. Scoping every
   rule to `.tv-clean` hid the first group and silently missed the second —
   caught in a browser, not by the spec, which only asserted the class was
   applied. */
.tv-clean .tv-panel,
.tv-clean .tv-controls,
.tv-clean .tv-hint,
.tv-clean .tv-state,
.tv-clean .maplibregl-ctrl-top-right,
.tv-clean .maplibregl-ctrl-bottom-right > .maplibregl-ctrl-group { display: none !important; }

body.rml-clean .nav,
body.rml-clean footer,
body.rml-clean .tv-head,
body.rml-clean .tv-scroll-cue,
body.rml-clean .tv-report,
body.rml-clean .tv-sources,
body.rml-clean #tvReport { display: none !important; }
/* svh, not vh. `.tv-shell` is `100svh`, and `.tv-map` is `position:absolute;
   inset:0` inside it — so forcing `height:100vh` over-constrains the box
   (top, bottom and height all non-auto), CSS 2.1 10.6.4 drops `bottom`, and
   the used height exceeds the parent by the mobile browser-chrome band:
   ~60-90px on iOS Safari, ~56px on Android Chrome. The watermark is anchored
   `bottom: 12px` inside it, so on a phone the ATTRIBUTION landed below the
   fold — and clean mode has already hidden .tv-sources, so nothing else on
   the page states the sources. An attribution licence is not satisfied by a
   credit that is off-screen. */
body.rml-clean .tv-map { height: 100svh !important; }

/* The badge. Bottom-LEFT so it never sits under MapLibre's attribution,
   which is bottom-right and must remain legible. */
.tv-mark {
  /* ABOVE the controls, not on top of them. `.tv-controls` is `left: 18px;
     bottom: 12px` in this same `.tv-canvas` and its buttons are `min-height:
     44px`, so it occupies 12-56px off the bottom edge. This badge at
     `bottom: 12px` with `z-index: 6` over their 4 covered the lower half of
     both buttons AND took their clicks — measured in Chromium,
     `elementFromPoint` at the 3D button's own centre returned `.tv-mark`,
     and the badge is an <a>, so the click navigated away instead of toggling
     3D. Clean mode hides the controls and puts it back down at 12px. */
  position: absolute; left: 12px; bottom: 64px; z-index: 6;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 10px; letter-spacing: .04em; text-decoration: none;
  color: var(--text-dim, rgba(232, 228, 217, .72));
  background: rgba(11, 12, 10, .72);
  border: 1px solid var(--border, rgba(232, 228, 217, .14));
  padding: 5px 9px; border-radius: 2px; backdrop-filter: blur(3px);
}
.tv-mark:hover { color: var(--accent, #a8b890); border-color: var(--accent, #a8b890); }

/* ── The watermark ───────────────────────────────────────────────────────
   Two treatments, one purpose split in half.

   THE CORNER LINE, always. Carries the parcel's own measured resolution and
   nothing else — the tile below carries the name, so saying it twice would be
   the same claim twice.

   THE TILE, EVERYWHERE. Its job is surviving a CROP: any crop of any region
   still carries the mark, which is what a screen recording or a third-party
   iframe needs and what a corner badge cannot do.

   It shipped clean-mode-only for one iteration on the argument that a buyer
   exploring their own parcel needs no crop protection and tiling their land
   would be noise charged to them. Henk's call, 2026-08-24, overrides that:
   the mark goes on every Explorer view. The reasoning that loses is not
   wrong about the paying buyer — it is wrong about who else is looking. The
   Explorer is reachable by anyone holding a job id, the four /sample/
   parcels are public and are the acquisition surface, and a screenshot taken
   from the normal view travels exactly as far as one taken from a recording.
   A watermark that is absent from the view most people actually open is not
   a watermark.

   Chosen from six live variants compared over the real Cadiz parcel
   (/design-shotgun, 2026-08-24). Three findings the mockup route would have
   missed, all of them only visible against real satellite imagery:

   1. A single white pass at 0.14 vanishes completely over sunlit limestone
      while reading fine over woodland. The dark twin under each tile is what
      makes ONE opacity work on both. It is structural, not decoration.
   2. Camel case beats lowercase here. `readmyland.com` is all x-height and
      smears at this opacity; `ReadMyLand.com` has three tall anchors (R, M, L)
      that survive it. This is the one place the repo deliberately departs
      from the lowercase-domain convention, and it departs for legibility.
   3. NO COPYRIGHT SYMBOL. The elevation and imagery under this mark are
      CNIG/IGN España, Environment Agency under OGL, Geoscience Australia
      under CC-BY-4.0 — licensed, not owned, and every one of those is an
      ATTRIBUTION licence. A tiled "(c) ReadMyLand.com" would assert ownership
      of other people's data on the most-read surface in the product, and do
      the opposite of what those licences require. The mark is provenance,
      not a claim. */
/* ON the map, over every layer drawn in it — and UNDER everything the reader
   can touch.

   It sat at 5. `.tv-canvas` is `position: relative` with `z-index: auto`, so
   it opens no stacking context and this pseudo-element competes directly with
   the chrome in the shell's context: the layer panel, the 3D/Orbit pair, the
   scroll cue and MapLibre's own controls are all 4, and the status line is 3.
   So the tile crawled across all of them — measured at 1280x820, the panel is
   268x681 at (994,66) and the watermark covered the whole of it.

   2 puts it above the WebGL canvas (`z-index: auto`, so 0) and below every one
   of those. It ties with `.tv-shell::before`, the masthead scrim, which is a
   `pointer-events: none` gradient across the top 190px; DOM order leaves the
   watermark above it, exactly as at 5, so nothing about the masthead changes.

   Not 3: `.tv-head` and `.tv-state` sit there, and a tie would let the tile
   paint over the parcel name and the status line. `.tv-mark` stays at 6 — it
   is the source credit and must stay legible over anything. */
.tv-canvas::after {
  content: ""; position: absolute; inset: 0; z-index: 2;
  pointer-events: none; background-repeat: repeat;
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27250%27%20height%3D%27145%27%3E%3Cg%20transform%3D%27rotate%28-28%20125%2072%29%27%3E%3Ctext%20x%3D%2713%27%20y%3D%2773%27%20font-family%3D%27Inter%2Csystem-ui%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27600%27%20letter-spacing%3D%270.5%27%20fill%3D%27rgba%280%2C0%2C0%2C0.15%29%27%3EReadMyLand.com%3C%2Ftext%3E%3Ctext%20x%3D%2712%27%20y%3D%2772%27%20font-family%3D%27Inter%2Csystem-ui%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27600%27%20letter-spacing%3D%270.5%27%20fill%3D%27rgba%28255%2C255%2C255%2C0.15%29%27%3EReadMyLand.com%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
/* Separators live in CSS, never in the span's own text.
   Baked into the string, the leading "\u00b7 " survived its own sibling being
   hidden and clean mode rendered a dangling "\u00b7 elevation read at 50 cm".
   Here each part owns the separator BEFORE it, and the mode that hides a part
   also clears the separator that would have preceded the next one. */
.tv-mark-res::before { content: " \00b7  "; }

/* The source line. Hidden in the normal view because the Sources table under
   the map already prints every row in full, with resolution and licence —
   repeating 342 characters in the corner would be noise, not compliance.
   Clean mode is the case where that table is gone. */
.tv-mark-attrib { display: none; }

/* The tile carries the name, so the corner line drops it. */
.tv-clean .tv-mark {
  /* The controls are hidden here, so the floor is free again. */
  bottom: 12px;
  background: none; border: none; backdrop-filter: none; padding: 4px 2px;
  color: rgba(240, 237, 229, .88);
  text-shadow: 0 1px 1px rgba(0, 0, 0, .95), 0 0 6px rgba(0, 0, 0, .75);
  /* Wide enough that the source line reads as a caption strip rather than a
     paragraph. Measured at 1440x900: 46ch wrapped 342 characters onto SEVEN
     lines and put an 86px block of grey text in the corner of every
     recording; this lands it on three. The cap is still a cap — on a phone
     it takes the width it needs rather than wrapping to a column. */
  max-width: min(92vw, 620px);
}
.tv-clean .tv-mark-name { display: none; }
.tv-clean .tv-mark-res::before { content: ""; }
.tv-clean .tv-mark-res::first-letter { text-transform: capitalize; }
.tv-clean .tv-mark-attrib {
  display: block; margin-top: 3px;
  font-size: 9px; line-height: 1.5; letter-spacing: .02em;
  color: rgba(240, 237, 229, .82);
  /* A DOUBLE shadow, not a box. Photographed at 8.5px/62% over the Cadiz
     parcel the source line was present but not readable, and "present but
     illegible" is a weak answer to an attribution licence. A scrim behind it
     would read as chrome in a mode whose whole job is having none; two
     stacked shadows — a tight dark core plus a wide halo — give the glyphs an
     edge over both the pale limestone and the olive canopy, which is the
     range this one parcel already spans. */
  text-shadow: 0 1px 1px rgba(0, 0, 0, .95), 0 0 6px rgba(0, 0, 0, .75);
}
