/* ---------------------------------------------------------------
   fish2real project page
   Palette is drawn from the deployment footage: pool blue on deep
   water, with the amber of the AprilTag targets as the second accent.
   --------------------------------------------------------------- */

:root {
  --abyss:   #091A23;
  --deep:    #0E2733;
  --surface: #143441;
  --line:    #22505f;
  --foam:    #E9F2F4;
  --muted:   #8FAFBC;
  --pool:    #52C8DE;
  --tag:     #E8A33C;

  /* Prose runs the full width of the column rather than a typographic measure. Long
     lines are harder to track line-to-line, so the line height below carries more of
     the work than it would at 68ch. */
  --measure: none;
  --gap: clamp(1.1rem, 2.5vw, 2rem);

  --display: "Space Grotesk", "Helvetica Neue", Arial, sans-serif;
  --body: var(--display);   /* one family across the page and inside every figure */
}

*, *::before, *::after { box-sizing: border-box; }

html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

body {
  margin: 0;
  background: var(--abyss);
  color: var(--foam);
  font-family: var(--body);
  font-size: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  font-weight: 300;
  line-height: 1.72;
  -webkit-font-smoothing: antialiased;
}

a { color: var(--pool); text-decoration-thickness: 1px; text-underline-offset: 0.18em; }
a:hover { color: var(--foam); }
:focus-visible { outline: 2px solid var(--tag); outline-offset: 3px; }

.skip {
  position: absolute; left: -9999px;
  background: var(--tag); color: var(--abyss);
  padding: 0.6rem 1rem; font-family: var(--display);
}
.skip:focus { left: 1rem; top: 1rem; z-index: 10; }

/* ----------------------------------------------------------- hero */

.hero {
  display: grid;
  grid-template-columns: 1fr;
  border-bottom: 1px solid var(--line);
}

.hero__media { position: relative; background: var(--deep); overflow: hidden; }
.hero__media img { display: block; width: 100%; height: 100%; object-fit: cover; }
.hero__media::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(to bottom, rgba(9,26,35,0) 55%, rgba(9,26,35,0.92) 100%);
  pointer-events: none;
}

.hero__text {
  padding: clamp(1.75rem, 5vw, 3.5rem);
  max-width: 62rem;
  margin-inline: auto;
  width: 100%;
}

.venue {
  font-family: var(--display);
  font-size: 1.15rem;
  letter-spacing: 0.01em;
  color: var(--pool);
  margin: 0 0 1.25rem;
}

.hero h1 {
  font-family: var(--display);
  font-weight: 700;
  font-size: clamp(2.1rem, 1.3rem + 3.6vw, 4.1rem);
  line-height: 1.02;
  letter-spacing: -0.025em;
  margin: 0;
}

.subtitle {
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(1.05rem, 0.9rem + 0.7vw, 1.5rem);
  color: var(--muted);
  line-height: 1.25;
  margin: 0.6rem 0 2rem;
}

.authors {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem 1.4rem;
  padding: 0;
  margin: 0 0 0.5rem;
  font-family: var(--display);
  font-size: 1rem;
}
.authors a { text-decoration: none; border-bottom: 1px solid transparent; }
.authors a:hover { border-bottom-color: var(--pool); }
/* A default <sup> is raised with vertical-align, which grows the line box it sits in. The
   names carrying one therefore sat lower than Ronan Hinchet and Robert K. Katzschmann, which
   carry none. Lifting it with position instead takes it out of the line box, so every name
   shares one baseline. The affiliation line's markers get the same treatment. */
.authors sup,
.affil sup {
  color: var(--tag);
  vertical-align: baseline;
  position: relative;
  top: -0.42em;
  font-size: 0.72em;
}

.affil { color: var(--muted); font-size: 1.02rem; margin: 0 0 2rem; }

.links { display: flex; flex-wrap: wrap; gap: 0.6rem; }

.btn {
  font-family: var(--display);
  font-size: 0.95rem;
  text-decoration: none;
  color: var(--foam);
  border: 1px solid var(--line);
  padding: 0.55rem 1.3rem;
  border-radius: 999px;
  transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
.btn:hover { border-color: var(--pool); color: var(--pool); }
.btn--solid { background: var(--pool); border-color: var(--pool); color: var(--abyss); font-weight: 500; }
.btn--solid:hover { background: var(--foam); border-color: var(--foam); color: var(--abyss); }

@media (min-width: 60rem) {
  .hero { grid-template-columns: 1fr; }
  .hero__media { max-height: 62vh; }
}

/* -------------------------------------------------------- sections */

main { max-width: 62rem; margin-inline: auto; padding-inline: clamp(1.25rem, 5vw, 3.5rem); }

.band { padding-block: var(--gap); border-bottom: 1px solid var(--line); }
.band:last-of-type { border-bottom: none; }

h2 {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.3rem, 1.1rem + 0.9vw, 1.9rem);
  letter-spacing: -0.015em;
  margin: 0 0 1.4rem;
}

p { max-width: var(--measure); }
.band p + p { margin-top: 1.1rem; }

/* --------------------------------------------- the band-limit idea */

.idea {
  padding-block: var(--gap);
  border-bottom: 1px solid var(--line);
  display: grid;
  gap: clamp(2rem, 5vw, 3.5rem);
}
@media (min-width: 58rem) {
  .idea { grid-template-columns: 1fr 1fr; align-items: center; }
}

/* The heading spans both columns: capped at 20ch inside the left one, a title like
   "Why it worked: ..." broke over four lines against all the empty space beside it. */
.idea > h2 { grid-column: 1 / -1; max-width: none; margin-bottom: 0; }

/* This column sits beside a plot and describes it, so it reads as that plot's caption rather
   than as body prose: the same muted grey the figure captions above it are set in. */
.idea__text p { color: var(--muted); }

.scale { margin: 0; }

.scale__track {
  position: relative;
  height: 8.5rem;
  border-left: 1px solid var(--line);
  border-right: 1px solid var(--line);
  background:
    repeating-linear-gradient(to right,
      var(--line) 0 1px, transparent 1px 12.5%);
}

.scale__valid {
  position: absolute;
  top: 2.4rem; bottom: 1.6rem;
  left: var(--from); right: calc(100% - var(--to));
  background: rgba(82, 200, 222, 0.14);
  border: 1px solid var(--pool);
}
.scale__validlabel {
  position: absolute;
  inset-inline: 0; top: 50%;
  transform: translateY(-50%);
  text-align: center;
  font-family: var(--display);
  font-size: 0.72rem;
  color: var(--pool);
  line-height: 1.2;
}

.scale__flutter {
  position: absolute;
  top: 2.4rem; bottom: 1.6rem;
  left: var(--from); right: 0;
  background: repeating-linear-gradient(135deg,
    rgba(143,175,188,0.16) 0 4px, transparent 4px 9px);
}
.scale__flutter span {
  position: absolute;
  inset-inline: 0; top: 50%;
  transform: translateY(-50%);
  text-align: center;
  font-family: var(--display);
  font-size: 0.7rem;
  color: var(--muted);
}

.scale__mark { position: absolute; top: 0; bottom: 1.6rem; left: var(--at); }
.scale__mark::before {
  content: "";
  position: absolute; top: 2rem; bottom: 0; left: 0;
  border-left: 2px dashed var(--muted);
}
.scale__mark span {
  position: absolute; top: 0; left: 0;
  transform: translateX(-50%);
  font-family: var(--display);
  font-size: 0.85rem;
  text-align: center;
  white-space: nowrap;
  color: var(--muted);
}
.scale__mark small { display: block; font-size: 0.67rem; font-weight: 400; opacity: 0.8; }

.scale__mark--beat::before { border-left: 3px solid var(--tag); }
.scale__mark--beat span { color: var(--tag); font-weight: 700; }

.scale__axis {
  display: flex;
  justify-content: space-between;
  font-family: var(--display);
  font-size: 0.72rem;
  color: var(--muted);
  margin-top: 0.45rem;
}

.scale figcaption {
  font-size: 0.98rem;
  color: var(--muted);
  margin-top: 1.1rem;
  max-width: 46ch;
}

/* --------------------------------------------------------- figures */

.fig { margin: 0 0 clamp(1rem, 2vw, 1.5rem); }
.fig:last-child { margin-bottom: 0; }
.fig img {
  display: block;
  width: 100%;
  height: auto;
  background: var(--foam);
  border: 1px solid var(--line);
  border-radius: 3px;
}
.fig figcaption {
  font-size: 1.14rem;
  color: var(--muted);
  margin-top: 0.4rem;
  max-width: var(--measure);
}

/* ------------------------------------------ the disturbance grid */
/* Four deployment clips played together. The footage carries nothing but the picture and the
   fish's own trail (tools/build_clips.py); the three plates over each cell are set here, in the
   page's type, so they match the rest of the site rather than the talk video's overlays. */

.clipgrid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(0.4rem, 0.9vw, 0.7rem);
  width: 100%;
}
@media (max-width: 40rem) {
  .clipgrid { grid-template-columns: 1fr; }
}

.clip {
  position: relative;
  margin: 0;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--deep);
}
.clip video { display: block; width: 100%; height: auto; }

.clip__plates {
  position: absolute;
  inset: 0;
  pointer-events: none;            /* the picture stays clickable underneath */
  font-family: var(--display);
  font-size: clamp(0.72rem, 0.55rem + 0.6vw, 1rem);
  line-height: 1.2;
}
.clip__plates > span {
  position: absolute;
  padding: 0.25em 0.55em;
  border-radius: 3px;
  /* The plates sit over moving water, so they carry their own ground rather than relying on
     whatever happens to be behind them in a given frame. */
  background: color-mix(in srgb, var(--abyss) 82%, transparent);
  color: var(--foam);
}
.clip__label { top: 0.4rem; left: 0.4rem; font-weight: 700; }
.clip__time {
  top: 0.4rem; right: 0.4rem;
  color: var(--pool);
  font-variant-numeric: tabular-nums;   /* so the hundredths do not jitter the plate's width */
}
.clip__rate { bottom: 0.4rem; right: 0.4rem; color: var(--tag); font-weight: 500; }

/* One control per cell, each acting on all four. It covers the whole cell so the target is the
   picture itself rather than a small corner widget, and it is invisible until it is wanted. */
.clip__btn {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border: 0;
  padding: 0;
  background: color-mix(in srgb, var(--abyss) 32%, transparent);
  color: var(--foam);
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.16s ease;
}
.clip__btn svg { width: clamp(2rem, 4vw, 3.1rem); height: auto; fill: currentColor; }
.clip__btn:hover, .clip__btn:focus-visible { opacity: 1; }
.clip__btn:focus-visible { outline: 2px solid var(--pool); outline-offset: -3px; }

/* Paused or finished, the control stops hiding: a stopped grid with nothing on it looks broken
   rather than paused, and at the end the replay is the only thing left to offer. */
.clipgrid[data-state="paused"] .clip__btn,
.clipgrid[data-state="ended"] .clip__btn,
.clipgrid[data-state="idle"] .clip__btn { opacity: 1; }

@media (hover: none) {
  .clip__btn { opacity: 1; background: none; }   /* no hover to reveal it with */
  .clip__btn svg { filter: drop-shadow(0 1px 3px rgba(9, 26, 35, 0.9)); }
}

/* Two 16:9 cells by two is 16:9 overall, so capping the grid's WIDTH by the height the pinned
   stage has left over is what keeps it in frame. A max-height would clamp the box without
   telling the cells to narrow, and they would just overflow it. */
/* 13rem is what the title, the caption and the two gaps measure out to; below roughly an
   800 px viewport the cap binds and the cells narrow instead of running off the stage. */
.reveal__stage .clipgrid { max-width: min(100%, calc((84vh - 13rem) * 16 / 9)); }

/* All four together, not in turn: they are four views of one claim and they start playing in
   step, so staggering them in would put the grid out of step with its own clocks. The window
   closes at 0.05, which is where figure-scroll.js starts the playback. */
.js-reveal .clipgrid .clip { --reveal: clamp(0, calc(var(--p, 1) / 0.05), 1); }

/* ------------------------------------------------- scroll reveals */
/* A tall track with a pinned stage. assets/js/figure-scroll.js writes how far the track has
   travelled into --p, in [0, 1]; everything below reads that one number. The figure is drawn
   complete by default and only takes the reveal under .js-reveal, so no script and reduced
   motion both leave a finished graphic rather than an empty frame. */

.fig--reveal { margin-bottom: clamp(0.5rem, 1vw, 0.9rem); }

/* The tall track and the pin exist only while something is actually being revealed. Left on
   unconditionally they would cost a reader with no JavaScript, or one who has asked for
   reduced motion, an extra screen and a half of scrolling past a figure that never changes. */
.reveal { position: relative; }
/* Figure and caption travel together. The caption used to sit after the track, which meant it
   only scrolled into view once the animation had finished and the figure was already leaving --
   the explanation arriving exactly as the thing it explains departs. Pinned here, it is on
   screen for the whole animation. justify-content centres the pair, so the air above the figure
   and the air below the caption match, and the gap between them is deliberately tighter than
   either so the two read as one object. */
.reveal__stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
}

.js-reveal .reveal { height: 250vh; }
/* A wide, short figure needs less travel than a near-square one to read as animated. */
.js-reveal .reveal--short { height: 200vh; }
/* The curriculum is thirteen beats, one of which is a whole swum episode. On the ordinary
   track that left the stroke about fifty pixels of scroll to play out in. */
.js-reveal .reveal--long { height: 340vh; }
.js-reveal .reveal__stage { position: sticky; top: 1.5vh; height: 84vh; }

/* The figure is taller than it is wide (viewBox 86.36 x 95.654), so height is the binding
   constraint on a laptop and width on a phone. min() picks whichever actually fits. */
.reveal__stage svg {
  display: block;
  width: 100%;
  height: auto;
  /* Whatever the title and caption leave. A fixed vh cap had to assume the worst case and so
     shrank every figure to fit it; measuring against the stage lets each figure take the rest
     of the column instead. */
  max-height: calc(100% - 10.5rem);
}

/* The figure's title, pinned in the frame with it. It travels with the artwork instead of
   scrolling away above it, so whichever figure is on screen is always the one named. It keeps
   the full section-heading size so it reads as a peer of the headings above it. */
.reveal__stage .fig__title {
  align-self: stretch;
  margin: 0;
}

.reveal__stage > figcaption {
  margin-top: 0;            /* the column's gap does this, so it is not applied twice */
  align-self: stretch;      /* span the column, like the figure above it */
}

.js-reveal .beat { opacity: var(--reveal, 1); }

/* Each layer's window of the scroll. They overlap, so one is always arriving as the previous
   settles and the figure never stalls. The order is the talk video's: identification, then
   the learning chain, then deployment and the result it produces.

   The first window opens at a negative position on purpose. The stage pins at exactly --p 0,
   so a sequence starting there would greet the reader with an empty screen for the first few
   pixels of scroll; leading it in means the frame is already on the page when it arrives.
   The last closes by 0.84, which leaves the finished figure held for a sixth of the track
   before it unpins -- long enough to read, which is the point of pinning it at all. */
.js-reveal #l_box1  { --reveal: clamp(0, calc((var(--p, 1) + 0.06) / 0.08), 1); }
.js-reveal #l_ident { --reveal: clamp(0, calc((var(--p, 1) - 0.02) / 0.10), 1); }
.js-reveal #l_box2  { --reveal: clamp(0, calc((var(--p, 1) - 0.14) / 0.08), 1); }
.js-reveal #l_rl    { --reveal: clamp(0, calc((var(--p, 1) - 0.22) / 0.09), 1); }
.js-reveal #l_tg    { --reveal: clamp(0, calc((var(--p, 1) - 0.31) / 0.09), 1); }
.js-reveal #l_box3  { --reveal: clamp(0, calc((var(--p, 1) - 0.42) / 0.08), 1); }
.js-reveal #l_hw    { --reveal: clamp(0, calc((var(--p, 1) - 0.50) / 0.09), 1); }
.js-reveal #l_loop  { --reveal: clamp(0, calc((var(--p, 1) - 0.60) / 0.10), 1); }
.js-reveal #l_result{ --reveal: clamp(0, calc((var(--p, 1) - 0.70) / 0.14), 1); }

/* ------------------------------------ fig10: traces that draw, one tail that follows */
/* The two rows draw in turn, direct action first and the trajectory generator second, and a
   single five-link tail underneath replays whichever row is currently drawing. One tail rather
   than one per row: the comparison lands harder when the same chain does both in sequence.
   Its pose comes from the twin simulated in MuJoCo, baked to coordinates by the build. */

.sweep { transform-box: fill-box; transform-origin: left center; }

.js-reveal #f10 {
  --sweep-da: clamp(0, calc((var(--p, 1) - 0.04) / 0.42), 1);
  --sweep-tg: clamp(0, calc((var(--p, 1) - 0.52) / 0.40), 1);
}
.js-reveal #f10-sweep0 .sweep { transform: scaleX(var(--sweep-da, 1)); }
.js-reveal #f10-sweep1 .sweep { transform: scaleX(var(--sweep-tg, 1)); }

#f10_da_cmd, #f10_da_act { clip-path: url(#f10-sweep0); }
#f10_tg_cmd, #f10_tg_act { clip-path: url(#f10-sweep1); }

/* The transmission as the talk video's tail-stiffness panel draws it: five rigid links on
   torsional springs, the crossed tendon pair that bends them, the pulley on the motor shaft,
   and the settled pose behind as a dashed ghost to read the bend against. */
.tail__chain {
  fill: none;
  stroke-width: 2.8;
  stroke-linecap: round;
  stroke-linejoin: miter;
  stroke: var(--muted);
}
.tail__ghost {
  fill: none;
  stroke: var(--line);
  stroke-width: 1.4;
  stroke-dasharray: 3 2.5;
  stroke-linejoin: miter;
}
.tail__tendon {
  fill: none;
  stroke: var(--tag);
  stroke-width: 1.1;
  opacity: 0.8;
  stroke-linejoin: round;
}
.tail__pulley { fill: var(--surface); stroke: var(--muted); stroke-width: 1.1; }
/* ----------------------------------------- curriculum: the target dial */
/* The band the policy draws its targets from, in the fish's own start frame. Drawn smallest
   first and left in place, so the widening accumulates rather than flicking between states. */
.ar-ring { fill: none; stroke: var(--line); stroke-width: 0.18; stroke-dasharray: 0.7 0.7; }
.ar-spoke { stroke: var(--line); stroke-width: 0.15; stroke-dasharray: 0.7 0.9; }
.ar-fill { fill: #2FD1A0; fill-opacity: 0.16; stroke: #2FD1A0; stroke-width: 0.22; }
.ar-fish { fill: var(--foam); }
/* The episode: the track the fish leaves, and the target it was given. */
.ar-track {
  fill: none;
  stroke: var(--foam);
  stroke-width: 0.45;
  stroke-opacity: 0.55;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ar-target circle { fill: var(--tag); }
.ar-target circle + circle { stroke: var(--tag); stroke-width: 0.35; fill: none; }
.ar-rlab, .ar-blab, .ar-title {
  font-family: var(--display);
  fill: var(--muted);
  text-anchor: middle;
}
.ar-rlab { font-size: 1.9px; }
.ar-blab { font-size: 2.3px; fill: #2FD1A0; letter-spacing: 0.02em; }
.ar-title { font-size: 2.4px; fill: var(--foam); }

.tail__key text {
  fill: var(--muted);
  font-family: var(--display);
  font-size: 7.5px;
  dominant-baseline: middle;
}
.tail__key line { stroke-linecap: round; }

.tail__spokes line {
  stroke: var(--tag);
  stroke-width: 1.2;
  stroke-dasharray: 2 2;
  stroke-linecap: round;
}
.tail__pin { fill: var(--abyss); stroke: var(--muted); stroke-width: 0.9; }

/* The chain takes the colour of the row it is currently playing, so the tail and the trace
   above it are visibly the same thing. */
#f10-tail[data-row="da"] .tail__chain { stroke: #E08FC0; }
#f10-tail[data-row="tg"] .tail__chain { stroke: #5FB8EA; }

/* A phone has no room to pin a near-square figure for two screens; show it whole and let it
   scroll past like any other figure. */
@media (max-width: 40rem) {
  .js-reveal .reveal { height: auto; }
  .js-reveal .reveal__stage { position: static; height: auto; }
  .reveal__stage svg { width: 100%; }
  .js-reveal .beat { opacity: 1; }
}

.embed { position: relative; padding-bottom: 56.25%; height: 0; background: var(--deep); }
.embed iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

/* The still that stands in for the player until someone asks for it. */
.embed__play {
  position: absolute;
  inset: 0;
  display: block;
  text-decoration: none;
  overflow: hidden;
}
.embed__thumb {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.embed__btn {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
}
.embed__btn circle {
  fill: rgba(9, 26, 35, 0.55);
  stroke: var(--foam);
  stroke-width: 2;
}
.embed__btn path { fill: var(--foam); }
.embed__play:hover .embed__btn circle { fill: var(--pool); stroke: var(--pool); }
.embed__play:hover .embed__btn path { fill: var(--abyss); }


/* ---------------------------------------------------------- bibtex */

pre {
  background: var(--deep);
  border: 1px solid var(--line);
  border-left: 3px solid var(--pool);
  padding: 1.25rem 1.4rem;
  overflow-x: auto;
  font-size: 0.82rem;
  line-height: 1.6;
}
code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }

/* ---------------------------------------------------------- footer */

footer {
  border-top: 1px solid var(--line);
  padding: 2.5rem clamp(1.25rem, 5vw, 3.5rem);
  max-width: 62rem;
  margin-inline: auto;
  font-family: var(--display);
  font-size: 0.85rem;
  color: var(--muted);
}
footer p { margin: 0 0 0.4rem; }
.fine { font-size: 0.78rem; opacity: 0.75; }
