/* ============================================================
   ICHEP 2026 talk site — style.css
   Designed as a 16:9 slide deck (1920×1080): every section
   fills the screen, type is sized for a conference projector.
   Brand palette measured from Accel Kitchen slides:
   blue #3860a8 · red #e85058 · yellow #f0b810 · cream #f0f0d8
   ============================================================ */

/* ---------- fonts (self-hosted, offline) ---------- */
@font-face {
  font-family: 'Fredoka';
  src: url('../fonts/fredoka-var.woff2') format('woff2');
  font-weight: 300 700;
  font-display: swap;
}
@font-face {
  font-family: 'Inter';
  src: url('../fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

/* ---------- tokens ---------- */
:root {
  --blue: #3860a8;
  --blue-soft: #9dbcfb;
  --red: #e85058;
  --yellow: #f0b810;
  --cream: #f0f0d8;
  --navy: #141b2e;
  --navy-card: #1e2745;
  --ink: #161d2a;
  --paper: #ffffff;
  --muted: #55607a;
  --muted-dark: #b9c6e2;

  --head: 'Fredoka', 'Inter', system-ui, sans-serif;
  --body: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;

  /* Type scale for a projector. `--fs-floor` is the SMALLEST type used
     anywhere on the site — captions, labels, notes and body copy all sit
     on it (~34 px at 1080p). Nothing may be set below it; that is why the
     copy in content-en.js is kept deliberately short. */
  --fs-floor:  clamp(1.18rem, 1.98vw, 2.12rem);
  --fs-kicker: var(--fs-floor);
  --fs-lead:   var(--fs-floor);
  --fs-cap:    var(--fs-floor);
  --fs-small:  var(--fs-floor);
  --fs-h1:     clamp(3rem, 7vw, 6.6rem);
  --fs-h2:     clamp(2.3rem, 4.9vw, 5rem);
  --fs-h3:     clamp(1.45rem, 2.7vw, 2.8rem);
  --fs-stat:   clamp(2.6rem, 5.6vw, 5.6rem);

  --radius: 20px;
  --shadow: 0 12px 34px rgba(20, 27, 46, .14);
  --gap: clamp(10px, 1.6vh, 22px);

  /* always-on QR dock (see .qr-dock). Published as tokens so the three slides
     whose top band runs the full width can reserve exactly the dock's width
     instead of a guessed number. */
  --qr-col: clamp(46px, 5.2vw, 86px);
  --qr-pad: clamp(6px, .7vw, 11px);
  --qr-w: calc(2 * var(--qr-col) + 3 * var(--qr-pad));
  /* what a slide must keep free on its right: the dock, its 12px offset from the
     edge, and a gap so type does not end flush against the card */
  --qr-clear: calc(var(--qr-w) + 32px);
}

/* ---------- base ---------- */
* { box-sizing: border-box; }
html {
  scroll-snap-type: y proximity;
  scroll-behavior: smooth;
}
body {
  margin: 0;
  font-family: var(--body);
  color: var(--ink);
  background: var(--paper);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}
img { max-width: 100%; display: block; }
figure { margin: 0; }
a { color: var(--blue); }
kbd {
  font-family: var(--body);
  font-size: .95em;
  border: 1px solid currentColor;
  border-radius: 5px;
  padding: 0 .4em;
  opacity: .85;
}

/* ---------- slide shell ---------- */
.slide {
  height: 100vh;
  height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  scroll-snap-align: start;
  position: relative;
  overflow: hidden;
  padding: clamp(22px, 3.4vh, 48px) clamp(22px, 3vw, 58px);
}
.inner {
  width: min(1800px, 96vw);
  margin: 0 auto;
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  min-height: 0;
  max-height: 100%;
}
/* sections whose visual should grow to fill the screen */
.slide.fill .inner { height: 100%; }

/* full-bleed slide: one image, edge to edge, no padding at all.
   The research map is authored 16:9, so on a 16:9 screen `cover`
   shows it whole with no cropping and no letterboxing. */
.slide.bleed { padding: 0; display: block; }
.bleed-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}
.stage { flex: 1; min-height: 0; display: flex; gap: clamp(14px, 1.8vw, 34px); }

/* themes */
.theme-white { background: var(--paper); }
.theme-cream { background: var(--cream); }
.theme-navy  { background: var(--navy); color: #eef2fc; }
.theme-blue  { background: var(--blue); color: #f5f8ff; }

.hl-blue   { color: var(--blue); }
.hl-red    { color: var(--red); }
.hl-yellow { color: #cf9700; }
.theme-navy .hl-blue, .theme-blue .hl-blue { color: var(--blue-soft); }
.theme-navy .hl-yellow, .theme-blue .hl-yellow { color: var(--yellow); }
.theme-navy a, .theme-blue a { color: var(--blue-soft); }

/* ---------- typography ---------- */
h1, h2 {
  font-family: var(--head);
  font-weight: 600;
  line-height: 1.1;
  margin: 0;
  letter-spacing: .002em;
}
h1 { font-size: var(--fs-h1); }
h2 { font-size: var(--fs-h2); }
.kicker {
  font-family: var(--head);
  font-weight: 600;
  font-size: var(--fs-kicker);
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--red);
  margin: 0;
}
.theme-navy .kicker, .theme-blue .kicker { color: var(--yellow); }
.lead {
  font-size: var(--fs-lead);
  max-width: 60ch;
  margin: 0;
  color: #303a4d;
}
.theme-navy .lead, .theme-blue .lead { color: #e2e9f9; }
.theme-cream .lead { color: #2f3849; }
.note {
  font-size: var(--fs-small);
  color: var(--muted);
  max-width: 74ch;
  margin: 0;
}
.theme-navy .note, .theme-blue .note { color: var(--muted-dark); }
.note.center { margin: 0 auto; text-align: center; }
.closing {
  font-family: var(--head);
  font-weight: 600;
  font-size: clamp(1.3rem, 2.6vw, 2.5rem);
  text-align: center;
  margin: 0 auto;
  color: var(--yellow);
  max-width: 32ch;
  line-height: 1.22;
}
.closing.dark-text { color: var(--ink); }

/* header block used at the top of most slides */
.slide-head { display: flex; flex-direction: column; gap: .25em; flex: none; }
/* heading and its lead share one row so the visual below gets the height */
.head-row {
  display: flex;
  align-items: flex-end;
  gap: clamp(16px, 2.4vw, 48px);
}
.head-row > h2 { flex: 1 1 56%; min-width: 0; }
.head-row > .lead { flex: 1 1 34%; min-width: 24ch; max-width: 46ch; }

/* ---------- reveal animation ---------- */
.rv {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity .65s ease, transform .65s ease;
}
.rv.on { opacity: 1; transform: none; }

/* ---------- progress dots ---------- */
#dots {
  position: fixed;
  right: 15px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 40;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
#dots button {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 2px solid rgba(125, 138, 165, .5);
  background: transparent;
  cursor: pointer;
  padding: 0;
  transition: all .25s ease;
}
#dots button.active {
  background: var(--red);
  border-color: var(--red);
  transform: scale(1.4);
}
#dots button:hover { border-color: var(--red); }

/* ---------- always-on QR dock ----------
   Sits in the same right-hand chrome column as the dots, above them. White card
   on purpose: it has to stay scannable over navy, cream and white slides alike,
   and a phone wants a quiet zone of plain white around the code. Sized in vw so
   it keeps the same share of the projection at any resolution. */
.qr-dock {
  position: fixed;
  top: 12px;
  right: 12px;
  z-index: 40;              /* same layer as the dots: under .tip and .lightbox */
  display: flex;
  gap: var(--qr-pad);
  padding: var(--qr-pad);
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 4px 18px rgba(20, 27, 46, .28);
}
.qr-dock .qr {
  display: block;
  width: var(--qr-col);
  text-decoration: none;
  color: var(--navy);
}
.qr-dock img {
  display: block;
  width: 100%;
  aspect-ratio: 1;          /* the SVGs carry a viewBox and no intrinsic size */
}
/* These slides run type across the whole top band, and where it wraps depends on
   the window: at some widths the last line slid under the dock. Reserving the
   dock's own width keeps the corner clear at every size — it costs one earlier
   wrap in the headline, nothing else.
   NOT #global: there the header's height comes straight off the map, which is
   meant to be the whole slide, and reserving width made the lead wrap two lines
   longer — 86 px of map gone at 1440×810. That slide moves the dock instead.
   Scoped to body.has-qr, because the Neuquén deck shares this stylesheet and has
   no dock — it keeps its full-width top band. */
.has-qr #research2 .slide-head,
.has-qr #papers .slide-head,
.has-qr #storm .slide-head { padding-right: var(--qr-clear); }

/* These put type in their own top-right corner (or, on #global, cannot spare the
   width), so the dock steps aside there. body[data-slide] is stamped by
   presentation.js; with JS off the dock just stays in the corner — it is never
   hidden. On #global the bottom right of the map is open water at every size. */
body[data-slide="research"] .qr-dock,
body[data-slide="global"] .qr-dock,
body[data-slide="tour"] .qr-dock,
body[data-slide="end"] .qr-dock { top: auto; bottom: 12px; }
/* the model flow fills its frame corner to corner; the one clear patch is the
   low band between the community disc and the "design and build" caption —
   hence the odd left offset, which was measured, not guessed */
body[data-slide="model"] .qr-dock { top: auto; bottom: 12px; right: auto; left: 22%; }

.qr-dock span {
  display: block;
  margin-top: .35em;
  font-family: var(--head);
  font-weight: 600;
  /* deliberately below --fs-floor: this is a caption on a scannable code, not
     something the room has to read from the back */
  font-size: clamp(7px, .78vw, 13px);
  line-height: 1.15;
  letter-spacing: .02em;
  text-align: center;
}

/* ══════════ HERO ══════════ */
/* an empty night sky sits under the particle canvas — the air shower itself is
   drawn by particles.js, so the art must stay free of it. The gradient is a
   legibility scrim; the horizon band is bright enough to fight white type */
#hero {
  background:
    linear-gradient(rgba(14, 20, 38, .40), rgba(14, 20, 38, .34) 45%, rgba(14, 20, 38, .70)),
    url('../img/cover-sky.webp') center / cover no-repeat,
    var(--navy);
}
#sky { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; }
.hero-inner { text-align: center; align-items: center; justify-content: center; }
/* the title runs long, so it needs a wider measure than a short punchline
   would — 24ch keeps it to three lines without shrinking the type */
#hero h1 { max-width: 24ch; margin: .1em auto .05em; }
.hero-sub {
  font-size: clamp(1.3rem, 2.4vw, 2.55rem);
  color: #ccd7f0;
  max-width: 50ch;
  margin: 0 auto;
  line-height: 1.35;
}
.hero-byline {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 20px;
  margin-top: clamp(14px, 2.6vh, 34px);
}
/* ---------- Accel Kitchen logo (inline <symbol>) ---------- */
.svg-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.akl-word {
  /* the original art calls for A-OTF Maru Folk Pro; Fredoka is the closest
     rounded face we ship, so the wordmark keeps its character offline */
  font-family: 'Fredoka', 'Hiragino Maru Gothic ProN', system-ui, sans-serif;
  font-size: 30px;
  font-weight: 500;
  fill: #fff;
}
.akl-white { fill: #fff; }
.akl-blue { fill: #4fa9d2; }
.akl-red { fill: #df6756; }
.akl-stroke-w { fill: none; stroke: #fff; stroke-width: .9px; stroke-miterlimit: 10; }
.akl-stroke-b { fill: none; stroke: #4fa9d2; stroke-width: .9px; stroke-linecap: round; stroke-linejoin: round; }

.hero-logo { height: clamp(46px, 6vh, 68px); width: auto; aspect-ratio: 337 / 52.7; }
.hero-author {
  font-family: var(--head);
  font-weight: 600;
  font-size: clamp(1.45rem, 2.7vw, 2.8rem);
  margin: 0;
  text-align: left;
}
.hero-affil {
  font-size: var(--fs-floor);
  color: #a9b7d8;
  margin: 3px 0 0;
  text-align: left;
}
.hero-hint {
  margin: clamp(12px, 2.4vh, 30px) 0 0;
  color: #8f9cbd;
  font-size: var(--fs-cap);
  animation: bob 2.2s ease-in-out infinite;
}
@keyframes bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(8px); } }

/* ══════════ generic cards ══════════ */
.photo-card {
  background: #fff;
  border-radius: var(--radius);
  overflow: hidden;
  box-shadow: var(--shadow);
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.photo-card img { width: 100%; flex: 1; min-height: 0; object-fit: cover; }
.photo-card figcaption {
  padding: 10px clamp(12px, 1vw, 18px) 12px;
  font-size: var(--fs-cap);
  color: var(--muted);
  flex: none;
}
.photo-card.dark { background: var(--navy-card); }
.photo-card.dark figcaption { color: var(--muted-dark); }

.thumb {
  background: #fff;
  border-radius: 15px;
  overflow: hidden;
  box-shadow: var(--shadow);
  display: flex;
  flex-direction: column;
  min-height: 0;
}
/* --op nudges the crop, same idiom as .mdisc: these cells are much wider than
   they are tall, so a photo whose subject is not in the middle band needs to say
   where to look */
.thumb img { width: 100%; flex: 1; min-height: 0; object-fit: cover; object-position: var(--op, 50% 50%); }
.thumb figcaption {
  padding: 8px 11px 10px;
  font-size: var(--fs-small);
  color: var(--muted);
  line-height: 1.28;
  flex: none;
  /* reserve two lines everywhere so every image in a row is the same
     height, whether its caption wraps or not */
  min-height: calc(2 * 1.28em + 18px);
}
.theme-blue .thumb { background: rgba(255, 255, 255, .12); }
.theme-blue .thumb figcaption { color: #dbe4f8; }

/* ══════════ 1 · ANYWHERE ══════════
   Same aspect-locked frame trick as the research map: the overlays are placed in
   % of the artwork, so the frame must keep the art's 16:9 or the labels drift off
   the globe. Type scales off the frame width (cqw), not the viewport. */
#anywhere { display: flex; align-items: center; justify-content: center; padding: 0; }
.aw {
  position: relative;
  aspect-ratio: 1368 / 768;
  width: min(100%, calc(100vh * 1368 / 768));
  container-type: inline-size;
  overflow: hidden;
}
/* the artwork is transparent (no background plate), so nudging it down to clear
   the headline just lets the navy show through at the top — no seam appears */
.aw-bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: translateY(3%);
}
.aw > *:not(.aw-bg) { position: absolute; z-index: 1; }

.aw-head { left: 4.5%; top: 5.5%; width: 58%; }
.aw-head .kicker { margin: 0 0 .3em; font-size: 1.5cqw; }
.aw-head h2 { margin: 0; font-size: 3.5cqw; line-height: 1.14; }

/* sits under the globe, which the artwork puts at roughly x 7–28 %, y 27–77 % */
.aw-globe {
  left: 6%;
  top: 80%;
  width: 28%;
  margin: 0;
  font-size: 1.55cqw;
  line-height: 1.4;
  color: #cdd9f2;
  text-align: center;
}
.aw-globe b { display: block; font-size: 2.5cqw; color: #fff; letter-spacing: .02em; }

/* photo disc: the shot is cropped into a circle, and the white ring is the same
   idiom as the model-flow discs (.mdisc) — it keeps a photograph from reading as
   a hole punched in the dark slide. The plate underneath still shows through for
   a moment while the JPEG loads. */
.aw-shot {
  left: 39%;
  top: 28.5%;
  width: 21%;
  aspect-ratio: 1;
  margin: 0;
  border-radius: 50%;
  background: #e8ecf3;
  border: .55cqw solid #fff;
  box-shadow: 0 1.2cqw 3cqw rgba(0, 0, 0, .45);
  display: grid;
  place-items: center;
  overflow: hidden;
}
/* 4:3 source into a square box, so the crop takes the sides and object-position
   only matters horizontally — centred keeps the detector and the laptop in */
.aw-shot img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 50%; }
.aw-shot-cap {
  left: 37.5%;
  top: 80%;
  width: 24%;
  margin: 0;
  font-size: 1.55cqw;
  line-height: 1.4;
  color: #cdd9f2;
  text-align: center;
}
.aw-shot-cap b { display: block; font-size: 2.5cqw; color: #fff; letter-spacing: .02em; }

/* ---------- .aw-nocap: the ICHEP deck, without the two art captions ----------
   With `Arriving ANYWHERE` / `Measured BY YOURSELF` gone the whole band below
   the globe is free, so the globe grows into it. The globe is drawn at 23.32%
   / 49.15% of the artwork and is 27.63% of its width, so scaling about that
   point grows it in place; the translate then parks its centre at 20.5% /
   56.5% of the frame — left edge and headline keep the same 4.5% margin, and
   the bottom reaches 86% instead of 77%. The photo disc slides right by the
   same amount the globe gained, so the two keep their gap.
   Not folded into .aw itself: the Neuquén deck shares this file and still
   shows both captions, where a bigger globe would sit on top of them. */
.aw-nocap .aw-bg {
  transform-origin: 23.32% 49.15%;
  transform: translate(-2.82%, 7.35%) scale(1.2);
}
/* the disc used to hang above its caption; with the caption gone it drops to
   the globe's centre line, so the middle column doesn't leave a hole under it */
.aw-nocap .aw-shot { left: 40.5%; top: 38.5%; width: 20%; }
/* and the stats follow it down, to sit against the taller globe */
.aw-nocap .aw-stats { top: 30%; }

.aw-stats {
  right: 4.5%;
  top: 27%;
  width: 33%;
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 2.9cqw;
}
.aw-stats li { display: flex; align-items: baseline; gap: .6em; }
.aw-stats b {
  flex: none;
  font-family: var(--head);
  font-weight: 700;
  font-size: 4cqw;
  line-height: 1;
  color: var(--blue-soft);
}
.aw-stats b i { font-style: normal; font-size: .6em; }
.aw-stats span { font-size: 1.5cqw; line-height: 1.25; color: #e2e9f9; }

.aw-net { right: 4.5%; bottom: 6%; width: 33%; }
.aw-net p { margin: 0 0 .55em; font-size: 1.5cqw; color: #e2e9f9; }
.aw-net b { color: #fff; }
.aw-flags { display: flex; flex-wrap: wrap; gap: .55cqw; }
.aw-flags img { width: 2.5cqw; height: 2.5cqw; border-radius: 50%; }

/* ══════════ 1 · GAP （退避済み） ══════════
   この節（.gap-* / .detector-fig / .det-badge / .spectra-row / .spectrum）を
   使うのは archive/2026-07-25-gap-spectra/slide.html だけ。スライドは 2026-07-25 に
   デッキから外したが、貼り戻すだけで戻せるようルールは消していない。
   下の @media (max-width:1100px) 内の .gap-* も同じ。 */
/* the exploded board gets the full slide height at left; headline + spectra at right */
.gap-inner {
  display: grid;
  grid-template-columns: .76fr 1.2fr;
  grid-template-rows: 100%;
  align-items: stretch;
  gap: clamp(12px, 1.8vw, 40px);
}
/* the two renders read as ONE exploded object: stacked, overlapping, bleeding off
   the left edge of the slide the way the printed Japanese sheet does.
   It is HEIGHT-constrained, so it only grows by bleeding past the top and bottom
   edges as well — widening the column alone changes nothing. It then runs right,
   under the spectra, which sit on an opaque plate so the tuck-under reads. */
.gap-left {
  position: relative;
  z-index: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 0;
  margin-left: calc(-1 * clamp(14px, 2vw, 40px));
  margin-right: calc(-1 * clamp(24px, 12vw, 260px));
  /* pure offset, not a margin change: the stack keeps its size (so the bleed past
     the top and bottom edges stays as tuned) and only slides left, closing the
     dead strip that opened along the left edge */
  left: calc(-1 * clamp(20px, 4.4vw, 88px));
}
/* headline stacks above the two spectra; transparent so the enlarged detector
   running underneath stays visible through the overlap */
/* ranged right: the detector runs underneath this column, so a ragged LEFT edge
   is what keeps the short lines off the render instead of sitting on top of it */
.gap-right .slide-head { flex: none; padding-bottom: clamp(4px, 1vh, 14px); text-align: right; }
/* headline shares the column with the spectra, so cap it by viewport HEIGHT too —
   on a 16:10 screen the width-only clamp ran to 4 lines and starved the plots */
.gap-right .slide-head h2 { margin: .06em 0 .12em; font-size: min(var(--fs-h2), 6.6vh); }
/* max-width would otherwise anchor the block left and undo the right-ranging */
.gap-right .lead { max-width: 44ch; margin-left: auto; }
/* Sized by WIDTH, not by the row height: with object-fit the renders were capped by
   the available height, so widening the column did nothing. Driving them off the
   width lets the stack grow and simply overflow the slide, which `.slide` clips. */
.detector-fig {
  position: relative;
  flex: none;
}
/* the lower face tucks up under the upper one — that overlap is what makes it
   read as an exploded assembly rather than two unrelated renders */
.gap-left .detector-fig:last-child { margin-top: -5%; }
.detector-fig img {
  display: block;
  width: 100%;
  height: auto;
}
/* 表 / 裏 badges, pinned to the left edge like the reference sheet.
   The added term cancels the .gap-left offset, so the badges stay put in SLIDE
   coordinates while the renders slide left underneath them — without it the
   stack carries them off the left edge and they get clipped. Keep the two
   clamps identical. */
.det-badge {
  position: absolute;
  left: calc(clamp(6px, .8vw, 18px) + clamp(20px, 4.4vw, 88px));
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: grid;
  place-items: center;
  width: clamp(52px, 5.2vw, 104px);
  aspect-ratio: 1;
  border-radius: 50%;
  color: #fff;
  font-family: var(--head);
  font-weight: 800;
  font-size: clamp(.72rem, 1.12vw, 1.35rem);
  letter-spacing: .04em;
  box-shadow: 0 6px 20px rgba(20, 27, 46, .28);
}
.badge-top { background: var(--red); }
.badge-bottom { background: var(--blue); }
.gap-right {
  position: relative;
  z-index: 1; /* the enlarged detector runs underneath this column */
  display: grid;
  grid-template-rows: auto 1fr;
  gap: clamp(8px, 1.4vh, 20px);
  min-height: 0;
  align-content: center;
}
/* the two spectra run side by side, frameless, and bleed past the slide padding
   to the screen edge — with no card and no prose block the plots take the width */
.spectra-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(6px, .9vw, 20px);
  min-height: 0;
  /* Bleed to exactly the screen edge and no further. The plots are width-constrained,
     so overshooting clips the ADC axis labels instead of enlarging anything. The inner
     is min(1800px, 96vw), so its distance to the viewport edge is 50vw - 900px whenever
     the 1800px cap binds, and ~0 below that — a fixed vw bleed overshot at 1440. */
  margin-right: min(0px, calc(904px - 50vw));
  align-content: center;
}
/* transparent: the detector passes underneath and stays visible through the plots */
.spectrum {
  display: flex;
  flex-direction: column;
  min-height: 0;
  justify-content: center;
  padding: clamp(3px, .6vh, 9px) clamp(4px, .5vw, 12px);
}
.spectrum img { flex: 1; min-height: 0; width: 100%; object-fit: contain; object-position: center top; }
.spectrum figcaption {
  flex: none;
  font-family: var(--head);
  font-weight: 700;
  font-size: clamp(.95rem, 1.25vw, 1.6rem);
  color: var(--ink);
  line-height: 1.15;
  padding-bottom: clamp(2px, .6vh, 8px);
}

/* ══════════ 2 · MODEL — diagonal flow ══════════
   Aspect-locked frame, same as .rmap / .aw. Node coordinates are % of the frame
   and the connector SVG shares that box, so the two cannot drift apart.
   `inline-size` containment, never `size`: below 1100px .slide becomes
   height:auto and size containment needs a definite height to resolve. */
#model { display: flex; align-items: center; justify-content: center; padding: 0; }
.mflow {
  position: relative;
  aspect-ratio: 16 / 9;
  width: min(100%, calc(100vh * 16 / 9));
  container-type: inline-size;
  overflow: hidden;
}
.mflow > * { position: absolute; }

/* zero-size anchor (same idiom as .rtag) — disc and label hang off the point */
.mnode { left: var(--x); top: var(--y); width: 0; height: 0; z-index: 2; }
.mdisc {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, -50%);
  width: var(--d, 12cqw);
  aspect-ratio: 1;
  border-radius: 50%;
  overflow: hidden;
  display: grid;
  place-items: center;
  background: #fff;
  border: .55cqw solid #fff;
  box-shadow: 0 .8cqw 2.2cqw rgba(20, 27, 46, .22);
}
/* --op nudges the square crop so a landscape photo does not lose heads.
   The four film discs are already cut square by make_disc_clips.py, so there is
   nothing to nudge on those — the rule is shared anyway, and is a no-op when the
   source and the box have the same aspect. */
.mdisc img,
.mdisc video { width: 100%; height: 100%; object-fit: cover; object-position: var(--op, 50% 50%); }
/* grid placement centres the video, but a replaced element still needs to be a
   block or the line box leaves a sliver of white inside the circle */
.mdisc video { display: block; }
.mnode figcaption {
  position: absolute;
  left: 0;
  top: calc(var(--d, 12cqw) / 2 + 1.2cqw);
  transform: translateX(-50%);
  width: max-content;
  max-width: 18cqw;
  font-family: var(--head);
  font-weight: 600;
  font-size: 1.8cqw;          /* ≈34.5px on a 1920 frame — at --fs-floor, never under */
  line-height: 1.15;
  text-align: center;
  color: var(--ink);
}
/* the number under a label — the thing the audience should take away from the
   step. It stays at --fs-floor like the label (projector rule) and separates by
   colour instead: red data under near-black caption. */
.mnode .mstat {
  display: block;
  margin-top: .3em;
  font-size: 1.8cqw;
  font-weight: 500;
  line-height: 1.15;
  color: var(--red);
}
.mnode.is-aside .mstat { color: #a17a00; }
.mnode.is-zoom  .mstat { color: var(--blue); }
/* the beamline node's label goes above: the balloon branch owns the space below */
.mnode[data-lab="t"] figcaption {
  top: auto;
  bottom: calc(var(--d, 12cqw) / 2 + 1.2cqw);
  transform: translate(-50%, 0);
}
/* the conference label is three lines tall AND reaches up toward the title, so
   it tucks tighter against its own disc and is allowed to run a little wider */
.mnode.is-conf figcaption {
  bottom: calc(var(--d) / 2 + .55cqw);
  max-width: 21cqw;
}

/* connectors — viewBox is 16:9 like the frame, so the default `meet` scales
   uniformly and the arrowheads stay round */
.mlines { inset: 0; width: 100%; height: 100%; z-index: 0; overflow: visible; }
.mline {
  fill: none;
  stroke: var(--red);
  stroke-width: 6;
  stroke-linecap: round;
  marker-end: url(#mArrow);
}
/* the aside is dashed and arrowless — an arrowhead would read as "step 6" */
.mline.is-aside { stroke: var(--yellow); stroke-width: 6; stroke-dasharray: 3 16; marker-end: none; }
.mdot { fill: var(--yellow); }

/* mentoring link: blue and double-headed, so it reads as an exchange running
   during "measure at home" rather than as the next step in the red chain */
.mline.is-zoom {
  stroke: var(--blue);
  marker-start: url(#mArrowZoom);
  marker-end: url(#mArrowZoom);
}


/* the nationwide meet-up: parallel to the chain, not a step in it */
.mnode.is-aside .mdisc {
  border-color: var(--yellow);
  box-shadow: 0 0 0 .35cqw rgba(240, 184, 16, .28), 0 .8cqw 2.2cqw rgba(20, 27, 46, .22);
}
.mnode.is-aside figcaption { color: #6a5200; }

/* companion to the measure step — blue ring echoes its connector */
.mnode.is-zoom .mdisc {
  box-shadow: 0 0 0 .35cqw rgba(56, 96, 168, .28), 0 .8cqw 2.2cqw rgba(20, 27, 46, .22);
}
.mnode.is-zoom figcaption { color: var(--blue); }

.mtag {
  position: absolute;
  left: 0;
  bottom: calc(var(--d, 11cqw) / 2 + .9cqw);
  transform: translateX(-50%);
  white-space: nowrap;
  font-family: var(--head);
  font-weight: 700;
  font-size: 1.25cqw;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: #a17a00;
}

/* the duration badge over "measure at home". Same position idiom as .mtag but a
   different job: .mtag is a quiet qualifier ("Throughout") set below the floor
   size, while this is a headline number the back row has to read — so it sits at
   1.8cqw like every caption, and takes the outlined-pill shape used for .chip on
   #storm. White backing because it overlaps the drawing's open sky. */
.mkey {
  position: absolute;
  left: 0;
  /* 1.5cqw, not the 1.2cqw the captions use: the mentoring arrow passes just
     below and to the right of this pill, and the extra .3cqw is what keeps the
     two apart at 1280 as well as at 1920 */
  bottom: calc(var(--d, 12cqw) / 2 + 1.5cqw);
  transform: translateX(-50%);
  white-space: nowrap;
  font-family: var(--head);
  font-weight: 700;
  font-size: 1.8cqw;
  letter-spacing: .1em;
  padding: .3cqw 1cqw;
  border: .18cqw solid var(--red);
  border-radius: 999px;
  color: var(--red);
  background: rgba(255, 255, 255, .82);
}

.mf-head { right: 3.5%; top: 3.5%; width: 38%; text-align: right; z-index: 2; }
.mf-head .kicker { font-size: 1.85cqw; margin: 0 0 .3em; }
.mf-head h2 { font-size: 3.5cqw; line-height: 1.1; margin: 0; }
.mf-head .lead { font-size: 1.6cqw; margin: .5em 0 0; margin-left: auto; max-width: 30ch; }
.mf-note {
  right: 3.5%;
  bottom: 3.5%;
  width: 26%;
  text-align: right;
  font-size: 1.6cqw;
  line-height: 1.35;
  max-width: none;            /* .note caps at 74ch, which fights the % width */
  z-index: 2;
}

.stats {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: clamp(9px, 1.2vw, 20px);
  flex: none;
}
.stat {
  text-align: center;
  background: rgba(255, 255, 255, .7);
  border-radius: 16px;
  padding: clamp(9px, 1.5vh, 18px) 8px;
}
.theme-white .stat { background: #f4f7fd; }
.stat-n {
  display: block;
  font-family: var(--head);
  font-weight: 700;
  font-size: var(--fs-stat);
  color: var(--blue);
  line-height: 1.02;
  font-variant-numeric: tabular-nums;
}
.stat-l {
  font-size: var(--fs-small);
  color: var(--muted);
  line-height: 1.25;
  display: block;
  margin-top: 5px;
}
.theme-blue .stat { background: rgba(255, 255, 255, .14); }
.theme-blue .stat-n { color: #fff; }
.theme-blue .stat-l { color: #d5e0f7; }

/* ══════════ 3 · RESEARCH (full-bleed map) ══════════ */
.research-stage { position: relative; }
.map-hero { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; }
.map-hero img {
  height: 100%;
  width: 100%;
  object-fit: contain;
  border-radius: var(--radius);
}
/* optional inset card for a small figure (e.g. the Mt. Fuji chart in
   charts.js). Unused by default so the research map owns its whole slide. */
.chart-float {
  position: absolute;
  right: 0;
  bottom: 0;
  width: clamp(280px, 27vw, 520px);
  background: rgba(255, 255, 255, .96);
  border: 2px solid #e3e9f6;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: clamp(8px, 1.2vh, 14px);
}
.chart-card {
  background: #fff;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: clamp(10px, 1.5vh, 20px);
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.chart-card figcaption, .chart-float figcaption {
  font-size: var(--fs-small);
  color: var(--muted);
  margin-top: 6px;
}
.chart-card.dark { background: var(--navy-card); }
.chart-card.dark figcaption { color: var(--muted-dark); }
.chart-title {
  margin: 0 0 8px;
  font-family: var(--head);
  font-weight: 600;
  font-size: var(--fs-h3);
  color: #dfe7f9;
  flex: none;
}
#fuji-chart svg { width: 100%; height: auto; display: block; }
#storm-chart svg { width: 100%; height: 100%; display: block; }
#storm-chart { flex: 1; min-height: 0; }

/* ══════════ 4 · BEYOND THE KIT ══════════ */
.cs-title {
  font-family: var(--head);
  font-weight: 600;
  font-size: var(--fs-h3);
  margin: 0;
  flex: none;
  color: var(--blue);
}
.grid-stage { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: var(--gap); }
.cs-grid, .zoo-grid { display: grid; gap: clamp(9px, 1.2vw, 18px); flex: 1; min-height: 0; }
/* .cs-grid is used only by archive/2026-07-30-research2-citizen-science/ now */
.cs-grid { grid-template-columns: repeat(6, 1fr); }
/* Now that this row owns the whole stage, six across would each be a narrow
   column and `cover` would slice these landscape photos into vertical strips.
   Three by two instead: the cells stay wider than they are tall, which is the
   shape the photographs actually are. */
.zoo-grid { grid-template-columns: repeat(3, 1fr); grid-auto-rows: 1fr; }

/* ══════════ 4b · PUBLISHED ══════════
   Two papers, each drawn as a pile of sheets. The point of the slide is that
   there are physically two of them, so the piles carry it and the type below
   only names them. Built on .stage, which already stacks the two columns
   below 1100px. */
.papers-stage { justify-content: center; gap: clamp(20px, 3.2vw, 64px); }
/* Full half-width, deliberately. Narrowing the column looks like it should pull
   the pair together, but the caption is what sets the column's text measure:
   at 620px the paper titles go to three lines, that height comes off the pile,
   and the two piles end up different sizes. The pair is held together by
   centring instead — see figcaption below. */
/* the gap is wider than the deck default on purpose: the fan below throws its
   lowest sheet well past the bottom of .sheets, and that has to land in space
   rather than in the paper's title */
.pstack {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: clamp(24px, 4.4vh, 54px);
}

/* The A4 box. Height comes from flex, aspect-ratio returns the width — looking
   like paper is the whole job here, so the ratio is the one thing that must not
   drift with the viewport. tools/render_paper_pages.py pads both renders to
   this same 1:1.4142, so `cover` never actually crops one. */
.sheets {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  aspect-ratio: 1 / 1.4142;
  max-width: 100%;
  margin: 0 auto;
  /* The box is one page wide but the spread is ~1.5 pages, all of it hanging
     off the right — 156% of a page wide in all. Nudging back by half the
     overhang puts the spread's optical centre where the box's centre is, so it
     sits over the centred caption instead of drifting right. A transform, not a
     margin: the layout must not move, or the two piles stop being symmetric
     about the slide. */
  transform: translateX(-28%);
}
.sheet {
  position: absolute;
  inset: 0;
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 14px 34px rgba(0, 0, 0, .48);
  object-fit: cover;
}

/* THE FAN. Four real pages, spread far enough that you can read what is on the
 * ones underneath — a strip about a fifth of a page wide, which is why
 * render_paper_pages.py picks figure pages for the three behind the cover.
 * A narrower spread only exposes the page margin, i.e. blank paper.
 *
 * It opens to the RIGHT, so the sheets come out in reading order: the cover
 * sits leftmost and fully visible, p2, p3, p4 step out behind it. Opening left
 * would run the pages backwards.
 *
 * One direction, not alternating: sheets thrown out to both sides read as an
 * untidy heap, whereas a single sweep reads as a deliberate spread and keeps
 * every exposed edge parallel.
 *
 * The rotation steps grow (2.3° → 1.8° → 1.5° apart is what the numbers below
 * come to) rather than staying equal, so the sheets do not fan so hard at the
 * far end that the last one lies almost sideways.
 *
 * All percentages, and rotation scales on its own, so the spread holds its
 * shape at any projector size. It does NOT change the pile's layout box: only
 * .sheet.top sits square in it and the rest hang out to the right, which the
 * translateX on .sheets re-centres — see there. */
.sheet.s4 { transform: rotate(5.4deg) translate(50%, 3.4%); box-shadow: 0 6px 16px rgba(0, 0, 0, .34); }
.sheet.s3 { transform: rotate(3.6deg) translate(33.5%, 2.2%); box-shadow: 0 8px 20px rgba(0, 0, 0, .38); }
.sheet.s2 { transform: rotate(1.8deg) translate(17%, 1.1%); box-shadow: 0 10px 25px rgba(0, 0, 0, .42); }
.sheet.top { transform: rotate(-.5deg); transition: transform .22s ease; }
.sheets:hover .sheet.top,
.sheet.top:focus-visible { transform: rotate(-.5deg) translateY(-8px); }

/* Centred, so the pile and its citation stack up as one object. Left-ranging
   the text would leave the pile floating in the middle of its own lane. */
.pstack figcaption { flex: none; display: flex; flex-direction: column; gap: .3em; text-align: center; }
/* Every block reserves its two lines whether it needs them or not. The sheets
   are `flex: 1`, so ANY difference in caption height between the two columns
   comes straight off one pile and not the other, and the pair stops matching —
   the same reservation .thumb figcaption makes, for the same reason. */
.p-title {
  margin: 0;
  font-family: var(--head);
  font-weight: 600;
  font-size: var(--fs-h3);
  color: #fff;
  line-height: 1.16;
  min-height: calc(2 * 1.16em);
}
.p-auth {
  margin: 0;
  font-size: var(--fs-small);
  color: #e2e9f9;
  line-height: 1.35;
  min-height: calc(2 * 1.35em);
}
.p-auth b { color: var(--yellow); font-weight: 600; }
.p-ref {
  margin: 0;
  font-size: var(--fs-small);
  color: var(--muted-dark);
  line-height: 1.3;
  min-height: calc(2 * 1.3em);
}

/* ══════════ 5 · ACCELERATORS ══════════ */
.timeline {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: clamp(12px, 1.8vw, 30px);
  position: relative;
  padding-top: 26px;
  flex: 1;
  min-height: 0;
  align-content: start;   /* keeps the circles hanging off the timeline bar */
}
.timeline::before {
  content: '';
  position: absolute;
  top: 8px;
  left: 4%;
  right: 4%;
  height: 6px;
  border-radius: 3px;
  background: linear-gradient(90deg, var(--blue), var(--red), var(--yellow));
  opacity: .9;
}
.tl-item { text-align: center; display: flex; flex-direction: column; min-height: 0; }
.tl-item img {
  /* stays a true circle: the column width caps the diameter */
  width: min(100%, clamp(110px, 41vh, 450px));
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 50%;
  margin: 0 auto 12px;
  border: 5px solid #fff;
  box-shadow: var(--shadow);
}
.tl-item b {
  display: block;
  font-family: var(--head);
  font-weight: 600;
  font-size: var(--fs-h3);
  color: var(--blue);
  line-height: 1.1;
}
.tl-item span { display: block; font-weight: 700; font-size: var(--fs-lead); }
.tl-item em { display: block; font-style: normal; color: var(--muted); font-size: var(--fs-cap); }

/* ══════════ 6 · SAKURA ══════════
   Nothing selects .sakura-stage / .story / .story-steps any more — the slide
   they belonged to predates archive/. Kept per the "don't delete" policy. */
.sakura-stage { display: grid; grid-template-columns: 1.4fr 1fr; gap: clamp(14px, 2vw, 34px); }
.story {
  background: #fdf2f3;
  border: 2px solid #f4d6d9;
  border-radius: var(--radius);
  padding: clamp(12px, 1.7vh, 22px);
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-height: 0;
}
.story-title {
  font-family: var(--head);
  font-weight: 600;
  color: var(--red);
  margin: 0;
  font-size: var(--fs-h3);
  flex: none;
}
.story-steps {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: clamp(9px, 1.2vw, 16px);
  flex: 1;
  min-height: 0;
}
.story-steps figure { display: flex; flex-direction: column; min-height: 0; }
.story-steps img { width: 100%; flex: 1; min-height: 0; object-fit: cover; border-radius: 12px; }
.story-steps figcaption { font-size: var(--fs-small); color: var(--muted); margin-top: 5px; line-height: 1.3; flex: none; }

/* ══════════ 7 · GLOBAL (full-bleed map) ══════════ */
.global-stage { flex-direction: column; }
.map-wrap { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; }
#global-map, #tour-map { width: 100%; height: 100%; }
#global-map svg, #tour-map svg { width: 100%; height: 100%; display: block; }
.map-note { font-size: var(--fs-small); color: #cfdaf3; text-align: center; margin: 0; flex: none; }
/* the map is the message here — keep the surrounding furniture slim
   so the map gets as much height (and therefore width) as possible.
   Everything above the map lives on ONE row: kicker, then title + lead +
   the two numbers side by side. */
/* the map is the slide, so #global drops its side padding altogether and the
   header carries its own — that way the map runs edge to edge without a
   `100vw` breakout, which overshoots by the width of the scrollbar */
#global { padding: clamp(14px, 1.9vh, 24px) 0; }
#global .inner { width: 100%; gap: clamp(8px, 1.2vh, 18px); }
#global .slide-head { padding-inline: clamp(22px, 3vw, 58px); }
#global .stats {
  flex: 0 0 auto;
  grid-template-columns: repeat(2, 1fr);
  width: clamp(190px, 16vw, 262px);
}
#global .stat { padding: clamp(5px, .9vh, 11px) 8px; }
#global .stat-n { font-size: clamp(1.7rem, 3.3vw, 3.3rem); }
#global h2 { font-size: clamp(1.7rem, 3.15vw, 3.15rem); }   /* keeps the title on one line */
#global .head-row { align-items: center; }
/* The lead is the tallest thing in this header, and the header's height is map
   height: at 40/34 it wrapped to six lines on a 1440-wide screen. Giving it the
   width instead buys the map ~40 px there and ~50 px at 1080p, and the title
   still gets its own two lines. */
#global .head-row > h2 { flex: 1 1 32%; }
#global .head-row > .lead { flex: 1 1 46%; min-width: 20ch; max-width: none; }
#global .map-note { margin: -2px 0; }

/* ══════════ 3 · RESEARCH MAP ══════════
   The label positions are percentages of the artwork, so the frame is pinned to
   the artwork's own 16:9 ratio — `cover` would crop it and slide every pin off
   its feature. `container-type` lets the type scale off the frame width (cqw)
   rather than the viewport, so the labels hold their size on any screen. */
/* the frame takes its height from `aspect-ratio`, never from a percentage —
   below 1100px `.slide` switches to height:auto, and a `min(100%, …)` height
   against that indefinite parent collapsed the frame and stacked every pin.
   `inline-size` (not `size`) containment for the same reason: size containment
   needs a definite height, which an aspect-ratio box does not have up front. */
#research { display: flex; align-items: center; justify-content: center; }
.rmap {
  position: relative;
  aspect-ratio: 1368 / 768;
  width: min(100%, calc(100vh * 1368 / 768));
  container-type: inline-size;
}
.rmap-bg { width: 100%; height: 100%; object-fit: contain; display: block; }
.rmap-head { position: absolute; left: 3.2%; top: 5%; max-width: 34%; }
.rmap-head .kicker { color: var(--red); margin: 0 0 .15em; }
.rmap-head h2 {
  font-size: 3.4cqw;
  line-height: 1.08;
  margin: 0;
  color: var(--ink);
}
/* pin sits exactly on the feature; the chip hangs off whichever side is free */
.rtag {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: 0;
  height: 0;
}
.rtag::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 1.35cqw;
  height: 1.35cqw;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: var(--red);
  border: .3cqw solid #fff;
  box-shadow: 0 .12cqw .45cqw rgba(16, 32, 61, .45);
}
.rtag b {
  position: absolute;
  white-space: nowrap;
  font-family: var(--head);
  font-weight: 600;
  font-size: 1.42cqw;
  line-height: 1.2;
  color: #0e1b30;
  /* near-opaque plate: the artwork runs from white sky to dark soil, so the
     labels cannot rely on the illustration for contrast */
  background: rgba(255, 255, 255, .95);
  border: .16cqw solid rgba(56, 96, 168, .45);
  border-radius: 999px;
  padding: .3em .85em;
  box-shadow: 0 .22cqw .6cqw rgba(16, 32, 61, .2);
}
/* labels backed by real data advertise it: dotted underline + a chart glyph */
.rtag.has-graph { cursor: pointer; }
.rtag.has-graph b {
  border-color: var(--red);
  padding-right: 2.5cqw;
}
.rtag.has-graph b::after {
  content: '';
  position: absolute;
  right: .85cqw;
  top: 50%;
  width: 1.05cqw;
  height: 1.05cqw;
  transform: translateY(-50%);
  /* tiny bar-chart mark, inlined so it works offline */
  background: var(--red);
  -webkit-mask: var(--chart-glyph) center / contain no-repeat;
  mask: var(--chart-glyph) center / contain no-repeat;
}
.rtag.has-graph:hover b,
.rtag.has-graph:focus-visible b { background: #fff; border-color: var(--red); }
.rtag.has-graph:focus { outline: none; }
:root {
  --chart-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect x='1' y='9' width='3.4' height='6' rx='.8'/%3E%3Crect x='6.3' y='5' width='3.4' height='10' rx='.8'/%3E%3Crect x='11.6' y='1' width='3.4' height='14' rx='.8'/%3E%3C/svg%3E");
  /* frame + hill + sun, for the two topics that ship a photo and no plot */
  --photo-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.6 2.2h12.8a1.1 1.1 0 0 1 1.1 1.1v9.4a1.1 1.1 0 0 1-1.1 1.1H1.6A1.1 1.1 0 0 1 .5 12.7V3.3a1.1 1.1 0 0 1 1.1-1.1zm0 1.6v8.4h12.8V3.8zm2 7.2 3-4 2.2 2.8 1.6-1.9 2.2 3.1zm8.1-5.3a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5z'/%3E%3C/svg%3E");
}
/* Longhand, and it MUST stay after the `mask` shorthand above — the shorthand
   resets mask-image, so an earlier or shorthand override would be wiped. */
.rtag.has-graph[data-graph-kind="photo"] b::after {
  -webkit-mask-image: var(--photo-glyph);
  mask-image: var(--photo-glyph);
}
/* ---------- the detail panel ----------
   One shared panel showing a topic's one or two images side by side.
   interactive.js parks it in whichever corner of the frame clears the label
   that opened it, writing `left`/`top` in cqw — NOT %, which would resolve
   against the container's height and land it somewhere else entirely.

   THE PANEL'S HEIGHT IS CONSTANT (26.7cqw) AND LOAD-BEARING. The placement
   maths assumes it, and at 28.5cqw the gap between the top and bottom
   candidate positions grows wide enough that `rmap.ceiling` clears none of
   the four corners. So both the image row and the caption are fixed heights:
     padding-top 1.2 + wells 18.2 + caption 6.3 + padding-bottom 1 = 26.7cqw
   If you need a taller panel, re-derive the candidates in interactive.js. */
.rgraph {
  position: absolute;
  left: 45cqw;                /* both overwritten by place() on every show */
  top: 3cqw;
  width: 52cqw;
  margin: 0;
  padding: 1.2cqw 1.2cqw 1cqw;
  background: #fff;
  border-radius: 1.2cqw;
  box-shadow: 0 1cqw 3cqw rgba(16, 32, 61, .3);
  z-index: 5;
  /* glides when moving between labels; a display:none → block change does not
     transition, so opening it fresh still lands with no slide-in */
  transition: left .18s ease, top .18s ease;
}
.rgraph.is-1up { width: 38cqw; }
.rgraph[hidden] { display: none; }

/* Fixed row HEIGHT, deliberately not an aspect-ratio: with `flex:1 1 0` a lone
   well is twice as wide as one of a pair, so a fixed ratio would make the
   one-image panel twice as tall as the two-image one. */
.rgraph-wells {
  display: flex;
  gap: 1.2cqw;
  height: 18.2cqw;
  justify-content: center;
}
.rg-well {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.rg-well[hidden] { display: none; }
/* The well is the definite box and object-fit does the fitting. Sizing from
   the image's own dimensions instead (width:auto + max-width) is NOT an
   option: the plot SVGs carry a viewBox but no width/height, so they have no
   intrinsic size — fuji.svg reports 200x150 and collapses to 0x0 in a
   shrink-wrapped well, and muon-lifetime.svg's 142x94 viewBox would never
   scale up. `contain` is ratio-correct for all of them, up or down. */
.rg-well img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  border-radius: .4cqw;
  cursor: zoom-in;
  /* a mat, not a frame: only visible where a very wide or very tall image
     doesn't reach the well edge, and it gives snow.png's baked-in white
     margin an edge instead of dissolving into the panel */
  background: #f6f8fb;
}
.rg-well img:focus-visible { outline: .22cqw solid var(--red); outline-offset: .22cqw; }
.rgraph figcaption {
  font-size: 1.35cqw;
  line-height: 1.35;
  color: #33405a;
  padding-top: .8cqw;
  height: 6.3cqw;             /* exactly 3 lines — see the note above */
  overflow: hidden;
}
.rgraph figcaption b { color: var(--ink); }

.rtag[data-side="r"] b { left: 1.4cqw;  top: 50%; transform: translateY(-50%); }
.rtag[data-side="l"] b { right: 1.4cqw; top: 50%; transform: translateY(-50%); }
.rtag[data-side="t"] b { bottom: 1.4cqw; left: 50%; transform: translateX(-50%); }
.rtag[data-side="b"] b { top: 1.4cqw;   left: 50%; transform: translateX(-50%); }

/* ══════════ 8 · STORM ══════════ */

/* .chips / .chip は 2026-07-31 に #storm から外した。archive/ の方針どおり
   CSS は消さずに残してある —— 消すと戻すのが「貼り直し」でなく「作り直し」に
   なるため。いま使っているのは archive/2026-07-31-storm-chips/ だけ。 */
.chips { display: flex; gap: 12px; flex-wrap: wrap; flex: none; }
.chip {
  border: 2px solid var(--yellow);
  color: var(--yellow);
  border-radius: 999px;
  padding: 5px 18px;
  font-size: var(--fs-cap);
  font-weight: 700;
}
/* チャートがこのスライドの主役なので、右の写真＋締め文より広く取る。
   1.62fr から広げた（チャート幅 931 -> 1020px）。これ以上振ると右の
   photo-card が縦長になりすぎて Zoom の顔が潰れる。 */
.storm-stage { display: grid; grid-template-columns: 2.1fr 1fr; gap: clamp(14px, 2vw, 30px); }
.storm-side { display: flex; flex-direction: column; gap: var(--gap); min-height: 0; }
.storm-side .photo-card { flex: 1; }
.storm-side .closing { margin-top: auto; text-align: left; max-width: none; }

/* ══════════ 9 · TOUR ══════════ */
/* the map IS the slide here: it spans the whole frame, edge to edge, and the
   heading and closing line ride on top of it. Overlap with the OCEAN is
   intentional — but not with the land or the photo bubbles.

   The map SVG is viewBox="0 42 1000 372" (aspect 2.69) inside a 16:9 box, so
   with the default xMidYMid it is width-bound and gets centred vertically —
   which parked the top row of photo bubbles directly under the heading. The
   top inset pushes the whole map below the header block instead of letting it
   float up into it; the bottom inset keeps it clear of the closing line.
   Measured: bubbles used to sit at y=193/217 against a header running to
   y=367.

   26%/3% was picked by sweeping both insets at 1366x768, 1600x900 and
   1920x1080: it is the largest map that clears the heading at the top AND the
   closing line at the bottom at every one of them. Going to 28%/3% or 30%/3%
   puts the lowest photo bubble back on top of the closing line at 1366x768. */
#tour .inner { position: static; }
#tour .stage {
  position: absolute;
  inset: 26% 0 3% 0;
  z-index: 0;
}
/* the heading rides at the TOP of its row here, not baseline-aligned with the
   lead — every pixel it drops is a pixel of map it covers */
#tour .head-row { align-items: flex-start; }
#tour-map { max-width: none; margin: 0; }
/* text layers stay above the map, and the closing line stays pinned low */
#tour .slide-head, #tour .tour-foot { position: relative; z-index: 2; }
#tour .tour-foot { margin-top: auto; }

/* ══════════ 10 · LIVE cue ══════════ */
.demo-cue { align-items: center; text-align: center; justify-content: center; }
/* Take the whole slide, so .xv-wrap's flex:1 gets the real leftover height.
   Without this .inner sizes to content and the 3D falls back to its 58vh
   floor — leaving ~100px of the slide unused underneath it. */
#demo .inner { height: 100%; }
/* The exploded view below is height-bound on a 16:9 slide, so the heading runs
   smaller than the deck default to hand back vertical space. The widths matter
   as much as the size: this title carries its own <br>, and at a narrower
   max-width the second line wraps again and the slide overflows its 100vh. */
.demo-cue h2 { max-width: 32ch; font-size: clamp(1.8rem, 3.1vw, 3.2rem); }
.demo-cue .lead { max-width: 78ch; }
.cue-photo { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; }
.cue-photo img {
  max-height: 46vh;
  width: auto;
  border-radius: var(--radius);
  box-shadow: 0 18px 46px rgba(0, 0, 0, .45);
}

/* ---- exploded view (exploded.js) + signal chain (sigchain.js) ---- */
/* two columns: the 3D on the left, the signal chain on the right. The model is
   HEIGHT-bound at every size we ship (its view-space Y extent is the binding
   one), so handing a quarter of the width to the chain costs it no size at
   all — only the empty margin it used to have. */
.xv-wrap {
  display: flex; align-items: stretch; gap: clamp(14px, 1.8vw, 34px);
  flex: 1 1 auto; width: 100%; min-height: 58vh;
}
/* everything exploded.js draws is absolutely positioned, so it needs its own
   positioned box; without min-height/min-width:0 the flex item would refuse
   to shrink and the canvas would collapse */
.xv-stage { position: relative; flex: 1 1 auto; min-width: 0; }
#xv-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.xv-leaders {
  position: absolute; inset: 0; width: 100%; height: 100%;
  /* hidden, not visible: the cosmic-ray track is deliberately longer than the
     model and would otherwise be drawn up across the heading */
  pointer-events: none; overflow: hidden; color: var(--blue-soft);
}
/* Small inset only. The model is fitted to the band BETWEEN the rails (see
   frame() in exploded.js), so every pixel spent here is taken off the 3D —
   and the old 46px was there to clear the progress dots back when the stage
   ran the full slide width, which the signal-chain column now occupies.
   exploded.js reads this back off offsetLeft, so the two stay in step. */
.xv-caps { position: absolute; inset: 0 12px; pointer-events: none; }
/* detection animation: cosmic ray -> scintillation light -> SiPM -> Arduino.
   Blue for light, yellow for the electrical pulse — past the sensor it is a
   signal, not photons, and the colour split is what makes that legible. */
.xv-fx { pointer-events: none; }
.xv-fx path { stroke-linecap: round; stroke-linejoin: round; }
/* every stroke is drawn twice: a wide faint halo, then a bright core on top */
.fx-muon.fx-halo { stroke: #cfe0ff; stroke-width: 12; }
.fx-muon.fx-core { stroke: #ffffff; stroke-width: 3; }
.fx-ray.fx-halo { stroke: #7fa8ff; stroke-width: 11; }
.fx-ray.fx-core { stroke: #dbe8ff; stroke-width: 2.6; }
.fx-sig.fx-halo { stroke: var(--yellow); stroke-width: 14; }
.fx-sig.fx-core { stroke: #ffe89a; stroke-width: 3.6; }
.fx-head { fill: #fff3c4; stroke: var(--yellow); stroke-width: 5; stroke-opacity: .35; }
.fx-muon-head { fill: #ffffff; stroke: #cfe0ff; stroke-width: 7; stroke-opacity: .35; }
.fx-burst { fill: none; stroke: #dbe8ff; stroke-width: 3; }

/* stage narration — anchored to where each stage happens, so it is placed by
   transform every frame; `left/top` stay at 0 */
.xv-steps { position: absolute; inset: 0; pointer-events: none; }
.xv-step {
  position: absolute; left: 0; top: 0; margin: 0; white-space: nowrap;
  font-family: var(--head); font-weight: 600; font-size: var(--fs-small);
  line-height: 1; opacity: 0;
  text-shadow: 0 2px 10px rgba(10, 14, 26, .95), 0 0 22px rgba(10, 14, 26, .8);
  will-change: transform, opacity;
}
.s-muon { color: #ffffff; }
.s-light { color: #cfe0ff; }
.s-sig, .s-count { color: var(--yellow); }
.xv-cap {
  position: absolute; top: 0; margin: 0;
  font-family: var(--head); font-size: var(--fs-floor); font-weight: 600;
  /* the widest caption sets the rail, and the rail is subtracted from the
     model's fit — 12ch still keeps "OLED counter" on one line */
  line-height: 1.2; color: #eef2fc; max-width: 12ch;
  opacity: 0;                       /* exploded.js drives this per frame */
  will-change: transform, opacity;
}
.xv-cap[data-side="L"] { left: 0;  text-align: right; }
.xv-cap[data-side="R"] { right: 0; text-align: left; }
.xv-cap.on { opacity: 1; }          /* static / no-JS path */
.xv-replay {
  position: absolute; right: 0; bottom: 0;
  width: 2.2em; height: 2.2em; border-radius: 50%;
  border: 1px solid rgba(157, 188, 251, .35);
  background: rgba(30, 39, 69, .72); color: var(--blue-soft);
  font-size: var(--fs-small); line-height: 1; cursor: pointer;
  opacity: .5; transition: opacity .2s ease;
}
.xv-replay:hover, .xv-replay:focus-visible { opacity: 1; }
/* only shown when WebGL is unavailable — exploded.js sets .xv-fallback */
.xv-poster { display: none; }
.xv-fallback .xv-poster {
  display: block; position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: contain;
}
.xv-fallback .xv-caps, .xv-fallback .xv-replay { display: none; }

/* ---- signal chain (sigchain.js) ----
   The column beside the model: raw pulse -> amplify -> peak hold -> threshold
   -> ADC value. It inherits the 3D's colour language deliberately — blue is
   light, yellow is the electrical signal past the sensor — so the two halves
   of the slide read as one chain. Red is reserved for the threshold and for
   the one failure mode the audience should recognise, clipping. */
.sig {
  flex: 0 0 clamp(300px, 26%, 520px);
  display: flex; flex-direction: column; gap: clamp(5px, .9vh, 14px);
  min-width: 0;
  /* .demo-cue centres the slide; an instrument panel reads down its own left
     edge, which is also what keeps the stage labels and the readout in line */
  text-align: left;
  /* The progress dots are fixed 15px from the viewport edge, and .inner (96vw,
     and it ignores the slide padding) runs right up under them on a 1366px
     laptop — the card's border would be printed through. Self-limiting: zero
     once the viewport is wide enough for .inner to hit its 1800px cap. */
  margin-right: max(0px, calc(52px - 2vw));
  padding: clamp(10px, 1.4vh, 20px) clamp(12px, 1vw, 22px);
  border-radius: var(--radius);
  background: rgba(30, 39, 69, .72);
  border: 1px solid rgba(157, 188, 251, .18);
}
.sig-kicker {
  margin: 0; font-family: var(--head); font-weight: 600;
  font-size: var(--fs-floor); letter-spacing: .08em;
  color: var(--blue-soft); opacity: .85;
}
.sig-lab {
  margin: 0; font-family: var(--head); font-weight: 600;
  font-size: var(--fs-floor); line-height: 1.15; color: #eef2fc;
}

/* one graph box: the processing lane sits over the ADC histogram, both drawn
   into a single stretched <svg> so a dropped peak can travel between them */
.sig-graph {
  position: relative; flex: 1 1 auto; min-height: 120px;
  border-radius: 10px;
  background: rgba(10, 14, 26, .55);
  box-shadow: inset 0 0 0 1px rgba(157, 188, 251, .12);
  overflow: hidden;
}
.sig-graph svg { display: block; width: 100%; height: 100%; }
/* HTML overlays, so these labels keep --fs-floor rather than being squashed by
   the viewBox. proc/hist are pinned to corners; γ/β and cosmic are positioned
   by sigchain.js under the two peaks and move with the gain. */
.sig-ov {
  position: absolute; margin: 0; pointer-events: none; white-space: nowrap;
  font-family: var(--head); font-weight: 600; font-size: var(--fs-floor); line-height: 1;
  text-shadow: 0 1px 6px rgba(10, 14, 26, .9);
}
.sig-ov-proc   { top: 4px; left: 8px; color: #eef2fc; }
.sig-ov-hist   { top: 51.5%; left: 8px; color: #eef2fc; }

/* trace captions: each is placed beside its trace by sigchain.js and revealed
   as that trace is drawn (opacity is driven per frame). A translucent backing
   keeps each word legible over the trace behind it. */
.sig-cap {
  position: absolute; top: 0; left: 0; margin: 0;
  pointer-events: none; white-space: nowrap; opacity: 0;
  font-family: var(--head); font-weight: 700;
  font-size: clamp(.72rem, 1.15vw, 1.05rem); line-height: 1;
  padding: 1px 5px; border-radius: 5px;
  background: rgba(10, 14, 26, .62);
  will-change: transform, opacity;
}
.sig-cap-raw    { color: var(--blue-soft); }
.sig-cap-amp    { color: var(--yellow); }
.sig-cap-hold   { color: #6ee7a8; }
.sig-cap-detect { color: #f6979d; }
/* the two region labels ride under their peaks (positioned by sigchain.js) and
   move with the gain; they annotate the axis, so they may sit below --fs-floor
   like the deck's own spectrum tick labels */
.sig-ov-gamma, .sig-ov-cosmic {
  transform: translateX(-50%);
  font-size: clamp(.7rem, 1.15vw, 1.05rem); font-weight: 700;
}
.sig-ov-gamma  { color: var(--yellow); }
.sig-ov-cosmic { color: var(--blue-soft); }

/* traces & bars. Every stroke carries vector-effect="non-scaling-stroke" (see
   sigchain.js), so widths are px on screen at any box aspect. */
.sg-axis  { stroke: rgba(157, 188, 251, .28); stroke-width: 1; }
.sg-div   { stroke: rgba(157, 188, 251, .16); stroke-width: 1; stroke-dasharray: 2 4; }
.sg-tick  { stroke: rgba(157, 188, 251, .35); stroke-width: 1; }
.sg-bar   { }   /* fill is set per bin (γ/β yellow, cosmic blue, else grey) */
/* three distinct traces, each named in the legend above the lane:
   raw = blue (small µs spike), amplified = yellow (same µs spike, taller),
   peak-hold = green (the ms plateau) */
.sg-raw   { stroke: var(--blue-soft); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.sg-amp   { stroke: var(--yellow); stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; }
.sg-hold  { stroke: #6ee7a8; stroke-width: 2.6; stroke-linejoin: round; stroke-linecap: round; }
.sg-peak  { stroke: #fff3c4; stroke-width: 6.5; }
.sg-thr   { stroke: var(--red); stroke-width: 1.8; stroke-dasharray: 7 6; }   /* lane: horizontal */
.sg-thr-v { stroke: var(--red); stroke-width: 1.6; stroke-dasharray: 5 5; opacity: .6; }  /* hist: vertical */
/* the peak dropping into its bin */
.sg-token { stroke: #fff3c4; stroke-width: 7; }

/* the amplifier is the settable knob — where the two peaks land depends on it.
   The big ×N and the slider under it are the point of the whole panel. */
.sig-amp {
  display: grid; grid-template-columns: auto 1fr;
  align-items: center; column-gap: .55em; row-gap: 2px;
  padding: .3em .6em; border-radius: 12px;
  background: rgba(240, 184, 16, .10);
  border: 1px solid rgba(240, 184, 16, .3);
}
.sig-gain-chip {
  grid-row: 1 / span 2;
  font-family: var(--head); font-weight: 600; font-size: var(--fs-h3);
  line-height: 1; color: var(--yellow); white-space: nowrap;
}
.sig-hint {
  grid-column: 2; font-size: var(--fs-floor); line-height: 1.1;
  color: var(--yellow); opacity: .9;
}

.sig-thr {
  display: grid; grid-template-columns: 1fr auto;
  align-items: baseline; column-gap: .5em; row-gap: 2px;
}
.sig-thr .sig-lab { color: #f6c7ca; }
.sig-thr-val { font-family: var(--head); font-size: var(--fs-floor); color: var(--red); }
.sig-thr .sig-slider { grid-column: 1 / -1; }

/* range inputs: sized in em so they scale with the projector type scale */
.sig-slider {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: .34em; margin: .3em 0;
  border-radius: 999px; background: rgba(157, 188, 251, .22);
  outline: none; cursor: pointer;
}
.sig-slider::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 1.25em; height: 1.25em; border-radius: 50%;
  background: var(--yellow); border: 2px solid rgba(20, 27, 46, .75); cursor: pointer;
}
.sig-slider::-moz-range-thumb {
  width: 1.25em; height: 1.25em; border-radius: 50%; border: 2px solid rgba(20, 27, 46, .75);
  background: var(--yellow); cursor: pointer;
}
#sig-thr::-webkit-slider-thumb { background: var(--red); }
#sig-thr::-moz-range-thumb { background: var(--red); }
.sig-slider:focus-visible { box-shadow: 0 0 0 3px rgba(157, 188, 251, .4); }

.sig-out {
  margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .35em;
  font-family: var(--head); font-size: var(--fs-floor); color: #eef2fc;
}
.sig-count { font-size: var(--fs-h3); line-height: 1; color: #eef2fc; }
/* only shown when the gain is high enough to pile events up at full scale */
.sig-state { font-style: normal; color: var(--red); }
.sig.is-sat .sig-count { color: var(--red); }

/* ══════════ 11 · JOIN ══════════ */
.join-inner { text-align: center; align-items: center; }
.join-title { font-size: clamp(2.4rem, 5.4vw, 5rem); }
.quads {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(12px, 1.5vw, 22px);
  width: min(1250px, 100%);
  margin: 0 auto;
  flex: 1;
  min-height: 0;
}
.quad {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius);
  padding: clamp(18px, 3vh, 40px) clamp(18px, 2vw, 36px);
  color: #fff;
  text-decoration: none;
  text-align: left;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  box-shadow: var(--shadow);
}
.quad img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: .22;
  transition: transform .5s ease, opacity .3s ease;
}
.quad:hover img { transform: scale(1.06); opacity: .32; }
.q-blue { background: var(--blue); }
.q-red { background: var(--red); }
.q-yellow { background: #d09600; }
.q-olive { background: #77854a; }
.quad b {
  position: relative;
  font-family: var(--head);
  font-weight: 600;
  font-size: clamp(1.3rem, 2.35vw, 2.45rem);
  line-height: 1.15;
  text-shadow: 0 1px 10px rgba(0, 0, 0, .4);
}
.quad span {
  position: relative;
  font-size: var(--fs-cap);
  opacity: .95;
  text-shadow: 0 1px 10px rgba(0, 0, 0, .4);
  margin-top: 3px;
}
.join-email a {
  font-family: var(--head);
  font-weight: 600;
  font-size: clamp(1.3rem, 2.5vw, 2.3rem);
  color: var(--red);
  text-decoration: none;
}
.join-email a:hover { text-decoration: underline; }
.join-email { margin: 0; flex: none; }
.join-links { font-size: var(--fs-small); color: var(--muted); flex: none; }
.join-links p { margin: 0 0 4px; font-weight: 700; }
.join-links ul {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  gap: 8px 26px;
  justify-content: center;
  flex-wrap: wrap;
}

/* ══════════ FOOTER ══════════ */
.slide-footer {
  min-height: 46vh;
  display: flex;
  align-items: center;
  padding: clamp(30px, 6vh, 70px) clamp(22px, 3vw, 58px);
  text-align: center;
  scroll-snap-align: end;
}
.slide-footer .inner { gap: 14px; align-items: center; }
.footer-logo { height: 52px; width: auto; aspect-ratio: 337 / 52.7; }
.footer-ack { max-width: 80ch; margin: 0 auto; font-size: var(--fs-cap); color: #b3bfdc; }
.footer-credit { font-size: var(--fs-small); color: #7986a8; margin: 0; }

/* ══════════ interactive layer ══════════ */

/* map pins (Going Global) — links */
.map-pin { cursor: pointer; }
/* the marker is a country flag now, so highlight with the ring and a glow —
   a fixed hover `r` would shrink the bigger home pin instead of growing it */
.map-pin .pin-dot { transition: stroke-width .18s ease, filter .18s ease; }
.map-pin:hover .pin-dot,
.map-pin:focus-visible .pin-dot { stroke-width: 4.5; filter: drop-shadow(0 0 7px rgba(255, 255, 255, .95)); }
.map-pin:focus { outline: none; }
.map-pin:focus-visible .pin-dot { stroke: #fff; }
/* photo bubbles sit under the flags and must not eat pin clicks */
.map-photo { pointer-events: none; }

/* tour stops */
.tour-stop { cursor: pointer; }
.tour-stop:focus { outline: none; }
.tour-stop circle { transition: r .18s ease; }
.tour-stop:hover circle:last-of-type,
.tour-stop:focus-visible circle:last-of-type { r: 10; }

/* legends under both maps */
.map-legend {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px 22px;
  flex-wrap: wrap;
  margin: 0;
  font-size: var(--fs-small);
}
.map-legend .lg {
  width: 26px;
  height: 0;
  border-top-width: 4px;
  border-top-style: solid;
  border-radius: 2px;
  margin-right: -14px;
}
/* dot markers need to beat the `.map-legend .lg` line defaults */
.map-legend .lg-network,
.map-legend .lg-partner {
  border: 2px solid #fff;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  margin-right: -14px;
}
.map-legend .lg-network { background: var(--yellow); }
.map-legend .lg-partner { background: var(--red); }
.map-legend .lg-2026 { border-top-color: var(--red); }
.map-legend .lg-2025 { border-top-color: var(--blue); border-top-style: dashed; }
.tour-foot { display: flex; flex-direction: column; gap: clamp(6px, 1.2vh, 14px); flex: none; }
.map-legend .map-note { opacity: .8; }
.theme-blue .map-legend { color: #dce6fb; }

/* floating tip — shared by stats and map pins */
.tip {
  position: fixed;
  z-index: 60;
  max-width: 320px;
  background: var(--navy-card);
  color: #eef2fc;
  border-radius: 12px;
  padding: 10px 14px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, .38);
  font-size: var(--fs-small);
  line-height: 1.4;
  pointer-events: none;
}
.tip::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -7px;
  margin-left: -7px;
  border: 7px solid transparent;
  border-top-color: var(--navy-card);
  border-bottom: 0;
}
.tip.below::after { bottom: auto; top: -7px; border-top: 0; border-bottom-color: var(--navy-card); }
.tip b { font-family: var(--head); font-weight: 600; display: block; font-size: var(--fs-cap); }
.tip .tip-sub { display: block; color: var(--yellow); font-size: 1em; }
.tip .tip-body { display: block; margin-top: 5px; color: #cdd8f0; }
.tip .tip-link { display: block; margin-top: 6px; color: var(--blue-soft); font-weight: 600; }
.tip .tip-src { display: block; color: #cdd8f0; }

/* lightbox */
/* not img[data-zoom]: the film discs on #model are <video>, and they open the
   lightbox too, so the affordance has to follow the attribute rather than the
   tag or those four are the only zoomables on the deck with no zoom cursor */
[data-zoom] { cursor: zoom-in; }
[data-zoom]:focus-visible { outline: 3px solid var(--red); outline-offset: 3px; }
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 80;
  background: rgba(10, 14, 26, .93);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(24px, 5vh, 70px);
  cursor: zoom-out;
}
.lightbox[hidden] { display: none; }
.lb-fig {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  max-width: 100%;
  max-height: 100%;
}
/* the row the image(s) sit in. One image on its own behaves exactly as before;
   with data-zoom-pair a second one joins it on the right. */
.lb-imgs {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(12px, 2vw, 28px);
  max-width: 100%;
  min-height: 0;
  flex: 0 1 auto;
}
.lb-fig img {
  max-width: 100%;
  max-height: 82vh;
  width: auto;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 20px 60px rgba(0, 0, 0, .5);
  /* the picture gives up height so the caption always fits: with a two-line
     explanation under it (.lb-note) a fixed 82vh image pushed the last line off
     the bottom of the screen. `contain` keeps the aspect while it shrinks. */
  flex: 0 1 auto;
  min-height: 0;
  object-fit: contain;
}
/* The second image is hidden by CLASS, not by its `hidden` attribute: the
   global `img { display: block }` up at the top of this file is an author rule
   and so beats the UA sheet's `[hidden] { display: none }`. Left as `hidden` in
   the DOM as well, for assistive tech — but this is what actually hides it. */
.lb-second { display: none; }
.lb-imgs.is-pair .lb-second { display: block; }
/* paired view: the two share one row, so neither may take more than half of it.
   The detector photos are landscape and the result plots are near-square, so
   without the width cap the photo would eat the row and squash the plot. */
.lb-imgs.is-pair img { max-width: calc(50% - clamp(6px, 1vw, 14px)); }
@media (max-width: 900px) {
  /* too narrow to read two plots side by side — stack them instead */
  .lb-imgs.is-pair { flex-direction: column; }
  .lb-imgs.is-pair img { max-width: 100%; max-height: 38vh; }
}
.lb-fig figcaption { flex: none; }
.lb-fig figcaption {
  color: #dbe3f7;
  font-size: var(--fs-cap);
  max-width: 80ch;
  text-align: center;
}
/* the longer explanation (data-zoom-note): reads as a second line under the
   thumbnail's own caption, so the caption stays the title of the two */
.lb-note {
  display: block;
  margin-top: .5em;
  font-size: .82em;
  line-height: 1.45;
  color: #b9c6e2;
}
.lb-note b { color: #dbe3f7; font-weight: 600; }
.lb-close {
  position: absolute;
  top: clamp(14px, 2.5vh, 30px);
  right: clamp(14px, 2vw, 34px);
  width: 52px;
  height: 52px;
  border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, .5);
  background: transparent;
  color: #fff;
  font-size: 2.4rem;
  line-height: 1;
  cursor: pointer;
}
.lb-close:hover { background: rgba(255, 255, 255, .14); }

/* ---------- SVG chart shared ---------- */
.axis-line { stroke: currentColor; stroke-opacity: .35; stroke-width: 1; }
.axis-label { font-family: var(--body); fill: currentColor; opacity: .7; }
.chart-anno { font-family: var(--head); font-weight: 600; }
.drawable { transition: stroke-dashoffset 1.6s ease; }

/* ---------- responsive (shared / mobile) ---------- */
@media (max-width: 1100px) {
  .slide { height: auto; min-height: 100vh; padding-top: 54px; padding-bottom: 54px; }
  .inner { max-height: none; }
  .stage, .sakura-stage, .storm-stage, .research-stage { display: flex; flex-direction: column; }
  .sakura-stage, .storm-stage { display: grid; grid-template-columns: 1fr; }
  /* the gap slide's two columns stack: no bleed, no tuck-under, nothing to overlap */
  .gap-inner { grid-template-columns: 1fr; grid-template-rows: auto; }
  .gap-left { margin: 0; left: 0; }
  /* both offsets exist only to serve the two-column overlap: with the columns
     stacked the badges have no shift to cancel, and the headline has no render
     to keep clear of, so both go back to the plain left-ranged layout */
  .det-badge { left: clamp(6px, .8vw, 18px); }
  .gap-right .slide-head { text-align: left; }
  .gap-right .lead { margin-left: 0; }
  .gap-left .detector-fig:last-child { margin-top: 0; }
  .gap-right { grid-template-rows: none; align-content: start; }
  /* no width to share once the columns stack — one spectrum per row, no bleed */
  .spectra-row { grid-template-columns: 1fr; margin-right: 0; }
  .chart-float { position: static; width: auto; }
  .cs-grid, .zoo-grid { grid-template-columns: repeat(3, 1fr); }
  /* The pile is sized off the leftover HEIGHT on a slide-shaped screen. Here the
     slide grows to fit its content instead, so there is no height to take a
     share of — and because every child of .sheets is absolutely positioned, its
     content height is 0, so `flex: 1` + `min-height: 0` collapses the paper to
     nothing. Below this breakpoint the width is what's definite: pin it and let
     aspect-ratio supply the height.
     52%, not 100%: the spread is 156% of a page wide and .slide clips
     (overflow: hidden), so the box can be at most 1/1.564 = 64% of the column
     before the outermost sheet is cut. 52% leaves a real margin either side. */
  .sheets { flex: none; width: min(52%, 340px); }
  .stats { grid-template-columns: repeat(3, 1fr); }
  /* the two numbers ride in #global's heading row on a 16:9 screen; there is
     no width for that here, so the row unstacks and they get their own line */
  #global { padding-inline: 0; }
  #global .head-row { flex-direction: column; align-items: stretch; gap: 10px; }
  #global .stats { width: 100%; grid-template-columns: repeat(2, 1fr); }
  /* Two caption rails need width that is not there below this breakpoint.
     Rather than drop the labels, unpin them into a legend under the model —
     the !important beats the per-frame inline styles exploded.js writes when
     the window is dragged back across the breakpoint. */
  /* back to content height: stacked, the column is taller than the slide and
     a hard 100% would just clip it */
  #demo .inner { height: auto; }
  .xv-wrap { min-height: 62vh; flex-direction: column; gap: 12px; }
  /* the two columns become two stacked blocks: model over signal chain */
  .xv-stage { display: flex; flex-direction: column; gap: 12px; min-height: 46vh; }
  .sig { flex: 0 0 auto; }
  /* Stacked, the panel has no column height to hand the graph, and a viewBox
     <svg> left to itself would take its intrinsic square. Give the graph box a
     fixed, roughly-square height so the lane and histogram both stay legible. */
  .sig-graph { flex: 0 0 auto; min-height: 0; height: clamp(300px, 46vw, 460px); }
  /* hide the caption leaders only — the direct <path> children. The detection
     animation lives in the nested .xv-fx group and has to stay, or its stage
     labels end up pointing at nothing. */
  .xv-leaders > path { display: none; }
  .xv-replay { display: none; }
  #xv-canvas { position: static; flex: 1 1 auto; min-height: 0; }
  .xv-caps {
    position: static; display: flex; flex-wrap: wrap;
    justify-content: center; gap: 6px 18px;
  }
  .xv-cap {
    position: static; max-width: none; font-size: var(--fs-small);
    opacity: 1 !important; transform: none !important; text-align: center !important;
  }
  /* #model: unlock the frame and stack the chain in narrative order.
     container-type MUST go back to normal or every cqw resolves against the
     now-full-width container and the type explodes. */
  #model { display: block; padding: 54px clamp(22px, 3vw, 58px); }
  .mflow {
    aspect-ratio: auto;
    width: 100%;
    container-type: normal;
    overflow: visible;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: clamp(14px, 2.6vh, 26px);
  }
  .mflow > *, .mnode { position: static; width: auto; height: auto; }
  .mlines { display: none; }   /* the 2-D geometry is gone, so are the arrows */
  .mnode { display: flex; flex-direction: column; align-items: center; gap: 10px; }
  .mdisc { position: static; transform: none; width: min(46vw, 240px); border-width: 4px; box-shadow: var(--shadow); }
  .mnode.is-aside .mdisc { box-shadow: 0 0 0 5px rgba(240, 184, 16, .3), var(--shadow); }
  .mnode.is-zoom  .mdisc { box-shadow: 0 0 0 5px rgba(56, 96, 168, .3), var(--shadow); }
  .mnode figcaption,
  .mnode[data-lab="t"] figcaption {
    position: static;
    transform: none;
    top: auto;
    bottom: auto;
    max-width: 32ch;
    font-size: var(--fs-small);
  }
  .mnode .mstat { font-size: var(--fs-small); }
  .mtag { position: static; transform: none; font-size: var(--fs-small); }
  /* the pill keeps its outline in the stack — it is still the badge on this
     step, and the discs are far enough apart here that it cannot collide */
  .mkey {
    position: static; transform: none; font-size: var(--fs-small);
    padding: 4px 14px; border-width: 2px;
  }
  .mf-head { text-align: left; width: 100%; }
  .mf-head .kicker { font-size: var(--fs-kicker); }
  .mf-head h2 { font-size: var(--fs-h2); }
  .mf-head .lead { font-size: var(--fs-lead); margin-left: 0; }
  .mf-note { position: static; width: 100%; text-align: left; font-size: var(--fs-small); }
  .photo-card img, .thumb img, .story-steps img { height: 200px; flex: none; }
  /* cap by width, not height — the renders are width-driven now, so a max-height
     with width:100% would stretch them out of aspect */
  .detector-fig img { max-width: 560px; margin: 0 auto; }
  .map-hero img, #global-map, #tour-map { height: auto; }
  .tl-item img { height: 130px; }
}
@media (max-width: 700px) {
  #dots { display: none; }
  .det-badge { width: 46px; font-size: .6rem; }
  .mdisc { width: min(62vw, 220px); }
  .timeline { grid-template-columns: repeat(2, 1fr); gap: 20px; }
  .timeline::before { display: none; }
  .cs-grid, .zoo-grid { grid-template-columns: repeat(2, 1fr); }
  .quads { grid-template-columns: 1fr; }
  .demo-head { flex-direction: column; align-items: flex-start; }
  .spectra { flex-wrap: wrap; }
}

/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .rv { opacity: 1; transform: none; transition: none; }
  .hero-hint { animation: none; }
  .drawable { transition: none; }
  #demo-flash.flash { animation: none; }
  .xv-cap { transition: none; }
  .xv-replay { display: none; }   /* nothing to replay: one static frame */
  /* the sheet still lifts on hover, it just arrives there at once */
  .sheet.top { transition: none; }
  /* the sliders still work — they are controls, not animation — but nothing
     eases or sweeps on its own */
  .sig-hint { transition: none; }
}
