/* gobble web frontend — small local styles layered on top of Tailwind CDN.
   Kept tiny on purpose (kilobyte commits); most styling is utility classes. */

:root {
  --accent: #34d399;
  /* The brighter accent (Tailwind `accent`). Both move together under
     html[data-brand-palette] (SITE_BRAND_PALETTE); keep every accent use on
     these two so the switch reaches it. */
  --accent-hi: #6ee7b7;

  /* --------------------------------------------------------------------
     Type-scale + spacing tokens (2026 de-noise pass). A small, explicit
     ramp so the meta / claim surfaces can establish a clear hierarchy —
     headline vs tally vs body — instead of a flat wall of same-size text.
     rem-based (colour-free) so they are theme-neutral; the app is
     dark-locked (ink palette) but a light fallback is provided below for
     any host that honours prefers-color-scheme.
     -------------------------------------------------------------------- */
  --fs-1: 0.7rem;    /* fine print / receipts */
  --fs-2: 0.8rem;    /* body */
  --fs-3: 0.95rem;   /* sub-head */
  --fs-4: 1.15rem;   /* card headline */
  --fs-5: 1.45rem;   /* pane headline / the tally */

  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;

  /* Meta surface ink (dark default). */
  --meta-ink-strong: #f1f5f9;
  --meta-ink: #cbd5e1;
  --meta-ink-muted: #94a3b8;
  --meta-ink-faint: #64748b;
  --meta-surface: #0f1524;
  --meta-border: #273043;
}

/* Light fallback for hosts that honour the OS theme (the shell itself
   hard-sets class="dark", so in-app this never triggers — provided for
   correctness on standalone/embedded renders). */
@media (prefers-color-scheme: light) {
  :root[data-theme="light"] {
    --meta-ink-strong: #0f172a;
    --meta-ink: #1e293b;
    --meta-ink-muted: #475569;
    --meta-ink-faint: #64748b;
    --meta-surface: #f8fafc;
    --meta-border: #cbd5e1;
  }
}

/* SITE_BRAND_PALETTE (2026-09-25): the chrome accent takes the logo's mint. Data-viz
   colours in static/js carry meaning and do not move. */
html[data-brand-palette] {
  --accent: #4fba9a;
  --accent-hi: #7ccbb3;
}

/* --------------------------------------------------------------------------
   Brand wordmark (the site name is a Django setting, SITE_NAME).
   so it scales fluidly with the viewport (never wraps; whitespace-nowrap on the
   anchor) and tightens its tracking + mark gap on phones so it always fits the
   56px header beside the Sources tab. The wordmark stood alone (2026-08-24) pending a real mark;
   the owner's logo arrived 2026-09-25 and takes its slot under SITE_BRAND_MARK (.brand-logo).
   -------------------------------------------------------------------------- */
.brand-wordmark { font-size: clamp(0.9rem, 0.78rem + 1.1vw, 1.125rem); line-height: 1; }
.brand-wordmark .brand-name { letter-spacing: -0.01em; }
/* The owner's logo (SITE_BRAND_MARK) in the text's slot: 1em of the same clamped
   font-size, so it is as tall as the name it replaces at every width. */
.brand-logo { display: block; height: 1em; width: auto; }
@media (max-width: 380px) {
  /* Very small phones: shrink the mark gap so the name keeps its room. */
  .brand-wordmark { gap: 0.3rem; }
}

/* AI-assisted badge — the responsible-journalism marker for any AI summary. */
.ai-badge {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-size: 0.65rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  padding: 0.15rem 0.5rem;
  border-radius: 9999px;
  color: var(--accent-hi);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
  white-space: nowrap;
}
.ai-badge::before {
  content: "✦";
  font-size: 0.7em;
}

/* "inference, not fact" pill. */
.infer-badge {
  display: inline-flex;
  font-size: 0.6rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 0.1rem 0.45rem;
  border-radius: 9999px;
  color: #fcd34d;
  background: rgba(252, 211, 77, 0.1);
  border: 1px solid rgba(252, 211, 77, 0.3);
}

/* Warning flags (single-source / disputed). */
.flag-warn {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-size: 0.65rem;
  font-weight: 600;
  padding: 0.15rem 0.5rem;
  border-radius: 9999px;
  color: #fda4af;
  background: rgba(244, 63, 94, 0.12);
  border: 1px solid rgba(244, 63, 94, 0.3);
}

/* Tooltip helper (disclaimer on hover/focus). */
[data-tip] { position: relative; cursor: help; }
[data-tip]:hover::after,
[data-tip]:focus::after {
  content: attr(data-tip);
  position: absolute;
  left: 0;
  top: 125%;
  z-index: 50;
  width: max-content;
  max-width: 22rem;
  padding: 0.5rem 0.65rem;
  font-size: 0.7rem;
  font-weight: 400;
  line-height: 1.3;
  text-transform: none;
  letter-spacing: 0;
  color: #e2e8f0;
  background: #0b0f17;
  border: 1px solid #273043;
  border-radius: 0.5rem;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}

/* --------------------------------------------------------------------------
   Civic-context UI: "Understand this" drawer + click-based explainers.
   Accessible, click-based disclosure (native <details> + Popover API). No
   hover dependency. Dark theme, readable, capped width.
   -------------------------------------------------------------------------- */

/* The drawer stacks BELOW the bubble open-summary z (6) — it used Tailwind's
   z-40, which floated it over the whole bubble ladder. (The bubbles' own z 5-8
   live inside .bubble-layer's stacking context, so against the drawer the layer's
   z of 2 is what competes; the real guard is the drilled rule below.) The element
   selector outguns the utility class regardless of stylesheet order. */
details.civic-drawer { z-index: 5; }
/* Minimized: the drawer is just the ⓘ chip; labels + minimize control hide. */
.civic-drawer.civic-min { width: auto; }
.civic-drawer.civic-min .civic-summary { padding: 0.3rem 0.55rem; }
.civic-drawer.civic-min .civic-label, .civic-drawer.civic-min .civic-min-btn { display: none; }
.civic-min-btn {
  margin-left: 0.4rem; color: #64748b; border: 1px solid #273043;
  border-radius: 0.3rem; padding: 0 0.35rem; font-size: 0.7rem; line-height: 1.3;
  background: transparent; cursor: pointer;
}
.civic-min-btn:hover, .civic-min-btn:focus-visible { color: #cbd5e1; border-color: #3b4a63; }
/* While the reader is drilled into any bubble (bubbles.js mirrors that state as
   body.bubbles-drilled), the drawer gets out of the way entirely. */
body.bubbles-drilled .civic-drawer { display: none; }

/* Drawer hover affordance on the summary (visual only; not required to use). */
.civic-summary:hover { background: rgba(39, 48, 67, 0.6); }
.civic-summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
/* Hide the default disclosure triangle (we provide our own ⓘ marker). */
.civic-summary::-webkit-details-marker { display: none; }
.civic-drawer[open] .civic-summary { border-bottom: 1px solid #273043; border-radius: 0.75rem 0.75rem 0 0; }
.civic-sift li { line-height: 1.35; }

/* The reusable "why?" trigger button. */
.why { display: inline-flex; }
.why-trigger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.15rem;
  height: 1.15rem;
  font-size: 0.7rem;
  line-height: 1;
  color: #94a3b8;
  background: rgba(148, 163, 184, 0.12);
  border: 1px solid #273043;
  border-radius: 9999px;
  cursor: pointer;
}
.why-trigger:hover { color: var(--accent); border-color: var(--accent); }
.why-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Native [popover] panel + fallback for engines without the Popover API. */
[popover].why-pop {
  max-width: 22rem;
  margin: 0;
  padding: 0.85rem 0.9rem;
  color: #e2e8f0;
  background: #111726;
  border: 1px solid #273043;
  border-radius: 0.6rem;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
  font-size: 0.8rem;
  line-height: 1.4;
}
.why-pop .why-text { margin: 0; }
.why-pop .why-foot { margin: 0.5rem 0 0; font-size: 0.7rem; color: #94a3b8; }
.why-close {
  margin-top: 0.6rem;
  font-size: 0.7rem;
  padding: 0.2rem 0.55rem;
  color: #cbd5e1;
  background: transparent;
  border: 1px solid #273043;
  border-radius: 0.4rem;
  cursor: pointer;
}
.why-close:hover { border-color: var(--accent); color: var(--accent); }

/* Fallback display for browsers lacking the Popover API (civic.js toggles
   [data-civic-open]). Compliant browsers ignore this; native [popover] wins. */
[popover][data-civic-open] {
  display: block;
  position: fixed;
  inset: auto auto 5rem 1rem;
  z-index: 60;
}

/* Graph + map canvases. The map keeps a grid-friendly fixed-ish height; the
   ENTITIES graph canvas instead fills its flex pane ([data-graph-pane], the
   only stretchy child of #panel-entities — rules in the desktop block below)
   so the graph is as large as the layer card allows. min-height guards the
   content-sized cases; mobile gets an explicit viewport-derived height (no
   definite parent height there — see the <1024px block). */
#graph-canvas { width: 100%; height: 100%; min-height: 260px; background: #0b0f17; border-radius: 0.75rem; }
#map-canvas { width: 100%; height: clamp(300px, 42vh, 460px); }

/* Entities-pane overlays: the live count + "+N more" tail chip, the Reset-view
   chip, and the co-occurrence caveat float OVER the canvas so the chrome costs
   zero layout height. Everything is pointer-transparent except the two buttons. */
[data-graph-pane] .graph-count-overlay {
  position: absolute; top: 0.5rem; left: 0.75rem; z-index: 5;
  /* Clear of the Reset-view chip at top-right: beside the side list the canvas
     can be ~400px wide, and the count line ran under the chip. It wraps instead. */
  max-width: calc(100% - 8.5rem);
  display: flex; flex-wrap: wrap; align-items: center; column-gap: 0.6rem; row-gap: 0.1rem;
  font-size: 11px; line-height: 1.4; color: #64748b;
  pointer-events: none;
}
[data-graph-pane] .graph-count-overlay [data-graph-show-tail] {
  pointer-events: auto;
  appearance: none; background: transparent; border: none; padding: 0;
  font-size: 11px; color: var(--accent); cursor: pointer;
}
[data-graph-pane] .graph-count-overlay [data-graph-show-tail]:hover { text-decoration: underline; }
[data-graph-pane] .graph-reset-btn {
  position: absolute; top: 0.5rem; right: 0.75rem; z-index: 5;
  pointer-events: auto;
  appearance: none;
  background: rgba(11, 15, 23, 0.8);   /* translucent ink — legible over any graph */
  border: 1px solid #273043;           /* chip family (.layer-chip) styling */
  color: #94a3b8;
  font-size: 0.7rem; font-weight: 600;
  padding: 0.25rem 0.65rem;
  border-radius: 999px;
  cursor: pointer; white-space: nowrap;
}
[data-graph-pane] .graph-reset-btn:hover { color: var(--accent); border-color: var(--accent); }
[data-graph-pane] .graph-reset-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
#panel-entities .graph-caveat {
  margin-top: 0.4rem;
  font-size: 11px; line-height: 1.5;
  color: rgba(253, 230, 138, 0.9);           /* amber-200/90 */
  background: rgba(11, 15, 23, 0.8);         /* semi-transparent ink strip */
  border: 1px solid rgba(245, 158, 11, 0.3); /* amber-500/30 */
  border-radius: 999px;
  padding: 0.2rem 0.8rem;
}
#panel-entities .graph-caveat strong { color: #fde68a; }

/* Entities side list (2026-09-22): every entity the canvas shows, most central
   first. The canvas and the list share [data-graph-body]; below 32rem of panel
   width the list stacks under the canvas, from 32rem it sits beside it and its
   rows box flexes to the canvas height. graph.js pages the rows to what fits
   ("+N more" opens the next page), so the rows box never scrolls and no row is
   clipped. The PANEL is the query container: a container cannot restyle
   itself, and the body's own direction is what switches. */
#panel-entities { container-type: inline-size; }
#panel-entities .graph-body {
  display: flex; flex-direction: column; gap: 0.5rem;
  min-height: 0;
}
#panel-entities .graph-list {
  flex: 0 0 auto;
  display: flex; flex-direction: column; min-height: 0;
  border: 1px solid #273043; border-radius: 0.75rem;
  background: rgba(11, 15, 23, 0.6);
  padding: 0.5rem 0.35rem 0.4rem;
}
#panel-entities .graph-list[hidden] { display: none; }
#panel-entities .graph-list-head {
  margin: 0 0.4rem 0.3rem; font-size: 11px; font-weight: 600; color: #cbd5e1;
}
#panel-entities .graph-list-head span { font-weight: 400; color: #64748b; }
#panel-entities .graph-list-rows {
  list-style: none; margin: 0; padding: 0;
  min-height: 0; overflow: hidden;
}
#panel-entities .graph-list-row {
  appearance: none; background: transparent; border: 0; width: 100%;
  display: grid; grid-template-columns: 0.6rem 1fr; column-gap: 0.45rem; align-items: baseline;
  text-align: left; padding: 0.28rem 0.45rem; border-radius: 0.5rem;
  color: #e2e8f0; cursor: pointer;
}
#panel-entities .graph-list-row:hover,
#panel-entities .graph-list-row[data-hover="self"] { background: color-mix(in srgb, var(--accent-hi) 12%, transparent); }
#panel-entities .graph-list-row[data-hover="neighbour"] { background: rgba(148, 163, 184, 0.10); }
#panel-entities .graph-list-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
#panel-entities .graph-list-dot {
  width: 0.55rem; height: 0.55rem; border-radius: 999px; align-self: center;
}
#panel-entities .graph-list-name {
  font-size: 12.5px; line-height: 1.3; overflow-wrap: anywhere;
}
#panel-entities .graph-list-meta {
  grid-column: 2; font-size: 11px; line-height: 1.3; color: #94a3b8;
}
#panel-entities .graph-list-more {
  appearance: none; background: transparent; border: 0; padding: 0.3rem 0.45rem 0;
  text-align: left; font-size: 11px; color: var(--accent); cursor: pointer;
}
#panel-entities .graph-list-more:hover { text-decoration: underline; }
@container (min-width: 32rem) {
  #panel-entities .graph-body { flex-direction: row; }
  #panel-entities .graph-body > [data-graph-pane] { flex: 1 1 auto; min-width: 0; }
  #panel-entities .graph-list { flex: 0 0 13rem; }
  #panel-entities .graph-list-rows { flex: 1 1 auto; }
}

/* --------------------------------------------------------------------------
   Bubble spine ("Global News Synthesis"): HTML bubbles floated by ForceAtlas2
   (bubbles.js). Each node is an absolutely-positioned .bubble whose full text
   lives INSIDE it; a summary panel opens within the bubble on the first click.
   On load only the root bubble (the global summary) is present; clicks spread
   themes -> stories outward. The field fills the spine column and auto-widens
   when the Analysis pane closes; on mobile it sits in the "Overview" tab.
   -------------------------------------------------------------------------- */
.gobble-bubble-section { display: flex; flex-direction: column; min-height: 0; }
.gobble-bubble-pane { flex: 1 1 auto; min-height: 0; height: calc(100vh - 11rem); }
.gobble-bubble-pane #bubble-canvas { width: 100%; height: 100%; background: #0b0f17; }
@media (max-width: 1023.98px) {
  /* Mobile: the spine is a block in a scrolling page, so the field has no parent
     height to fill — it still needs an explicit viewport-derived one.

     PHONE FLOW (2026-09-05). That viewport slice moved DOWN one level. The phone canvas
     now grows with the two-column bucket flow beneath the root (bubbles.js paint() stamps
     narrowStageHeight() inline on the canvas), and a pane pinned to one screen would clip
     it and re-introduce the very "everything else is behind the synthesis" the flow fixes.
     So the pane is content-sized and the viewport-derived height lives on the canvas as
     the NO-JS floor — the first paint before paint() runs, and what a reader with JS
     disabled sees. Both selectors are listed because the top-level
     `.gobble-bubble-pane #bubble-canvas { height: 100% }` is (1,1,0): a bare #bubble-canvas
     rule alone would lose to it and the floor would silently be 0. */
  .gobble-bubble-pane { height: auto; min-height: 340px; }
  .gobble-bubble-pane #bubble-canvas,
  #bubble-canvas { height: calc(100vh - 13.5rem); min-height: 340px; }
}

/* --------------------------------------------------------------------------
   LAYERS strip — the chips, lifted out of the detail pane so they outlive its
   fold (index.html explains why). Slim and quiet: it's a control rail, not a
   headline. Desktop-only; mobile keeps its own tab bar.
   -------------------------------------------------------------------------- */
.gobble-layer-strip {
  flex: 0 0 auto;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 1rem;
  border-bottom: 1px solid #273043;
  background: #0b0f17;
}
.layer-bar-label {
  font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em;
  color: #64748b; margin-right: 0.15rem;
}
.strip-collapse {
  margin-left: auto; font-size: 0.7rem; line-height: 1.4; color: #64748b;
  border: 1px solid #273043; border-radius: 0.375rem; padding: 0.1rem 0.5rem;
  background: transparent; cursor: pointer;
}
.strip-collapse:hover, .strip-collapse:focus-visible { color: #cbd5e1; border-color: #3b4a63; }
/* The handle lives in the header info bar; collapsing removes the WHOLE strip
   row — no residual band, no dead space. */
.gobble-layer-strip.strip-collapsed { display: none; }

/* The bubble field: a positioned stage holding an SVG connector layer under the
   absolutely-placed bubbles. */
.bubble-field { position: relative; overflow: hidden; }
.bubble-edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; }
.bubble-edge  { stroke: #2b384c; stroke-width: 1.25; }
.bubble-layer { position: absolute; inset: 0; z-index: 2; }

/* A bubble: content-sized (full text, NO truncation), placed by transform. The
   translate(-50%,-50%) centres it on its coordinate; bubbles.js appends the
   pixel translate. Solid background so a bubble stays readable over a connector.

   SIZING: every width/type value below is a var() so the container-query ramp at
   the bottom of this file can grow them with the FIELD's own width. The defaults
   here are the old fixed values — a narrow field looks exactly as it always did;
   a wide one (detail pane folded → spine owns the viewport) scales up instead of
   stranding 12.8px text in a 1900px void. */
.bubble {
  position: absolute; top: 0; left: 0;
  /* SMALLEST-ON-TOP (2026-07-31): paint() sets --bz 1..3 by inverse footprint,
     TOTAL ORDER (2026-08-21): paint() gives every bubble a DISTINCT --bz 1..RANK_MAX by
     descending footprint, so the smaller of any overlapping pair always hit-tests on top.
     The old three area buckets (18000/45000) stopped separating anything once bubbles grew
     to fit their text — the smallest real box is 35,840px, so 8 of 10 geometries landed in
     one tier and ties fell back to DOM order, leaving the loser unclickable.
     The semantic ladder (root 1000, hover 1100, summary/front/selected 1200, active 1300,
     traced 1400, focus 1500, hover 1600) is CLASS rules with higher specificity AND sits
     above RANK_MAX, so it still wins. .bubble-layer is a stacking context, so none of these
     escape to compete with the HUD (50) or the civic drawer (5). */
  z-index: var(--bz, 2);
  --bub-hue: var(--accent-hi);
  /* WIDTHS (2026-08-17). Raised across the board because the binding constraint on
     bubble height is the LINE MEASURE, not the font: at the old 15rem/0.86rem a theme
     wrapped at ~28 characters per line, so ordinary prose became a tall column that
     could only scroll. Readable measure is 45-75 characters (~66 ideal); a bubble field
     cannot afford 45 at 16px (that is 22.5rem a bubble), so these land at ~36 and the
     container ramp below carries the rest on a wide pane. */
  --bub-w: 18rem;          /* generic/theme bubble */
  --bub-root-w: 30rem;     /* the global-summary core */
  /* ...and the measure it OPENS to. One place, because three rules restated it and had
     already drifted apart in effect: the design=1 variant out-specified the breadcrumb
     squeeze further down and the Classic one did not, so the weighted canvas kept the
     Global Synthesis at full width while Classic shrank it to 22rem. */
  /* ...capped by the pane (--bub-pane-cap, right below). The cap lives HERE, in the token,
     rather than at the two `.show-summary` use sites, for a reason worth recording: the rule
     `.bubble.bubble-root.show-summary { max-width: var(--bub-root-open-w); }` is pinned
     VERBATIM by news/tests/test_bubble_no_burial.py::TestTheGlobalSynthesisNeverShrinks as the
     receipt that nothing narrows the root. The 2026-09-12 ruling narrows the 2026-09-08 one
     only where the box no longer FITS, so the rule text stays exactly as that test reads it
     and the pane bound rides in through the measure it already names. */
  --bub-root-open-w: min(38rem, 94cqi, var(--bub-pane-cap));
  /* THE PANE IS THE OUTER BOUND (owner, 2026-09-12). The Global Synthesis "should not decrease
     in size ever" (2026-09-08) and that still governs at every width where it FITS. Below the
     width where it no longer fits its pane it narrows just enough to sit inside — every bullet
     kept, the text re-wrapping TALLER, nothing truncated, faded, ellipsised or scrolled.
     A percentage resolves against the containing block, which for a .bubble is .bubble-layer
     (`position: absolute; inset: 0` inside #bubble-canvas) — i.e. THE PANE itself, so the cap
     tracks it pixel by pixel instead of stepping at a breakpoint. 16px is 2 x FIT_MARGIN, the
     stage margin every rung of the fit already keeps. ONE token, used by all four rules that
     bound the root's measure, so they cannot drift apart.
     INERT WHERE IT FITS, by construction: min() only ever caps. On a desktop the pane is ~954px
     at the narrowest (a 1024px window) against a 608px root, so this changes nothing there; it
     bites on a SPLIT pane, where the canvas is far narrower than the spine cqi is measured
     against (at a 600px pane, 94cqi is 564px and the root would overflow by 177px). */
  --bub-pane-cap: calc(100% - 16px);
  --bub-leaf-w: 16rem;     /* a story leaf */
  --bub-group-w: 16rem;    /* a section bucket — REVERTED 2026-08-17, see below */
  --bub-region-w: 18rem;   /* a region bucket under World */

  /* TYPE SCALE (2026-08-17). Replaces ten ad-hoc multipliers of a single head size
     (0.74 / 0.78 / 0.82 / 0.92 / 0.94 / 1.0 / 1.05 / 1.1 / 1.12 / 1.19) with four steps.
     Several of those differed by 2-6%, which is below the threshold at which a reader
     perceives "a different size" — they read as misalignment, not hierarchy.
     Two rules the old scale broke:
       1. BODY FLOOR. Prose was 0.86rem = 13.8px. 16px is the browser default and the
          practical accessibility floor for continuous reading; scan speed and
          comprehension fall off below ~14px.
       2. HEADS OUTRANK BODY. The head was 0.8rem against 0.86rem body — every theme
          headline was SMALLER than the prose beneath it. Only the trace-focus card
          (1.2/1.1) ever had this the right way round.
     Steps are ~1.125 apart (a major second), so each is unmistakably a step. */
  --bub-fs-body: 1rem;         /* 16px — all prose: summary, lead, bullets */
  --bub-fs-head: 1.125rem;     /* 18px — theme, leaf, country, loose, "+N more" */
  --bub-fs-head-lg: 1.25rem;   /* 20px — region bucket */
  /* REVERTED 2026-08-17 at review: promoting the section bucket to 23px/19rem made the
     section menu eat far too much of the canvas. --bub-fs-head-section is the size the
     section head COMPUTED to before this change (the old 0.8rem base x 1.19), restated as
     a token so it is not a magic number. --bub-fs-head-xl is kept for the trace card. */
  --bub-fs-head-section: 0.95rem;
  --bub-fs-head-xl: 1.4375rem; /* 23px — the traced card only */
  --bub-fs-chrome: 0.8125rem;  /* 13px — counts, trace pill, related links, receipts.
                                  Glanced, not read, so it sits below the body floor
                                  deliberately — but ABOVE the old 0.60-0.62rem soup. */
  /* Alias kept for the design=1 salience-tier rules, which multiply the head size by
     --tier-fs. Lazy var() substitution means this picks up the CONTAINER-RAMPED
     --bub-fs-head, not the base one, so the tier scaling still tracks the pane. */
  --bub-head-fs: var(--bub-fs-head);

  /* SUMMARY HEIGHT (2026-08-17). Was a flat 11rem, so any summary longer than ~7 lines
     scrolled INSIDE the bubble regardless of how much canvas was free — the single
     biggest source of in-bubble scrolling. The panel is content-sized up to this cap, so
     raising the cap IS "grow to fit": the bubble now takes the height its text needs and
     only falls back to scrolling when it would otherwise outgrow the stage. Kept
     stage-relative (vh) rather than a bigger constant so the ceiling tracks the canvas,
     which is what "too large for the canvas" has to mean. NOTE (2026-09-07): the base
     rule no longer caps or scrolls at all, so `--bub-sum-h` now feeds ONLY the three
     layout clamps (dimmed breadcrumb, open parent, phone root), which fade rather than
     scroll. There is no scrollbar inside a bubble any more.
     RE-TUNED 2026-08-17 after review: the first pass used 52vh (62vh ramped), which is
     half the canvas for ONE panel. With the build's three auto-opened hero summaries plus
     the always-open root, the field read as "too many bubbles open at once" and buried the
     global synthesis. 34vh/42vh still roughly doubles the old flat 11rem — most summaries
     stop scrolling — without letting one open panel own the stage. This is the real
     trade-off in "grow instead of scroll": taller panels scroll less but crowd more. */
  --bub-sum-h: min(28rem, 40vh);
  --bub-pad: 0.5rem 0.7rem;
  /* RING SCALE (2026-09-04). bubbles.js stamps --ring-w / --ring-fs inline on a bubble whose
     ring the synthesis floor lifted past what the stage fits (ringCap / ringScale): every
     theme the bucket's prose cites stays on screen, smaller. 1 = the measure and the head
     size exactly as before; the open (.show-summary) reading measure below is untouched. */
  --ring-w: 1; --ring-fs: 1;
  max-width: calc(var(--bub-w) * var(--ring-w)); box-sizing: border-box;
  padding: var(--bub-pad); border-radius: 1rem;
  background: #131a26; color: #e7edf5;
  border: 1px solid color-mix(in srgb, var(--bub-hue) 45%, #2b384c);
  box-shadow: 0 2px 10px rgba(0,0,0,0.45);
  cursor: pointer; user-select: none;
  transition: transform 0.55s cubic-bezier(0.22,0.61,0.36,1),
              box-shadow 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
  will-change: transform;
}
.bubble:hover { border-color: color-mix(in srgb, var(--bub-hue) 80%, #2b384c);
  box-shadow: 0 3px 16px color-mix(in srgb, var(--bub-hue) 25%, rgba(0,0,0,0.55)); z-index: 1100; }
.bubble-head { font-size: calc(var(--bub-fs-head) * var(--ring-fs)); line-height: 1.3; font-weight: 600;
  text-wrap: pretty; }
/* The root carries the whole global summary — give it more room + a warm accent. */
.bubble-root { max-width: min(var(--bub-root-w), var(--bub-pane-cap)); background: #0f1a17;
  border-color: color-mix(in srgb, var(--accent-hi) 55%, #2b384c); }
.bubble-root .bubble-head { font-weight: 500; color: #d7f5ea; }
.bubble-leaf { max-width: var(--bub-leaf-w); }
.bubble-leaf .bubble-head { font-weight: 500; color: #cbd6e6; }
/* A section (news-category) bucket bubble — larger + bolder than a theme and tinted by
   its section hue, so the 8 fixed sections read as the top navigation layer. */
/* The bucket measures ride --ring-w like every other bubble (PHONE FLOW 2026-09-05).
   They were the two kinds that did NOT: ringScale exempts bucket rings, so the phone's
   13-bucket root ring had no way to shrink to a 358px stage. --ring-w defaults to 1
   (declared on .bubble above) and bubbles.js stamps it only when it shrinks a ring, so
   a desktop window computes exactly the same measure it does today. */
.bubble-group { max-width: calc(var(--bub-group-w) * var(--ring-w)); background: color-mix(in srgb, var(--bub-hue) 16%, #131a26);
  border-color: color-mix(in srgb, var(--bub-hue) 70%, #2b384c); }
.bubble-group .bubble-head { font-size: var(--bub-fs-head-section); font-weight: 700; letter-spacing: 0.01em;
  color: color-mix(in srgb, var(--bub-hue) 42%, #ffffff); }
/* The "+N more" continuation bubble — a muted, dashed pill affordance. */
.bubble-more { max-width: 9rem; background: #1a2233; border-color: #3a4763; border-style: dashed; }
.bubble-more .bubble-head { font-size: var(--bub-fs-head); font-weight: 600; color: #94a3b8; text-align: center; }
/* The continuation pill is part of "where are the rest of the children" — make
   it read as actionable, not decoration. */
.bubble-more:hover { border-color: var(--accent); }
.bubble-more:hover .bubble-head { color: var(--accent); }
/* Region (news-desk) bucket bubble under World — like a section, a touch lighter. */
.bubble-region { max-width: calc(var(--bub-region-w) * var(--ring-w)); background: color-mix(in srgb, var(--bub-hue) 12%, #131a26);
  border-color: color-mix(in srgb, var(--bub-hue) 55%, #2b384c); }
.bubble-region .bubble-head { font-size: var(--bub-fs-head-lg); font-weight: 700;
  color: color-mix(in srgb, var(--bub-hue) 40%, #ffffff); }
/* Country bucket (dual-nav): EPHEMERAL — exists only while news about it does.
   Wears its region's hue one notch lighter; slightly tighter than a region. */
.bubble-country { max-width: calc(var(--bub-region-w) * 0.85);
  background: color-mix(in srgb, var(--bub-hue) 8%, #131a26);
  border-color: color-mix(in srgb, var(--bub-hue) 40%, #2b384c); }
.bubble-country .bubble-head { font-size: var(--bub-fs-head); font-weight: 700;
  color: color-mix(in srgb, var(--bub-hue) 32%, #ffffff); }
/* US STATE sub-bucket (US_STATE_BUBBLES, dual-nav): hangs under the United States country
   the way a country hangs under its region — EPHEMERAL too, it exists only while the build
   pinned a state on some theme from its sources. Wears its country's hue, one notch tighter
   again, so region > country > state reads as a ladder. */
.bubble-state { max-width: calc(var(--bub-region-w) * 0.75);
  background: color-mix(in srgb, var(--bub-hue) 6%, #131a26);
  border-color: color-mix(in srgb, var(--bub-hue) 34%, #2b384c); }
.bubble-state .bubble-head { font-size: var(--bub-fs-head); font-weight: 700;
  color: color-mix(in srgb, var(--bub-hue) 28%, #ffffff); }
/* SECTION sub-bucket (dual-nav): a LEAGUE under Sports, a COUNTRY under Politics — whatever
   dimension the build grouped that section along. Hangs under its section the way a country
   hangs under its region, and is EPHEMERAL the same way: it exists only while the build
   grouped something. Wears its section's hue one notch lighter and one notch tighter, so
   section > sub reads as the same ladder region > country > state does. Rides --ring-w
   (PHONE FLOW 2026-09-05) because a section ring can be spread on a phone. */
.bubble-sub { max-width: calc(var(--bub-group-w) * 0.85 * var(--ring-w));
  background: color-mix(in srgb, var(--bub-hue) 8%, #131a26);
  border-color: color-mix(in srgb, var(--bub-hue) 40%, #2b384c); }
.bubble-sub .bubble-head { font-size: var(--bub-fs-head); font-weight: 700;
  color: color-mix(in srgb, var(--bub-hue) 32%, #ffffff); }
/* The region ring's OVERFLOW node ("66 more in Americas"): the tail of the loose themes,
   collapsed into one labelled bubble instead of sliding behind "+N more". Sized and
   coloured like a country (it sits in the same ring and spreads themes the same way) but
   deliberately quieter — dashed border, lighter weight — because it is a container, not a
   place. */
.bubble-loose { max-width: calc(var(--bub-region-w) * 0.85);
  background: color-mix(in srgb, var(--bub-hue) 5%, #131a26);
  border-style: dashed;
  border-color: color-mix(in srgb, var(--bub-hue) 28%, #2b384c); }
.bubble-loose .bubble-head { font-size: var(--bub-fs-head); font-weight: 600;
  color: color-mix(in srgb, var(--bub-hue) 20%, #cbd6e6); }

/* WIDER WHEN OPEN (2026-08-17). Until now a bubble was the same width closed and open,
   which forced the two asks into conflict: narrow enough for a tidy resting field, and
   tall enough to hold its prose without scrolling — at a fixed width you can only trade
   one for the other, and raising --bub-sum-h to stop the scrolling is what made the
   canvas feel crowded.
   Width is the better lever, because line COUNT is what sets height. Worked on the real
   worst case, the Asia-Pacific region synthesis at 597 characters: at the closed 18rem
   the text column is ~266px ≈ 33 chars/line ≈ 18 lines ≈ 460px, well past the ~323px
   cap, so it scrolls. At 30rem it is ~57 chars/line ≈ 11 lines ≈ 285px and fits with
   room to spare — and 57 characters is also much closer to the 45-75 reading measure
   than 33 was.
   A CLOSED bubble is a label and stays exactly as narrow as it was; only the one you
   opened claims the space. */
.bubble.show-summary { max-width: min(30rem, 92cqi); }
.bubble-leaf.show-summary { max-width: min(26rem, 92cqi); }
.bubble.bubble-root.show-summary { max-width: var(--bub-root-open-w); }
/* The one-line LEAD on a section/region bucket: sits under the "Politics · 36"
   head, above the click-to-open summary panel. It carries the build's own headline
   for these themes (or, when the ladder produced none, an auditable story tally) —
   NOT the summary's first sentence it used to resolve to, which made every bucket
   print its opening line twice. Collapsed, this is the bucket's only prose, so it
   rides the same reading ramp as the summary. */
/* The lead rides --ring-fs (default 1, so a no-op on every ring that did not shrink): a
   bucket whose WIDTH ringScale cut without its TYPE just wraps into a taller box, which is
   the opposite of reducing the field's demand. */
.bubble-lead { margin-top: 0.3rem; font-size: calc(var(--bub-fs-body) * var(--ring-fs)); line-height: 1.5;
  font-weight: 500; color: #cbd6e6; text-wrap: pretty; }
/* THE RESULT RECEIPT (2026-09-24): under a theme an entity search brought up, the counts that
   say why it is there ("3 of 5 stories · 7 of 20 reports"). Muted chrome like the design=1
   receipts line, but unscoped, because it shows in every design. Wraps, never ellipsised:
   the fit measures the real box, so a second line is simply a taller bubble. */
.bubble-result-receipt { margin-top: 0.2rem; font-size: var(--bub-fs-chrome); line-height: 1.4;
  font-weight: 500; letter-spacing: 0.01em; color: #7d8ba1; overflow-wrap: anywhere; }
/* "See related themes" cross-links inside a theme's summary panel (shown with it). */
.bubble-related { display: none; margin-top: 0.5rem; padding-top: 0.4rem; border-top: 1px dashed #263247; }
.bubble.show-summary .bubble-related { display: block; }
.bubble-related-label { display: block; font-size: var(--bub-fs-chrome); text-transform: uppercase;
  letter-spacing: 0.08em; color: #64748b; margin-bottom: 0.2rem; }
/* Wraps: a related theme's headline is shown whole (2026-09-11: nothing in a bubble is
   ellipsised). */
.bubble-related-link { display: block; font-size: var(--bub-fs-chrome); line-height: 1.4; color: #7dd3fc;
  text-decoration: none; overflow-wrap: anywhere; }
.bubble-related-link:hover { text-decoration: underline; }

/* Sources layer rows (the manual Sources tab; fed by the current bubble scope). */
.gobble-sources-list { display: flex; flex-direction: column; gap: 1px; max-height: calc(100vh - 17rem); overflow-y: auto; }
.src-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-radius: 0.375rem; }
.src-row:hover { background: #111726; }
.src-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; background: var(--accent); flex: none; }
.src-name { font-weight: 500; color: #e2e8f0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.src-meta { color: #64748b; font-size: 0.72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.src-count { margin-left: auto; color: #94a3b8; font-size: 0.72rem; flex: none; }
.src-link { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.78rem; }

/* The summary panel: hidden until the first click adds .show-summary. Solid
   background, clearly separated from the headline, sized for comfortable reading. */
.bubble-summary {
  display: none; margin-top: 0.5rem; padding-top: 0.5rem;
  border-top: 1px solid #263247;
  font-size: var(--bub-fs-body); line-height: 1.5; font-weight: 400; color: #b7c2d4;
  /* NO CAP AND NO SCROLLBAR (2026-09-07, owner instruction, second time of asking).
     A panel you are reading grows to fit its prose. The scrollbar was the wrong tool:
     it put chrome inside a bubble and hid prose behind a gesture nobody makes on a
     canvas. `--bub-sum-h` survives only for the three CLAMPED states below (dimmed
     breadcrumb, open parent, phone root), which exist for LAYOUT, and those fade out
     instead of scrolling. Live measure: the global synthesis is 484 chars and section
     summaries 183-374, so at the open 30rem width nothing here reaches a cap anyway. */
  overflow: visible;
  text-wrap: pretty;
  /* Opaque track (the bubble's own background, incl. tinted buckets) so the
     sticky "Show full summary" control can mask prose scrolling beneath it. */
  background: inherit;
}
.bubble.show-summary .bubble-summary { display: block; }
/* An OPEN bubble sits above its neighbours. Since SECTION_SYNTHESIS the buckets carry
   prose, so opening one can triple its height — and the de-collide runs against the
   stage clamp, which on a crowded field cannot always separate a box that large. Rather
   than let the panel read as interleaved with the bubbles behind it, the one you just
   opened comes to the front: the overlap becomes a card on top, not a glitch. */
.bubble.show-summary { z-index: 1200; }
/* Active-branch forefront: the clicked node + its subtree stack above other
   bubbles (traced z:7 and focus z:8 still win) and read as a visual family;
   the CLICKED node carries an accent ring, and while a branch is active the
   rest of the field dims slightly so selection is unmistakable. */
/* DEPTH BANDS (2026-09-06), 1000 wide since 2026-09-11. The front tier was flat in DEPTH:
   `markFront` stamps the clicked node AND its whole subtree, so a parent and its children
   shared one z-index and an opened bucket could paint OVER its own themes — measured on the
   live page, one of four children covered at overlap fraction 1.00 and unclickable.
   bubbles.js stamps `--bdepth` (the node's own tree depth, capped at 9) on every bubble, and
   each depth owns a band of 1000, so EVERY slot of depth d sits under EVERY slot of depth
   d+1 and an ancestor can never bury a descendant.
   SIBLINGS: SMALLER ON TOP (2026-09-11). Inside a band, the per-bubble `--bz` rank paint()
   stamps (1-499 content, 500-899 navigation, the smallest box highest within each) is folded
   into the slot. The band used to be 10 wide with ONE value per slot, so every sibling on an
   open branch tied and DOM order decided who covered whom. Inside a band now: content
   1-499, navigation 500-899, the clicked node 950, pointer 980, keyboard focus 990. The
   deepest band tops out at 2000 + 9000 + 990 = 11990, still under .bubble-traced (12400). */
.bubble.bubble-front { z-index: calc(2000 + 1000 * var(--bdepth, 0) + var(--bz, 2)); border-color: #3b4a63; }
/* ...but the front tier must not be FLAT. `markFront` stamps the clicked node AND its whole
   subtree with .bubble-front, so every bubble on an open branch collapsed to this one value
   and CSS fell back to document order. `regionRing` emits countries.concat(loose), appended in
   that order, so the loose story-themes painted OVER the countries -- every time, and hit
   order is paint order here, so those countries were unclickable. That is the same DOM-order
   failure the --bz total order was written to remove, surviving one tier up inside the ladder
   it introduced. Navigation gets its own front tier, above content's, still under .bubble-active
   (1300) so the clicked bubble keeps topping its own siblings. */
.bubble-front.bubble-region,
.bubble-front.bubble-country,
.bubble-front.bubble-state,
.bubble-front.bubble-sub,
.bubble-front.bubble-group { z-index: calc(2000 + 1000 * var(--bdepth, 0) + var(--bz, 500)); }
.bubble.bubble-active {
  border-color: var(--accent) !important;
  box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--accent) 45%, transparent), 0 4px 20px color-mix(in srgb, var(--accent) 12%, transparent);
  /* the CLICKED bubble tops its own siblings AT ITS OWN DEPTH — never its own children
     (traced 1400 and focus still win outright) */
  z-index: calc(2950 + 1000 * var(--bdepth, 0));
}
/* Global News Synthesis: the page lead is never height-clamped, never
   collapsed, and reads as one bullet per world development. Being the field's
   big always-open BACKDROP, it must never paint over other bubbles — whatever
   its state, sections escaping its box stay visible and clickable. */
/* 1000 -> 0 (2026-09-06). The comment above was already the rule; the value contradicted it.
   RANK_MAX is 999, so at 1000 the root painted over every ring-1 bucket it had just spawned —
   the exact opposite of "must never paint over other bubbles". A positioned 0 still sits above
   the field's own background and below every ranked bubble, which is what a backdrop is. */
.bubble.bubble-root,
.bubble.bubble-root.show-summary,
.bubble.bubble-root.bubble-active { z-index: 0; }
/* HOVER INTENT, the resting half (2026-09-14, owner). When nothing fits clear, fitRingOverRoot
   packs buckets OVER the synthesis, and a pointer on its way to one of them crosses the root's
   exposed area first. With an instant lift the root rose over the very bucket being approached
   and the click landed on the open root, collapsing the ring: measured 2 of 5 over-root buckets
   lost at 1366x768, 5 of 7 at 1280x720, 7 of 7 on a first visit at 1280x720. The hover rule
   (after the lift, below) makes the RISE wait for a dwell. This list is what the browser uses
   when the pointer LEAVES, and its z-index item has no delay, so the DROP is instant: a delayed
   drop would hold the root on top after the pointer left and re-cover the bucket just reached.
   z-index interpolates as an integer, so `0s` with a delay is a jump at the delay.
   The list repeats `.bubble`'s (a shorthand replaces the whole list) plus z-index, and sits on
   the TWO-class selector alone: the open root always carries .show-summary.bubble-active, and a
   three-class copy would out-specify `.bubble.is-dragging { transition: none }` below and put the
   0.55s ease back on a dragged synthesis. Trace focus (three classes) still replaces the list
   while a story is traced; the traced card owns the stage then and no bucket is approached. */
.bubble.bubble-root {
  transition: transform 0.55s cubic-bezier(0.22,0.61,0.36,1),
              box-shadow 0.2s ease, border-color 0.2s ease, opacity 0.2s ease,
              z-index 0s linear 0s;
}
/* The GLOBAL SYNTHESIS grows to fit — no cap, no scroll. `d3a5a59` made it uncapped on
   owner instruction and `3b1482e` put the cap back the next day to keep the panel on the
   canvas; the owner asked again on 2026-09-07, so the cap goes and stays gone. The
   original worry (an uncapped bullet list running off the canvas) is not what the data
   shows: the synthesis is 484 characters, which at the open 30rem measure is ~9 lines,
   about 220px — inside even the cap this rule used to impose. Pinned by
   test_bubble_summary_never_scrolls.py so it cannot come back a third time. */
.bubble-root .bubble-summary { max-height: none; overflow: visible; }
/* Digest bullet list — the root synthesis AND multi-story theme summaries. */
/* Bullet separation carries most of the "pickable apart" work now that EVERY
   multi-sentence panel is a list: at gap 0.4rem with line-height 1.5 the space
   between two bullets was smaller than the space between two wrapped lines of the
   SAME bullet, so the list read as one block again. The gap must beat the line box. */
.syn-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.65rem; text-align: left; }
.syn-list li { position: relative; padding-left: 1rem; line-height: 1.5; }
.syn-list li::before { content: "•"; position: absolute; left: 0; top: 0; color: var(--accent); }
/* FULL TOPIC ISOLATION (both designs): while a branch is active, only that
   branch, its ancestor path (dimmed wayfinding), and a traced story remain
   visible — every other bubble hides (visibility keeps layout stable). */
.bubble-field.has-front
  .bubble:not(.bubble-front):not(.bubble-front-path):not(.bubble-traced) { visibility: hidden; }
/* The path back FADES BY STEP (owner, 2026-09-23). It used to dim the whole bubble to 0.6, and
   an opened bucket lays its themes over the root, so the root's bullets showed through every gap
   at that strength and read as half-hidden theme text ("hard to read"). Hiding the text outright
   was tried and revised: "the total loss of the section and region text looks weird. have it
   faded slightly and the global synthesis text faded moreso, and each step down the hierarchy
   causes more fading." bubbles.js::pathFadeSteps writes data-path-fade (distance from the open
   bubble, +1 for the root, max 4). The frame stays solid and the heading lightly dimmed (the way
   back); only the summary fades by step. No blanket bubble opacity: it would multiply every step.
   Hover or keyboard focus brings a faded bubble back to full brightness (2026-09-25, the
   hover-supremacy block near the end of the file). Pinned by test_bubble_backdrop_fades_by_step.py. */
.bubble-field.has-front .bubble.bubble-front-path .bubble-head { opacity: 0.8; }
.bubble-field.has-front .bubble.bubble-front-path[data-path-fade="1"] .bubble-summary { opacity: 0.6; }
.bubble-field.has-front .bubble.bubble-front-path[data-path-fade="2"] .bubble-summary { opacity: 0.4; }
.bubble-field.has-front .bubble.bubble-front-path[data-path-fade="3"] .bubble-summary { opacity: 0.25; }
.bubble-field.has-front .bubble.bubble-front-path[data-path-fade="4"] .bubble-summary { opacity: 0.15; }
/* NO CLAMPS (owner, 2026-09-11): "no truncation or text diet or scrolls". Two layout clamps
   used to sit here, and the stage-feasibility argument that installed them. The dimmed
   ancestor path had its panel held to 4.5rem, faded, and its width squeezed to 22rem. An
   open parent had its panel held to 9rem, faded. The Global Synthesis had a block of
   exemptions to escape both. All of it is gone.
   They existed because the old layout spawned more than the stage could hold (fixed counts,
   and the synthesis floor spawning every cited theme) and had to take the room back from
   the panels. The one fold rule fits the ring to the room instead (bubbles.js fitRing), over
   the boxes as they really are, so every panel renders whole in every state, the opened
   parent and the path are fixed obstacles its children never cover, and nothing inside a
   bubble is cut, faded, ellipsised or scrolled. */

/* A story leaf whose Analysis is being traced lights up. */
.bubble-selected { border-color: #ffffff !important;
  box-shadow: 0 0 0 2px rgba(255,255,255,0.35), 0 3px 18px rgba(255,255,255,0.15) !important; z-index: 1200; }

/* Expandability cue: a small "more inside" badge. The root announces its N
   sections (its lone-bubble load reads as an invitation); a theme/sub-theme with
   children shows the child count + a chevron so the tree's depth is legible
   BEFORE you click. */
.bubble-count { font-size: var(--bub-fs-chrome); font-weight: 600; white-space: nowrap;
  color: color-mix(in srgb, var(--bub-hue) 45%, #94a3b8); }
.bubble-root .bubble-count { color: var(--accent-hi); }
.bubble-count-mini { margin-left: 0.4rem; display: inline-flex; align-items: center;
  padding: 0.02rem 0.34rem; border-radius: 9999px;
  background: color-mix(in srgb, var(--bub-hue) 14%, transparent);
  border: 1px solid color-mix(in srgb, var(--bub-hue) 30%, #2b384c); }
.bubble-count-mini::after { content: "\203a"; margin-left: 0.2rem; opacity: 0.65; font-weight: 700; }

/* PROSE MARKER. A bubble whose whole head is a tally ("12 related articles") reads as a dead
   end, so readers never click it -- and 43% of canvas nodes wear one. This says "there is
   something written inside", and it is drawn ONLY where `hasNetNewProse` is true, so it can
   never promise content that is not there. Deliberately quiet: an affordance beside the
   label, not a badge competing with it. */
.bubble-prose-mark { margin-left: 0.4rem; display: inline-block; width: 0.85em; height: 0.85em;
  flex: none; vertical-align: -0.05em; border-radius: 2px;
  background: color-mix(in srgb, var(--bub-hue) 26%, transparent);
  border: 1px solid color-mix(in srgb, var(--bub-hue) 52%, #2b384c); }
.bubble-prose-mark::after { content: ""; display: block; margin: 0.2em 0.15em 0;
  border-top: 1px solid color-mix(in srgb, var(--bub-hue) 70%, #94a3b8);
  box-shadow: 0 0.22em 0 -0.02em color-mix(in srgb, var(--bub-hue) 55%, #94a3b8); }
.bubble-has-prose:hover .bubble-prose-mark { background: color-mix(in srgb, var(--accent) 30%, transparent); }
/* Once it IS open the marker has said its piece. */
.bubble.show-summary .bubble-prose-mark { opacity: 0.35; }

/* Trace pill on a story leaf: compact, legible, ALWAYS visible so tracing is
   discoverable (you can see a story is traceable). On click it opens the
   Analysis pane and isolates this story (trace-focus below). */
.bubble-trace { display: inline-flex; align-items: center; gap: 0.18rem;
  margin-top: 0.5rem; padding: 0.16rem 0.55rem; border-radius: 9999px;
  font-size: var(--bub-fs-chrome); font-weight: 600; letter-spacing: 0.02em; line-height: 1.1;
  color: #d7f5ea; background: color-mix(in srgb, var(--accent-hi) 15%, #0f1a17);
  border: 1px solid color-mix(in srgb, var(--accent-hi) 45%, #2b384c);
  cursor: pointer; transition: background 0.15s ease, border-color 0.15s ease; }
.bubble-trace:hover { background: color-mix(in srgb, var(--accent-hi) 26%, #0f1a17);
  border-color: color-mix(in srgb, var(--accent-hi) 72%, #2b384c); }
.bubble-trace:focus-visible { outline: 2px solid var(--accent-hi); outline-offset: 1px; }
.bubble-trace-arrow { font-weight: 700; }

/* Trace focus: while a story is being traced, every OTHER bubble blurs + dims so
   the traced story stands alone and the eye goes straight to it (and to the
   Analysis pane it just opened). The traced leaf itself stays crisp + lit. */
.bubble-field.trace-focus .bubble {
  filter: blur(2px) saturate(0.6); opacity: 0.4;
  transition: filter 0.2s ease, opacity 0.2s ease; }
.bubble-field.trace-focus .bubble-edge { opacity: 0.2; }
.bubble-field.trace-focus .bubble-traced {
  filter: none; opacity: 1; z-index: 12400;
  border-color: var(--accent-hi) !important;
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-hi) 45%, transparent), 0 4px 22px color-mix(in srgb, var(--accent-hi) 20%, transparent) !important;
  /* The traced story OWNS the canvas: with everything blurred behind it, the
     card centres and fills the stage, text scaled up, summary unclamped — the
     reader must NEVER scroll inside the one bubble they are tracing. The
     inline transform (pixel position) is overridden for the trace only;
     exiting trace restores the untouched inline value. */
  left: 50% !important; top: 50% !important;
  transform: translate(-50%, -50%) !important;
  width: min(94%, 1100px); max-width: none; max-height: 94%;
}
/* The traced card is the one place that was already right (head > body, no scroll).
   Now it says so in the shared scale rather than two magic rem values: one step up
   from the field's head, body one step up from the field's body. */
.bubble-field.trace-focus .bubble-traced .bubble-head { font-size: var(--bub-fs-head-xl); line-height: 1.3; }
.bubble-field.trace-focus .bubble-traced .bubble-summary {
  max-height: none; overflow-y: visible; font-size: var(--bub-fs-head); line-height: 1.55; }
.bubble-field.trace-focus .bubble-traced .bubble-lead { font-size: var(--bub-fs-head); line-height: 1.5; }

@media (prefers-reduced-motion: reduce) {
  .bubble { transition: none; }
  .bubble-field.trace-focus .bubble { transition: none; }
  /* The root's list above is two classes, so the one-class `none` does not reach it. Zero the
     DURATIONS only: the hover-intent delay is an interaction guard, not motion (z-index jumps,
     nothing animates), and `transition: none` would take it away and restore the hover trap for
     exactly the readers who asked for less motion. */
  .bubble.bubble-root { transition-duration: 0s; }
}

/* Keyboard focus ring on a bubble (the ARIA tree's roving-tabindex item). Mouse
   users never see it (:focus-visible); keyboard users get a clear accent ring. */
.bubble:focus { outline: none; }
.bubble:focus-visible { outline: 2px solid #7dd3fc; outline-offset: 2px; z-index: 12500; }

/* DESKTOP DRAG (2026-09-11). While a bubble is in the reader's hand its position is written
   on every animation frame, so the 0.55s transition every other move uses has to go — with it
   the box eases toward the pointer and lags a fast drag by its whole duration. It also rises
   above every semantic tier (focus is 12500, the highest) for the length of the gesture, so
   the thing being dragged is never behind the thing it is being dragged over.
   The class is added by bubbles.js only past the ~5px threshold, so a plain click never
   touches it — and only on a desktop, because a phone never starts a drag at all. */
.bubble.is-dragging {
  transition: none;
  cursor: grabbing;
  z-index: 12600;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
}
/* The cursor belongs to the whole field for the duration: the pointer regularly leaves the
   bubble's own box mid-drag (pointer capture keeps the gesture, but not the hover). */
.bubble-field.is-dragging,
.bubble-field.is-dragging .bubble { cursor: grabbing; }

/* "Reset layout" — offered ONLY while at least one bubble has been dragged, and removed the
   moment the last pin goes (bubbles.js syncPinControl). It sits on the field rather than in
   .bubble-layer, so it is not a .bubble and no ring or probe counts it as one; the fit takes
   its box as an obstacle, so it never ends up drawn over a bubble.
   z-index 5 clears .bubble-layer (2), which is a stacking context — every bubble z-index in
   this file is INSIDE it, so 5 is above all of them without joining that ladder. */
.bubble-reset {
  position: absolute; top: 0.6rem; right: 0.6rem; z-index: 5;
  appearance: none; cursor: pointer;
  padding: 0.3rem 0.7rem; border-radius: 9999px;
  font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.02em; line-height: 1.2;
  color: #d7f5ea; background: rgba(17, 23, 38, 0.95);
  border: 1px solid color-mix(in srgb, var(--accent-hi) 45%, #2b384c);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}
.bubble-reset:hover { border-color: color-mix(in srgb, var(--accent-hi) 72%, #2b384c); }
.bubble-reset:focus-visible { outline: 2px solid var(--accent-hi); outline-offset: 2px; }

/* Transient "no sub-themes" cue: a bubble with nothing to open or spread turns
   the click into a pure selection (bubbles.js flashNoChildren). Fades in and
   back out; the JS removes the element after ~1.8s. */
.bubble-nokids {
  display: block; margin-top: 0.35rem;
  font-size: var(--bub-fs-chrome); font-weight: 600; letter-spacing: 0.03em;
  color: #94a3b8; opacity: 0;
  animation: bubble-nokids-fade 1.8s ease forwards;
}
@keyframes bubble-nokids-fade {
  0% { opacity: 0; } 15% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .bubble-nokids { animation: none; opacity: 1; }
}

/* "New data available — refresh view" pill (app.js poll guard): floats over the
   bubble pane when a poll lands while the reader is drilled in, instead of the
   field being rebuilt under them. Chip-family look; z 9 rides above the bubble
   hover/open ladder (5-8) so an expanded bubble can never bury the offer. */
[data-refresh-pill] {
  position: absolute; top: 0.6rem; left: 50%; transform: translateX(-50%);
  z-index: 9; display: inline-flex; align-items: center; gap: 0.1rem;
  padding: 0.1rem 0.25rem 0.1rem 0.35rem;
  background: rgba(17, 23, 38, 0.95);
  border: 1px solid var(--accent); border-radius: 999px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
  max-width: calc(100% - 1.5rem);
}
[data-refresh-pill] .refresh-pill-action {
  appearance: none; background: transparent; border: 0; cursor: pointer;
  color: var(--accent); font-size: 0.72rem; font-weight: 600;
  padding: 0.25rem 0.3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
[data-refresh-pill] .refresh-pill-action:hover { text-decoration: underline; }
[data-refresh-pill] .refresh-pill-close {
  appearance: none; background: transparent; border: 0; cursor: pointer;
  color: #94a3b8; font-size: 0.85rem; line-height: 1; padding: 0.25rem 0.35rem;
  border-radius: 999px;
}
[data-refresh-pill] .refresh-pill-close:hover { color: #e2e8f0; }
[data-refresh-pill] .refresh-pill-action:focus-visible,
[data-refresh-pill] .refresh-pill-close:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 1px;
}

/* --- Orientation breadcrumb: Global › Section › Region › theme -------------
   A slim trail above the field; segments are buttons that step back up to that
   level (collapsing everything below — also thins the field). Hidden at root.

   ONE LINE, ALWAYS (2026-09-05). The bubble canvas is the flex FILLER of the spine
   column (.gobble-spine .gobble-bubble-pane {height:auto; flex:1 1 auto}), so every
   pixel this trail takes, the canvas loses — and it appears only once a story is
   traced, which is exactly the "extra space that shows up when a story is clicked".
   It used to WRAP: three segments became two or three lines on a narrower window,
   ~50 px straight out of the field. Now the row is nowrap + overflow:hidden and the
   segments are shrinkable (min-width: 0 — a flex item's automatic minimum size is its
   content, so without this a nowrap row just overflows). The trailing crumb, the story
   title, shrinks 4× harder than the leading ones, so the section labels a reader
   steers by survive and the long title takes the ellipsis. */
.gobble-spine-breadcrumb { display: flex; flex-wrap: nowrap; align-items: center; gap: 0.1rem;
  margin-bottom: 0.4rem; font-size: 0.68rem; line-height: 1.35; overflow: hidden; }
.spine-bc-seg { background: none; border: 0; padding: 0.05rem 0.25rem; border-radius: 0.3rem;
  color: #7dd3fc; cursor: pointer; min-width: 0; max-width: 22rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spine-bc-seg:hover { background: #16202f; text-decoration: underline; }
.spine-bc-seg:focus-visible { outline: 2px solid #7dd3fc; outline-offset: 1px; }
.spine-bc-seg[aria-current="true"] { color: #e2e8f0; font-weight: 600; cursor: default;
  flex-shrink: 4; }
.spine-bc-seg[aria-current="true"]:hover { background: none; text-decoration: none; }
.spine-bc-sep { color: #475569; font-size: 0.72rem; padding: 0 0.05rem; flex: 0 0 auto; }

/* --- Section colour legend: tiny hue dots + names, so the bubble colours are
   decodable at a glance. Compact + muted so it doesn't add visual weight. ---- */
.gobble-spine-legend { display: flex; flex-wrap: wrap; gap: 0.15rem 0.7rem; margin-bottom: 0.5rem; }
/* Legend items are BUTTONS now — a section menu that focuses the canvas +
   open tabs; the picked one reads pressed (accent). */
.spine-legend-item { display: inline-flex; align-items: center; gap: 0.3rem;
  font-size: 0.62rem; color: #94a3b8; white-space: nowrap;
  background: transparent; border: 1px solid transparent; border-radius: 999px;
  padding: 0 0.4rem; cursor: pointer; }
.spine-legend-item:hover, .spine-legend-item:focus-visible { color: #e2e8f0; border-color: #3b4a63; }
.spine-legend-item[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
/* SECTION strip (2026-07-30): the legend/focus buttons sit in one slim
   always-visible row directly above the bubble canvas — no label, no
   collapsible, desktop only (mobile never had it). [hidden] must actually
   hide it: the author display rule would otherwise beat the UA [hidden]. */
.section-strip { margin: 0 0 0.3rem; gap: 0.1rem 0.45rem; }
.section-strip[hidden] { display: none !important; }
@media (max-width: 1023.98px) { .section-strip { display: none; } }
/* LAYER strip: the tab chips + layout select in one slim row atop the detail
   pane, above whichever informational panels are showing. */
.layer-strip {
  flex-wrap: wrap; align-items: center; gap: 0.3rem;
  padding: 0.35rem 1.25rem 0.3rem;
  border-bottom: 1px solid #1c2434;
  flex: 0 0 auto;
}
.spine-legend-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; flex: none;
  box-shadow: 0 0 0 1px rgba(255,255,255,0.08); }

/* Reduce Plotly's default light backgrounds inside dark cards. */
.js-plotly-plot .plotly .main-svg { background: transparent !important; }

/* Tone charts (sentiment layer): the Plotly hosts fill their card and clip any
   transient oversize. A chart drawn while its card is laid out at 0/unknown width
   falls back to Plotly's ~700px default; clamping the host stops that SVG from
   spilling over the neighbouring grid column until the reflow (entity.js /
   relayoutLayer) re-measures it to the card width. */
.tone-chart { width: 100%; overflow: hidden; }
.tone-chart .js-plotly-plot, .tone-chart .svg-container { max-width: 100%; }

/* --------------------------------------------------------------------------
   Unified single-page infographic: brushing-and-linking states.
   Selecting any item DIMS non-matching items (never removes them); the active
   selection is gently emphasised. Colour is reserved for the highlight.
   -------------------------------------------------------------------------- */

/* Non-matching items dim to ~0.6 opacity (transition so a re-focus reads as a
   change, not a reload). Pointer stays active so you can still pick a dim item. */
.dim {
  opacity: 0.6;
  filter: saturate(0.7);
  transition: opacity 0.18s ease, filter 0.18s ease, border-color 0.18s ease;
}

/* The selected card/row gets an accent edge (the one colour we reserve). */
.selected {
  border-color: var(--accent) !important;
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
  opacity: 1;
}

/* Theme + story + source cards animate their dim/un-dim smoothly. */
.theme-card,
.story-card,
.source-row { transition: opacity 0.18s ease, border-color 0.18s ease; }

/* --------------------------------------------------------------------------
   MASTER–DETAIL layout.
   Desktop (>=1024px): a 100vh two-pane flex; the PAGE does not scroll — each
   pane scrolls internally (overflow:auto; min-height:0). Left spine ~340px;
   right detail flex-1. Mobile (<1024px): stacked; the spine becomes the
   "Overview" tab and the detail tabs swap panes via the mobile tab bar.
   -------------------------------------------------------------------------- */
.gobble-shell { min-height: 100vh; display: flex; flex-direction: column; }
/* Consolidated methodology fine print. On the SCROLLING pages (sources, entity
   dossier) it is the document footer, below the content. */
/* BORDERLESS by design (2026-08-13): the notice is required on every screen but
   should cost the canvas as little as possible, so there is no rule, no band and
   no vertical padding — just one muted line flush with the shell. The methodology
   text and the third-party attributions share ONE disclosure; two summary lines
   cost twice the permanent viewport band that one does. */
.gobble-fineprint {
  flex: 0 0 auto;
  padding: 0 1rem 0.15rem;
  font-size: 10px; line-height: 1.35; color: #64748b;
}
.gobble-fineprint em { font-style: normal; color: #94a3b8; }
/* The app shell is fixed at 100vh with page scrolling off, so this footer is a
   PERMANENT viewport band: open, the paragraph held ~75px of prime real estate
   away from the bubble canvas on every screen. It ships COLLAPSED instead, one
   summary line (~27px), text one click away.

   It cannot simply move inside the detail pane: that pane auto-collapses when
   no layer is on (app.js `autoCollapsed = activeLayers.length === 0`) and the
   boot default is no layer at all, so the notice would be invisible on a first
   visit rather than out of the way. A legal notice may be quiet, not absent. */
.fineprint-disclosure > summary {
  cursor: pointer;
  list-style: none;
  color: #64748b;
  user-select: none;
}
.fineprint-disclosure > summary::-webkit-details-marker { display: none; }
.fineprint-disclosure > summary::before {
  content: "▸";
  display: inline-block;
  width: 0.9em;
  color: #475569;
  font-size: 0.85em;
}
.fineprint-disclosure[open] > summary::before { content: "▾"; }
.fineprint-disclosure > summary:hover { color: #94a3b8; }
.fineprint-disclosure > p { margin-top: 0.3rem; max-width: 90ch; }
/* "What we know & don't" — ONE flat block (2026-09-04). The receipt paragraph, the
   source-divergence line, then a flat ledger of <li> rows. No nested <details> per row or
   group any more: every receipt is an inline chip, and its content (outlet names, a count,
   a one-line note) rides on hover/focus through the shared [data-tip] rule above. The only
   fold left is the single-source statements (.ku-unknowns). */
.ku-receipt { margin: 0.55rem 0 0; font-size: 0.8rem; line-height: 1.55; color: #cbd5e1; max-width: 90ch; }
.ku-clause[data-outlets] { border-bottom: 1px dotted #475569; cursor: help; }
.ku-clause[data-outlets]:hover,
.ku-clause[data-outlets]:focus { color: #f1f5f9; border-bottom-color: #94a3b8; outline: none; }
.ku-diverge { margin: 0.35rem 0 0; font-size: 0.72rem; color: #94a3b8; max-width: 90ch; }
.ku-cluster { color: #cbd5e1; }
.ku-cluster[data-tip] { border-bottom: 1px dotted #475569; }
.ku-ledger { list-style: none; margin: 0.5rem 0 0; padding: 0; font-size: 0.75rem; }
.ku-row {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.2rem 0.5rem;
  padding: 0.35rem 0; border-top: 1px solid #263247;
}
.ku-ledger > .ku-row:first-child { border-top: none; }
.ku-row-label { color: #e2e8f0; min-width: 0; }
.ku-glyphs { display: inline-flex; align-items: baseline; gap: 0.3rem; }
.ku-chip {
  display: inline-flex; align-items: baseline; gap: 0.2rem;
  font-size: 0.65rem; line-height: 1.4; padding: 0 0.4rem;
  border: 1px solid #273043; border-radius: 999px; color: #94a3b8;
}
.ku-chip[data-tip] { cursor: help; }
.ku-chip[data-tip]:hover,
.ku-chip[data-tip]:focus { border-color: #3b4a63; color: #e2e8f0; outline: none; }
.ku-src-chip { font-size: 0.7rem; flex: 0 0 auto; }
.ku-backers { font-size: 0.68rem; color: #64748b; }
.ku-note { font-size: 0.68rem; }
.ku-why { flex: 1 1 100%; font-size: 0.7rem; color: #94a3b8; }
/* Entity anchor: an inline chip-sized line; the Wikidata description is its title. */
.ku-ent-line { font-size: 0.7rem; }
.ku-ledger-fold { border-top: 1px solid #263247; }
.ku-unknowns > summary { cursor: pointer; font-size: 0.72rem; color: #7dd3fc; padding: 0.35rem 0; }
.ku-unknowns > .ku-ledger { margin: 0 0 0.25rem; padding-left: 0.6rem; border-left: 1px solid #263247; }
.ku-unknowns > .ku-ledger > .ku-row { border-top: none; padding: 0.2rem 0; }

/* LAYERS grid: only ONE tab shows at a time now (MAX_OPEN_LAYERS === 1), so the
   detail body is a single column — the Analysis (or a manually-opened Semantics /
   Sentiments) tab fills the pane. align-content:start keeps a short card top-
   aligned rather than stretched. */
.gobble-detail-body {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.75rem;
  align-content: start;
}

@media (min-width: 1024px) {
  /* Page itself is fixed at 100vh; panes scroll internally. */
  html { height: 100%; }
  body.gobble-app { height: 100%; overflow: hidden; }

  .gobble-main {
    flex: 1 1 auto;
    display: flex;
    min-height: 0;            /* allow children to shrink + scroll */
    overflow: hidden;
    position: relative;       /* anchors the spine's left-edge fold chip */
  }
  .gobble-spine {
    /* The bubble spine is the centre of the page, so it takes a generous slice —
       when a data layer is open it's a balanced split; with none open the pane
       folds and the spine takes the whole viewport (.detail-collapsed, below).
       The old hard 640px ceiling meant a 2560px monitor gave the spine no more
       room than a 1520px one — the cap is now a share of the screen, not a fixed
       pixel count, so a bigger display actually buys a bigger reading column. */
    flex: 0 0 clamp(380px, 46vw, 900px);
    width: clamp(380px, 46vw, 900px);
    border-right: 1px solid #273043;
    overflow: auto;
    min-height: 0;
    /* Slim top gap: the section strip is the first element and should sit
       tight under the banner, not float below dead space. */
    padding: 0.4rem 1rem 0.5rem;
    /* Definite height from the flex row → the field below can simply fill it,
       instead of guessing at the viewport with calc(100vh - <magic>). */
    display: flex;
    flex-direction: column;
  }
  .gobble-spine .gobble-bubble-section { flex: 1 1 auto; min-height: 0; }
  .gobble-spine .gobble-bubble-pane { height: auto; flex: 1 1 auto; min-height: 0; }
  /* THE DESKTOP STAGE IS THE PANE (2026-09-11 follow-up). bubbles.js never sizes the canvas
     past it and nothing scrolls it: a ring that cannot fit clear of the anchored root is laid
     over it as its backdrop instead (fitRingOverRoot; owner, 2026-09-08). */
  .gobble-detail {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    min-height: 0;
    min-width: 0;
    overflow: hidden;
    position: relative;       /* anchors the pane's left-edge fold chip */
  }
  /* (The old .gobble-detail-head row is gone — the fold control is now a
     floating .pane-fold-chip overlay, so panel content starts at the pane top.) */
  .gobble-detail-body {
    flex: 1 1 auto;
    overflow: auto;
    min-height: 0;
    padding: 1.25rem 1.25rem 0.5rem;
  }

  /* --- Collapsible detail pane (desktop) ---------------------------------
     A toggle (data-detail-collapse) adds .detail-collapsed to .gobble-main:
     the detail pane slides to 0 width and the spine claims the freed space
     (full-width reading mode — see the .gobble-spine container query below).
     A slim re-open rail (data-detail-rail) appears at the right edge. JS also
     sets [hidden]+aria-hidden on the detail so it leaves the a11y/tab tree,
     and calls relayoutVisibleLayers() on expand so charts re-measure. */
  .gobble-spine { transition: flex-basis .28s ease, width .28s ease, opacity .2s ease; }
  .gobble-detail { transition: flex-basis .28s ease, opacity .2s ease; }
  .gobble-main.detail-collapsed .gobble-detail {
    flex: 0 0 0;
    width: 0;
    opacity: 0;
    overflow: hidden;
    pointer-events: none;
  }
  .gobble-main.detail-collapsed .gobble-spine {
    flex: 1 1 auto;
    width: auto;          /* override the fixed 340px */
    border-right: none;   /* no divider when it's the only pane */
  }
  .gobble-main.detail-collapsed .gobble-detail-rail { display: flex; }
  /* The rail shows on EVERY fold (manual or auto): the LAYER strip lives inside
     the pane now, so the rail is the one-click route back to the chips. */

  /* --- Collapsible SPINE (desktop) — the mirror fold ---------------------
     The left-edge chip (data-spine-collapse) adds .spine-collapsed: the SPINE
     slides to 0 width and the detail pane spans the full screen width. A slim
     re-open rail (data-spine-rail) appears at the LEFT edge. At most ONE pane
     folds at a time — app.js un-folds the other side (setSpineCollapsed /
     applyDetailCollapsed), so .spine-collapsed.detail-collapsed never renders. */
  .gobble-main.spine-collapsed .gobble-spine {
    flex: 0 0 0;
    width: 0;
    opacity: 0;
    overflow: hidden;
    pointer-events: none;
    padding-left: 0;      /* border-box: the pane's padding would hold it ~2rem wide */
    padding-right: 0;
    border-right: none;
  }
  .gobble-main.spine-collapsed .gobble-detail { flex: 1 1 auto; }
  .gobble-main.spine-collapsed .gobble-spine-rail { display: flex; }
  /* The fold chip yields to the rail while the spine is away. */
  .gobble-main.spine-collapsed .gobble-spine-collapse { display: none; }
  /* Solo layer: when only ONE layer is on, let it fill the pane height instead
     of reading short under the grid's clamped card height. (app.js toggles
     .layers-solo when activeLayers.length === 1.) */
  .gobble-detail-body.layers-solo { grid-template-rows: minmax(0, 1fr); align-content: stretch; }
  .gobble-detail-body.layers-solo .detail-panel { height: 100%; }
  /* 10rem, not the old 12.5rem: the detail head row (~2.75rem) is gone, so the
     canvas reclaims that height (a small slack beats a scrollbar if fonts drift). */
  .gobble-detail-body.layers-solo #map-canvas { height: calc(100vh - 10rem); min-height: 360px; }
  /* Entities: the PANE (not the canvas) owns the height. Every direct child of
     the panel is content-sized except the canvas wrapper, which flexes to the
     whole remainder — so the graph claims every pixel the card has, and the
     solo row's minmax(0, 1fr) above keeps the card bounded by the viewport
     instead of pushing the canvas below the scroll fold. */
  #panel-entities > * { flex: 0 0 auto; }
  /* The graph body (canvas + side list) takes the remainder; inside it the
     canvas pane flexes, so the graph still claims every pixel the list leaves. */
  #panel-entities [data-graph-body] { flex: 1 1 auto; min-height: 0; }
  #panel-entities [data-graph-pane] { flex: 1 1 auto; min-height: 0; }
  /* The mobile tab bar is desktop-hidden via Tailwind lg:hidden already. */
}

/* Mobile / tablet (<1024px): stacked panes, only the active one shown. The
   LAYERS chips stay visible (they're role="group", not a tablist) so the user
   can still toggle which layers show. */
@media (max-width: 1023.98px) {
  .gobble-main { display: block; }
  .gobble-spine { padding: 1rem 1rem 2rem; }
  .gobble-detail-body { padding: 1rem 1rem 2rem; }
  /* Entities graph: a stacked scrolling page gives the pane no definite height,
     so the desktop height:100% would collapse — use a viewport slice instead. */
  #graph-canvas { height: clamp(300px, 60vh, 560px); }
  /* Hide non-active panes; .pane-hidden toggled by app.js for mobile. */
  .pane-hidden { display: none !important; }
  /* The pane-fold affordances are desktop-only: on mobile the section tabs
     own pane visibility, so the fold chips + rails never show. */
  .gobble-detail-collapse,
  .gobble-spine-collapse,
  .gobble-detail-rail,
  .gobble-spine-rail { display: none !important; }
}

/* Phones: stack the layer grid to a single column. */
@media (max-width: 640px) {
  .gobble-detail-body { grid-template-columns: 1fr; }
}

/* --------------------------------------------------------------------------
   Detail-collapse re-open rail + reduced-motion. The rail is hidden until the
   detail pane is collapsed (the desktop rule above flips it to display:flex);
   it sits as the last flex child of .gobble-main, pinned to the right edge.
   -------------------------------------------------------------------------- */
.gobble-detail-rail {
  display: none;                 /* shown only when .detail-collapsed (desktop) */
  flex: 0 0 2.25rem;
  align-self: stretch;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  border: none;
  border-left: 1px solid #273043;
  background: #0b0f17;
  color: #94a3b8;
  cursor: pointer;
  writing-mode: vertical-rl;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.04em;
}
.gobble-detail-rail:hover { color: var(--accent); border-left-color: var(--accent); }
.gobble-detail-rail:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Left-edge mirror: re-opens the SPINE when it's collapsed. Sits as the FIRST
   flex child of .gobble-main, pinned to the left edge (desktop rule above flips
   it to display:flex under .spine-collapsed). */
.gobble-spine-rail {
  display: none;                 /* shown only when .spine-collapsed (desktop) */
  flex: 0 0 2.25rem;
  align-self: stretch;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  border: none;
  border-right: 1px solid #273043;
  background: #0b0f17;
  color: #94a3b8;
  cursor: pointer;
  writing-mode: vertical-rl;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.04em;
}
.gobble-spine-rail:hover { color: var(--accent); border-right-color: var(--accent); }
.gobble-spine-rail:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* --------------------------------------------------------------------------
   Pane fold chips — small floating controls OVERLAID on the shell, one per
   pane, styled as a matching family (translucent-ink chip, matches
   .graph-reset-btn / .strip-collapse). They cost no layout row: panel content
   starts at the top of the pane. Each is pinned at its pane's LEFT edge,
   vertically centred — ⟨ folds the spine, ⟩ folds the data pane. Desktop-only
   (Tailwind `hidden lg:flex` + the <1024px hide above); display comes from the
   utility classes, so this rule never sets it.
   -------------------------------------------------------------------------- */
.pane-fold-chip {
  position: absolute;
  left: 0.35rem;
  top: 50%;
  transform: translateY(-50%);
  z-index: 30;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 2.6rem;
  appearance: none;
  background: rgba(11, 15, 23, 0.8);   /* translucent ink — legible over content */
  border: 1px solid #273043;           /* chip family (.layer-chip) styling */
  border-radius: 999px;
  color: #94a3b8;
  font-size: 0.8rem;
  line-height: 1;
  cursor: pointer;
}
.pane-fold-chip:hover { color: var(--accent); border-color: var(--accent); }
.pane-fold-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .gobble-spine, .gobble-detail { transition: none !important; }
}

/* --------------------------------------------------------------------------
   2026 fluid spine reading mode (container queries + clamp). The spine is a
   container (container-type: inline-size), so its content reacts to the spine's
   OWN width — enlarging whether it's wide because the detail collapsed (desktop)
   OR because it's full-width (mobile/tablet). Pure superset: at the 340px
   expanded-desktop width nothing changes (below the first breakpoint). `cqi` is
   the container-inline unit, so clamp() interpolates against the spine width.
   We override the exact Tailwind text utilities the spine rows emit
   (text-sm / text-xs / text-[0.7rem] / text-[0.65rem]); the descendant selector
   outscopes the bare utility (0,0,2,0 > 0,0,1,0) without !important and never
   touches identical classes elsewhere on the page.
   -------------------------------------------------------------------------- */
.gobble-spine {
  container-type: inline-size;
  container-name: spine;
}
@container spine (min-width: 520px) {
  .gobble-spine .text-sm { font-size: clamp(0.95rem, 0.85rem + 0.5cqi, 1.08rem); line-height: 1.5; }
  .gobble-spine .text-xs { font-size: clamp(0.85rem, 0.78rem + 0.4cqi, 0.98rem); line-height: 1.5; }
  .gobble-spine .text-\[0\.7rem\] { font-size: clamp(0.8rem, 0.72rem + 0.3cqi, 0.92rem); }
  .gobble-spine .text-\[0\.65rem\] { font-size: clamp(0.78rem, 0.7rem + 0.25cqi, 0.88rem); }
}
@container spine (min-width: 820px) {
  .gobble-spine .text-sm { font-size: clamp(1.05rem, 0.9rem + 0.7cqi, 1.3rem); line-height: 1.6; }
  .gobble-spine .text-xs { font-size: clamp(0.95rem, 0.82rem + 0.55cqi, 1.12rem); line-height: 1.6; }
  .gobble-spine .text-\[0\.7rem\] { font-size: clamp(0.86rem, 0.76rem + 0.35cqi, 1rem); }
  .gobble-spine .text-\[0\.65rem\] { font-size: clamp(0.82rem, 0.72rem + 0.3cqi, 0.95rem); }
}

/* --------------------------------------------------------------------------
   BUBBLE reading ramp — the one that actually bites.
   The ramp above targets the Tailwind utilities (.text-sm/.text-xs) that the OLD
   renderSpineTree() emitted; bubbles.js emits .bubble-* and inherits NONE of it,
   so a full-width spine used to strand 12.8px headlines and 11.5px prose in a
   ~1900px field. These rules grow the bubble vars against the spine's own inline
   size, so the type and the card measure track the room actually on screen.

   Every clamp()'s MIN is the previous fixed value and the whole block is gated at
   560px, so a narrow spine (detail pane open) and mobile render exactly as before
   — this can only ever add room, never take it away.
   -------------------------------------------------------------------------- */
@container spine (min-width: 560px) {
  .bubble {
    --bub-w:        clamp(18rem, 9rem  + 16cqi,  30rem);
    --bub-root-w:   clamp(30rem, 18rem + 18cqi,  44rem);
    --bub-leaf-w:   clamp(16rem, 9rem  + 12cqi,  24rem);
    /* SECTION_SYNTHESIS makes the section/region buckets prose-carrying — a lead line
       plus a full synthesis panel — so they need a reading measure, not the label
       measure a bare "Politics · 17" needed. Kept just under the root's, so the global
       synthesis still reads as the widest thing on the field. max-width only ever CAPS:
       a bucket the synthesis skipped is content-sized and stays a small pill. */
    --bub-group-w:  clamp(16rem, 9rem  + 15cqi,  32rem);   /* REVERTED 2026-08-17 */
    --bub-region-w: clamp(18rem, 11rem + 14cqi,  32rem);
    /* One SHARED slope (0.45cqi) for every type step. The old ramp gave the head
       0.5cqi and the body 0.55cqi, so the head/body relationship drifted as the pane
       resized — the body caught the head up and the hierarchy inverted harder on wide
       panes. Same slope = the scale keeps its proportions at every width. */
    --bub-fs-body:    clamp(1rem,      0.86rem + 0.45cqi, 1.1875rem);
    --bub-fs-head:    clamp(1.125rem,  0.97rem + 0.45cqi, 1.3125rem);
    --bub-fs-head-lg: clamp(1.25rem,   1.09rem + 0.45cqi, 1.4375rem);
    --bub-fs-head-section: clamp(0.95rem, 0.82rem + 0.45cqi, 1.125rem);
    --bub-fs-head-xl: clamp(1.4375rem, 1.28rem + 0.45cqi, 1.625rem);
    --bub-fs-chrome:  clamp(0.8125rem, 0.72rem + 0.25cqi, 0.9375rem);
    /* More pane = more room to GROW instead of scroll. */
    --bub-sum-h:    min(30rem, 42vh);
    --bub-pad: clamp(0.5rem, 0.3rem  + 0.35cqi, 0.85rem)
               clamp(0.7rem, 0.45rem + 0.45cqi, 1.1rem);
    border-radius: clamp(1rem, 0.8rem + 0.3cqi, 1.35rem);
  }
  /* The satellite chrome rides the same ramp so a grown bubble doesn't carry
     pin-sized pills — they stay proportional to the headline they hang off. */
  .bubble-related-link,
  .bubble-trace,
  .bubble-count        { font-size: var(--bub-fs-chrome); }
}

/* Spine lead readability: break a multi-sentence lead into stacked sentence
   blocks; the FULL headline/lead always shows — no clamp — in collapsed and expanded rows. */
.spine-sentence{ display:block; }
.spine-sentence + .spine-sentence{ margin-top:0.35em; }            /* the small break between sentences */
.spine-lead{ display:block; }                                       /* full lead, no truncation, in every state */

/* Breadcrumb: cap width + single line so a long trace never breaks the header. */
.breadcrumb-value {
  display: inline-block;
  max-width: 16rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: bottom;
}
@media (max-width: 640px) { .breadcrumb-value { max-width: 60vw; } }

/* --------------------------------------------------------------------------
   Detail-pane ARIA tabs + the mobile section tabs.
   -------------------------------------------------------------------------- */
.mobile-tab {
  appearance: none;
  background: transparent;
  border: 1px solid transparent;
  border-bottom: 2px solid transparent;
  color: #94a3b8;
  font-size: 0.85rem;
  font-weight: 600;
  padding: 0.6rem 0.8rem;
  border-radius: 0.5rem 0.5rem 0 0;
  cursor: pointer;
  white-space: nowrap;
}
.mobile-tab:hover { color: #e2e8f0; }
.mobile-tab[aria-selected="true"] {
  color: var(--accent-hi);
  border-bottom-color: var(--accent);
}
.mobile-tab:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* LAYERS toggle chips — small pills (the old detail-tab look); a pressed chip
   takes the accent edge + text so the active layer set reads at a glance. */
.layer-chip {
  appearance: none;
  background: transparent;
  border: 1px solid #273043;
  color: #94a3b8;
  font-size: 0.8rem;
  font-weight: 600;
  padding: 0.35rem 0.75rem;
  border-radius: 999px;
  cursor: pointer;
  white-space: nowrap;
}
.layer-chip:hover { color: #e2e8f0; border-color: #3a465c; }
.layer-chip[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--accent);
}
.layer-chip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Layer-cap nudge: when two layers are open and a third chip is clicked, the
   open chips shake briefly — "close one of these first". Reduced-motion users
   keep the ~450ms accent flash (the class persists) without the shake. */
@keyframes chip-nudge {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-4px); }
  40% { transform: translateX(4px); }
  60% { transform: translateX(-3px); }
  80% { transform: translateX(3px); }
}
.layer-chip.chip-nudge {
  animation: chip-nudge 0.35s ease;
  border-color: var(--accent);
  color: var(--accent);
}
@media (prefers-reduced-motion: reduce) {
  .layer-chip.chip-nudge { animation: none; }
}

/* Each visible layer is a card in the grid. A hidden layer uses native
   [hidden]; we re-assert display:none because .detail-panel sets display:flex,
   which would otherwise win over [hidden]{display:none}. */
.detail-panel {
  outline: none;
  border: 1px solid #273043;
  border-radius: 0.75rem;
  background: #0b0f17;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
}
.detail-panel[hidden] { display: none !important; }
/* THE ANALYSIS PANE HAS NO FRAME (2026-09-28, owner: "remove the box and keep the content").
   #panel-meta's frame was a border behind every card, from the tab-chip row down, over a fill
   the body already has (measured at 1440x900: it inset the cards 17px from the tab row). Only
   #panel-meta carries detail-panel-bare; the other five panes keep their frame. The 1rem padding
   STAYS at every width: the split's collapse chip (.pane-fold-chip.gobble-detail-collapse, 24px)
   is centred on the pane's left edge, and with no side padding it covered the left 10px of a card
   (measured: 1 card at 1440 and 1280, 2 at 1024, against 0 with the padding).
   Pinned by news/tests/test_analysis_pane_cosmetics.py. */
.detail-panel.detail-panel-bare {
  border: 0;
  background: transparent;
}

/* --------------------------------------------------------------------------
   ⌘K command palette.
   -------------------------------------------------------------------------- */
.kbd-hint {
  display: inline-flex;
  align-items: center;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.65rem;
  line-height: 1;
  padding: 0.15rem 0.35rem;
  color: #cbd5e1;
  background: rgba(148, 163, 184, 0.12);
  border: 1px solid #273043;
  border-radius: 0.3rem;
}

.palette-overlay {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(2px);
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 12vh 1rem 1rem;
}
.palette-overlay.hidden { display: none; }
.palette-box {
  width: min(40rem, 100%);
  background: #111726;
  border: 1px solid #273043;
  border-radius: 0.75rem;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
  overflow: hidden;
}
.palette-input-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.85rem 1rem;
  border-bottom: 1px solid #273043;
}
.palette-input {
  flex: 1 1 auto;
  background: transparent;
  border: 0;
  outline: none;
  color: #e2e8f0;
  font-size: 0.95rem;
}
.palette-input::placeholder { color: #64748b; }
.palette-results {
  max-height: 50vh;
  overflow: auto;
  margin: 0;
  padding: 0.35rem;
  list-style: none;
}
.palette-item {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.55rem 0.7rem;
  border-radius: 0.5rem;
  cursor: pointer;
  color: #cbd5e1;
}
.palette-item .palette-kind {
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: #64748b;
  border: 1px solid #273043;
  border-radius: 9999px;
  padding: 0.1rem 0.45rem;
  flex: 0 0 auto;
}
.palette-item .palette-label {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.palette-item[aria-selected="true"],
.palette-item:hover {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: #ffffff;
}
.palette-empty { padding: 1rem 0.9rem; color: #64748b; font-size: 0.85rem; }
/* CANVAS_ENTITY_SEARCH (palette.js armed rows): the detail line after a label, and the
   "Off today's canvas" group header, which is a presentation row nobody can select. */
.palette-item .palette-sub {
  flex: 0 1 auto;
  min-width: 0;
  max-width: 50%;
  font-size: 0.7rem;
  color: #64748b;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.palette-group {
  padding: 0.65rem 0.7rem 0.25rem;
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: #64748b;
  cursor: default;
}
.palette-foot {
  display: flex;
  gap: 1.25rem;
  padding: 0.55rem 1rem;
  border-top: 1px solid #273043;
  font-size: 0.7rem;
  color: #64748b;
}
.palette-foot kbd { margin-right: 0.2rem; }

/* Respect reduced-motion: disable smooth-scroll + transitions when asked. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .dim, .theme-card, .story-card, .source-row { transition: none; }
}

/* "What we know" ledger rows: keep a jumped-to row clear of the scroll edge, and
   briefly flash it (rose) when a "conflicts with #N" link lands on it. */
.ku-row { scroll-margin-top: 0.75rem; scroll-margin-bottom: 0.75rem; border-radius: 0.375rem; }
.ku-flash { animation: ku-flash 1.5s ease-out; }
@keyframes ku-flash {
  0%   { background-color: rgba(244, 63, 94, 0.28); box-shadow: 0 0 0 2px rgba(244, 63, 94, 0.45); }
  100% { background-color: transparent; box-shadow: 0 0 0 2px transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .ku-flash { animation: none; outline: 2px solid rgba(244, 63, 94, 0.6); }
}

/* --------------------------------------------------------------------------
   ANALYSIS PANE — summary-first drill-down (2026 de-noise pass).
   The pane leads with ONE compact receipt-headline + a tally line; the full
   corroboration / coverage / claim breakdown lives behind a single
   "Full breakdown" <details>. No data is removed — everything is one click
   away. Uses the type-scale tokens for clear headline-vs-tally-vs-body
   hierarchy.
   -------------------------------------------------------------------------- */

/* The lead receipt: the one line a reader scans first. */
.meta-receipt {
  border: 1px solid var(--meta-border);
  border-radius: 0.85rem;
  background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 6%, transparent), rgba(15, 21, 36, 0.4));
  padding: var(--space-3) var(--space-4);
}
.meta-receipt-tally {
  font-size: var(--fs-5);
  line-height: 1.2;
  font-weight: 600;
  color: var(--meta-ink-strong);
  letter-spacing: -0.01em;
  text-wrap: pretty;
}
.meta-receipt-tally b { color: var(--accent); font-weight: 700; }
.meta-receipt-note { font-size: var(--fs-2); font-weight: 500; color: var(--meta-ink-muted); }
.meta-receipt-sep { color: var(--meta-ink-faint); font-weight: 400; margin: 0 0.15rem; }
.meta-receipt-corr {
  margin-top: var(--space-2);
  font-size: var(--fs-2);
  color: var(--meta-ink-muted);
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
}

/* The shared disclaimers explainer — every per-view disclaimer, de-duplicated
   into ONE place (the per-card repeats are gone; the inference badges still carry
   each view's own disclaimer on hover/focus). The lead line stays visible so a
   disclaimer is never hidden entirely; the full list folds away. */
.meta-disclaimers-wrap { display: flex; flex-direction: column; gap: var(--space-1); }
.meta-disclaimers-lead {
  margin: 0; font-size: var(--fs-1); line-height: 1.45; color: var(--meta-ink-muted);
}
.meta-disclaimers {
  border: 1px solid var(--meta-border);
  border-radius: 0.65rem;
  background: rgba(15, 21, 36, 0.4);
  padding: 0 var(--space-3);
}
.meta-disclaimers > summary {
  cursor: pointer; user-select: none; list-style: none;
  display: flex; align-items: center; gap: 0.5rem;
  padding: var(--space-2) 0;
  font-size: var(--fs-1);
  color: var(--meta-ink-muted);
}
.meta-disclaimers > summary::-webkit-details-marker { display: none; }
.meta-disclaimers > summary::before { content: "\24d8"; color: var(--meta-ink-faint); } /* ⓘ */
.meta-disclaimers[open] > summary { border-bottom: 1px solid var(--meta-border); }
.meta-disclaimers ul { margin: 0; padding: var(--space-2) 0 var(--space-3) 1rem; }
.meta-disclaimers li {
  font-size: var(--fs-1); line-height: 1.45; color: var(--meta-ink-faint);
  margin-top: var(--space-1); list-style: disc;
}

/* ABOUT THIS VIEW (2026-09-22): ONE tiny collapsible at the very bottom of the Sentiment and
   Analysis panes holding every explanation, closed by default (test_show_on_canvas.py). Same
   quiet look as .meta-disclaimers, smaller. */
.pane-notes {
  margin-top: var(--space-4);
  border-top: 1px solid var(--meta-border);
  padding-top: var(--space-1);
  font-size: 0.72rem; line-height: 1.45; color: var(--meta-ink-faint);
}
.pane-notes > summary {
  cursor: pointer; user-select: none; list-style: none;
  display: inline-flex; align-items: center; gap: 0.35rem;
  padding: 0.2rem 0; color: var(--meta-ink-faint);
}
.pane-notes > summary::-webkit-details-marker { display: none; }
.pane-notes > summary::before { content: "\24d8"; } /* ⓘ */
.pane-notes > summary:hover, .pane-notes > summary:focus-visible { color: var(--meta-ink-muted); }
.pane-notes-body { padding: var(--space-1) 0 var(--space-2); }
.pane-notes-body p { margin: var(--space-1) 0; }
.pane-notes-body b { color: var(--meta-ink-muted); font-weight: 600; }
.pane-notes-list { margin: 0; padding-left: 1rem; }
.pane-notes-list li { list-style: disc; margin-top: 0.2rem; }
.pane-notes-head { margin: var(--space-2) 0 0; font-size: 0.72rem; font-weight: 600; color: var(--meta-ink-muted); }
.pane-notes [data-method-briefs] { margin-top: var(--space-2); }

/* SHOW ON CANVAS: a button that brings the stories it names up as bubbles. Reads as a link. */
.canvas-link {
  background: none; border: 0; padding: 0; margin: 0;
  font: inherit; color: inherit; cursor: pointer; text-align: left;
}
.canvas-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
.canvas-miss {
  margin-left: 0.4rem; font-size: 0.7rem; color: var(--meta-ink-faint); white-space: nowrap;
}
/* CANVAS_ENTITY_SEARCH: a picked entity that is off today's canvas (entity_index.js
   offCanvasCue). A small status over the foot of the canvas pane; it removes itself. */
[data-entity-offcanvas] {
  position: absolute; bottom: 0.75rem; left: 50%; transform: translateX(-50%);
  z-index: 9; max-width: calc(100% - 1.5rem);
  padding: 0.3rem 0.75rem;
  font-size: 0.72rem; color: #cbd5e1;
  background: rgba(17, 23, 38, 0.95);
  border: 1px solid #273043; border-radius: 999px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}
[data-entity-offcanvas] a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.entity-chip-group { display: inline-flex; align-items: center; gap: 0.15rem; }
.chip-canvas-btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.6rem; min-height: 1.6rem; border-radius: 999px;
  color: var(--meta-ink-muted); font-size: 0.85rem;
}
.chip-canvas-btn:hover { color: var(--meta-ink-strong); }
.canvas-chart-links { margin-top: var(--space-1); font-size: 0.7rem; color: var(--meta-ink-faint); }
.canvas-chart-links > summary { cursor: pointer; user-select: none; }
.canvas-chart-links > div { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; padding-top: 0.25rem; }
.canvas-chart-links .canvas-link { color: var(--meta-ink-muted); text-decoration: underline; text-underline-offset: 2px; }

/* The single "Full breakdown" fold that holds everything else. */
.meta-fullbreakdown {
  border: 1px solid var(--meta-border);
  border-radius: 0.85rem;
  background: rgba(15, 21, 36, 0.35);
  overflow: hidden;
}
.meta-fullbreakdown > summary {
  cursor: pointer; user-select: none; list-style: none;
  display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem;
  padding: var(--space-3) var(--space-4);
}
.meta-fullbreakdown > summary::-webkit-details-marker { display: none; }
.meta-fullbreakdown > summary:hover { background: rgba(39, 48, 67, 0.4); }
.meta-fullbreakdown > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.meta-fb-title {
  font-size: var(--fs-3); font-weight: 600; color: var(--meta-ink-strong);
  display: inline-flex; align-items: center; gap: 0.4rem;
}
.meta-fb-title::before {
  content: "\203a"; /* › — rotates to ⌄ when open */
  display: inline-block; color: var(--meta-ink-muted); font-weight: 700;
  transition: transform 0.18s ease;
}
.meta-fullbreakdown[open] > summary .meta-fb-title::before { transform: rotate(90deg); }
.meta-fb-hint { font-size: var(--fs-1); color: var(--meta-ink-faint); }
.meta-fb-body { padding: 0 var(--space-4) var(--space-4); }

@media (prefers-reduced-motion: reduce) {
  .meta-fb-title::before { transition: none; }
}

/* --------------------------------------------------------------------------
   ENTITY DOSSIER claim groups: lead with per-group counts, each group's full
   claims behind a <details> (the ku-group accordion look). All claims remain.
   -------------------------------------------------------------------------- */
.dossier-group > summary { list-style: none; }
.dossier-group > summary::-webkit-details-marker { display: none; }
.dossier-group > summary::before {
  content: "\203a"; display: inline-block; margin-right: 0.4rem;
  color: #64748b; font-weight: 700; transition: transform 0.18s ease;
}
.dossier-group[open] > summary::before { transform: rotate(90deg); }
.dossier-group > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 0.3rem; }

/* The collapsed "Three ways we read tone" method briefs (sentiment pane). */
.meta-methods > summary { list-style: none; }
.meta-methods > summary::-webkit-details-marker { display: none; }
.meta-methods > summary h3 { display: inline-flex; align-items: center; }
.meta-methods > summary h3::before {
  content: "\203a"; display: inline-block; margin-right: 0.4rem;
  color: #64748b; font-weight: 700; transition: transform 0.18s ease;
}
.meta-methods[open] > summary h3::before { transform: rotate(90deg); }
.meta-methods > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 0.3rem; }

@media (prefers-reduced-motion: reduce) {
  .dossier-group > summary::before,
  .meta-methods > summary h3::before { transition: none; }
}

/* --------------------------------------------------------------------------
   Layout (design-variant) toggle — shared infra, visible in EVERY design (it's
   the way in and out of a variant). Chip-family look beside the LAYERS strip.
   -------------------------------------------------------------------------- */
.design-select {
  appearance: none;
  background: #111726;
  border: 1px solid #273043;
  color: #94a3b8;
  font-size: 0.72rem;
  font-weight: 600;
  padding: 0.25rem 0.6rem;
  border-radius: 999px;
  cursor: pointer;
}
.design-select:hover { color: #e2e8f0; border-color: #3a465c; }
.design-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* --------------------------------------------------------------------------
   Debug HUD (?debug=1, any design — bubbles.js builds it only when the debug
   tap is on). Fixed bottom-right, above the bubble field's z-ladder (5–9),
   click-through except its collapse button.
   -------------------------------------------------------------------------- */
.bubble-hud {
  position: fixed; right: 0.6rem; bottom: 0.6rem; z-index: 50;
  pointer-events: none;
  font: 500 0.62rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
  color: #94a3b8;
  background: rgba(11, 15, 23, 0.92);
  border: 1px solid #273043;
  border-radius: 0.5rem;
  padding: 0.4rem 1.2rem 0.4rem 0.55rem;
  max-width: 24rem;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.bubble-hud-toggle {
  pointer-events: auto;          /* the ONE interactive spot on the overlay */
  position: absolute; top: 0.1rem; right: 0.25rem;
  background: transparent; border: 0; padding: 0.1rem 0.2rem;
  color: #64748b; cursor: pointer; font-size: 0.72rem; line-height: 1;
}
.bubble-hud-toggle:hover { color: #e2e8f0; }
.bubble-hud.hud-collapsed .bubble-hud-body { display: none; }
.bubble-hud-body div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* ==========================================================================
   DESIGN VARIANT 1 — "weighted canvas". EVERYTHING below is scoped to
   body[data-design="1"]; design=0 renders byte-identical without this block.
   bubbles.js rankScale() stamps el.dataset.tier from per-ring salience RANK
   quantiles (hero / major / standard / tail). Tier drives size + emphasis;
   the receipts line prints rank + source tallies — auditable order, never an
   importance verdict.
   ========================================================================== */

/* Tier factors. --tier-w scales the bubble's measure (hero 1.35 · major 1.15
   · standard 1 · tail 0.85); --tier-fs scales the label via the existing
   container-query type vars. Untiered bubbles (root, buckets, "+N more") keep
   the defaults and render exactly as the classic ramp sizes them. */
body[data-design="1"] .bubble { --tier-w: 1; --tier-fs: 1; }
body[data-design="1"] .bubble[data-tier="hero"]  { --tier-w: 1.35; --tier-fs: 1.12; }
body[data-design="1"] .bubble[data-tier="major"] { --tier-w: 1.15; --tier-fs: 1.05; }
body[data-design="1"] .bubble[data-tier="tail"]  { --tier-w: 0.85; --tier-fs: 0.92; }

/* The tier multiplies each kind's own measure (the vars stay the per-kind
   bound the classic ramp computes); 94cqi of the spine is the hard outer cap
   so a grown hero can never run off the stage. deCollide measures the real
   (scaled) element, so layout spacing sees these boxes automatically. */
body[data-design="1"] .bubble        { max-width: min(calc(var(--bub-w) * var(--tier-w) * var(--ring-w)), 94cqi); }
body[data-design="1"] .bubble-root   { max-width: min(calc(var(--bub-root-w) * var(--tier-w)), 94cqi, var(--bub-pane-cap)); }
body[data-design="1"] .bubble-leaf   { max-width: min(calc(var(--bub-leaf-w) * var(--tier-w)), 94cqi); }
/* The bucket pair also multiplies by --ring-w (PHONE FLOW 2026-09-05): these overrides are
   (0,2,1) and outrank the classic `.bubble-group` rule, so without it `?design=1` on a phone
   would drop the ring shrink the classic canvas gets. --ring-w is 1 unless bubbles.js stamps
   it, so the weighted desktop canvas is byte-identical. */
body[data-design="1"] .bubble-group  { max-width: min(calc(var(--bub-group-w) * var(--tier-w) * var(--ring-w)), 94cqi); }
body[data-design="1"] .bubble-region { max-width: min(calc(var(--bub-region-w) * var(--tier-w) * var(--ring-w)), 94cqi); }
/* country and loose were MISSING from this block, so `body[data-design="1"] .bubble` (0,2,1)
   outranked `.bubble-country` (0,1,0) and a country lost its intended 0.85x measure, rendering
   at the full generic width -- wider than a story leaf, and therefore lower in an area-ranked
   stack. Banding --bz fixes the stacking; this fixes the size that caused it. */
body[data-design="1"] .bubble-country { max-width: min(calc(var(--bub-region-w) * 0.85 * var(--tier-w)), 94cqi); }
body[data-design="1"] .bubble-state   { max-width: min(calc(var(--bub-region-w) * 0.75 * var(--tier-w)), 94cqi); }
/* ...and the section sub-bucket, for the same reason: without this `body[data-design="1"]
   .bubble` (0,2,1) outranks `.bubble-sub` (0,1,0) and a sub-bucket renders at the full
   generic width — wider than the themes beside it in its own ring. --ring-w rides along so
   the phone ring shrink survives the design toggle. */
body[data-design="1"] .bubble-sub     { max-width: min(calc(var(--bub-group-w) * 0.85 * var(--tier-w) * var(--ring-w)), 94cqi); }
body[data-design="1"] .bubble-loose   { max-width: min(calc(var(--bub-w) * var(--tier-w)), 94cqi); }
/* The open-state widening again, at design=1's specificity — the tier ramp rules above
   are (0,2,1) and would otherwise keep an OPEN weighted bubble at its resting width, so
   the weighted canvas would still scroll where Classic no longer does. Tier keeps
   scaling the RESTING size; the open size is about the reading measure, not weight. */
body[data-design="1"] .bubble.show-summary { max-width: min(30rem, 92cqi); }
body[data-design="1"] .bubble-leaf.show-summary { max-width: min(26rem, 92cqi); }
body[data-design="1"] .bubble.bubble-root.show-summary { max-width: var(--bub-root-open-w); }

/* Label type rides the tier. Per-tier rules only (never a blanket .bubble-head
   override) so the bucket heads' own 1.19/1.1 boosts keep winning for the
   untiered section/region bubbles. */
body[data-design="1"] .bubble[data-tier="hero"] .bubble-head  { font-size: calc(var(--bub-head-fs) * var(--tier-fs) * var(--ring-fs)); }
body[data-design="1"] .bubble[data-tier="major"] .bubble-head { font-size: calc(var(--bub-head-fs) * var(--tier-fs) * var(--ring-fs)); }
body[data-design="1"] .bubble[data-tier="tail"] .bubble-head  { font-size: calc(var(--bub-head-fs) * var(--tier-fs) * var(--ring-fs)); }

/* hero: slightly brighter surface + accent border, full opacity. */
body[data-design="1"] .bubble[data-tier="hero"] {
  background: color-mix(in srgb, var(--bub-hue) 9%, #182130);
  border-color: color-mix(in srgb, var(--bub-hue) 80%, #2b384c);
  opacity: 1;
}
/* major: normal surface with a subtle border lift. */
body[data-design="1"] .bubble[data-tier="major"] {
  border-color: color-mix(in srgb, var(--bub-hue) 60%, #2b384c);
}
/* tail: recedes — dimmed, muted border, smaller label (via --tier-fs) — but
   comes back to full presence when engaged (open / hover / keyboard focus). */
body[data-design="1"] .bubble[data-tier="tail"] {
  opacity: 0.5;   /* resting view leads with the salient tiers */
  border-color: #263247;
}
body[data-design="1"] .bubble[data-tier="tail"].show-summary,
body[data-design="1"] .bubble[data-tier="tail"]:hover,
body[data-design="1"] .bubble[data-tier="tail"]:focus-visible { opacity: 1; }

/* Specificity guard: trace-focus must still outrank the tail dim — the traced
   story stays lit at 1, everything else (tail included) blurs to 0.4. The
   z-ladder (hover 5 · open 6 · traced 7 · focus 8 · pill 9) is untouched. */
body[data-design="1"] .bubble-field.trace-focus .bubble { opacity: 0.4; }
body[data-design="1"] .bubble-field.trace-focus .bubble-traced { opacity: 1; }

/* HOVER SUPREMACY: whatever bubble the pointer is on is the one being READ —
   it tops the entire z-ladder (open 6, active 7, traced 8, focus 9, and the
   root's backdrop pin at 4 all yield) and shakes off any tier/branch/trace
   dim or blur for the duration of the hover. `body`-prefixed and last in the
   file so it wins every specificity tie above. */
body .bubble-field .bubble:hover { z-index: 12600; }
/* ...but hover supremacy is DEPTH-BOUNDED (2026-09-06). The pointer is still on what you just
   clicked, so 1600 put the opened parent over its own freshly spread children — the exact
   1600-over-1200 state measured on the live page. Inside an isolated branch, and on the root
   backdrop anywhere, a hovered bubble takes the focus slot at ITS OWN depth instead: it still
   tops everything at its level and below, and still cannot bury what it contains. Placed after
   the rule above, and more specific, so it wins the tie. */
/* The ROOT left this rule on 2026-09-08 (owner: it "only needs to come to the foreground in
   a hover"). Worth knowing WHY it had to leave rather than just being added to the lift
   below: `.bubble.bubble-root:hover` is FOUR classes and the lift is three, so the root
   would have kept 1208 while the lift rule looked like it covered it — done in the diff,
   undone by the cascade, and the string test would have passed. Measured live at 1208: the
   hovered root sat under its own children at 1216 and 1220. */
body .bubble-field.has-front .bubble:hover { z-index: calc(2980 + 1000 * var(--bdepth, 0)); }
body .bubble-field.has-front .bubble:focus-visible { z-index: calc(2990 + 1000 * var(--bdepth, 0)); }
body .bubble-field .bubble.bubble-front-path:hover { opacity: 1; }
/* ...and the path back's STEP FADE lives on the head and summary, not the bubble (2026-09-23),
   so the rule above no longer reaches it: a bubble at 1 cannot undo a child at 0.15. Hover and
   keyboard focus bring the faded text itself back to full brightness (owner, 2026-09-25: "return
   to normal brightness if the mouse is hovering over them"). `.has-front` is load-bearing: the
   fade rules are (0,6,0), and without it this is (0,5,1) and loses the cascade while reading
   right in the diff. The ROOT waits out the same 250 ms dwell it rises on (the hover-intent rule
   below): until then it sits under the opened bucket's themes, and brightening it at once would
   flash the synthesis bullets through the gaps a pointer crosses on its way to a theme. The delay
   sits on the hover state only, so leaving drops the fade back at once. Pinned by
   test_bubble_backdrop_fades_by_step.py and tests/test_browser_path_fade_hover.py. */
body .bubble-field.has-front .bubble.bubble-front-path:hover .bubble-head,
body .bubble-field.has-front .bubble.bubble-front-path:hover .bubble-summary,
body .bubble-field.has-front .bubble.bubble-front-path:focus-visible .bubble-head,
body .bubble-field.has-front .bubble.bubble-front-path:focus-visible .bubble-summary { opacity: 1; }
body .bubble-field.has-front .bubble.bubble-root.bubble-front-path:hover .bubble-head,
body .bubble-field.has-front .bubble.bubble-root.bubble-front-path:hover .bubble-summary {
  transition: opacity 0s linear 250ms; }
/* ...and a story bubble a SELECTION dimmed. app.js::dimStories puts the panels' shared `.dim`
   (0.6, saturate 0.7) on every [data-story-id] outside the selection, and story bubbles carry
   that attribute since 5e00787, so tracing a story dims every other story on the canvas and
   closing the trace leaves them dimmed. The owner, 2026-09-25, with the path-back fix live: "I
   hover and they are not brighter on 8012". The dim stays; the pointer or keyboard focus lifts
   it off the one bubble being read, at `.dim`'s own 0.18 s. Scoped to the canvas: panel cards
   keep theirs. Pinned by test_bubble_selection_dim_hover.py and
   tests/test_browser_selection_dim_hover.py. */
body .bubble-field .bubble.dim:hover,
body .bubble-field .bubble.dim:focus-visible { opacity: 1; filter: none; }

/* SECTION HOVER LIFT (2026-09-07, owner request). A section's synthesis sits UNDER its own
   themes and stories, and that is RIGHT: they are what the reader came for, and the
   depth-bounded rule above exists so a hovered parent can never bury the children it just
   spread. The cost is that a section's prose becomes unreadable the moment its branch is
   open — occluded by the ring, and clamped to a couple of lines by the breadcrumb rule.

   Hovering the SECTION ITSELF is an unambiguous "I want to read this", so it alone lifts
   clear of the front band, and drops back the instant the pointer leaves. Nothing else
   moves: scoped to `.bubble-group`, so hovering a theme or a story lifts nothing, and the
   resting order is untouched.

   1650 tops the bubble ladder (front band maxes at 1200+10*9=1290, generic hover 1600) and
   still cannot escape `.bubble-layer`'s stacking context, so the HUD and the drawers are
   unaffected. Placed after the depth-bounded rule and more specific, so it wins the tie.

   GENERALISED WITH THE UNCLAMP (2026-09-08). The lift used to be section-only, and once a
   hovered THEME or STORY also un-clamps and grows, a section-only lift is incoherent: the
   extra prose renders UNDERNEATH the neighbours it just grew past. Measured — a hovered
   "Region: Global" grew, shifted, and slid beneath its own children, which the burial probe
   reported as "covered by a bubble that is NOT the one under the pointer". Whatever expands
   on hover must also rise while it is expanded; both drop back together the moment the
   pointer leaves. */
body .bubble-field .bubble:hover,
body .bubble-field.has-front .bubble:hover { z-index: 12650; }
/* HOVER INTENT, the rising half (2026-09-14, owner). The synthesis still comes forward on hover
   (the 2026-09-08 ruling), but only once the pointer DWELLS on it, so a pointer that is only
   crossing it on the way to a bucket packed over it gets there first. Entering the hover uses
   this rule, leaving uses the resting list on `.bubble.bubble-root`, which has no delay: slow to
   rise, instant to drop. It sets ONLY transition-delay, one value per item of that list and in
   its order (transform, box-shadow, border-color, opacity, z-index), and NO z-index: the lift
   above still decides the height (see the 2026-09-08 note above for why a root rule that sets
   z-index would re-bury the root).
   250ms, measured with real frame-aligned pointer input: a glide whose whole trip from the
   exposed root to a bucket takes 100/150/200ms spends at most 100/133/183ms on the resting root
   (the trip itself took at most 117/167/217ms), so 250ms clears the slowest realistic glide by
   two frames. A slower 400ms approach can still dwell past it; the owner accepted that a pause on
   the synthesis may still bring it forward. */
body .bubble-field .bubble.bubble-root:hover { transition-delay: 0s, 0s, 0s, 0s, 250ms; }

/* HOVER IS A Z-RAISE AND NOTHING ELSE (owner, 2026-09-11). The rules that used to follow
   here grew a hovered bubble: they lifted the panel clamps to `none`, bounded that at a
   per-bubble `--hover-cap` that paint() computed, faded a `hover-clipped` panel, slid the
   box by `--hover-dy` on `translate`, and put the clamps back under `is-measuring` so the
   layout could not see any of it. With no clamps left there is nothing for a hover to lift,
   so the whole apparatus went: a hovered bubble keeps its exact box and position and only
   comes to the front (the lift above). That makes the layout blind to hover by
   construction, which is stronger than the measuring guard ever was; bubbles.js still
   measures through measureRest() so a future hover style cannot leak into it either. */
body .bubble-field.trace-focus .bubble:hover { filter: none; opacity: 1; }
/* The receipts line under a hero/major head: "rank #k of n · m sources · v
   independent voices" — muted, single line, rides the container type ramp. */
body[data-design="1"] .bubble-receipts {
  margin-top: 0.2rem;
  font-size: var(--bub-fs-chrome);
  line-height: 1.4;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: #7d8ba1;
  overflow-wrap: anywhere;   /* wraps, never ellipsised (2026-09-11) */
}

/* --------------------------------------------------------------------------
   LOCKED (unsubscribed) treatment.

   IMPORTANT: this is COSMETIC ONLY. The text a locked reader must not see is never
   sent — `accounts/entitlement.py` redacts the payload server-side before it is
   serialised, so there is nothing here to un-blur with devtools. The blur is applied
   to a SKELETON, not to withheld words: nothing is replaced with placeholder prose,
   because fabricating filler would break the no-mock rule and is a worse lie than an
   honest gap.

   `user-select: none` and `pointer-events` are not security either — the click
   handler routes to signup regardless.
   -------------------------------------------------------------------------- */
/* `position: relative` is here so a badge can anchor inside a locked PANEL — and it must
   never reach a canvas bubble. `.bubble` is `position: absolute` (see the bubble-field
   section ~1,500 lines above) at the SAME specificity (0,1,0), so this rule won the cascade
   on declaration order alone and dropped every locked bubble out of the positioned layer
   into `.bubble-layer`'s normal FLOW. paint() then offset its transform from that flow slot
   instead of the layer origin, so a de-collide result that really was inside the stage
   landed nowhere near it. Measured anonymous in a real browser, 1200x800 canvas 1130x689:
   `.bubble-layer` scrollHeight 2161 against clientHeight 689, with all seven SECTION buckets
   drawn between y=1013 and y=2028 while their transforms read y=107..597. Regions survived
   because buildDualNav emits them first and they took the earlier flow slots — which is why
   the defect was "sections gone, most regions fine" at EVERY screen size: flow order, not
   fit. A locked bubble needs no relative positioning of its own; it is already absolute,
   which is a positioned ancestor. */
.is-locked:not(.bubble) { position: relative; }

.locked-skeleton {
  border-radius: 3px;
  background: linear-gradient(90deg, #1d2430 25%, #262f3d 37%, #1d2430 63%);
  background-size: 400% 100%;
  filter: blur(2.5px);
  user-select: none;
  min-height: 0.7em;
}
@media (prefers-reduced-motion: no-preference) {
  .locked-skeleton { animation: locked-shimmer 2.4s ease-in-out infinite; }
}
@keyframes locked-shimmer {
  0% { background-position: 100% 0; }
  100% { background-position: 0 0; }
}

/* The lock affordance sits ABOVE the blur so it stays legible — a blurred lock icon
   reads as a rendering fault rather than a deliberate state. */
.locked-badge {
  display: inline-flex; align-items: center; gap: 0.35rem;
  font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase;
  color: #9fb0c6; cursor: pointer;
}
.locked-badge:hover { color: #cfe0f5; }

/* Whole-panel lock: used where a section is entirely paid. */
.locked-panel {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 0.75rem; text-align: center; padding: 2rem 1.25rem; color: #9fb0c6;
}

/* Locked bubbles (SUBSCRIPTION_GATE_ENABLED). The server has already removed the prose,
   so there is nothing to blur — these style the ABSENCE instead: a skeleton stands where
   text would be and the badge says why. Without them a locked bubble is indistinguishable
   from an empty one, which is exactly how the gate first went live: nothing looked
   withheld and a click went nowhere. */
.bubble-locked { margin-top: .4rem; }
.is-locked { cursor: pointer; }
.is-locked .bubble-head { opacity: .82; }
.canvas-locked .bubble.is-locked:hover { border-color: var(--accent, #7dd3fc); }

/* ==========================================================================
   PHONE FLOW (2026-09-05) — keep the canvas, fix the flow.

   Reported from a phone: the reader "can't get past the global news synthesis;
   everything else is behind it". Under 1024px the Overview tab was ONLY the canvas,
   the first paint drew only the root bubble with its synthesis open at up to 54vh,
   and the sections spawned on a ring around/behind it. The exits were a TOP tab strip
   with no scroll cue whose Analysis/Sources tabs sat off the right edge.

   Every rule below lives inside @media (max-width: 1023.98px). That is the whole
   desktop-unchanged story: a >=1024px window never reads any of it, so the desktop
   render stays byte-identical. The one top-level change this wave makes is the two
   bucket max-widths multiplying by --ring-w, which is 1 unless bubbles.js shrinks a ring.
   ========================================================================== */
@media (max-width: 1023.98px) {
  /* The root synthesis is WHOLE on a phone too (owner, 2026-09-11: "no truncation or text
     diet or scrolls"). It used to be capped at min(35vh, 320px) with an inner scroller and a
     fade, which is a scroll box on the one surface an unsubscribed reader gets in full. The
     canvas grows beneath it and the page scrolls through the field instead; the sections are
     still reached by scrolling the page, never by scrolling inside a bubble. */

  /* 44px (2.75rem) is the touch floor. A bubble shrunk by the phone root ring can fall
     under it — a target the reader cannot reliably hit is a branch of the tree that does
     not exist for them. min-height only ever grows a box, so the ring shrink still does
     its job on the WIDTH, which is what a 358px stage is short of. */
  .bubble { min-height: 2.75rem; }

  /* The root's "Sections" disclosure pill: the same affordance as the Trace pill, because
     it does the same kind of thing (it opens what is behind this bubble). Styled here and
     nowhere else — bubbles.js renders it only under isNarrow(), so it has no desktop
     appearance to keep identical. */
  .bubble-sections {
    appearance: none;
    margin-top: 0.5rem;
    border-radius: 9999px;
    font-weight: 600; letter-spacing: 0.02em; line-height: 1.1;
    color: #d7f5ea;
    background: color-mix(in srgb, var(--accent-hi) 15%, #0f1a17);
    border: 1px solid color-mix(in srgb, var(--accent-hi) 45%, #2b384c);
    cursor: pointer;
  }
  .bubble-sections:focus-visible { outline: 2px solid var(--accent-hi); outline-offset: 1px; }

  /* Every pill and badge INSIDE a bubble meets the same floor, with the padding and type
     to match — on a phone these are the controls, not chrome hanging off a headline. */
  .bubble-trace,
  .bubble-sections,
  .locked-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.75rem;
    padding: 0.5rem 0.95rem;
    font-size: 1rem;
  }
  .bubble-related-link {
    display: flex;
    align-items: center;
    min-height: 2.75rem;
    padding: 0.35rem 0.1rem;
    font-size: 1rem;
  }

  /* ---- The bottom bar -------------------------------------------------------
     The tab strip moves from the top of a scrolling page (where it scrolls away and
     nothing says the last tabs exist) to the bottom of the viewport, where a thumb is.
     Overview / Analysis / Sources lead it; the rest scroll horizontally behind a fade. */
  .gobble-mobile-tabs {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 45;
    background: #0b0f17;
    border-top: 1px solid #273043;
    border-bottom: 0;
    /* The home indicator sits over the last row on a notched phone. */
    padding-bottom: env(safe-area-inset-bottom);
  }
  /* The scroll cue the old strip never had: the tabs past the right edge fade out
     instead of simply ending. Purely decorative, so it never eats a tap. */
  .gobble-mobile-tabs::after {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 2rem;
    pointer-events: none;
    background: linear-gradient(to right, rgba(11, 15, 23, 0), #0b0f17);
  }
  .mobile-tab {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 3rem;
    /* Grow to share the bar, never shrink below the label — six tabs then overflow into
       the inner scroller (and the fade above says so) rather than crushing to 3 letters. */
    flex: 1 0 auto;
  }
  /* At >=640px all six tabs fit, so the cue would be a gradient over nothing. Nested
     inside the narrow block on purpose: a top-level (min-width: 640px) rule would leak
     the phone chrome out of the block every desktop-identity guard reads. */
  @media (min-width: 640px) {
    .gobble-mobile-tabs::after { display: none; }
  }
  /* A fixed bar covers whatever the page ends with — the last bubbles, the last source
     row — unless the page reserves its height. */
  body.gobble-app { padding-bottom: calc(3.75rem + env(safe-area-inset-bottom)); }

  /* ---- The pane-nav rows ----------------------------------------------------
     A story tap opens #panel-meta full-screen, so each story pane carries its own way
     back and its sibling on top: "‹ Overview | Sources ›". These are buttons, not tabs
     (the tablist keeps its own roles), and they route through the same setMobileTab(). */
  .mobile-pane-nav {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
  }
  .mobile-pane-nav button {
    appearance: none;
    display: inline-flex;
    align-items: center;
    min-height: 2.75rem;
    padding: 0.5rem 0.9rem;
    border: 1px solid #273043;
    border-radius: 0.5rem;
    background: transparent;
    color: #94a3b8;
    font-size: 0.9rem;
    font-weight: 600;
    cursor: pointer;
  }
  .mobile-pane-nav button:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
  }

  /* ---- The cookie notice ----------------------------------------------------
     It is `fixed bottom-0` too, so it landed exactly on top of the new bar and covered
     the tabs. Lift it by the bar's own height and make it one compact row: the Tailwind
     min-w-[16rem] on its paragraph forced a wrap that cost a whole extra row on a 390px
     screen. "Got it" stays — the notice is a statement, and this is the only way to
     dismiss it. */
  #cookie-notice { bottom: calc(3.75rem + env(safe-area-inset-bottom)); }
  #cookie-notice > div { padding: 0.5rem 0.75rem; gap: 0.5rem; font-size: 0.8125rem; }
  #cookie-notice p { min-width: 0; line-height: 1.35; }

  /* ---- The header folds to ONE row (2026-09-05) ------------------------------
     Measured at 390px: the header wrapped into FOUR rows — Spuuun · Sources · Reset /
     Sign in · Start free trial · Refresh now / ⌘K · About / Pricing · Terms · Privacy /
     Tracing — about 46% of the screen, and the canvas root began 201px down.

     The phone keeps the wordmark and the auth control; everything else moved into the
     `≡ More` <details> the template renders beside this nav. The DESKTOP copies are still
     in the DOM, byte-identical, and hidden HERE rather than by `hidden lg:*` classes on
     them — that is what keeps a >=1024px window rendering exactly as before, since it never
     reads this block at all. Direct-child selectors on purpose: the fold's own copies are
     grandchildren of the nav, so none of these rules can reach them. */
  .gobble-header > nav > [data-nav-sources],
  .gobble-header > nav > [data-reset],
  .gobble-header > nav > [data-refresh-now],
  .gobble-header > nav > [data-open-palette],
  .gobble-header > nav > [data-open-methodology],
  .gobble-header > nav > nav[aria-label="Site information"],
  .gobble-header > nav > [data-breadcrumb],
  .gobble-header > [data-breadcrumb] { display: none; }
  /* The ⌘K trigger carries the `ml-auto` that pushes the auth controls right; it is folded
     away, so the auth block takes the job over. */
  .gobble-header > nav > [data-auth] { margin-left: auto; }

  /* The fold itself: its own row under the wordmark, aligned to the header's px-4. */
  /* AN ITEM IN THE BAR, NOT A ROW UNDER IT (owner 2026-09-06). The 1rem/0.375rem padding
     here was for a full-width strip beneath the nav; the element now sits INSIDE the nav's
     flex row, so it carries no padding of its own and the bar's own px-4 and gap place it.
     `margin-left: auto` parks it at the right end of the row without touching source order
     (so the tab order and every querySelectorAll hook stay as they were). The canvas is the
     flex FILLER below, so this row not existing is worth ~44px of field on every phone. */
  .mobile-more { padding: 0; margin-left: auto; }
  .mobile-more > summary {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    min-height: 2.75rem;
    padding: 0.25rem 0.1rem;
    font-size: 0.875rem;
    font-weight: 600;
    color: #94a3b8;
    cursor: pointer;
    list-style: none;
  }
  /* The default disclosure triangle would sit beside the ≡ and read as two markers. */
  .mobile-more > summary::-webkit-details-marker { display: none; }
  .mobile-more > summary::marker { content: ""; }
  .mobile-more[open] > summary { color: var(--accent); }
  .mobile-more > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  /* Wrapping flex, nothing shrink-0: the row that ran to ~690px in a 390px viewport and
     scrolled the whole PAGE sideways cannot come back through here. */
  .mobile-more-panel {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    padding: 0.25rem 0 0.5rem;
  }

  /* ---- The open fold FLOATS; it never displaces the canvas (W1.6, 2026-09-05) ----
     Owner: "the More dropdown ... should not drop down and create all the dead space above
     the canvas that the canvas needs." Same mechanism as W1.5's breadcrumb: the panel above
     was an ordinary in-flow row inside the sticky header, so OPENING the fold grew the
     header — and the bubble canvas is the flex FILLER of the spine column, so every pixel
     the panel took came straight off the field. Closing it handed the space back, which is
     why it read as dead space appearing above the bubbles.

     So the OPEN panel comes out of flow and floats over the page instead. The header's
     height is then the same open or closed, and the canvas never pays for the fold.

     The containing block is the <header> ITSELF — it is `sticky top-0 z-40`, and sticky is
     a positioned element, so it already establishes one. Nothing here needs a wrapper or a
     `position: relative`: `top: 100%` resolves against the header's padding box, i.e. its
     bottom edge, which under 1024px is exactly the underside of this summary row (the nav
     folds to one line and the mobile breadcrumb below is display:none in this same block).
     Anchoring to the header rather than to .mobile-more also means no hard-coded header
     height to go stale when the nav wraps.

     left/right: 0 span the header's full width and the 1rem padding restores the header's
     own px-4 — the panel is still a wrapping flex with nothing shrink-0, so the ~690px row
     that once scrolled the whole PAGE sideways cannot come back through here either.

     OPAQUE, not a Tailwind /NN: the panel floats over the bubbles, and the bottom bar
     shipped exactly that bug (bg-ink-900/95) with the fine print ghosting through it.

     z-index: 1 orders it inside the header's own z-40 stacking context — above the canvas,
     never above the wordmark or the auth control (which it does not overlap anyway).

     The cap is the space between the header and the fixed bottom bar: 6.625rem of header
     (3.5rem nav + 2.75rem summary + 0.375rem), the 3.75rem + safe-area body.gobble-app
     reserves for the bar, and 0.5rem of daylight. overflow-y: auto so a long fold scrolls
     INSIDE itself rather than running off-screen or under the bar. */
  .mobile-more[open] > .mobile-more-panel {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    z-index: 1;
    padding: 0.5rem 1rem 0.75rem;
    background: #0b0f17;
    border-top: 1px solid #273043;
    box-shadow: 0 0.75rem 1.5rem -0.5rem #05080f;
    max-height: calc(100vh - 6.625rem - 3.75rem - 0.5rem - env(safe-area-inset-bottom));
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  .mobile-more-panel > a,
  .mobile-more-panel > button {
    display: inline-flex;
    align-items: center;
    min-height: 2.75rem;
    font-size: 0.9375rem;
  }
  .mobile-more-legal,
  .mobile-more-trace { flex: 1 0 100%; min-height: 2.75rem; }
  .mobile-more-legal a { display: inline-flex; align-items: center; min-height: 2.75rem; }
}

/* ==========================================================================
   PHONE NAVIGATION (2026-09-28). Every rule is inside the narrow block and keyed on a
   <body data-phone-*> flag attribute, so a desktop window and a flag that is OFF read none
   of it. Owner: "the analysis page comes up too easily and the bubbles that the user was on
   are hard to get back to." See PHONE_* in gobble/settings.py.
   ========================================================================== */
@media (max-width: 1023.98px) {
  /* PHONE_PEEK_FIRST: a CLOSED theme is its headline plus the 44px Analysis pill, so a centre
     tap hit the pill (4 of 16 themes jumped to Analysis on the first tap, measured; one of them a
     multi-story theme whose pill started 5px below its centre). Every bubble but a story leaf
     keeps its pill hidden until it is opened (a summary, a spread ring, or the trace); a single-
     story theme's second tap still opens Analysis, as a story's does. Story leaves keep theirs. */
  body[data-phone-peek="1"] .bubble:not(.bubble-leaf):not(.show-summary):not(.bubble-traced):not([aria-expanded="true"]) > .bubble-trace { display: none; }

  /* PHONE_BACK_STEPS: the "where am I" row, pinned in the sticky header. Measured before: the
     canvas breadcrumb sat ~224px above the screen after drilling in, at 0.68rem. Here it is one
     line that scrolls sideways, 44px tap targets, and a "‹ Back" that is history.back(). The
     canvas's own crumb is hidden when this row carries the trail. */
  body[data-phone-back="1"] .phone-nav {
    display: flex; align-items: center; gap: 0.4rem;
    padding: 0 0.75rem 0.35rem; min-width: 0;
  }
  .phone-nav-back {
    flex: 0 0 auto; min-height: 2.75rem; padding: 0 0.85rem; border-radius: 9999px;
    font-size: 0.95rem; font-weight: 600; color: #d7f5ea;
    background: color-mix(in srgb, var(--accent-hi) 15%, #0f1a17);
    border: 1px solid color-mix(in srgb, var(--accent-hi) 45%, #2b384c);
  }
  .phone-nav-back[hidden] { display: none; }
  .phone-nav-crumbs {
    display: flex; align-items: center; gap: 0.15rem; flex: 1 1 auto; min-width: 0;
    overflow-x: auto; white-space: nowrap; scrollbar-width: none;
  }
  .phone-nav-crumbs::-webkit-scrollbar { display: none; }
  .phone-nav-crumbs[hidden] { display: none; }
  .phone-nav-crumbs .spine-bc-seg {
    flex: 0 0 auto; min-height: 2.75rem; padding: 0 0.35rem; font-size: 0.95rem; color: #cbd5e1;
  }
  .phone-nav-crumbs .spine-bc-seg[aria-current="true"] { color: #fff; font-weight: 600; }
  .phone-nav-crumbs .spine-bc-sep { flex: 0 0 auto; color: #64748b; }
  body[data-phone-back="1"] [data-spine-breadcrumb]:not([data-phone-crumbs]) { display: none !important; }

  /* PHONE_SECTION_CHIPS: the section legend as one row of chips in the header, on Overview only
     (setMobileTab stamps body[data-phone-tab]). Measured before: a section sat ~1,600px down the
     resting page. An explicit [hidden] rule, because an author display beats the UA's [hidden]. */
  body[data-phone-chips="1"] .phone-chips {
    display: flex; gap: 0.4rem; padding: 0 0.75rem 0.4rem;
    overflow-x: auto; white-space: nowrap; scrollbar-width: none;
  }
  .phone-chips::-webkit-scrollbar { display: none; }
  body[data-phone-chips="1"] .phone-chips[hidden],
  body[data-phone-chips="1"][data-phone-tab]:not([data-phone-tab="overview"]) .phone-chips { display: none; }
  .phone-chips .spine-legend-item {
    flex: 0 0 auto; display: inline-flex; align-items: center; gap: 0.4rem; min-height: 2.75rem;
    padding: 0 0.85rem; border-radius: 9999px; border: 1px solid #2b384c; background: #131a26;
    font-size: 0.9rem; color: #cbd5e1;
  }
  .phone-chips .spine-legend-item[aria-pressed="true"] { border-color: var(--accent-hi); color: #fff; }

  /* PHONE_TABS_MORE: four tabs share the bar, so there is nothing past the edge to fade into; the
     rest open from a sheet that sits above the bar. */
  body[data-phone-more="1"] .gobble-mobile-tabs::after { display: none; }
  .mobile-tabs-sheet {
    position: fixed; left: 0.75rem; right: 0.75rem;
    bottom: calc(3.75rem + env(safe-area-inset-bottom) + 0.5rem); z-index: 46;
    display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem;
    background: #0b0f17; border: 1px solid #273043; border-radius: 0.75rem;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  }
  .mobile-tabs-sheet[hidden] { display: none; }
  .mobile-tabs-sheet-item {
    min-height: 2.75rem; padding: 0 0.75rem; border-radius: 0.5rem; text-align: left;
    font-size: 1rem; color: #e2e8f0;
  }
  .mobile-tabs-sheet-item:focus-visible { outline: 2px solid var(--accent-hi); outline-offset: 1px; }
}

/* ==========================================================================
   THE SENTIMENTS PANE FITS ITS WIDTH (2026-09-28, no flag). A story's scoped charts are drawn
   while the pane is hidden, at Plotly's 700px fallback, and below xl this grid had no column
   template, so its one implicit auto column grew to the chart: a 390px phone's page went 767px
   wide and the bottom tab bar fell off the screen (y 1611 of 844), and every resize read the
   stretched card and kept 700. A minmax(0, 1fr) column cannot grow to its content, so the resize
   reads the pane's own width. The rule sizes IMPLICIT columns only, so it needs no media query:
   at xl Tailwind's two explicit columns take over and it does nothing.
   Pinned by news/tests/test_phone_sentiments_fit.py and tests/test_browser_phone_sentiments_fit.py.
   ========================================================================== */
#panel-sentiment .grid { grid-auto-columns: minmax(0, 1fr); }

/* ==========================================================================
   THE ANALYSIS PANE FITS ITS WIDTH (2026-09-28, no flag). The same fault as the Sentiments
   pane above: the Evolution chart is drawn while the Full-breakdown fold is shut, at Plotly's
   700px fallback, and below xl the Context fold's grid had no column template, so its implicit
   auto column grew to the chart. "Who covered it" and Evolution measured 734px wide in a 513px
   pane at 1024x768 and in a 358px pane at 390, and the fold's overflow-hidden cut off the right
   side of "Who covered it" (the Outlets and Owners numbers, the ends of the country and language
   lines). Implicit columns only, so no media query: at xl the two explicit columns take over.
   Pinned by news/tests/test_analysis_pane_cosmetics.py.
   ========================================================================== */
#panel-meta .grid { grid-auto-columns: minmax(0, 1fr); }
