/* ============================================================
   analytic.css — ANALYTIC MODE ONLY (the three.js viewer).

   Every selector in this file starts with [data-mode="analytic"]. That is
   not decoration: both modes' DOM is in the page at all times (the viewer
   is hidden, not removed), so an unscoped rule here would leak into portal
   mode — and a leak that only shows up after switching modes is the kind
   of bug nobody finds by looking at one screen. verify_ui_tokens.mjs
   assertion 16 enforces the prefix.

   ── TWO NAMES FOR ONE THING, ON PURPOSE ──
   The user-facing name is "Analytic Mode"; every code identifier is
   `viewer` (js/viewer/, #viewer-root, wf-enter-viewer, viewer-exit). That
   is a deliberate UI-naming choice; the identifiers were not renamed to
   match. So this file scopes with the design vocabulary's word
   ([data-mode="analytic"]) and selects with the codebase's word
   (.viewer-*). Both are correct; neither is a leftover.

   ── WHAT THIS MODE IS ──
   js/viewer/ is not a layer. It is a parallel render space: its own
   canvas, its own GL context, its own frame loop, running beside MapLibre
   rather than inside it. It exists because MapLibre caps pitch at 85° and
   offers no FreeCameraOptions, so "stand in the street canyon and look up
   past the roofline" cannot be expressed there at all.

   ── LOAD ORDER ──
   tokens.css → base.css → topbar.css → controls.css → portal.css →
   analytic.css. The panel shell (.col-left / .fpanel*) is in controls.css
   and shared with portal mode verbatim; this file only adds what is
   genuinely analytic-only.
   ============================================================ */

/* ============================================================
   THE SHELL — same .app/.stage as portal mode, with the map swapped for
   the canvas. #viewer-root is a child of .stage, not of <body>, and
   absolute rather than fixed: that is what puts the status bar back on
   screen in this mode.

   RULE: #viewer-root and #viewer-canvas are the two id selectors this
   file is allowed to keep. They are the shell itself, not controls:
   mode.js binds #viewer-root by id to toggle `hidden`, and engine/world.js
   is handed the #viewer-canvas element to build a renderer on. Giving
   either a class as well would mean two names for the same singleton
   with no second caller to justify it.
   ============================================================ */
[data-mode="analytic"] #viewer-root {
  position: absolute;
  inset: 0;
  z-index: 100;

  /* THE VIEWER'S TEXT, from the same tokens the panel uses. Without this
     the viewer inherited from <body>, which declares no font-family, and
     fell back to the browser's default serif. Set on the root rather than
     on the panel so the HUD, the attribution, the notes and the legend all
     follow one declaration. */
  font: var(--fs-label)/var(--lh-body) var(--font-mono);
  /* matches the renderer's clear colour, so a slow first frame is not a
     white flash */
  background: var(--bg-ground);
}

[data-mode="analytic"] #viewer-canvas {
  display: block;
  width: 100%;
  height: 100%;
  /* camera-rig.js reads pointer deltas; without this a drag selects the
     page text behind the canvas instead. */
  user-select: none;
  touch-action: none;
}

/* RULE: ANALYTIC MODE HAS NO TOP BAR — and hiding it is not the same
   as covering it, which is what #viewer-root's old position:fixed did.
   .stage grows by --h-topbar the instant this applies, and the canvas does
   NOT follow on its own: engine/world.js's resize() reads
   canvas.clientWidth/clientHeight and is only called from start() and from
   the window resize event. mode.js therefore writes data-mode
   BEFORE world.start() on the way in, and after world.stop() on the way
   out. Get that order wrong and the canvas comes up 48px short, the whole
   scene is stretched, and nothing appears in the console. */
[data-mode="analytic"] .topbar { display: none; }

/* The PORTAL column, not this mode's own. Both are .col-left and both are
   in the page at once, so the two are told apart structurally: portal's is
   a direct child of .stage, analytic's lives inside #viewer-root.
   Hidden rather than merely covered — it acts on a map nobody can see, and
   covering it is not even fully effective: #viewer-root spans .stage, but
   the portal card sizes itself to its content, and any part that runs
   past the stage's bottom edge shows through over the status bar (a
   button poking out under the scene) unless it is actually hidden. */
[data-mode="analytic"] .stage > .col-left { display: none; }

/* THE STATUS BAR'S STEP FIELD — hidden here, and only this one field.
   All four fields (STEP / GRID / PHOTOS / MASKS) are equally frozen the
   moment this mode is entered: updateStatusBar() lives in
   js/ui/workflow-controls.js, is map-only, and is never called again from
   inside the viewer. STEP is the one that reads as a live workflow-step
   indicator, so a stale one states something false; the other three read
   as counts of what was loaded, which stay true however old they are.
   A project decision.

   Hidden from CSS rather than by teaching the viewer to write the bar:
   mode.js may not touch the workflow (its own header says so), and a rule
   scoped to this mode reads nothing and writes nothing. .statusbar itself
   stays in controls.css, shared and unchanged, so portal mode keeps all
   four fields. */
[data-mode="analytic"] .statusbar-state { display: none; }
/* Its trailing separator, which would otherwise open the bar with a rule
   that divides nothing from GRID. */
[data-mode="analytic"] .statusbar-state + .statusbar-sep { display: none; }

/* THE KEY REMINDER, revealed only here. Declared hidden in controls.css
   because that sheet is loaded by both pages and the map has no viewer
   camera to describe; this is the one line that turns it on, and it is
   scoped, so the map cannot pick it up.

   It came off the top of the scene: a permanent strip of key bindings
   lying across the picture is the same cost as the massing caveat that
   used to sit in the opposite corner, and this bar was already on screen
   in both modes doing exactly this job for the map. */
[data-mode="analytic"] .statusbar-hint { display: block; }

/* ============================================================
   THE BETA TAG STRIP.

   Along the top of the scene, starting to the RIGHT of the left column:
   with no top bar, the column starts at the same 20px inset portal's does,
   and this strip would otherwise sit on top of it. The column's inset is
   the thing that stays pinned; the strip is the thing that moves instead
   of pushing the panel down to make room for it.

   WHAT LEFT: the key reminder. It is in the status bar now
   (.statusbar-hint above), which is off the scene entirely rather than
   lying across the top of it. What stays is the beta tag, which has to be
   ON the picture — it is a statement about what the picture is worth, and
   in a screenshot of the scene alone it is the only thing that says so.

   The layout rules below therefore now position a single short tag. They
   are kept as they were rather than collapsed to `left/top` on purpose:
   the right-hand clearance is what keeps the strip out from under the tool
   rail and the right column, and a tag is short today only because nothing
   has been added beside it yet.
   ============================================================ */
[data-mode="analytic"] .viewer-hud {
  position: absolute; z-index: 5;
  top: var(--inset-overlay);
  /* the band BETWEEN the two columns, both insets spelled out the same way,
     so adding a column later cannot leave this one silently underneath it.
     right: the tool rail was moved to sit left of .col-right
     (see #viewer-root > .vt-root), so clearance is the rail + its --sp-2
     gap + the panel, in one calc(), rather than a second
     --inset-right-col token. */
  left:  calc(var(--inset-overlay) + var(--w-panel) + var(--sp-4));
  right: calc(var(--inset-overlay) + var(--w-toolbtn) + var(--sp-2)
              + var(--w-panel-analysis) + var(--sp-4));
  display: flex; align-items: center; gap: var(--sp-3);
  font-size: var(--fs-hint); color: var(--text-2);
  /* it is a caption over a scene you fly through — never a click target */
  pointer-events: none;
}
/* .viewer-hint and .viewer-credit have no rules here any more.
   #viewer-attribution was removed because the OpenStreetMap credit belongs
   in one place and .statusbar already shows it in both modes; the key
   reminder followed it there for the same reason (see .statusbar-hint
   above). The rule this block used to need — "the credit stays whole and
   the key reminder gives up characters when the strip is too narrow" —
   has nothing left to arbitrate, and the truncation it described moved
   into .statusbar-hint's own rule in controls.css. */

/* Data-quality caveat for the OSM massing (js/viewer/layers/massing.js). A
   standing statement about what is on screen, not a control.

   IN A POPOVER NOW, behind the info icon at the end of the tool column's
   MAP group (js/viewer/tools.js builds both). It used to be pinned to the
   bottom-left corner of the scene, and the reason it moved is not that it
   was wrong there — it is that it is read once and then never again, while
   sitting on the picture for the entire session with no way to dismiss it.

   No box of its own here: .vt-popover already brings the surface, the
   border and the blur, so this only sets the type. The accent hairline
   that used to mark it as "the one caveat on screen" is gone with the
   same reasoning — inside a panel opened deliberately, nothing has to
   compete for the reader's attention. */
[data-mode="analytic"] .vt-popover-massing {
  /* WIDER THAN THE COLUMN'S OTHER POPOVERS, and it has to override
     max-width rather than set width: controls.css caps every .vt-popover at
     --vt-popover-width (220px), which is sized for a list of one-word
     camera modes. This one holds two sentences of counts, and at 220px
     they run about ten lines — a paragraph in a slot shaped for a menu.

     A local value rather than a new token: nothing else wants this width,
     and a token would invite the next popover to inherit a size that was
     measured for one particular sentence. */
  max-width: 300px;
}
[data-mode="analytic"] .viewer-note {
  font-size: var(--fs-hint); line-height: var(--lh-tight); color: var(--text-2);
}
[data-mode="analytic"] .viewer-note:empty { display: none; }

/* RECOMPUTE FEEDBACK — the floating box shown while a material edit is
   being recomputed (js/viewer/edit/material-dialog.js's setBusy()).

   It exists because a recompute takes seconds during which the scene does
   not move at all, and the two status lines that do report it are both
   plain text in a panel: one in the LAYERS card, one in the SELECTION
   card, neither with anything that turns. Nothing moving reads as nothing
   happening.

   The box borrows .viewer-tool-status's vocabulary (border, --overlay-bg,
   pointer-events) without sharing its class: that one is a narrow strip
   pinned to the left of the tool rail, this one is centred over the whole
   scene, and one rule holding both positioning schemes would be a rule
   with an if in it. */
[data-mode="analytic"] .viewer-recompute-toast {
  display: none;
  position: absolute;
  /* One step above .viewer-hud / .viewer-note / .viewer-tool-status, which
     all sit at 5: this is a transient notice that outranks the standing
     captions for as long as it is up. */
  z-index: 6;
  /* One row of chrome below the hint strip's own top inset. --h-status is
     borrowed as "the height of one line of chrome" rather than measuring
     .viewer-hud: that strip has no declared height, so pixel-matching it
     here would drift silently the first time its font-size or padding
     changed. Clearing it by a few px too many costs nothing; landing on
     top of the key bindings would. */
  top: calc(var(--inset-overlay) + var(--h-status) + var(--sp-2));
  /* Centred on the whole scene, not on the band between the two columns
     (which is what .viewer-hud's left/right pair does). The columns are
     near enough symmetric that the two agree today; when a column is
     collapsed this box will sit off-centre toward the open side, which is
     accepted, not overlooked. */
  left: 50%; transform: translateX(-50%);
  align-items: center; gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  font-size: var(--fs-hint); line-height: var(--lh-tight); color: var(--text-1);
  background: var(--overlay-bg);
  border: var(--border) solid var(--line-strong); border-radius: var(--radius);
  /* Never a click target — the same rule .viewer-hud and
     .viewer-tool-status both carry. The camera keeps working underneath
     it, which is the whole point of it not being a modal. */
  pointer-events: none;
}
[data-mode="analytic"] .viewer-recompute-toast.is-busy { display: flex; }

/* The one turning thing in this mode. It reuses @keyframes wf-spin —
   already shared by controls.css's CTA button and .wf-status-busy — but
   not the .wf-status.wf-status-busy rule itself: that one sizes and
   places a ::before inside an inline status LINE, and none of that
   geometry survives being moved into a standalone pill. */
[data-mode="analytic"] .viewer-recompute-toast-spinner {
  width: 10px; height: 10px; flex: none;
  border: 2px solid var(--line-strong); border-top-color: var(--accent);
  border-radius: 50%;
  animation: wf-spin 0.7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  [data-mode="analytic"] .viewer-recompute-toast-spinner { animation: none; }
}

/* ============================================================
   THE RIGHT COLUMN — three same-width cards in a plain flex column.

   This geometry (three .fpanel--thin/--grow cards, --w-panel wide, gap
   --sp-3 between them) is UNCHANGED from before the rail move — it
   is deliberately NOT touched by the rail move below. Nothing in this
   rule or in index.html's markup gives any one card a width of its
   own; that is what keeps all three flush and reading as one connected
   block instead of one card looking wider or narrower than its
   neighbours.

   The rail move took the tool rail from the right of this column to the
   left of
   it: the rail used to sit at its own corner with
   --inset-right-col carved out of this column's inset just to leave it
   34px + a gap of clearance; now nothing sits to the RIGHT of this
   column any more, so `right` is back to the plain --inset-overlay
   every other corner uses. See #viewer-root > .vt-root below for the
   rail's side of the same move.

   The cards carry no body and no JS. See index.html for why that is a
   decision and not an omission.
   ============================================================ */
[data-mode="analytic"] .col-right {
  position: absolute; z-index: 4;
  right: var(--inset-overlay); top: var(--inset-overlay); bottom: var(--inset-overlay);
  width: var(--w-panel-analysis);
  display: flex; flex-direction: column; gap: var(--sp-3);
  pointer-events: none;
}
[data-mode="analytic"] .col-right > * { pointer-events: auto; }
/* opens the whole gap in one place, so ANALYTICS keeps its own height at
   the top and the other two sit flush at the bottom, without any card
   being stretched into an empty rectangle */
[data-mode="analytic"] .col-right-bottom { margin-top: auto; }

/* THE RAIL'S SIDE OF THE SAME MOVE. Portal mode's own rail keeps
   controls.css's unscoped `.toolbar, .vt-root { top; right }` verbatim
   (top-right corner) — this overrides only the analytic instance,
   scoped by #viewer-root (the rail mounts as a plain child of it, same
   as before this whole round of changes; there is no separate slot
   element to look up).

   `right` is what moves it: instead of hugging --inset-overlay like
   portal's rail does, it lands just left of .col-right — inset-overlay
   + the column's own width + a gap, so growing --w-panel or
   --inset-overlay keeps the two from overlapping without a second
   number to maintain here.

   `top` is left alone, on purpose: it already reads var(--inset-overlay)
   from controls.css's unscoped rule, the same value .col-right's own
   `top` uses two rules up — that shared literal, not a flex row, is
   what keeps the rail's top level with the column's at any window
   height, and is exactly how the two lined up before this file
   existed. Moving only `right` and leaving `top` untouched is what
   keeps that true after the swap.

   gap: --sp-2 — the same gap the rail's OWN icons use
   between each other (controls.css's .vt-col), so "the rail's distance
   from the column" and "an icon's distance from its neighbour" are the
   same number rather than two choices that happen to look similar. */
[data-mode="analytic"] #viewer-root > .vt-root {
  right: calc(var(--inset-overlay) + var(--w-panel-analysis) + var(--sp-2));
}

/* ============================================================
   THE PANEL'S CONTENTS

   The shell (.col-left / .fpanel*) is styles/controls.css's and is shared
   verbatim with portal mode. What follows is only what is specific to
   what this panel HOLDS.
   ============================================================ */

/* ------------------------------------------------------------
   TWO CARDS IN THIS COLUMN, BOTH SIZED TO THEIR OWN CONTENT.

   Neither one absorbs the column's leftover height. Both take exactly
   the height their content needs, sit flush against each other and
   against the "Portal" button below them, and whatever height is left
   over falls below that button -- open scene, not a blank rectangle
   inside a card border. Matches .col-right, which does the same thing
   the same way (see .col-right-bottom below).

   ------------------------------------------------------------ */
[data-mode="analytic"] #viewer-root .col-left > .fpanel:first-of-type { pointer-events: auto; }
[data-mode="analytic"] #viewer-edit-card {
  flex: 0 1 auto;
  max-height: none;
} 

/* NO ROW IN EITHER LEFT CARD MAY SHRINK BELOW ITS CONTENT — the same rule
   #viewer-material-dock's own `> *` carries, and the same reason: a flex
   item's automatic minimum size is its content height UNLESS the item has
   an `overflow` other than `visible`, in which case that automatic minimum
   is ZERO. `.viewer-preset-row` and the 24h/Metric switch are both `.seg`,
   and `.seg` carries `overflow: hidden` (controls.css, for its rounded
   corners and the border between buttons) -- so without `flex: none` they
   are the items the flexbox algorithm reaches for first when the column
   runs short of height, and they can be squeezed all the way to a sliver
   while every plain-text sibling around them, which DOES have a content
   floor, stays full size.

   MEASURED: at 1600x900 (a window height already documented elsewhere in
   this file as tight for this mode) the preset row collapsed from 33px to
   17.6px, its three button labels half-clipped, while the 24h/Metric
   switch two rows below it stayed at full height -- because that one is
   nested inside `.viewer-surface-timebasis`, a plain div with no
   `overflow` of its own, so IT still has a content-sized floor and passed
   the shrinkage down to whichever sibling had none.

   `flex: none` on every direct child hands all of that overflow to the
   card's own `overflow-y: auto` instead, exactly as #viewer-material-dock's
   note already argues. */
[data-mode="analytic"] #viewer-panel > *,
[data-mode="analytic"] #viewer-edit-panel > * {
  flex: none;
}

/* Group headings inside the panel. Same visual language as .t-eyebrow in
   base.css; the class name is kept because it is what the markup says and
   renaming it would buy nothing (checked before touching it: zero
   references to viewer-panel-title anywhere in js/). */
[data-mode="analytic"] .viewer-panel-title {
  margin: var(--sp-4) 0 0;
  font-size: var(--fs-eyebrow); font-weight: var(--fw-eyebrow);
  letter-spacing: var(--ls-eyebrow); text-transform: uppercase;
  color: var(--text-3);
}
[data-mode="analytic"] .viewer-panel-title:first-child { margin-top: 0; }

/* ONE RHYTHM FOR BOTH LEFT CARDS, AND IT IS THE SAME TWO-LEVEL RHYTHM THE
   RIGHT COLUMN USES: a section gap, and a tighter gap inside a section.

   --sp-4 between SECTIONS (a section is one question: which selection mode,
   what to replace, what to replace it with, what to do about it), --sp-2
   INSIDE one (.viewer-selection-group, .mat-section, .mat-controls,
   .mat-actions, .mat-pick, and .metric-chart-block on the other side). Two
   levels is what makes the grouping readable without a rule between every
   pair of rows.

   WARNING: THIS USED TO BE --sp-1 (4px) AT BOTH LEVELS, which is not a
   rhythm at all -- with the same gap inside a group as between two groups,
   nothing on the card said where one control ended and the next began, and
   every row read as a member of one undifferentiated list.

   ONE STEP TIGHTER THAN THE RIGHT COLUMN, which separates its two groups by
   .fpanel-body's own --sp-5. That card has two children; Select & Modify has
   about ten, so --sp-5 here would be 240px of gap in a card that already has
   to fit above the mode switch. The LEVELS match; the step does not, and
   that is the trade. */
[data-mode="analytic"] #viewer-panel,
[data-mode="analytic"] #viewer-edit-panel { gap: var(--sp-4); }
[data-mode="analytic"] #viewer-panel input[type="range"],
[data-mode="analytic"] #viewer-edit-panel input[type="range"] { width: 100%; margin: var(--sp-1) 0; }

/* THE SELECTION GROUP: the Box select / Region+Facade row, `Clear
   selection`, and (appended by edit/material-dialog.js) the `Modified
   area` recall dropdown -- one question, "what is currently selected",
   answered by three controls. #viewer-edit-panel's own gap above is the
   SECTION-level one now that this wraps them: it fires exactly once,
   between this group and `.mat-section` below. */
[data-mode="analytic"] .viewer-selection-group {
  display: flex; flex-direction: column; gap: var(--sp-2);
}

/* The appearance sliders belonging to one surface, indented under its
   checkbox. Five surfaces x up to two sliders is a lot of vertical space
   in a panel that also carries the camera modes, the particle field and
   the model inputs — so these rows are deliberately tighter than the rest,
   and the indent is what says which checkbox they belong to. */
[data-mode="analytic"] .viewer-surface-rows {
  margin: 0 0 var(--sp-2) 24px;
  border-left: var(--border) solid var(--line);
  padding-left: var(--sp-2);
}
[data-mode="analytic"] .viewer-surface-rows .wf-inline-label {
  display: block; margin: 0; cursor: default;
  font-size: var(--fs-eyebrow); letter-spacing: var(--ls-eyebrow);
  text-transform: uppercase; color: var(--text-3);
}
[data-mode="analytic"] #viewer-panel .viewer-surface-rows input[type="range"] { margin: 0 0 var(--sp-1); }

/* RULE: THE TWO :has() RULES BELOW MUST STAY CHARACTER FOR CHARACTER
   IDENTICAL to their portal-mode counterpart. Only the declarations
   changed, and only the second one's.

   A surface's sliders appear only while that surface is on. Five surfaces
   with up to two sliders each would otherwise make this panel taller than
   a 720p window before the particle field and the model inputs are even
   reached — and an opacity slider for a hidden layer cannot show what it
   does, so hiding it removes a control that was doing nothing anyway.
   Pure CSS on purpose: no JS means no state that can get out of step with
   the checkbox.

   The #viewer-panel prefix makes these id selectors, which this file
   otherwise avoids — an explicitly allowed exception, precisely because
   leaving the selector alone is worth more than the rule about
   selectors. */
[data-mode="analytic"] #viewer-panel label:has(> input[type="checkbox"]:not(:checked)) + .viewer-surface-rows {
  display: none;
}
[data-mode="analytic"] #viewer-panel label:has(input:disabled) { color: var(--text-off); cursor: default; }

/* A detail appended INSIDE a control's own label — currently only the
   forecast cycle's "+1 h". Dimmer than the label it sits in, but it
   inherits the disabled colour above when the control is off, so the two
   never contradict each other.
   The old `label input:disabled + *` rule is NOT carried over: it existed
   to dim exactly this span, and the :has() rule one line up already dims
   the whole label including it. Two rules for one effect is how they end
   up disagreeing. */
[data-mode="analytic"] .viewer-note-inline { color: var(--text-3); }
[data-mode="analytic"] #viewer-panel label:has(input:disabled) .viewer-note-inline { color: inherit; }

/* THE AIR TEMPERATURE AT THE SELECTED HOUR, under the time slider.

   A readout, not a control: the curve is the weather file's own 24 hours
   and nothing in this panel moves it. There WAS a slider here, in a
   .scenario bed, because it defined a different day; that feature is
   retired and .scenario no longer exists in controls.css.

   Shaped like the slider's own label rather than like a caption — it is a
   number the reader looks up, not a note about a limit, so it sits at the
   panel's normal label colour with no indent to align it under a
   checkbox that is not there. */
[data-mode="analytic"] .viewer-air-readout {
  font-size: var(--fs-label); color: var(--text-2);
  line-height: var(--lh-tight);
}

/* Captions under the particle toggles and the camera modes. Each states a
   limit of the DATA rather than decorating a control, so they stay legible
   rather than dropping to the faintest grey. */
[data-mode="analytic"] .viewer-caption {
  margin: var(--sp-1) 0 var(--sp-1) 24px;
  font-size: var(--fs-hint); line-height: var(--lh-tight); color: var(--text-2);
}

/* THE CAMERA-MODE SWITCH IS NOT STYLED HERE. It is not a 2x2 grid of
   .viewer-mode-btn text buttons in the left panel; the four modes are
   rows in the icon column's camera popover, built by js/viewer/tools.js
   and wearing the UNSCOPED .vt-mode-row / .vt-popover rules in
   controls.css — the same ones the map's camera popover uses.

   The two modes look identical there by construction rather than because
   two scoped rules happen to agree. Nothing analytic-specific is needed,
   so nothing analytic-specific is written. */

/* The camera note is the one piece that came along, as the popover's
   caption. It has to lose the panel indentation: 24px was the width of a
   checkbox plus its gap, so a caption sat under the label it explained. In a
   popover there is no checkbox to align to and the inset would just push the
   text off-centre. */
[data-mode="analytic"] .vt-popover-camera .viewer-caption { margin-left: 0; }

/* RULE: ok / warn / busy are written by panel/live.js — same contract, the
   names are not ours to change. Only the colours moved onto the token
   ladder: there is no red in this design system, so a warning takes the
   accent. */
/* The rule under it is the separator between "what is being shown" and
   "the readouts that describe it". It used to sit under the follow-along
   switch at the very top; the two switches and the controls they govern
   are one block now, so the line marks where that block ENDS instead.
   Declared here rather than as a second rule for the same selector — one
   selector, one place, or the next reader has to find both. */
[data-mode="analytic"] #viewer-predict-status {
  margin-top: var(--sp-2); font-size: var(--fs-hint); color: var(--text-3);
  padding-bottom: var(--sp-3);
  /* border-bottom: var(--border) solid var(--line); */
}
[data-mode="analytic"] #viewer-predict-status.ok   { color: var(--done); }
[data-mode="analytic"] #viewer-predict-status.warn { color: var(--accent); }
[data-mode="analytic"] #viewer-predict-status.busy { color: var(--accent); }

/* Transient feedback for the tool rail (js/viewer/tools.js): "Saved
   PNG", "Recording", and the failures. Disappears when empty so it
   never occupies the frame. pointer-events off — it must not steal a
   drag that starts near the rail. The class is added in tools.js
   beside the id that file already sets.

   Two bugs, found and fixed together, chased under the same
   symptom ("the icons look pushed/floating when a message shows"):
   ① `right` used to be a pixel offset bigger than this box's own
   34px-wide containing block (#viewer-root > .vt-root), so with
   `width` left at `auto` the browser solved left/width/right down to a
   62px-wide, 101px-tall single-word column sitting well away from the
   rail — fixed by `right: calc(100% + var(--sp-2))` (the same pattern
   .vt-popover uses two rules up: relative to the CONTAINING BLOCK'S OWN
   width, so it can't exceed it) plus `width: max-content` bounded by
   `max-width: 30ch`, so a short message stays one line and a long one
   wraps like a normal paragraph.
   ② the REAL cause of the icons visibly moving: tools.js's setStatus()
   was replacing this element's whole class list on every call
   (`className = kind`), which deleted the `viewer-tool-status` class
   itself — every rule below, including `position: absolute`, went with
   it, so the message became a real flex ITEM inside .vt-root and
   widened it, and align-items: center then centred the narrower icon
   buttons inside that wider box. Fixed in tools.js with
   classList.toggle(), which is what actually made ① take effect at
   all — the class it was scoped to was being deleted before either fix
   landed.

   `top` here is only the FALLBACK (vertical middle of the whole rail);
   tools.js's setStatus(text, kind, anchor) sets an inline `top` per
   call instead, level with whichever button (screenshot / record /
   bookmark) the message is actually about — asked for once ① and ②
   were both fixed and the box stopped moving on its own, was still
   "why is it in the middle of the rail instead of by the button I
   clicked." */
[data-mode="analytic"] .viewer-tool-status {
  position: absolute; z-index: 5;
  right: calc(100% + var(--sp-2));
  top: 50%; transform: translateY(-50%);
  width: max-content; max-width: 30ch; padding: var(--sp-1) var(--sp-2);
  font-size: var(--fs-hint); line-height: var(--lh-tight); color: var(--text-1);
  background: var(--overlay-bg);
  border: var(--border) solid var(--line-strong); border-radius: var(--radius);
  pointer-events: none;
}
[data-mode="analytic"] .viewer-tool-status:empty { display: none; }
/* RULE: `vt-status-error` is JS-owned too, and so is the `is-error` this
   element gets. No red exists in the ladder, so an error reads as the
   accent, like every other attention state here. */
[data-mode="analytic"] .viewer-tool-status.is-error { color: var(--accent); }

/* ============================================================
   THE ANALYSIS CARD — js/viewer/panel/metric-select.js
   builds two .seg rows and one figure into #viewer-metric-dock.

   The .seg rows themselves are NOT styled here: they use the shared
   segmented-control vocabulary in controls.css, unscoped, so this card
   and whatever map mode does with .seg later are identical by
   construction rather than by two people keeping two rules in step.
   What IS here is only what is specific to this card.
   ============================================================ */

/* The figure. --fs-display is the largest role in the UI scale and this is
   the one number the card exists to show; tabular-nums so it does not
   reflow horizontally as the value changes under a button press, which
   would read as the layout twitching rather than the number updating
   (same reason .delta uses it in controls.css). */
[data-mode="analytic"] .metric-value {
  font-size: var(--fs-display); font-weight: var(--fw-display);
  letter-spacing: var(--ls-display); line-height: var(--lh-tight);
  color: var(--text-1);
  font-variant-numeric: tabular-nums;
  margin-top: var(--sp-2);
}

/* Every caption in this card loses the panel indentation, for exactly the
   reason the camera popover does above: the 24px inset is a checkbox's
   width plus its gap, so that a caption lines up under the label it
   explains. This card has no checkboxes — the inset would just push the
   text off-centre under a full-width segmented control.

   It said "both" and there were two. One of them — the line printing
   "{reduction} · {criterion} · {window}" under the figures — is gone,
   because its three values are the pressed buttons and the dropdown
   directly above it. The rule stays as-is: the locked-criterion hint and
   the charts' own captions are still in here, and it was never a count. */
[data-mode="analytic"] #viewer-metric-dock .viewer-caption { margin-left: 0; }

/* The line under a locked criterion, naming the step that produces it.
   Hidden via the `hidden` attribute when every criterion is ready, so the
   card does not reserve an empty row — but it is BUILT either way, so the
   card's height does not jump the moment step 3.3 finishes. */
[data-mode="analytic"] .metric-hint { margin-top: calc(var(--sp-1) * -1); }

/* ============================================================
   SELECTION — js/viewer/panel/selection.js

   Two things live here: the rubber-band rectangle the box tool draws,
   and the line in the Analysis card saying WHAT the figure describes.

   RULE: THE DIMMING OF UNSELECTED ZONES IS NOT HERE, and cannot be. It is a
   vertex colour on a WebGL mesh (js/viewer/layers/field.js's
   setSelection()), not a DOM property — there is no selector that could
   reach a triangle. Named so the next person looking for "why is the
   ground dark" does not search this file for it.
   ============================================================ */

/* The rubber band. Positioned in CSS pixels by selection.js, which sets
   left/top/width/height and nothing else — how it LOOKS is entirely here:
   CSS owns appearance, JS owns behaviour.

   pointer-events: none is load-bearing, not tidiness: the element sits
   under the cursor for the whole drag, and without it the mouseup that
   finishes the box would land on the band instead of the canvas. */
[data-mode="analytic"] .viewer-marquee {
  position: absolute;
  z-index: 120;
  pointer-events: none;
  border: 1px dashed var(--text-1);
  /* A wash rather than a fill: the point of the box is to show what is
     inside it, and anything opaque enough to read as a surface would hide
     the zones being chosen. */
  background: rgb(255 255 255 / 0.06);
}

/* The canvas while the box tool is armed. The crosshair is the only
   on-canvas signal that the camera has stood down — without it a user who
   armed the tool and then tried to orbit gets a scene that does not move
   and no explanation. (The lit button in the tool column is the other
   signal, and it is in the corner of the screen.) */
[data-mode="analytic"] #viewer-canvas.is-boxselect {
  cursor: crosshair;
}

/* The canvas while a recompute is running (js/viewer/panel/selection.js's
   setFrozen(), through the runner in js/lib/coalesced-run.js).

   THE ONLY THING ON SCREEN THAT SAYS THE SCENE IS NOT LISTENING. The toast
   above says a recompute is running; it does not say that the camera has
   stood down, and a camera that ignores a drag with no explanation reads as
   the viewer having crashed — the same reasoning as .is-boxselect above, one
   state along.

   `progress` rather than `wait`: the page is not blocked. The panels on both
   sides stay live, and so do the screenshot, record and bookmark buttons. */
[data-mode="analytic"] #viewer-canvas.is-frozen {
  cursor: progress;
}

/* The ANALYSIS card while a recompute is running
   (js/viewer/panel/metric-select.js's setFrozen()).

   RULE: THE REASON IS COST, NOT THE CAMERA. Nothing in this card moves the 3D
   world. Pressing any of these buttons asks a different question of the same
   day, which recuts the ground field on the same main thread the inference is
   queued on — two heavy jobs interleaved, one of which the user is waiting
   for.

   One class on the container rather than `disabled` on each control. The card
   holds two button rows, a select and two ECharts canvases, and the charts
   are clickable (clicking the day curve writes the window): per-control
   `disabled` reaches neither them nor any row added later, and re-applying it
   after every repaint — paint() runs DURING the frozen window, from
   onSweepChange — would mean re-deriving which controls were legitimately
   disabled already. pointer-events on the container reaches all of it and has
   no state to keep.

   RULE: THE FADE IS THE STATE, NOT A RANKING, and that is why it is opacity
   here when nothing else in these sheets is allowed to be. Every other
   disabled control in the portal says so with a SHAPE difference (dashed
   border + --text-off) because those live permanently beside enabled ones and
   two brightnesses of the same thing is the confusion that rule exists to
   stop. This card is inert for a few seconds and then is not, and the one
   thing it contains that a colour rule cannot reach is a chart drawn into a
   canvas. Same reasoning as controls.css's vt-rec-pulse keyframe: the
   brightness IS the signal. tools/verify_ui_tokens.mjs's assertion 7 carries
   the exception by name, so no OTHER bare opacity gets in behind it.

   Not `visibility` or `display`: the numbers stay readable. What is suspended
   is asking a NEW question, not reading the answer to the current one. */
[data-mode="analytic"] #viewer-metric-dock.is-frozen {
  pointer-events: none;
  opacity: 0.5; /* STATE-NOT-HIERARCHY(#viewer-metric-dock.is-frozen) */
}

/* The scope line, above the figure in the Analysis card: 'Whole site',
   'Zone C · 148 squares · 3,700 m²'.

   --fs-hint and --text-3, the same weight as .viewer-caption below the
   figure — the two are a matched pair bracketing one number, and giving
   the top one more presence would make the card read as two facts rather
   than as one sentence. */
[data-mode="analytic"] .metric-scope {
  font-size: var(--fs-hint);
  /* --text-2, not --text-3. It answers "which ground is this number about",
     which is the first thing a reader needs and the one thing a highlight
     on the scene can be missed. At --text-3 it was the quietest text in the
     card while carrying the subject of its sentence. */
  color: var(--text-2);
  margin-top: var(--sp-2);
  /* One line, ellipsised. A long zone label wrapping would move the
     figure down, and the figure moving as you click around the ground is
     the layout twitching rather than the number updating — the same
     reasoning as .metric-value's tabular-nums. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The figure's own top margin is redundant once the scope line sits
   directly above it, and two stacked margins push the number away from
   the label that names it. */
[data-mode="analytic"] .metric-scope + .metric-value {
  margin-top: var(--sp-1);
}

/* ============================================================
   MATERIAL EDIT — js/viewer/edit/material-dialog.js
   builds the whole card into #viewer-material-dock.

   Nothing here invents a control. The dropdown is `select` /
   `.wf-select`, the buttons are `.btn` / `.btn--primary` / `.btn-row`,
   the delta line is `.delta` — all from controls.css, unscoped, so this
   card and the map's panels are identical by construction rather than by
   two people keeping two rules in step. What is below is only what is
   specific to this card: the swatches, the history list and the
   composition columns.
   ============================================================ */

/* The colour chip. Used in three places — beside the dropdown, in the
   edit history, and in the composition rows — and identical in all
   three on purpose: one visual token for "this is the material", so the
   history reads as a record of what the chooser was showing.

   flex: none is load-bearing. Inside a flex row a chip with no explicit
   basis is squeezed to nothing by a long material name, and a swatch of
   zero width looks like a swatch that failed to get a colour. */
/* RULE: THE BODY IS A FLEX COLUMN THAT SHRINKS ITS CHILDREN, AND THIS
   CARD IS TALLER THAN IT. .fpanel-body's items default to flex-shrink: 1,
   so the moment the content overflows, flex takes the height back out of
   whichever items have the least of it — a scope line can compute to 0px
   while its text is still there, leaving the card with no subject line
   and nothing saying why. `flex: none` makes every section keep its
   natural height and hands the overflow to the body's own
   `overflow-y: auto`, which is where it belongs.

   WARNING: this is a property of ANY card whose content can outgrow the
   column, not a quirk of this one. Scoped to this dock rather than to
   .fpanel-body because changing it globally would move the two cards
   above, and neither of them overflows today. */
[data-mode="analytic"] #viewer-material-dock > * { flex: none; }

/* The two groups inside `.mat-section` (what to apply / how, and the
   buttons that apply it). Their own gap is the SAME --sp-2 `.mat-section`
   itself uses between them, on purpose: at this scale (a colour row and
   the sentence explaining it; a button and its two follow-ups) there is
   no third, tighter level worth a name of its own -- the whole section
   reads as one consistent rhythm rather than as sub-groups within
   sub-groups. */
[data-mode="analytic"] .mat-controls,
[data-mode="analytic"] .mat-actions {
  display: flex; flex-direction: column; gap: var(--sp-2);
}

[data-mode="analytic"] .mat-swatch {
  flex: none;
  width: 12px; height: 12px;
  border: var(--border) solid var(--line-strong);
  border-radius: var(--radius);
}

/* One labelled dropdown: `Replace` over its select, `With` over its own.
   Two bare dropdowns stacked read as one control that changed
   shape, and which of the two is the SOURCE is exactly the thing a
   reader must not have to guess.

   The label is tighter to its select than the 8px between the pairs, so
   the grouping is carried by spacing rather than by another rule. */
[data-mode="analytic"] .mat-pick {
  display: flex; flex-direction: column; gap: var(--sp-1);
}
[data-mode="analytic"] .mat-pick > .wf-inline-label {
  display: block; margin: 0; cursor: default;
  font-size: var(--fs-eyebrow); letter-spacing: var(--ls-eyebrow);
  text-transform: uppercase; color: var(--text-3);
}
/* The source swatch is hidden for "All materials" -- there is no one
   colour that means "everything" -- so the row must not keep its gap.
   `hidden` alone loses to the flex display above. */
[data-mode="analytic"] .mat-chooser .mat-swatch[hidden] { display: none; }

[data-mode="analytic"] .mat-chooser {
  display: flex; align-items: center; gap: var(--sp-2);
}
/* The select takes the rest of the row. A bare `select` cannot say "the
   one in this row", which is the same reason .wf-row .ui-slider exists
   in controls.css. */
[data-mode="analytic"] .mat-chooser select { flex: 1; min-width: 0; }

[data-mode="analytic"] .mat-colour {
  display: flex; align-items: center; gap: var(--sp-3);
}
[data-mode="analytic"] .mat-colour .wf-inline-label {
  display: flex; align-items: center; gap: var(--sp-2);
  flex: 1;
}
/* <input type="color"> is one of the few controls with no useful
   default size — browsers give it a button's padding around a tiny
   swatch. Sized here so it reads as a swatch that can be clicked,
   matching the chip beside the dropdown. */
[data-mode="analytic"] .mat-colour input[type="color"] {
  flex: none;
  width: 34px; height: 22px; padding: 0;
  background: var(--bg-inset);
  border: var(--border) solid var(--line-strong);
  border-radius: var(--radius);
  cursor: pointer;
}
/* Disabled says so with a TOKEN COLOUR, not with opacity — assertion 7 in
   tools/verify_ui_tokens.mjs guards exactly this, and it is right to: a
   faded control is "hierarchy done with transparency", which stops being
   legible the moment the thing behind it changes. The softer border is
   the same signal controls.css gives every other disabled input. */
[data-mode="analytic"] .mat-colour input[type="color"]:disabled {
  cursor: not-allowed;
  border-color: var(--line);
  background: var(--bg-panel);
}

/* The primary action gets the full width of the card; undo and reset
   share a row underneath in .btn-row, which already splits its children
   evenly. Apply being wider than the two below it is the whole visual
   hierarchy of this card — it is the one button that recomputes. */
[data-mode="analytic"] #viewer-material-dock .btn--primary { width: 100%; }
/* The panel's 24px caption indent is a checkbox's width plus its gap, so
   that a caption lines up under the label it explains. Nothing in this
   card is indented by a checkbox. */
[data-mode="analytic"] #viewer-material-dock .viewer-caption { margin-left: 0; }
/* An empty status line must take NO space, or the card carries a hole
   between the buttons and the history whenever there is nothing to say —
   which is most of the time. :empty is exact here because the element is
   written with textContent and holds a text node or nothing. */
[data-mode="analytic"] #viewer-material-status:empty { display: none; }

/* The edit history: what was applied, to what, oldest first.

   An <ol> because the ORDER IS THE MEANING — where two edits overlap the
   later one wins (js/viewer/edit/material-edit.js), so a list that could
   be read in any order would be describing a different rule. The markers
   are dropped because the numbers would be read as "step 1 of 3"; the
   sequence is carried by the reading order alone. */
/* RECENT EDITS — one <select> that goes back to a selection already worked on.

   Hidden until there is something in it (panel/live.js's paintRecall sets
   `hidden`), because a dropdown offering nothing but its own placeholder
   reads as a control that failed to populate.

   No heading: the placeholder option IS the label, and it stays selected
   between uses because picking one is an ACTION rather than a state. */
[data-mode="analytic"] .mat-recall {
  display: flex; flex-direction: column;
}

/* THE LEGEND CHIPS ARE CLICKABLE NOW: picking one sets the `Replace`
   dropdown in the left column. Only the ones describing the CURRENT field
   do anything (edit/material-dialog.js holds that list), and the ones that
   do not are left looking exactly as they did.

   RULE: NO DISABLED STYLING AND NO EXPLANATORY LINE for the inert ones. A
   greyed chip in a legend reads as a material that is somehow not counted,
   which is a stronger and wrong claim; a sentence explaining which half of
   a legend is live is the kind of standing text this mode does without. A
   click that does nothing is the least misleading of the three.

   The pointer is the only affordance, and it is on every chip because
   whether one is live depends on data this sheet cannot see. */
[data-mode="analytic"] .mat-bar-legend .mat-comp-row { cursor: pointer; }
[data-mode="analytic"] .mat-bar-legend .mat-comp-more { cursor: default; }

/* The composition. One column until something is edited, two side by
   side afterwards — the Delta rule, in layout as well as in the numbers
   (js/viewer/edit/material-dialog.js). There is no third state and no
   selector: the second column appears because an edit exists. */
/* RULE: NO border-top. The composition is the only thing in the Material
   composition card, so a rule under the card head would read as a stray
   line rather than as a separator between two sections. */
[data-mode="analytic"] .mat-composition { border-top: 0; }

/* Everything edit/material-dialog.js puts in the SECOND LEFT CARD, wrapped in
   one element so its disposer can remove exactly what it added without
   touching the sampling controls panel/panel.js put in the same card. The
   wrapper is a flex item, so it has to re-state the column's own rhythm --
   otherwise its children stack with no spacing at all.

   RULE: --sp-2, THE INSIDE-A-SECTION LEVEL, NOT --sp-4. This section's own
   heading, scope line, controls and buttons are one group (what to apply,
   how, and the action that applies it), not four sections of their own --
   the --sp-4 boundary belongs ABOVE this element, between it and
   .viewer-selection-group, where #viewer-edit-panel's own gap already
   puts it. Getting this backwards is what made "Apply to selection" and
   "Undo last / Reset all" read as two unrelated groups instead of one
   action and its two follow-ups. */
[data-mode="analytic"] #viewer-edit-panel .mat-section {
  display: flex; flex-direction: column; gap: var(--sp-4);
}

/* The 24h/Metric surface time-basis switch: a label above a segmented
   control, in its own class rather than a shared one, because it is the only
   control of that shape left in this file — the material card's
   `Compare surfaces` row had it too until those three buttons moved to the
   Analysis card's figure tiles (04_S02). */
[data-mode="analytic"] .viewer-surface-timebasis {
  display: flex; flex-direction: column; gap: var(--sp-1);
  margin-top: var(--sp-2);
}


/* TWO RAILS IN ONE CARD. The walls read a fixed domain, the ground reads this
   site's own band range, and they are not even the same
   palette — so each gradient has to say which surface it is for. The label is
   the only thing that separates them; without it the second bar reads as a
   continuation of the first.

   Each group is toggled with `hidden` from panel/live.js. See the rule under
   .viewer-legend-group for why that needs a declaration of its own. */
[data-mode="analytic"] .viewer-legend-what {
  font-size: var(--fs-hint); line-height: var(--lh-tight);
  letter-spacing: var(--ls-label); text-transform: uppercase;
  color: var(--text-3); margin-bottom: var(--sp-1);
}

/* THE LEGEND, FLOATING ON THE CANVAS. It was the third .fpanel in
   .col-right; it is now a bare child of #viewer-root, pinned to the
   bottom of the same column the panels occupy.

   `right` is .col-right's own inset, and `width` is --w-legend rather
   than --w-panel: two colour ramps and four numbers do not need a
   control panel's width, and the narrower it is the more scene stays
   visible. `bottom` is the plain overlay inset — the same corner the
   column's last card used to reach.

   RULE: NO BOARD, BUT NOT BARE. Removing the panel background is what
   this was for; removing legibility is not. The scene under this corner
   is 3D terrain in the Ladybug palette — bright yellow and bright red —
   and unbacked text on that is unreadable at any weight. So: the
   faintest overlay ground, no border, plus a text shadow that does the
   work where the ground is not enough. If the ramps ever become hard to
   read, raise --overlay-bg-thin's alpha here rather than reinstating a
   card.

   RULE: NOT A CLICK TARGET, like .viewer-hud. It sits over a scene the
   user flies through with the mouse; a legend that swallowed a drag
   would stop the camera in the one corner it covers.

   The gap between the two rails is --sp-3, not a panel body's 24px:
   these two children are the same thing twice, differing only in which
   surface they describe. At 24px they read as two sections; at 10px as
   one key with two rows, which is what they are. */
[data-mode="analytic"] #viewer-legend-dock {
  position: absolute; z-index: 4;
  /* LEFT OF THE COLUMN, NOT UNDER IT. At the plain --inset-overlay this
     dock and .col-right share a corner, and .col-right runs
     `bottom: var(--inset-overlay)` -- so on any window where the two cards
     reach the bottom, the legend was drawn ON TOP of the Material
     composition card (both are z-index 4; this one comes later in the DOM).
     It read as a legend that had escaped its panel.

     Same calc() the tool rail uses one rule up, plus the rail's own width:
     the legend clears the column AND the rail, and the three sit in a row
     along the right-hand edge. */
  /* ALIGNED WITH THE ICON RAIL'S RIGHT EDGE, which is the same vertical
     line as the panel column's left edge less one --sp-2 gap. It used to
     read `+ w-toolbtn + sp-2` on top of this, which cleared the rail
     entirely and left the dock hanging off the rail's LEFT edge -- one
     rail-width further out than anything else in the corner, so it lined
     up with nothing. The rail is top-anchored and this is bottom-anchored,
     so sharing the line costs no overlap. */
  right: calc(var(--inset-overlay) + var(--w-panel-analysis) + var(--sp-2));
  bottom: var(--inset-overlay);
  width: var(--w-legend);
  display: flex; flex-direction: column; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  background: var(--overlay-bg-thin);
  text-shadow: 0 1px 2px var(--bg-ground);
  pointer-events: none;
}

/* RULE: EACH RAIL IS ONE CHILD OF .fpanel-body, and this is what makes the two
   look alike. A rail whose title, bar and scale sat in the body directly took
   the body's gap twice INSIDE itself and read as three loose rows, while the
   wrapped one under it read as one tight block. The gap belongs BETWEEN the
   rails, not inside one.

   Inside a rail the spacing is the type's own: .viewer-legend-what carries the
   only margin, and the bar and its two numbers sit straight under it. */
[data-mode="analytic"] .viewer-legend-group {
  display: flex; flex-direction: column; gap: var(--sp-1);
}

/* RULE: AND `hidden` HAS TO BE SAID OUT LOUD, BECAUSE THE RULE ABOVE BEATS IT.

   An earlier comment here claimed base.css carried a global
   `[hidden] { display: none !important }`. IT DOES NOT, AND IT COULD NOT HELP
   IF IT DID: `[hidden]` is (0,1,0), `.viewer-legend-group` is (0,1,0) too, and
   base.css loads FIRST — so at equal specificity the `display: flex` above wins
   on order. The browser's own UA rule loses to any author declaration for the
   same reason.

   What that cost: panel/live.js has always hidden the ground rail when nothing
   was drawing the field, and the attribute has always been set — and the rail
   stayed on screen as the words "GROUND HEAT ZONES" over a blank strip, which
   reads as a colour scale that failed to load. Measured: `hidden` true,
   `display` flex, 40px tall.

   Scoped to the class rather than added globally, precisely because a global
   `[hidden]` would lose the same specificity race everywhere else too. */
[data-mode="analytic"] .viewer-legend-group[hidden] { display: none; }

/* AND THE DOCK GOES WITH ITS LAST RAIL. Both can be hidden at once — MASK
   draws neither a wall reading nor a ground one — and what was left was the
   dock's own padding and translucent ground as a small empty box in the
   corner of the scene, which reads as something that failed to load. */
[data-mode="analytic"] #viewer-legend-dock:not(:has(.viewer-legend-group:not([hidden]))) {
  display: none;
}

/* THE SEPARATOR IS BETWEEN TWO VISIBLE RAILS, WHICH IS NOT THE SAME AS
   "every rail but the first".

   Either rail can be hidden on its own now (panel/live.js asks each one's
   layers whether they are drawing), so the old `:first-child` exception was
   about DOM POSITION while the question is about what is on screen: with the
   walls hidden, the ground rail was the only thing in the dock and still drew
   a rule above itself with nothing over it.

   `:not([hidden]) ~ :not([hidden])` says the thing itself — a rail that has a
   visible rail somewhere above it. */
[data-mode="analytic"] .viewer-legend-group:not([hidden]) ~ .viewer-legend-group:not([hidden]) {
  padding-top: var(--sp-3);
  border-top: var(--border) solid var(--line);
}

/* The title already sits in the group's gap, so its own margin would be a
   second one. */
[data-mode="analytic"] .viewer-legend-group .viewer-legend-what { margin-bottom: 0; }

/* EVERY CONTROL IN THIS MODE'S PANELS IS THE SAME HEIGHT.

   Nothing was wrong with any one of them; they simply came from three
   different rules in controls.css and landed within a few pixels of each
   other. A .btn is padding 9px around a 13px line (33px), a .seg button is
   padding 7px around the same line (29px), and a select carries
   `min-height: var(--h-control)` (32px) already. Three near-misses read
   worse than three obvious differences: a row of controls that is almost
   aligned looks like a rendering fault, where a deliberately taller primary
   button looks like a decision.

   RULE: min-height PLUS zero vertical padding, not a fixed `height`.
   `height` on a <select> is unreliable across engines, and both <button>
   and <select> centre their own content box vertically -- so removing the
   padding and setting a floor gives all three exactly --h-control with the
   text centred, and a control whose label ever wraps can still grow rather
   than clip.

   SCOPED TO #viewer-root, so the map's own panels keep controls.css's
   values unchanged. The icon rail is deliberately not included: those are
   --w-toolbtn squares holding a glyph, and stretching them to a text
   control's height would make the rail ragged against the column it
   sits beside. */
[data-mode="analytic"] #viewer-root .btn,
[data-mode="analytic"] #viewer-root .wf-btn,
[data-mode="analytic"] #viewer-root .seg > button,
[data-mode="analytic"] #viewer-root select,
[data-mode="analytic"] #viewer-root .mode-switch {
  min-height: var(--h-control);
  padding-top: 0;
  padding-bottom: 0;
}

/* THE THREE VIEW PRESETS, at the very top of the Control Panel card: one
   press writes a whole combination of layers.

   RULE: NO RULE UNDER IT. The 24h/Metric switch sits directly beneath and
   the two are ONE group — together they decide what the card is showing,
   and a line between them would split the pair. The separator is below the
   readouts instead; see .viewer-time-group's own rule.

   It carries `.seg` as well, so every button rule comes from controls.css
   unscoped and these three look exactly like the switch under them. This
   rule is only the space beneath.

   (It replaced `.viewer-layer-sync`, a single `Follow metric` checkbox in
   the same slot, with the same margin.) */
[data-mode="analytic"] .viewer-preset-row {
   margin-bottom: 0;
}

[data-mode="analytic"] .viewer-preset-group {
   display: flex;
   flex-direction: column;
   gap: var(--sp-1);
}

/* Keep the label and preset buttons close to the following surface control. */
[data-mode="analytic"] .viewer-preset-group + .viewer-surface-timebasis {
   margin-top: calc(var(--sp-2) * -1);
}

/* THE SELECTION ROW, at the top of the Select & Modify card: one button and
   one pair.

   `Box select` is a TOOL — it arms, takes one rectangle and releases itself
   — and Region/Facade are mutually exclusive MODES, which is what `.seg`
   says. The layout follows that split rather than making three equal
   buttons: the tool takes only the width it needs, the pair spreads across
   the rest.

   RULE: THE ARMED LOOK IS PART OF THE TOOL, NOT POLISH. Arming the box
   takes the left mouse button away from the camera, and a user who cannot
   see that it is armed finds a viewer whose camera has stopped responding.
   controls.css has no pressed state for a plain `.btn` — the tool column's
   `.vt-active` does not apply here — so this is where the state becomes
   visible. Accent on accent-soft, the same pair `.seg button[aria-pressed]`
   uses two rules up, so the tool and the modes agree about what "on" looks
   like. */
[data-mode="analytic"] .viewer-select-row {
  /* `center`, NOT `stretch`. Stretch made the segment as tall as the LINE,
     and the line is the button plus controls.css's `margin: var(--sp-1) 0`
     on it -- so Region/Facade came out 38px beside a 32px Box select while
     every other control in the mode was 32. Measured, and it is the shape
     the uniform-height rule above cannot reach on its own: the pair was
     not too small, it was being stretched past the size that rule gives
     it. */
  display: flex; align-items: center; gap: var(--sp-2);
}

/* WHY `.btn` NEEDS ITS WIDTH TAKEN AWAY HERE, AND WHAT HAPPENED WITHOUT IT.

   controls.css gives every .btn `display: block; width: 100%`, which inside
   a flex row becomes a flex-basis of the WHOLE row. The `.seg` beside it is
   `flex: 1` -- basis 0 -- so the two bases already summed to exactly the
   container width: no free space, nothing to grow into. And `.seg` carries
   `overflow: hidden`, which makes its `min-width: auto` resolve to 0 rather
   than to its content, so it had nothing holding it open either.

   The result was a row that laid out cleanly with the segment at ZERO
   pixels: Box select filled the line, and Region / Facade -- the two
   buttons that say which of the two selection modes is in force -- were in
   the DOM, bound, and invisible. No error, no overflow, no clipping to
   notice. `width: auto` gives the button its own content width back and the
   segment the rest.

   `flex: 0 0 auto` as well as `width: auto`, so a long label cannot start
   eating the segment instead. */
[data-mode="analytic"] .viewer-select-row > .btn {
  flex: 0 0 auto; width: auto;
}
[data-mode="analytic"] .viewer-select-row .seg { flex: 1 1 auto; min-width: 0; }
[data-mode="analytic"] .viewer-select-row .btn[aria-pressed="true"] {
  color: var(--accent);
  background: var(--accent-soft);
  border-color: var(--accent);
}

/* THE CLOCK AND THE AIR READOUT, as one element because they leave as one.

   On the metric basis there is no current hour for either of them to
   describe, so panel/live.js hides the whole group rather than greying the
   slider — the pressed button above says which basis is in force, and a
   greyed slider would state that a second time while keeping three rows of
   a card that does not fit a short screen.

   RULE: `display: none` through a class, not the `hidden` attribute. The
   repo's split is that CSS owns appearance and JS owns behaviour, and
   `hidden` is silently beaten by any `display:` rule that lands on the same
   element later. */
[data-mode="analytic"] .viewer-time-group {
  display: flex; flex-direction: column; gap: var(--sp-1);
}
[data-mode="analytic"] .viewer-time-group.is-hidden { display: none; }

/* THE TWO CONTROL SETS THAT MOVED INTO THE ICON COLUMN.

   `.viewer-layer-controls` holds Scene, Simulation surfaces and Heat
   transformation — every layer's checkbox and opacity slider.
   `.viewer-sampling-controls` holds the ground field's row, its three
   drawing switches, the raised-surface height and the caption under them.
   Both are built detached by panel/panel.js and mounted in a .vt-popover by
   viewer/tools.js.

   RULE: THE RHYTHM IS THE CARD'S, NOT THE POPOVER'S. These are the same
   controls that used to sit in .fpanel-body, and every rule that shapes
   them (.wf-row, .ui-slider, .viewer-surface-rows) is written against that
   context. Restating the body's own column gap here is what keeps them
   reading identically in their new home; without it the sections stack with
   no space at all.

   WHAT THIS REPLACED: a `SETTINGS` fold inside the card — one container,
   one is-open class, one boolean in analyticState. That shape is exactly
   what made moving the whole set possible in one piece, and the boolean
   went with it: which popover is open is where the cursor is, not a
   setting to remember across entries. */
[data-mode="analytic"] .viewer-layer-controls,
[data-mode="analytic"] .viewer-sampling-controls {
  display: flex; flex-direction: column; gap: var(--sp-2);
}

/* THE RHYTHM INSIDE A POPOVER IS TIGHTER THAN A CARD BODY'S, and this is
   where the "restate the card's rhythm" rule above stops applying.

   .fpanel-body's 24px gap is sized for a column that also carries step
   headings, a mode switch and a status line, and it separates SECTIONS
   there. Here the whole popover IS one section: six checkboxes, each with
   an OPACITY label and a slider under it. At 24px between the sections and
   16px around every slider the list ran past 60vh and started scrolling
   with three rows visible, so the control a reader came for was usually
   below the fold.

   Every value below is a REDUCTION of an inherited one, never a new idea:
   the sections keep a gap (--sp-4), the label keeps its 4px to its own
   control, and the indent that says which checkbox a slider belongs to is
   unchanged in kind and only narrower. */
[data-mode="analytic"] .viewer-layer-controls .wf-row,
[data-mode="analytic"] .viewer-sampling-controls .wf-row {
  margin: 0;
}
[data-mode="analytic"] .viewer-layer-controls input[type="range"],
[data-mode="analytic"] .viewer-sampling-controls input[type="range"] {
  margin: 0;
}
[data-mode="analytic"] .viewer-layer-controls label,
[data-mode="analytic"] .viewer-sampling-controls label {
  margin: 0;
}
/* THE GROUPING MOVES FROM THE GAP TO THE HEADING, which is the whole of how
   this list got shorter without losing its structure. Every row was a direct
   child of a column with one gap, so a section break and the space between a
   checkbox and its own slider were the same 24px. Now the rows sit at 6px and
   the heading carries the break. */
[data-mode="analytic"] .viewer-layer-controls .viewer-panel-title,
[data-mode="analytic"] .viewer-sampling-controls .viewer-panel-title {
  margin: var(--sp-4) 0 0;
}
[data-mode="analytic"] .viewer-layer-controls > .viewer-panel-title:first-child,
[data-mode="analytic"] .viewer-sampling-controls > .viewer-panel-title:first-child {
  margin-top: 0;
}

/* The indented block a row's own sliders sit in: the rule down its left edge
   is what says which checkbox they belong to, so it stays — only the air
   around it comes off. */
[data-mode="analytic"] .viewer-layer-controls .viewer-surface-rows,
[data-mode="analytic"] .viewer-sampling-controls .viewer-surface-rows {
  margin: 0 0 0 18px; padding-left: var(--sp-2);
}
[data-mode="analytic"] .viewer-layer-controls .viewer-caption,
[data-mode="analytic"] .viewer-sampling-controls .viewer-caption {
  margin: var(--sp-1) 0 0;
}

/* WIDER THAN THE COLUMN'S OTHER POPOVERS, and it has to override max-width
   rather than set width: controls.css caps every .vt-popover at
   --vt-popover-width (220px), which is sized for a list of one-word camera
   modes. These two hold six checkboxes and six sliders between them, and a
   slider at 220px minus the label beside it is a control you cannot aim.

   RULE: AND THEY SCROLL RATHER THAN GROW. The layer set is as tall as the
   layer TABLE, so it grows every time a row is added to js/viewer/layers.js
   — and a popover taller than the window is one whose last row cannot be
   reached at all. 60vh leaves the trigger visible beside it.

   Local values rather than a new token, the same reasoning
   .vt-popover-massing gives: nothing else wants this width, and a token
   would invite the next popover to inherit a size measured for one
   particular set of controls. */
[data-mode="analytic"] .vt-popover-layers,
[data-mode="analytic"] .vt-popover-sampling {
  /* RULE: `width`, NOT `max-width`, AND THAT IS WHY THE OLD OVERRIDE DID
     NOTHING. controls.css gives .vt-popover `min-width: 220px` and
     `max-width: 220px`; this rule used to set `max-width: 300px`, which
     raises the CAP and stretches nothing — a flex column of labels and
     sliders has no intrinsic width to grow into, so the box sat at its
     min-width of 220 the whole time. Measured: 220px with the 300px rule
     in force.

     340, DOWN FROM 390. 390 was 300 x 1.3 -- a proportion asked for while
     the box was still stuck at its 220px min-width, so it was measured
     against a number that was not on screen. With the width actually
     applied it overhung the column it opens beside. At 340 an OPACITY
     slider still has about 290px of travel for a 0-100 range, which is
     what the widening was for; at the original 220 it had under 190 and
     could not be aimed. */
  width: 340px;
  max-width: 340px;
  max-height: 60vh;
  overflow-y: auto;
}


/* `.mat-comp-pair` is gone, along with the
   two-column before/after list it laid out. The Material composition card is
   a horizontal stacked bar now (analysis/panels.js's createCompositionBar),
   which shows both states in one picture rather than in two columns.
   `.mat-comp-share` is gone too — the percentage moved onto the bar itself
   as an in-segment ECharts label, so the legend no longer prints it a
   second time. `.mat-comp-row` / `-name` / `-more` below SURVIVE, now
   shaped as a wrapping row of chips instead of a vertical list — see
   `.mat-bar-legend` further down. */

[data-mode="analytic"] .mat-comp-row {
  display: inline-flex; align-items: center; gap: var(--sp-1);
  /* Sized to its own content, not stretched across the card — this is
     what lets several chips share one line. Padding and border borrow
     .chip's visual language (controls.css) rather than inventing a
     second version of the same idea. */
  flex: none;
  font-size: var(--fs-hint);
  color: var(--text-2);
  padding: 2px 6px;
  border: var(--border) solid var(--line-strong);
  border-radius: var(--radius);
}
[data-mode="analytic"] .mat-comp-name {
  white-space: nowrap;
}
/* The collapsed tail of a long composition list, muted so the count
   reads as a note about the list rather than as another material.

   RULE: `.mat-comp-unknown` is gone, with the row it styled. A cell with
   no wall behind it is not a material
   the list is missing; composition() now divides by the answered cells, so
   the rows sum to 100% and the coverage figure goes to the console
   instead — see edit/material-edit.js's note on the share. */
[data-mode="analytic"] .mat-comp-more .mat-comp-name { color: var(--text-3); }

/* ============================================================
   THREE FIGURES AND THE CHARTS

   js/viewer/panel/metric-select.js builds the figure tiles into the
   Analysis card; js/viewer/analysis/panels.js appends the chart block
   under them and the stacked bar into the Material composition card.

   RULE: EVERY RULE BELOW IS SCOPED, like every other rule in this file.
   Both modes' DOM is on the page at once (the viewer is `hidden`, not
   removed), so a missing prefix does not break what you are looking at
   — it reaches into the OTHER mode, and only shows up when you switch.
   Harness assertion 16 splits every selector list on its commas and
   checks each one, because a list is only as scoped as its loosest part.
   ============================================================ */

/* THE TWO GROUPS metric-select.js builds into this card, plus the chart
   block panels.js appends as the third. .fpanel-body's 24px gap then
   separates SECTIONS instead of separating every element from every
   other — see metric-select.js's note, and .mat-section on the other
   side of the column for the same rule stated first.

   Each is a flex ITEM of .fpanel-body, so each has to restate a rhythm
   of its own; without this their children stack with no spacing at all. */
[data-mode="analytic"] .metric-controls,
[data-mode="analytic"] .metric-readout {
  display: flex; flex-direction: column; gap: var(--sp-2);
}

/* THE DIVIDER between what to measure and what it reads.

   A border on the readout rather than an element of its own: an <hr> would
   be a third child of .fpanel-body, which would put its 24px gap on BOTH
   sides of a 1px line and read as two cards rather than as one card with a
   seam. The 24px above the line is that gap; the padding below is this
   group's own air.

   RULE: --line, not --line-strong. It separates two halves of one card, and
   anything heavier reads as a boundary between two panels. */
[data-mode="analytic"] .metric-readout {
  border-top: var(--border) solid var(--line);
  padding-top: var(--sp-3);
}

/* THE SCOPE LINE AND THE STATISTIC DROPDOWN, on one row.

   WHAT the figure describes on the left, HOW it was reduced on the right —
   the two halves of the sentence the card is saying, so they belong on one
   line rather than stacked with the number between them.

   align-items: center rather than baseline: the control is 32px tall
   (--h-control) and the scope line is one line of --fs-hint, so a baseline
   alignment would hang the text off the top of the dropdown. */
[data-mode="analytic"] .metric-scope-row {
  display: flex; align-items: center; gap: var(--sp-2);
}
/* The scope takes the slack and ellipsises inside it (min-width: 0 —
   a flex item refuses to shrink below its content without it, so a long
   zone name would push the dropdown off the card instead of truncating). */
[data-mode="analytic"] .metric-scope-row .metric-scope {
  flex: 1 1 auto; min-width: 0; margin-top: 0;
}

/* RULE: THE COMPOUND SELECTOR IS DELIBERATE, and `width: auto` is the whole
   point of the rule. controls.css sets `select { width: 100% }` at (0,1,0),
   which in a flex row would give the dropdown the entire line and leave the
   scope at zero. This is (0,2,0) and wins — but note it wins on
   SPECIFICITY, not on load order, which is decided first. The same trap
   .ui-number.wf-number-sm documents in controls.css.

   flex: none so it keeps its content width whatever the scope does. */
[data-mode="analytic"] .metric-scope-row .metric-reduction {
  width: auto; flex: none; max-width: 55%;
}

/* THE THREE FIGURES. A grid rather than flex, so the three tiles are
   exactly equal width whatever their contents — a "no data" tile beside
   two numbers must not be narrower, or the row reads as misaligned
   rather than as one of three being empty.

   minmax(0, 1fr): 1fr alone refuses to shrink below its content, so a
   long value would push its neighbours off the card instead of wrapping
   inside its own tile. */
[data-mode="analytic"] .metric-figs {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--sp-3);
  margin-top: var(--sp-2);
  align-items: start;
}

/* Each tile IS the statistic selector (see metric-select.js's figuresEl),
   so it is a <button> — but it must not look like one. The controls in
   this column are `.btn` and `.seg`; a third button shape here would read
   as a fourth kind of control rather than as a number you can press.
   What marks it is the pressed state below, nothing else. */
[data-mode="analytic"] .metric-fig {
  display: block; width: 100%;
  appearance: none; background: none; border: 0;
  padding: var(--sp-2) 0 0;
  border-top: var(--border) solid var(--line);
  font: inherit; color: inherit; text-align: left;
  cursor: pointer;
}
/* aria-pressed IS the state, matching .seg in controls.css — the
   accessible state and the visible one are then the same attribute and
   cannot disagree. The accent rule on top rather than a fill: this is a
   figure first and a control second, and a filled tile would make three
   numbers read as three buttons. */
[data-mode="analytic"] .metric-fig[aria-pressed="true"] { border-top-color: var(--accent); }
[data-mode="analytic"] .metric-fig[aria-pressed="true"] .metric-fig-label { color: var(--accent); }
[data-mode="analytic"] .metric-fig:hover .metric-fig-label { color: var(--text-2); }
[data-mode="analytic"] .metric-fig:focus-visible { outline: var(--border) solid var(--accent); outline-offset: 2px; }

[data-mode="analytic"] .metric-fig-label {
  font-size: var(--fs-micro); font-weight: var(--fw-micro);
  letter-spacing: var(--ls-micro); text-transform: uppercase;
  color: var(--text-3);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* RULE: THE FIGURE IS --fs-figure INSIDE A TILE, NOT --fs-display, and that
   is a consequence of there being three of them rather than a taste
   change. At --w-panel (320px) a tile is about 86px wide; "1.64 kWh/m²"
   at 24px is roughly 145px and would either clip or push the row wider
   than the card. At 18px it wraps at its own space, which puts the number
   on one line and the unit under it — legible, and still the largest thing
   in the tile.

   16, DOWN FROM 18 AND UP FROM --fs-title's 15. It went to 18 to stop the
   figure competing with the section headings, which worked and overshot:
   the tile's problem is WIDTH, not prominence, and at 18 the three of them
   read as crowded. The measurement behind the ceiling is in tokens.css.

   RULE: THE LETTER-SPACING IS THE OTHER HALF, and it was tried first. A
   tile is about 86px at --w-panel and the longest string it has to hold is
   `-1.64 kWh/m²`; the only two ways to narrow that are the size and the
   tracking, because JetBrains Mono ships no condensed face and
   `font-stretch` on a font without one does nothing at all. -0.01em is
   about a pixel across that string -- small on its own, and it is what
   lets the size stop at 16 rather than 15.

   The 24px role is not lost: it is what portal mode's single readouts
   use, and .metric-value outside a tile is unchanged above. */
[data-mode="analytic"] .metric-fig .metric-value {
  font-size: var(--fs-figure); font-weight: var(--fw-display);
  letter-spacing: -0.01em; line-height: var(--lh-tight);
  /* A little air above the figure, which is what the removed third line
     used to provide by accident. No padding under it any more: the sub
     line below carries its own margin, and on the two tiles that have no
     sub line the empty box still holds the same height. */
  margin-top: var(--sp-2);
  overflow-wrap: break-word;
}

/* THE SECOND LINE OF THE DELTA TILE: the percentage.

   RULE: BUILT ON ALL THREE TILES AND EMPTY ON TWO, so the row keeps one
   height whether or not anything has been edited — the same reason the two
   right-hand tiles are disabled rather than hidden. An empty box with a
   line-height still occupies its line, which is exactly what is wanted.

   It is smaller than the figure and reads as subordinate to it: the signed
   difference is the answer and this is the same answer as a proportion.
   See panel/metric-select.js's paintFigures() for why it stays blank when
   the baseline is near zero rather than printing a large, arithmetically
   correct, completely false number. */
[data-mode="analytic"] .metric-fig-sub {
   font-size: calc(var(--fs-hint) + 1px); line-height: var(--lh-tight);
  letter-spacing: -0.01em;
  color: var(--text-3);
  margin-top: var(--sp-1);
  padding-bottom: var(--sp-1);
  min-height: 1em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* `.metric-fig-note` HAS NO RULE HERE ANY MORE — the element is gone.

   It was the third line of each tile ("the site as handed over", "edit a
   material to fill this"), and it is a `title` attribute on the tile
   button now. Three tiles x up to two lines of that is a paragraph under a
   row of figures, in the one card whose measured problem is that it does
   not fit a 900px screen. See panel/metric-select.js's makeTile() for why
   a tooltip rather than a hover-revealed element: only removing the box
   gives the space back.

   The delta tile's merit colour is unaffected — it was applied to BOTH the
   value and the note, and the value is the one on screen. */

/* THE DELTA TILE'S MERIT COLOUR.

   RULE: THE THREE COLOURS ARE RESTATED HERE, AND THAT IS NOT A DUPLICATE —
   IT IS THE ONLY WAY THEY REACH THE TILE. controls.css already declares
   `.delta[data-dir="better"|"worse"|"none"]`, but that selector is (0,2,0)
   and so is this file's own `.metric-value` rule above. Equal specificity
   is decided by LOAD ORDER, analytic.css loads after controls.css, and so
   this file's plain --text-1 wins.

   The symptom is exactly the kind this codebase keeps meeting: nothing
   throws, the tile prints a correct signed number, and only the JUDGEMENT
   disappears — a night wall discharging harder and a night wall improving
   look identical. metrics.js's CRITERIA[].betterIs is the whole reason the
   number is not read on its sign, and this is where that reaches a reader.
   Measured with getComputedStyle on a real edit before it was fixed; a test
   that reads class attributes cannot see it, the same trap .ui-slider
   documents in controls.css.

   (0,4,0), so it also beats a future `.metric-value` rule in this file.

   text-align: left overrides .delta's own right alignment — that default is
   for a value at the end of a control row, and here it sits under a
   left-aligned figure it belongs to. */
[data-mode="analytic"] .metric-fig .delta { text-align: left; }
[data-mode="analytic"] .metric-fig .delta[data-dir="better"] { color: var(--better); }
[data-mode="analytic"] .metric-fig .delta[data-dir="worse"]  { color: var(--worse); }
[data-mode="analytic"] .metric-fig .delta[data-dir="none"]   { color: var(--text-3); }

/* AND THE SAME THREE FOR THE PERCENTAGE UNDER IT, for the same reason one
   step down: `.metric-fig-sub` above is (0,2,0) and declares a colour, so
   without these the percentage would sit in --text-3 under a green or amber
   figure and read as a different, unjudged number. (0,4,0) beats it. */
[data-mode="analytic"] .metric-fig-sub[data-dir="better"] { color: var(--better); }
[data-mode="analytic"] .metric-fig-sub[data-dir="worse"]  { color: var(--worse); }
[data-mode="analytic"] .metric-fig-sub[data-dir="none"]   { color: var(--text-3); }

/* DISABLED means "there is nothing to compare yet", not "this is broken".
   Modified and Delta are built and keep their third of the row before the
   first edit (see metric-select.js's paintFigures) — this is what stops
   them reading as buttons that should work. */
[data-mode="analytic"] .metric-fig:disabled { cursor: default; }
[data-mode="analytic"] .metric-fig:disabled .metric-value,
[data-mode="analytic"] .metric-fig:disabled .metric-fig-sub,
[data-mode="analytic"] .metric-fig:disabled .metric-fig-label { color: var(--text-off); }

/* THE CHART BLOCK: chooser, plot, caption. Its own flex column, because
   it is one flex ITEM of .fpanel-body — without this its three children
   would stack with no spacing at all (the same reason .mat-section
   restates the rhythm on the other side). */
[data-mode="analytic"] .metric-chart-block {
  display: flex; flex-direction: column; gap: var(--sp-2);
}
/* The element ECharts owns. Height is set in JS because it differs per
   chart (the hexagon needs room for three-line axis labels, the day
   profile does not) and JS is what knows which chart is showing; width
   comes from here, and the ResizeObserver in panels.js is what tells the
   instance the width changed.

   WARNING: min-width: 0 is load-bearing. A flex item's default minimum is its
   content, and a canvas reports its own width as content — so without
   this the chart can only ever grow the card, never shrink with it. */
[data-mode="analytic"] .metric-chart { width: 100%; min-width: 0; flex: none; }

/* THE ANALYSIS CARD'S OWN OVERFLOW.

   RULE: flex-shrink: 1 IS THE DEFAULT AND IT IS WRONG HERE. .fpanel-body is
   a flex column; the moment its content is taller than the card, flex
   takes the height back from the items with the least content — a scope
   line can end up with a computed height of 0px while its text is still
   there. `flex: none` on every child hands the overflow to
   .fpanel-body's own overflow-y instead, which is the behaviour a
   scrolling panel is supposed to have. */
[data-mode="analytic"] #viewer-metric-dock > * { flex: none; }

/* THE MATERIAL COMPOSITION BAR — analysis/panels.js's
   createCompositionBar. Same shape as the chart block above: one flex
   item holding a plot and a legend. */
[data-mode="analytic"] .mat-bar-block {
  display: flex; flex-direction: column; gap: var(--sp-2);
}
/* The plot's own positioning context — .mat-bar-title is placed absolutely
   inside it, at pixel offsets analysis/panels.js computes from the SAME
   CHART_CONFIG.compositionGrid numbers ECharts draws the bars with. */
[data-mode="analytic"] .mat-bar-wrap { position: relative; }
[data-mode="analytic"] .mat-bar { width: 100%; min-width: 0; }
/* "Original" / "Modified" (or "Composition" with nothing edited), directly
   above their own row. Same visual language as .viewer-panel-title — an
   eyebrow, not a caption — reused rather than invented because this is the
   same kind of label: a short, standing name for what is below it.
   `pointer-events: none` so the label never steals the hover a segment
   underneath it would otherwise get. */
[data-mode="analytic"] .mat-bar-title {
  position: absolute; left: 0;
  pointer-events: none;
  font-size: var(--fs-eyebrow); font-weight: var(--fw-eyebrow);
  letter-spacing: var(--ls-eyebrow); text-transform: uppercase;
  /* --text-2, not the dimmer --text-3 other eyebrows use: this one sits a
     few px above a coloured bar rather than on the panel's own
     background, and needed the extra contrast to stay legible there. */
  color: var(--text-2);
  /* body's own --lh-body (1.75) would give a 10px label an ~18px line box —
     more than the whole gap above a row leaves it. --lh-tight keeps the
     text where `top` actually puts it. */
  line-height: var(--lh-tight);
}
/* Row/wrap, not a vertical list — a chip per material, several to a line.
   gap covers both the space between chips on one line and the space
   between lines when they wrap. */
[data-mode="analytic"] .mat-bar-legend {
  display: flex; flex-direction: row; flex-wrap: wrap;
  gap: var(--sp-1);
}
/* The per-material change, inside the same chip as its name rather than
   its own column — see .mat-comp-row above. --text-3 and NOT a merit
   colour: more brick is neither better nor worse than more stone, and
   this platform has no such claim to make. Better and worse are said on
   the figures, where metrics.js's CRITERIA[].betterIs defines them. */
[data-mode="analytic"] .mat-bar-delta {
  flex: none;
  color: var(--text-3);
  font-variant-numeric: tabular-nums;
}
[data-mode="analytic"] #viewer-material-dock > * { flex: none; }
