/* ==========================================================================
   HERO ILLUSTRATION — a Fab's molecular surface, beside the opening headline

   Kept in its own file, loaded last, so the whole component is two files plus a container
   and can be removed without touching base.css (which is byte-identical across both builds
   and enforced by tools/check_sync.py) or shape.css.

   TOKENS. The two ramps are declared here rather than in base.css because only this build has
   a 3D panel. They are read by JS through getComputedStyle, so the key, the panel and the
   structure cannot disagree, and the theme toggle rebuilds the surface from the same values.

   TWO RAMPS, ONE FOR EACH KIND OF QUANTITY

     sequential   --mol-lo -> --mol-hi.  Hydrophobicity (HIC) and patch propensity (SEC). One
                  hue, monotone in lightness, blended in LINEAR light by mix() in hero.js.
     diverging    --mol-neg / --mol-mid / --mol-pos.  Formal charge (HAC), which is signed and
                  takes exactly three values, so it is drawn as three discrete colours and its
                  key is three swatches rather than a bar.

   EVERY VALUE HERE WAS SOLVED, NOT PICKED, and the search is worth recording because two
   plausible palettes failed measurement before this one passed.

   1. The DIVERGING ramp is held to dE >= 24 on all three of its pairs (+/-, +/0, -/0) under
      normal vision AND deuteranopia AND protanopia — nine distances, scored on the worst. A
      first hand-picked set collapsed its blue arm against the neutral midpoint to dE 1.0 under
      protanopia. That is invisible in the one direction that matters, because a residue's SIGN
      is the entire claim the ramp makes.

   2. The two arms differ in LIGHTNESS by less than 14. On a signed quantity, a lightness
      difference between the arms reads as magnitude — +1 would look like "more" than -1
      instead of like its opposite.

   3. The SEQUENTIAL ramp gave up its chroma, and that is the interesting call. A warm brown
      marked end (#884d00) measured dE 4.8 from the diverging negative arm — the same colour
      standing for "most hydrophobic" in one panel and "acidic" in another. The two ramps are
      never on screen together, so no screenshot can catch that; a reader carries it from panel
      to panel. Neither convention could move: blue reads as WATER, so hydrophobicity must run
      warm, and positive-blue / negative-red is the convention every biologist already has for
      charge. So the sequential ramp became almost purely a LIGHTNESS ramp at chroma 0.02-0.03,
      which is the most colour-vision-robust encoding available anyway, and the collision went
      to dE 16.1 / 16.8.

   4. The two QUIET colours (--mol-lo and --mol-mid) are deliberately close, dE 7.1 and 4.7.
      "Not hydrophobic" and "not charged" are the same statement, so one recessive colour for
      both is correct rather than a collision. Only the MARKED colours must separate.

   The constraint inverts between themes, which is why there are two sets: on the pale panel a
   marked residue is DARKER than the quiet surface, on the dark panel LIGHTER.

     light   panel #eff3e9
       sequential  #d9cdc2 -> #392b1e   hue 65/64, span dL 55.4, monotone over all 12 steps
       diverging   +#4b74d8  0#b9b7b5  -#934c3d   worst of nine dE 24.1 (protanopia, +/- and +/0)
                   arms dL from midpoint 20.0 / 28.1, arms differ in L by 8.0
       marked end vs each arm: + 33.7   - 16.1
     dark    panel #1d2416
       sequential  #5e534a -> #fbe9da   hue 62/62, span dL 49.4, monotone over all 12 steps
       diverging   +#8477f3  0#494745  -#efa189   worst of nine dE 24.6 (protanopia, +/-)
                   arms dL from midpoint 24.2 / 38.1, arms differ in L by 13.9
       marked end vs each arm: + 36.0   - 16.8

   tools/check_hero_colours.py re-derives every number above from THIS FILE, including the
   dichromacy simulation and the monotonicity of the 12 steps hero.js actually builds. Run it
   after touching any value here; do not edit the numbers in this comment by hand.
   ========================================================================== */

:root {
  /* sequential — magnitude. Warm, and almost achromatic: see point 3 above. */
  --mol-lo: #d9cdc2;        /* nothing here; sits just clear of the panel (dL 10.3) */
  --mol-hi: #392b1e;        /* the most of it there is */

  /* diverging — signed. Positive blue, negative red: the molecular-graphics convention, which
     is worth keeping because the audience already reads it that way. */
  --mol-pos: #4b74d8;       /* +1, ARG/LYS */
  --mol-mid: #b9b7b5;       /* 0 — near-neutral by construction (chroma 0.004): a diverging
                               ramp with a hue at its midpoint invents a direction */
  --mol-neg: #934c3d;       /* -1, ASP/GLU */
}
:root[data-theme="dark"] {
  --mol-lo: #5e534a;
  --mol-hi: #fbe9da;
  --mol-pos: #8477f3;
  --mol-mid: #494745;
  --mol-neg: #efa189;
}

/* ---------- the opening becomes two columns ----------
   shape.css documents .opening as one flush-left column with no image beside it. That was a
   deliberate choice and this overrides it, so it says so here rather than editing that
   comment into a contradiction. The text column keeps its own measure — the h1 is capped at
   21ch and the ledes at 60ch, so widening the page never stretches a line of prose; the
   space that used to go to nothing now goes to the panel.
   Single column below 1080px: at that width the panel would be squeezed under 340px, where
   a Fab is a green smudge and the strip's 25 dots overlap into a bar. */
@media (min-width: 1080px) {
  .opening--split {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 420px;   /* minmax(0,…): 1fr floors at min-content,
                                                      and a canvas is wide enough to push a
                                                      grid column past the viewport */
    gap: 26px 52px;
    align-items: start;
  }
  .opening--split > .opening__text { min-width: 0; }
  .opening--split > .hero3d {
    min-width: 0; max-width: none; margin-top: 0;   /* the single-column cap does not apply here */
    grid-row: span 2;
  }
}
@media (min-width: 1240px) {
  .opening--split { grid-template-columns: minmax(0, 1fr) 470px; }
}

/* ---------- the panel ---------- */
.hero3d {
  /* Motion timings, declared here so the dots, the leader pin and the label all read the same
     two numbers. --dot-x is the meaningful horizontal slide; --dot-y is the meaningless
     beeswarm lane, deliberately six times faster. See the dot rules below for why. */
  --dot-x: 720ms;
  --dot-y: 140ms;

  /* NOT the house --ease. That token is cubic-bezier(0.16, 1, 0.3, 1), a very front-loaded
     ease-out which is right for UI *arriving* — a panel sliding in should feel already there.
     This animation has the opposite job: the reader is meant to TRACK individual dots across
     the move. Measured, the house curve covers 83% of the distance in the first 25% of the
     time, so the dots snap and then crawl the last sixth — it reads as abrasive and no
     pattern is legible. A symmetric slow-in/slow-out covers 13% / 50% / 87%, which is the
     standing recommendation for animated transitions in statistical graphics for exactly this
     reason. Swap for `linear` if you prefer it plainer; do not put --ease back. */
  --dot-ease: cubic-bezier(0.45, 0.05, 0.55, 0.95);

  /* The strip sits on --surface-2 here, not --surface, so the dots' separation ring has to
     be retinted or every dot carries a pale halo against a slightly darker card. One
     variable, read by base.css, instead of restating each state's whole box-shadow stack. */
  --dot-ring: var(--surface-2);

  /* Below 1080px the grid collapses and this sits in the full text column, where an unbounded
     width plus a 4:3 canvas would make a 750px-tall molecule under the headline. Capped, and
     left-aligned with the prose it now follows rather than sits beside. */
  max-width: 500px;
  margin-top: 30px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 14px 16px 13px;
  display: flex;
  flex-direction: column;
  gap: 11px;
  /* flex: none on a flex child that would otherwise shrink to its container — a scrolling
     flex column drops its own padding-bottom otherwise, which is how a card ends up flush
     against the viewport edge in this project's other builds. */
  flex: none;
}
.hero3d[hidden] { display: none !important; }   /* [hidden] loses to any display rule */

.hero3d__head {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.09em;
  text-transform: uppercase; color: var(--ink-4);
}

/* ---------- the controls, at the top ----------
   Both of them on one row: the assay tabs and the link to the full table. They answer the same
   question — which of these am I seeing, and where are the rest — and this row replaces the
   "1 / 3" counter that used to sit in the head, because three named tabs with one of them
   current ARE the counter, and they say what the other two would show.

   The tabs used to be blank dots at the FOOT of the panel, next to a Pause button. Nobody was
   noticing them there. Moving them up and giving them names is the whole fix; the panel lost its
   foot entirely as a result.

   Height is constant regardless of which tab is current and regardless of the assay — the row's
   content never changes — so this cannot reflow the panel as the picture changes. It may wrap at
   a narrow column, which is also stable for the same reason. */
.hero3d__nav {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px 14px; flex-wrap: wrap;
  margin-top: -4px;
}
.hero3d__tabs { display: flex; gap: 5px; }
.hero3d__tab {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.05em;
  color: var(--ink-3);
  background: none; border: 1px solid var(--border); border-radius: var(--r-pill);
  padding: 3px 9px; cursor: pointer;
  transition: color 0.18s var(--ease), border-color 0.18s var(--ease),
              background-color 0.18s var(--ease);
}
.hero3d__tab:hover { color: var(--ink); border-color: var(--border-2); }
/* The current-state rule comes AFTER hover: both are 0-2-0, so source order decides, and hover
   repainting the current tab is exactly the failure this ordering prevents. */
.hero3d__tab[aria-current="true"] {
  color: var(--accent-ink); border-color: rgba(var(--accent-rgb) / 0.5);
  background: rgba(var(--accent-rgb) / 0.09);
}
.hero3d__tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.hero3d__all {
  font-family: var(--font-mono); font-size: 10.5px; white-space: nowrap;
  color: var(--accent-ink); text-decoration: none;
  border-bottom: 1px solid rgba(var(--accent-rgb) / 0.4);
}
.hero3d__all:hover { border-bottom-color: var(--accent-ink); }

/* ---------- the viewer ----------
   A fixed aspect rather than a fixed height, so the molecule keeps its proportions at both
   column widths. 3Dmol needs a positioned box with real dimensions at createViewer time. */
.hero3d__view {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 3;
  min-height: 0;
  border-radius: var(--r-md);
  overflow: hidden;
  /* A faint inner ground so the pale light-mode ribbon has something to sit on without
     turning the panel into a second card. */
  background:
    radial-gradient(120% 100% at 50% 0%,
      rgba(var(--accent-rgb) / 0.07), transparent 70%);
}
.hero3d__view canvas { display: block; }
.hero3d__view[hidden] { display: none !important; }

/* Stops the rotation, overlaid on the molecule it controls. In the corner rather than in a row
   below the picture: it costs no layout height there, and a control that sits ON the thing it
   affects needs no label explaining what it affects. It is a chip rather than bare text because
   it sits over a rendered surface whose colour it cannot predict. */
.hero3d__spin {
  position: absolute; right: 7px; bottom: 7px; z-index: 2;
  font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--ink-3);
  background: color-mix(in srgb, var(--surface-2) 82%, transparent);
  border: 1px solid var(--border); border-radius: var(--r-pill);
  padding: 3px 9px; cursor: pointer;
  opacity: 0.62;
  transition: opacity 0.18s var(--ease), color 0.18s var(--ease);
}
/* Recessive until wanted, but never invisible — and it comes fully forward on keyboard focus as
   well as hover, or it would be unreachable without a pointer. */
.hero3d__view:hover .hero3d__spin,
.hero3d__spin:focus-visible { opacity: 1; }
.hero3d__spin:hover { color: var(--ink); }
.hero3d__spin:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.hero3d__spin[hidden] { display: none !important; }

/* ---------- the colour key ----------
   A coloured surface with no scale is decoration, so every panel gets one. Two shapes, and the
   difference between them is itself informative: a continuous bar for the two magnitudes, three
   discrete swatches for formal charge — which takes exactly three values, so a gradient would
   imply intermediate charges that no residue has. It also means the charge panel is visibly a
   different KIND of picture from the other two (flat patches of three colours against a
   continuous gradient), which is the main thing keeping the two ramps apart in a reader's head.

   FIXED HEIGHT, both shapes. This sits directly above the assay caption and the strip, and a
   key that changed height as the cycle advanced would shove everything below it — the reflow
   this component has already been bitten by twice. Measured equal rather than assumed: the
   panel-height test in the verification notes covers it. */
.hero3d__key {
  display: flex; align-items: center; gap: 8px;
  height: 20px; margin-top: -2px;
  font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em;
  color: var(--ink-4); white-space: nowrap; overflow: hidden;
}
.hero3d__bar {
  flex: 1 1 auto; min-width: 0; height: 8px; border-radius: 2px;
  /* A hairline, because the quiet end of either ramp is deliberately close to the panel it sits
     on and the bar would otherwise have no left edge at all. --border-2 rather than a literal:
     it is already themed, and a fixed black would vanish on the dark panel. */
  box-shadow: inset 0 0 0 1px var(--border-2);
}
/* One label at each end of the bar. They were both after it once, which put the pale end's name
   against the dark end — a scale label has to be adjacent to what it names. */
.hero3d__end { flex: 0 0 auto; }
.hero3d__swatches { display: flex; gap: 12px; align-items: center; }
.hero3d__sw { display: inline-flex; align-items: center; gap: 5px; }
.hero3d__sw i {
  width: 9px; height: 9px; border-radius: 2px; flex: none;
  box-shadow: inset 0 0 0 1px var(--border-2);
}

/* ---------- assay caption ---------- */
.hero3d__name {
  font-size: 17px; font-weight: 500; color: var(--ink); line-height: 1.25;
  display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
}
.hero3d__tag {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.05em;
  text-transform: uppercase; padding: 3px 7px; border-radius: var(--r-pill);
  border: 1px solid; white-space: nowrap;
  /* Border style, never border WIDTH, changes between states here: a 1px width change on a
     state class shifts everything after it by a pixel, which reads as a layout bug. */
}
.hero3d__tag.is-real { color: var(--accent-ink); border-color: rgba(var(--accent-rgb) / 0.45); }
/* Was `.is-schematic`, when a panel could be a drawing. Nothing is drawn any more — every panel
   is this structure — so the amber state has narrowed to mean "derived rather than measured":
   the aggregation map is a spatial average, not a published per-residue constant. Dashed for the
   same reason it always was: the reader should be able to tell at a glance, from the border
   alone, that this panel's colour is one step further from the data than the others. */
.hero3d__tag.is-proxy {
  color: var(--amber-ink); border-color: rgba(232, 167, 40, 0.5); border-style: dashed;
}
.hero3d__suite {
  font-family: var(--font-mono); font-size: 11px; color: var(--ink-4);
}
.hero3d__line {
  font-size: 13.5px; line-height: 1.5; color: var(--ink-2);
  /* Three lines' worth, which is what the longest caption needs at the narrowest panel width.
     It was 3em — two lines — which was a floor rather than a match, so the panel grew by 20px
     or 40px on the assays with longer captions and the whole thing reflowed as it cycled
     (measured: heights of 729, 749 and 770 across the eight). The captions are honesty-critical
     and must never be clipped, so the fix is a floor that matches the longest caption PLUS a
     test that fails if any caption ever outgrows it — not overflow: hidden. */
  min-height: 4.6em;
}

/* ---------- the strip ----------
   .strip, .strip__dot, .strip__zero and .strip__axis are base.css components, reused
   unchanged. Same functions, same scale, same tooltip as the benchmark table below — that
   is the point, and it is why the dots here are not a lookalike chart. */
.hero3d__data {
  border-top: 1px solid var(--border);
  padding-top: 9px;
}
.hero3d__data .strip { height: 40px; }

/* ---------- the moving dots ----------
   Two durations, and the difference between them is the whole design.

   --dot-x is the meaningful motion: a model's rho changing from one assay to the next. It
   is slow enough to follow with the eye.
   --dot-y is collision avoidance only. The beeswarm lane a dot sits in carries NO
   information (core.js says so at length), so easing it over the same duration would make a
   model whose rho barely moved appear to travel — motion asserting a change that did not
   happen. It resettles almost immediately instead, leaving the horizontal slide as the only
   displacement the eye reads.

   The dots are moved with `left`, not `transform`, because .strip__dot lives in base.css —
   byte-identical across both builds — where position and the recentring of the enlarged
   best/pinned states are both done with left plus a negative margin. Overriding that with a
   transform here would fight it. 25 elements easing `left` on a 420px track is cheap. */
.hero3d__data .strip__dot {
  transition:
    left var(--dot-x) var(--dot-ease),
    top var(--dot-y) ease-out,
    opacity 300ms linear,
    background-color var(--dot-x) linear,
    /* the hover ring and the best/pinned size step, short so they still feel like a
       response to the pointer rather than part of the assay transition */
    box-shadow 140ms linear,
    width 140ms ease-out, height 140ms ease-out, margin 140ms ease-out;
}
/* A model that was not run on this benchmark. It fades out where it stands rather than
   moving anywhere, because it has no value here and any position would be invented. Kept in
   the DOM so it can slide back in on an assay that does have it. */
.hero3d__data .strip__dot.is-absent { opacity: 0; pointer-events: none; }

/* ---------- the label on the leading dot ----------
   Direct labelling: the strongest model is named on the dot itself rather than in a legend,
   and it travels with the dot when the assay changes. */
.hero3d__best {
  position: relative; height: 22px; margin-bottom: 1px;
  transition: opacity 240ms linear;
}
.hero3d__best.is-off { opacity: 0; }
.hero3d__bestpin {
  position: absolute; bottom: 0; width: 1px; height: 7px; margin-left: -0.5px;
  /* --ink-3, not --border-2. A 1px hairline needs far more contrast than a text block to be
     seen at all: --border-2 measured 1.91:1 on the dark panel and was effectively invisible,
     which left the label floating with nothing tying it to a dot. --ink-3 is 5.65:1. */
  background: var(--ink-3);
  transition: left var(--dot-x) var(--dot-ease);
}
.hero3d__bestlab {
  position: absolute; bottom: 7px; white-space: nowrap;
  font-size: 11.5px; color: var(--ink-2);
  /* transform is transitioned as well as left, so that a best model moving from one end of
     the track to the other eases its anchor across instead of snapping it. */
  transition: left var(--dot-x) var(--dot-ease), transform var(--dot-x) var(--dot-ease);
}
.hero3d__bestlab b {
  font-family: var(--font-mono); font-weight: 400; color: var(--accent-ink); margin-left: 5px;
}
.hero3d__bestlab .eff { margin-left: 5px; }
/* Anchored by its own edge near the ends of the track, where a centred label would hang off
   the panel. The pin stays at the true value in all three cases. */
.hero3d__bestlab[data-align="mid"] { transform: translateX(-50%); }
.hero3d__bestlab[data-align="start"] { transform: translateX(-2px); }
.hero3d__bestlab[data-align="end"] { transform: translateX(calc(-100% + 2px)); }

@media (prefers-reduced-motion: reduce) {
  .hero3d__data .strip__dot,
  .hero3d__best,
  .hero3d__bestpin,
  .hero3d__bestlab { transition: none; }
}
.hero3d__nums {
  margin-top: 15px; font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-4);
}
.hero3d__nums b { color: var(--ink-2); font-weight: 400; }
/* The empty-slice message is base.css's own `.strip__none`, inside the strip where the dots
   would be, rather than a hero-specific paragraph below it — a reason shown where the missing
   thing was. (There was a `.hero3d__none` here for that; it is gone with the rebuild.) */

.hero3d__credit {
  font-size: 11px; line-height: 1.5; color: var(--ink-4);
  border-top: 1px solid var(--border); padding-top: 9px;
  /* Reserved for the LONGEST credit, for the same reason .hero3d__line is: this text quotes the
     current property's provenance rule verbatim from the data file, and those rules differ a lot
     in length ("published per-residue constant; nothing inferred" against the aggregation map's
     full description). Measured, that made the SEC panel 16px taller than the other two and the
     whole panel jumped as the cycle advanced — and it was the CREDIT, not the caption, which is
     why the fix went in here after measuring every child rather than after guessing.
     4 lines of text at 1.5, plus the 9px padding and the 1px border, box-sizing being
     border-box. It is a floor, not a clamp: an even longer rule would still be readable, it
     would just make the panel taller again — which the height assertion in the test harness
     catches. */
  min-height: calc(6em + 10px);
}
.hero3d__credit a { color: var(--ink-3); }
.hero3d__credit b { color: var(--ink-3); font-weight: 500; }
