/* interactions.css - the looks of the client-side interaction layer.
 * =====================================================================
 *
 * WHAT IS IN HERE, and why it is not in main.css: every component below is
 * painted by js/site.js and does not exist for a visitor without JavaScript.
 * main.css is the document's stylesheet and its HANDOVER CONTRACT names what it
 * owns; nothing here restyles any of it. This file only adds:
 *
 *   .theme-seg      the three-state Light / Dark / System control in the header
 *   .search-kbd     the shortcut chip inside the search field
 *   .cmdk           the command palette (Ctrl/Cmd+K, "/")
 *   .skel           the skeleton rows the palette and the comment thread use
 *                   while their content is in the air
 *   .toasts         the toast stack
 *   .crail          the continue-reading rail
 *   .rprog          the reading meter painted over a card's cover
 *
 * THE RULES OF main.css HOLD HERE TOO. They are not that file's house style,
 * they are the design:
 *   1. No literal colours. Every value below is a token from tokens/<theme>.css,
 *      so the dark theme, and a site's theme_json override, reach this file for
 *      free. The three jobs of the brand hue are kept apart - --brand for
 *      borders, rules, meters and icon graphics, --brand-strong / --link for
 *      brand-coloured TEXT, --brand-fill for a plate under a white label.
 *      --hot / --new stay signal-only and never carry a word; --danger-ink and
 *      --success-ink are the text stops for those two hues.
 *   3. Anything that lifts writes var(--lift), and every duration in this file
 *      is one of the --ix-* tokens declared below - which is the same
 *      contract var(--t-fast) carries in main.css, kept in this file's own
 *      namespace because this file is not loaded on every page main.css is.
 *      The prefers-reduced-motion block at the foot of main.css
 *      answers all of it: it clamps every transition AND every animation on the
 *      page to .01ms !important, which is what makes a shimmer safe to write.
 *   4. Hover changes colour, border, shadow and --lift. Never a size.
 *   6. Focus is main.css's one :focus-visible ring on every control here,
 *      including the two scroll panels, which carry tabindex="0". Nothing below
 *      re-declares it and nothing below removes it.
 *
 * LAYERING. The palette is the only new stacking context that matters:
 *   30 header   45 to-top   50 search dropdown   70 adult gate   80 skip link
 *   90 palette  95 toasts
 * The palette sits above the gate on purpose - it is opened deliberately, by a
 * keystroke, and a dialog that opens under an overlay is a dialog that hangs.
 *
 * MOTION, AND THE FOUR THINGS IT IS ALLOWED TO DO HERE. Everything below moves
 * with transform and opacity ONLY - no width, no height, no top, no margin - so
 * nothing in this file can cost a layout pass on the phone most of this traffic
 * is on. There is no exception left: the continue rail used to animate
 * grid-template-rows and it was the worst offender on the site, because it is
 * the FIRST element in <main> on the front page and every pixel it grew pushed
 * the carousel holding the only fetchpriority="high" cover down the screen, two
 * frames after paint, for every returning reader - 0.237 CLS, measured. Its box
 * is now RESERVED before the first frame instead, out of a stamp base.html's
 * pre-paint script writes; see the CONTINUE-READING RAIL block below.
 * TWO BLOCKS HERE EXIST TO STOP THE PAGE MOVING rather than to make something
 * move, and they are the same idea at two scales: that one, and the theme
 * control's, which holds its ~118px seat in the header laid out but invisible
 * so that un-hiding it re-lays out nothing. Both ship the box with the document
 * and let the script fill it; neither animates anything that has a size.
 *   press      a 1px translate on every control, because a plate that only
 *              changes colour under the thumb has not answered the touch.
 *   segment    the theme control's plate SLIDES between the three modes on one
 *              pseudo element, driven by two custom properties site.js measures.
 *   dialog     the palette fades its scrim and drops its panel 8px, in BOTH
 *              directions - site.js keeps the dialog in the tree until the exit
 *              has finished, so it is never ripped away from a screen reader
 *              mid-animation. It is made inert on the first line of close(), so
 *              a Tab straight after Escape cannot land inside it.
 *   skeleton   the shimmer stops and the skeleton cross-fades to the real rows
 *              from ON TOP of them (position:absolute), and site.js pins the
 *              host's height for the length of the fade, so the box it hands
 *              over does not change height under the answer.
 *
 * TWO RULES THAT USED TO LIVE HERE ARE IN main.css NOW: [data-busy] and the
 * .is-fresh entrance. Both are written by js/site.js on components this file
 * does not own, and site.js runs on the READER page too - which loads main.css
 * and reader.css and never this file - so a busy comment button and an arriving
 * comment thread were unstyled there. They are shared behaviour, so they belong
 * to the document's stylesheet; nothing here may redeclare them.
 * Nothing here spins and nothing bounces. Durations live in the block below;
 * main.css's prefers-reduced-motion answer clamps every one of them to a single
 * frame, and the block at the FOOT of this file takes the press travel out.
 * ===================================================================== */


/* ================================================================ MOTION === */

/* The file's own motion values, named once. These are deliberately NOT the
   --m-* set in features/motion.css: that file is the shared reveal layer, it is
   loaded from base.html and this one is loaded from site_header.html, and two
   files declaring the same property on :root is one of them silently winning.
   Same numbers, different namespace, no load-order argument. */
:root {
  --ix-fast: 150ms;
  --ix-in: 220ms;
  --ix-out: 150ms;
  --ix-ease: cubic-bezier(.2, .6, .2, 1);
  --ix-press: translateY(1px);
  /* The same press as a bare LENGTH, for the two controls that are already
     holding a transform of their own (the rail arrows are centred with
     translateY(-50%), so their press has to be added inside that calc rather
     than composed after it - a second transform function would replace the
     centring, and substituting `none` into a calc would invalidate the whole
     declaration and drop it). */
  --ix-press-y: 1px;
}

/* The two entrances every module here shares. A keyframe rather than a
   transition because both are used on elements that have just been un-hidden,
   and an element that was display:none a moment ago has no start value for a
   transition to run from - main.css's [hidden] is display:none !important, so
   there is no route to a transition here.
   THERE IS NO `to` FRAME, AND THAT IS THE POINT. The implicit one is the
   element's OWN opacity, so a rail arrow that arrives already disabled fades to
   its .45 instead of to 1 and then blinking. A forced `to: 1` would also
   outrank that rule for good, because a filled animation beats every normal
   declaration in the cascade - which is the bug this shape avoids rather than
   works around. */
@keyframes ix-fade-in {
  from { opacity: 0; }
}
@keyframes ix-fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}
/* .is-fresh - what arriving content does when it replaces a skeleton - is NOT
   here: it is in main.css, because the comment thread it dresses also arrives on
   the reader page, which never loads this file. Same for [data-busy]. */


/* ============================================== THEME SEGMENTED CONTROL === */

/* One control, three targets, each of them at the header's --tap. It reads as a
   single object - a track with the current mode raised out of it - which is
   what tells a visitor these three are one choice and not three switches. */
.theme-seg {
  align-items: center;
  background: var(--surface-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-pill);
  display: inline-flex;
  gap: 2px;
  padding: 2px;
  position: relative;
}

/* THE SEAT IS RESERVED BEFORE THE FIRST FRAME, the same fix the continue rail
   below gets and for the same measured reason. The control ships `hidden` from
   components/site_header.html - a theme switch does nothing without js/site.js,
   and a dead control is worse than an absent one - and site.js un-hides it on
   the run that stamps the current mode. That run is NOT reliably ahead of the
   first paint: on a document this size the page paints first, and ~118px of
   control then arrives in .hdr-tools, which is a flex row - so the search field
   beside it re-lays-out and everything in the row moves, after paint, on every
   load, for every visitor. There is nothing to animate here and nothing to
   measure: the box is three --tap-sized seats and its own padding, so the
   browser can lay it out now and simply not draw it.
   VISIBILITY, NOT OPACITY, and that is the whole reason this is safe: a
   visibility:hidden subtree is out of the accessibility tree and its buttons are
   out of the tab order, so the control the attribute says is hidden really is,
   right up until the frame site.js reveals it. The !important is not a shortcut
   but the only spelling available - main.css wins [hidden] back from the user
   agent with `display: none !important`, and specificity does not beat that.
   The cost is one empty seat in the tools row for a visitor whose script never
   runs, which is 118px of header and nothing else; the alternative is the whole
   header moving after paint for everyone whose script does. */
.theme-seg[hidden] {
  display: inline-flex !important;
  visibility: hidden;
}
/* The reveal, and it can move nothing - the box was already the right size and
   the only thing that changes is whether it is painted. Opacity alone, so it
   composites; main.css's prefers-reduced-motion block clamps it to a frame. */
.theme-seg:not([hidden]) {
  animation: ix-fade-in var(--ix-in) var(--ix-ease) backwards;
}

/* THE PLATE THAT SLIDES. One pseudo element for the whole control, moved by two
   custom properties js/site.js measures off the chosen button: --seg-x is how
   far along the track it sits and --seg-s scales a 100px reference box to its
   width. Both are TRANSFORMS, which is the only way to move a box without
   laying the page out again - animating left/width instead would reflow the
   header sixty times a second for a 200ms animation.
   It exists only once site.js has measured (that is what [data-seg] says), so a
   visitor whose script never runs keeps the plain painted plate below and never
   sees an indicator parked at zero width. */
.theme-seg[data-seg]::before {
  background: var(--brand-fill);
  border-radius: var(--r-pill);
  bottom: 2px;
  box-shadow: var(--shadow-1);
  content: "";
  left: 0;
  position: absolute;
  top: 2px;
  transform: translateX(var(--seg-x, 0px)) scaleX(var(--seg-s, 0));
  transform-origin: left center;
  transition: transform var(--ix-in) var(--ix-ease);
  width: 100px;
}

.theme-seg__btn {
  align-items: center;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r-pill);
  color: var(--text-muted);
  cursor: pointer;
  display: inline-flex;
  height: calc(var(--tap) - 8px);
  justify-content: center;
  min-width: calc(var(--tap) - 8px);
  /* Over the sliding plate, and its own stacking context, so the glyph is never
     painted under the thing that is meant to be behind it. */
  position: relative;
  transition: background var(--ix-fast) ease-out, border-color var(--ix-fast) ease-out,
              color var(--ix-in) var(--ix-ease), transform var(--ix-fast) ease-out;
  z-index: 1;
}
.theme-seg__btn:hover { color: var(--brand-strong); }
/* The current mode is a PLATE under a white glyph, so it is --brand-fill and
   not --brand: the fill is the one of the three brand tokens that stays deep in
   both themes, which is what keeps the glyph legible when the theme flips. */
.theme-seg__btn[aria-checked="true"] {
  background: var(--brand-fill);
  border-color: var(--brand-fill);
  box-shadow: var(--shadow-1);
  color: var(--on-brand);
}
/* ONE PLATE, NOT TWO. When the indicator is live the chosen button gives up its
   own fill and keeps only the ink, so what the eye follows is a single object
   travelling between three seats instead of one plate blinking off as another
   blinks on. The ink crosses over the length of the slide, which is what stops
   the label arriving before the plate it belongs to. */
.theme-seg[data-seg] .theme-seg__btn[aria-checked="true"] {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
}
/* The press. 1px, on the button and not on the track, so the answer to the
   thumb is the thing that was touched. */
.theme-seg__btn:active { transform: var(--ix-press); }
.theme-seg__glyph { height: 17px; width: 17px; }


/* ================================================= SEARCH SHORTCUT CHIP === */

/* A hint that is also a button. It sits in the flex row rather than floating
   inside the field: an absolutely positioned chip would need the field's own
   padding to change to make room for it, and .search-input belongs to main.css. */
.search-kbd {
  align-items: center;
  background: var(--surface-control);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-pill);
  color: var(--text-muted);
  cursor: pointer;
  display: inline-flex;
  flex: 0 0 auto;
  justify-content: center;
  min-height: var(--tap);
  padding: 0 var(--space-3);
  transition: background var(--ix-fast) ease-out, border-color var(--ix-fast) ease-out,
              box-shadow var(--ix-fast) ease-out, color var(--ix-fast) ease-out,
              transform var(--ix-fast) ease-out;
}
.search-kbd:hover {
  background: var(--brand-soft);
  border-color: var(--brand);
  box-shadow: var(--shadow-1);
  color: var(--brand-strong);
}
.search-kbd:active { transform: var(--ix-press); }
.search-kbd__key {
  font-size: var(--fs-meta);
  font-weight: 650;
  letter-spacing: var(--track-chip);
  white-space: nowrap;
}


/* ===================================================== COMMAND PALETTE === */

.cmdk {
  align-items: flex-start;
  display: flex;
  inset: 0;
  justify-content: center;
  padding: var(--space-4);
  position: fixed;
  z-index: 90;
}

.cmdk__scrim { background: var(--scrim); inset: 0; position: absolute; }

/* THE EXIT IS A STATE, NOT A DELETION. js/site.js sets data-state="closing" and
   only writes the `hidden` attribute when the animation below has finished, so
   the dialog is never torn out of the accessibility tree mid-flight; while it is
   leaving it stops taking clicks, because the page behind it is live again. */
.cmdk[data-state="closing"] { pointer-events: none; }
.cmdk[data-state="open"] .cmdk__scrim { animation: ix-fade-in var(--ix-in) var(--ix-ease) both; }
.cmdk[data-state="closing"] .cmdk__scrim { animation: ix-fade-out var(--ix-out) ease-out both; }

/* The dialog. It is offset from the top rather than centred on the axis: a
   result list that grows downwards from a fixed field is a list that does not
   move the field while the visitor is typing into it. */
.cmdk__panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-panel);
  box-shadow: var(--shadow-3);
  display: flex;
  flex-direction: column;
  margin-top: 8vh;
  max-height: 78vh;
  max-width: 620px;
  min-height: 0;
  position: relative;
  width: 100%;
}
/* Opening motion, and it is deliberately small: 8px and an opacity step. The
   dialog covers the page, so anything bigger reads as the page moving. The exit
   is shorter and eases out, which is the pair the docs ask for - an entrance
   settles, a dismissal gets out of the way. */
.cmdk[data-state="open"] .cmdk__panel { animation: cmdk-in var(--ix-in) var(--ix-ease) both; }
.cmdk[data-state="closing"] .cmdk__panel { animation: cmdk-out var(--ix-out) ease-out both; }
@keyframes cmdk-in {
  from { opacity: 0; transform: translateY(-8px); }
  to { opacity: 1; transform: none; }
}
@keyframes cmdk-out {
  from { opacity: 1; transform: none; }
  to { opacity: 0; transform: translateY(-6px); }
}

.cmdk__field {
  align-items: center;
  border-bottom: 1px solid var(--border);
  display: flex;
  gap: var(--space-2);
  padding: var(--space-3);
}
.cmdk__field-icon { color: var(--brand); flex: 0 0 auto; height: 20px; width: 20px; }

/* No box of its own: the dialog IS the field's box, and a second border inside
   it is a control drawn twice. The focus ring still lands, on the input. */
.cmdk__input {
  background: transparent;
  border: 0;
  color: var(--text);
  flex: 1 1 auto;
  font-size: var(--fs-lead);
  min-height: var(--tap);
  min-width: 0;
  outline-offset: -2px;
}

.cmdk__close {
  align-items: center;
  background: var(--surface-control);
  border: 1px solid var(--border);
  border-radius: var(--r-pill);
  color: var(--text-muted);
  cursor: pointer;
  display: inline-flex;
  flex: 0 0 auto;
  justify-content: center;
  min-height: 28px;
  padding: 0 var(--space-2);
  transition: background var(--ix-fast) ease-out, border-color var(--ix-fast) ease-out,
              color var(--ix-fast) ease-out, transform var(--ix-fast) ease-out;
}
.cmdk__close:hover {
  background: var(--brand-soft);
  border-color: var(--brand);
  color: var(--brand-strong);
}
.cmdk__close:active { transform: var(--ix-press); }

.cmdk__kbd {
  font-size: var(--fs-meta);
  font-weight: 650;
  letter-spacing: var(--track-chip);
}

/* position:relative is the anchor the leaving skeleton is laid over - see the
   SKELETONS block. It costs nothing while there is no skeleton. */
.cmdk__results {
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--space-2);
  position: relative;
}

.cmdk__group { display: block; }
.cmdk__grouptitle {
  color: var(--text-muted);
  display: block;
  font-size: var(--fs-meta);
  font-weight: 650;
  letter-spacing: var(--track-chip);
  padding: var(--space-2) var(--space-2) var(--space-1);
  text-transform: uppercase;
}

/* A row is an <a> for a result and a <button> for a recent search, so the two
   things a button brings with it - the user-agent plate main.css deliberately
   leaves on every button, and centred text - are undone here rather than in the
   reset, where undoing them would silently un-draw every other button on the
   site. display:flex makes the button block-level, so it fills the row. */
.cmdk__row {
  align-items: center;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r-card);
  color: var(--text);
  cursor: pointer;
  display: flex;
  gap: var(--space-3);
  min-height: var(--tap);
  padding: var(--space-2);
  text-align: left;
  /* The tint arrives and leaves in 150ms and the border is already there in
     transparent, so a row cannot jump by a pixel as the pointer crosses it. */
  transition: background var(--ix-fast) ease-out, border-color var(--ix-fast) ease-out,
              color var(--ix-fast) ease-out, transform var(--ix-fast) ease-out;
  width: 100%;
}
.cmdk__row:active { transform: var(--ix-press); }
/* The term reads first and the reason it is on the list is pushed to the far
   end, which is also what keeps the two kinds of row visibly different. */
.cmdk__row--recent .cmdk__rowmeta { margin-left: auto; padding-left: var(--space-3); }
.cmdk__row:hover { background: var(--surface-alt); color: var(--text); text-decoration: none; }
/* The keyboard cursor is NOT the hover state - the same reason .suggest-link
   gives: arrow keys move a highlight the pointer cannot see, so it gets its own
   plate and border or two rows look identical and nobody knows what Enter does. */
.cmdk__row[aria-selected="true"] {
  background: var(--brand-soft);
  border-color: var(--brand);
  color: var(--brand-strong);
  text-decoration: none;
}

.cmdk__cover {
  background: var(--surface-alt);
  border: 1px solid var(--border);
  border-radius: var(--r-card);
  box-shadow: var(--shadow-cover);
  flex: 0 0 38px;
  height: 55px;
  object-fit: cover;
  width: 38px;
}
.cmdk__rowtext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cmdk__rowtitle {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.cmdk__rowchapter { color: var(--link-chapter); font-size: var(--fs-meta); }
.cmdk__rowmeta { color: var(--text-muted); font-size: var(--fs-meta); }
/* The return glyph only appears on the row the keyboard is on: printed on every
   row it is decoration, printed on one it is an instruction. */
.cmdk__go {
  color: var(--brand-strong);
  flex: 0 0 auto;
  font-size: var(--fs-meta);
  margin-left: auto;
  opacity: 0;
  /* It fades rather than blinks as the keyboard cursor passes, and it is
     ALWAYS in the row - printing it on demand would move the row's text as the
     arrow keys walked down the list. */
  transition: opacity var(--ix-fast) ease-out;
}
.cmdk__row[aria-selected="true"] .cmdk__go { opacity: 1; }

.cmdk__more {
  color: var(--link);
  font-size: var(--fs-meta);
  font-weight: 650;
  justify-content: center;
}
.cmdk__more:hover,
.cmdk__more[aria-selected="true"] { color: var(--brand-strong); }

.cmdk__empty {
  color: var(--text-muted);
  font-size: var(--fs-meta);
  padding: var(--space-6) var(--space-4);
  text-align: center;
}

.cmdk__foot {
  align-items: center;
  border-top: 1px solid var(--border);
  color: var(--text-muted);
  display: flex;
  flex-wrap: wrap;
  font-size: var(--fs-meta);
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
}
.cmdk__foot .cmdk__kbd {
  background: var(--surface-control);
  border: 1px solid var(--border);
  border-radius: var(--r-card);
  color: var(--text-2);
  padding: 1px var(--space-2);
}

/* The document behind an open dialog does not scroll. The padding is written by
   js/site.js and is exactly the width of the scrollbar that just went away, so
   the page underneath does not jump sideways as the dialog opens. */
html.is-cmdk-open { overflow: hidden; }


/* ============================================================ SKELETONS === */

/* One skeleton vocabulary, shared by the palette's rows and the comment
   thread's. A skeleton beats the word "Loading" because it says WHAT is coming
   and how much of it, and because it does not move the layout when the real
   thing replaces it. Every part of it is aria-hidden in the markup site.js
   builds: the live region beside it is what speaks. */
.skel { display: flex; flex-direction: column; gap: var(--space-2); }
.skel-row {
  align-items: center;
  display: flex;
  gap: var(--space-3);
  padding: var(--space-2);
}
.skel-col { display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--space-2); min-width: 0; }

.skel-block,
.skel-line,
.skel-cover {
  background:
    linear-gradient(90deg,
      var(--surface-alt) 0%,
      var(--surface-control) 40%,
      var(--surface-alt) 80%);
  background-size: 300% 100%;
  border-radius: var(--r-card);
  /* Clamped to .01ms by main.css under prefers-reduced-motion, which leaves a
     flat grey block - the honest still frame of this animation. */
  animation: skel-sweep 1.4s linear infinite;
}
.skel-cover { flex: 0 0 38px; height: 55px; }
.skel-line { height: 10px; }
.skel-line--title { height: 12px; width: 62%; }
.skel-line--short { width: 38%; }
.skel-block { height: 56px; }

@keyframes skel-sweep {
  from { background-position: 150% 0; }
  to { background-position: -150% 0; }
}

/* THE HANDOVER. When the real rows arrive js/site.js lays the skeleton OVER
   them - absolute, inset 0, inside the positioned host - and fades it out while
   the content underneath fades in. Two things follow from doing it that way and
   both are the point: the box never changes height, because the thing leaving
   stopped taking part in layout the moment it started leaving; and the shimmer
   STOPS, because a skeleton that is still sweeping while the answer is on
   screen is an animation lying about what the page is doing. */
.skel.is-leaving {
  animation: none;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  position: absolute;
  transition: opacity var(--ix-out) ease-out;
}
.skel.is-leaving .skel-block,
.skel.is-leaving .skel-line,
.skel.is-leaving .skel-cover { animation: none; }


/* =============================================================== TOASTS === */

/* Bottom right, above everything, and never wider than a phone. The stack is
   column-reverse so a new toast appears at the bottom and pushes the older ones
   up, away from the thumb that is about to dismiss one. */
.toasts {
  bottom: var(--space-4);
  display: flex;
  flex-direction: column-reverse;
  gap: var(--space-2);
  max-width: min(360px, calc(100vw - var(--space-6)));
  pointer-events: none;
  position: fixed;
  right: var(--space-4);
  width: 100%;
  z-index: 95;
}

.toast {
  align-items: flex-start;
  /* backwards, NOT both. A forwards fill would keep applying the entrance's own
     `transform: none` for the rest of the toast's life, and a filled animation
     outranks every normal declaration and every inline style - which would
     silently kill the stack's FLIP below, because the transform site.js writes
     could never take effect. backwards still guarantees the from-frame on the
     first paint, which is the only thing `both` was buying here. */
  animation: toast-in var(--ix-in) var(--ix-ease) backwards;
  background: var(--surface);
  border: 1px solid var(--border);
  border-left: 3px solid var(--brand);
  border-radius: var(--r-panel);
  box-shadow: var(--shadow-3);
  color: var(--text);
  display: flex;
  font-size: var(--fs-meta);
  gap: var(--space-3);
  padding: var(--space-3);
  pointer-events: auto;
}
/* The two hues are SIGNALS - a 3px rule down the edge - and the word beside
   them is --text. Neither --new nor --hot is ever written as a word. */
.toast--ok { border-left-color: var(--new); }
.toast--bad { border-left-color: var(--hot); }
.toast__text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
/* The one place a status ink is used as ink: a short lead-in word that has to
   be readable at 12px, which --new and --hot are not. */
.toast--bad .toast__lead { color: var(--danger-ink); font-weight: 650; }
.toast--ok .toast__lead { color: var(--success-ink); font-weight: 650; }
.toast__close {
  background: transparent;
  border: 0;
  border-radius: var(--r-pill);
  color: var(--text-muted);
  cursor: pointer;
  flex: 0 0 auto;
  font-size: var(--fs-body);
  height: 24px;
  line-height: 1;
  transition: background var(--ix-fast) ease-out, color var(--ix-fast) ease-out,
              transform var(--ix-fast) ease-out;
  width: 24px;
}
.toast__close:hover { background: var(--surface-control); color: var(--text); }
.toast__close:active { transform: var(--ix-press); }
.toast.is-leaving { animation: toast-out var(--ix-out) ease-out both; }

/* THE STACK CLOSES UP INSTEAD OF SNAPPING SHUT. When one toast of three is
   dismissed, the survivors would otherwise be re-laid-out into their new slots
   between two frames. js/site.js measures where each one WAS, puts it back
   there with a transform and then releases it - so what moves is a composited
   layer, and the toast that is being read does not teleport. Spring-free on
   purpose: this is the site telling the visitor something, not a toy.
   THE PAGE IS NEVER TOUCHED by any of it - .toasts is position:fixed, so
   nothing above can shift, whatever the stack does. */
.toast.is-shifting { transition: transform var(--ix-in) var(--ix-ease); }

@keyframes toast-in {
  from { opacity: 0; transform: translateY(12px); }
  to { opacity: 1; transform: none; }
}
@keyframes toast-out {
  from { opacity: 1; transform: none; }
  to { opacity: 0; transform: translateY(6px); }
}


/* ================================================ CONTINUE-READING RAIL === */

/* THE HEIGHT IS NEVER ANIMATED, AND THIS IS THE MOST IMPORTANT BLOCK IN THE FILE.
   The rail used to be a one-row grid animating grid-template-rows 0fr -> 1fr,
   un-hidden by js/site.js and expanded two frames later. It is the FIRST element
   inside <main> on the front page, so those frames grew a 388px band underneath
   the Popular heading, the carousel that holds the only fetchpriority="high"
   cover on the site, and everything below them - measured at 0.237 CLS, landing
   on the LCP element, for every returning reader. A deferred script is NOT
   reliably ahead of the first paint on a document this size: measured in Chrome,
   the front page paints, and only then does site.js run. Un-hiding the rail
   without animating it moved that shift into one 396px jump instead of seven
   small ones - the same failing number.
   SO THE BOX IS RESERVED BEFORE THE FIRST FRAME INSTEAD. base.html's pre-paint
   script reads the same localStorage key the rail is built from and stamps
   html[data-crail] when there is history to show; the two rules below then give
   the band its full height while it is still empty, out of numbers CSS already
   knows. Nothing moves when site.js fills it, because the space was already
   its own. A visitor with no history is not stamped, is not shown a band and
   pays nothing - which was always the promise; what is new is that the visitor
   who DOES have one stops paying for it in layout.
   THE BAND'S HEIGHT IS A NUMBER, NOT A GUESS. A card is a fixed-ratio cover, a
   name clamped to two lines, one clipped chapter line and the meter row - every
   part of it bounded - so the tallest card is knowable, and pinning the card to
   it makes the row deterministic rather than dependent on which titles the
   visitor happens to have read. Measured in Chrome: 297.9px at a 152px card and
   259.6px at a 128px one. The track carries a card's height plus its own top
   inset whether or not there is a card in it yet.

   ONE TERM, TWO USES, AND THAT IS WHAT MAKES THE RESERVATION SAFE. The card used
   to be a flat 152px that snapped to 128px at a hard 479px breakpoint with the
   height token restated under it - two numbers, in two places, that had to be
   changed together or the empty band would reserve a box the filled one does not
   fill. Both are now derived from --crail-w:
     --crail-w     the WIDTH, a clamp solved between a 360px phone and a 1440px
                   desktop exactly as the type and space scales in
                   tokens/<theme>.css are, so the cell grows a fraction of a
                   pixel at a time instead of jumping 24px at one width. It lands
                   on the two values it used to snap between, at the two ends.
     --crail-card  the HEIGHT, as a line through the two measurements above:
                   1.6 x width + 55px gives 259.8px at 128 and 298.2px at 152,
                   each a shade OVER what was measured, which is the direction a
                   reservation is allowed to be wrong in. The line is exact
                   rather than an approximation because every part of the card
                   that is not the cover is --fs-meta type, and --fs-meta is
                   itself linear between the same 360px and 1440px - so height is
                   linear in width across the whole range, and two points fix it.
   Because .crail__card carries that height explicitly, the filled track is
   card + its top inset, which is the min-height below to the pixel, at every
   viewport width rather than at two of them. */
:root {
  --crail-w: clamp(128px, 2.222vw + 120px, 152px);
  --crail-card: calc(var(--crail-w) * 1.6 + 55px);
}

/* The !important is not a shortcut, it is the only spelling available: main.css
   wins [hidden] back from the user agent with `display: none !important`, and an
   !important declaration is not beaten by specificity. The band stays `hidden`
   for assistive technology and for a visitor whose script never runs - all this
   says is that the space it will take is already spoken for. */
html[data-crail] .crail[hidden] { display: block !important; }

.crail__track { min-height: calc(var(--crail-card) + var(--space-1)); }

/* The one entrance left, and it can move nothing: the box is already the right
   size before it starts, and the head above the track was painted with the
   page. Only the cards arrive. */
.crail.is-ready .crail__track {
  animation: ix-fade-in var(--ix-in) var(--ix-ease) backwards;
}

.crail__head {
  align-items: center;
  display: flex;
  gap: var(--space-3);
  padding: var(--space-5) 0 var(--space-3);
}
.crail__title {
  align-items: center;
  display: flex;
  font-size: var(--fs-h2);
  gap: var(--space-2);
}
.crail__icon { color: var(--brand); display: inline-flex; }
.crail__clear {
  background: transparent;
  border: 1px solid var(--border);
  border-radius: var(--r-pill);
  color: var(--text-muted);
  cursor: pointer;
  font-size: var(--fs-meta);
  margin-left: auto;
  min-height: 32px;
  padding: 0 var(--space-3);
  transition: background var(--ix-fast) ease-out, border-color var(--ix-fast) ease-out,
              color var(--ix-fast) ease-out, transform var(--ix-fast) ease-out;
}
.crail__clear:hover {
  background: var(--brand-soft);
  border-color: var(--brand);
  color: var(--brand-strong);
}
.crail__clear:active { transform: var(--ix-press); }

/* The wrapper js/site.js puts around the scroller so the two arrows have
   something to be positioned against. It cannot be the scroller itself: an
   absolutely positioned child of an overflow:auto box scrolls away with the
   content it is meant to be driving. */
.crail__rail { position: relative; }

/* Snapping lands a card at the track's own padding rather than hard against
   the clipped edge, so an arrow press finishes on a whole card every time.
   THE SCROLLBAR'S ROOM IS HELD WHETHER OR NOT IT IS THERE. On a platform with
   classic scrollbars the bar is ~15px of layout that appears the moment the
   track overflows - which is the moment site.js fills it, which is after the
   page has been painted, which is a 15px shift of everything below the rail.
   scrollbar-gutter: stable is the declaration for exactly this and Chrome
   honours it only on the inline axis, so it does nothing for a bar along the
   bottom - measured. `scroll` instead of the .scroll-x utility's `auto` is what
   is left: the groove is drawn from the first frame, empty or full, and the box
   stops depending on its content. On a platform with overlay scrollbars - every
   phone this rail was designed for - there is no groove either way and this
   changes nothing at all. */
.crail__panel { overflow-x: scroll; scroll-padding-inline: var(--space-1); }

/* THE ARROWS ARE BUILT BY THE SCRIPT, because the whole rail is: the section
   ships empty and hidden (see components/continue_rail.html) and a visitor with
   no history or no JavaScript never sees any of it. They are revealed only when
   the track actually overflows - an arrow that scrolls nothing is a control
   that lies - and they FADE in rather than appearing, which is the difference
   between a control that arrived and one that was always there and you missed
   it. No fill mode: the moment the fade is over the arrow is back on its own
   opacity, which is what lets [disabled] below still mean something. */
.crail__arrow {
  align-items: center;
  animation: ix-fade-in var(--ix-in) var(--ix-ease) backwards;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-pill);
  box-shadow: var(--shadow-2);
  color: var(--text-2);
  cursor: pointer;
  display: inline-flex;
  height: 36px;
  justify-content: center;
  position: absolute;
  /* On the artwork, not on the title under it - the cards are a cover with two
     lines of text below, so the middle of the CARD is well below the middle of
     the picture the arrow should be sitting on. */
  top: 38%;
  transform: translateY(-50%);
  transition: background var(--ix-fast) ease-out, border-color var(--ix-fast) ease-out,
              color var(--ix-fast) ease-out, opacity var(--ix-fast) ease-out;
  width: 36px;
  z-index: 3;
}
.crail__arrow--prev { left: var(--space-1); }
.crail__arrow--next { right: var(--space-1); }
.crail__arrow:hover:not([disabled]) {
  background: var(--brand-soft);
  border-color: var(--brand);
  color: var(--brand-strong);
}
/* The press keeps the centring translate, so the button moves 1px DOWN from
   where it sits rather than jumping to the top of the rail. */
.crail__arrow:active:not([disabled]) { transform: translateY(calc(-50% + var(--ix-press-y))); }
/* At an end of the scroll: dimmed, not removed, so the rail does not reflow
   under the pointer that is using it. */
.crail__arrow[disabled] {
  box-shadow: var(--shadow-1);
  color: var(--text-muted);
  cursor: default;
  opacity: .45;
}
.crail__svg { height: 18px; pointer-events: none; width: 18px; }

/* The track carries the inset, not the scroller: a card that lifts 1px under
   the pointer is clipped by the overflow box without it, and so is the focus
   ring on the first card. */
.crail__track {
  display: flex;
  gap: var(--space-3);
  padding: var(--space-1) var(--space-1) 0;
}
/* Pinned, not stretched. A row of flex items already came out the height of its
   tallest card; saying so in a number is what lets the empty band above reserve
   exactly the same box - and the number and the width it is derived from are the
   same term, so the two cannot drift apart at a width nobody tested. */
.crail__card { flex: 0 0 var(--crail-w); height: var(--crail-card); scroll-snap-align: start; }

.crail__link {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-panel);
  box-shadow: var(--shadow-1);
  color: var(--text);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  height: 100%;
  padding: var(--space-2);
  transition: border-color var(--ix-fast) ease-out, box-shadow var(--ix-fast) ease-out,
              transform var(--ix-fast) ease-out;
}
.crail__link:hover {
  border-color: var(--brand);
  box-shadow: var(--shadow-2);
  color: var(--text);
  text-decoration: none;
  transform: var(--lift);
}
/* After :hover, or the pointer's lift would win and the card would never
   answer the press it is being given. */
.crail__link:active { transform: var(--ix-press); }

.crail__cover {
  aspect-ratio: var(--cover-ratio);
  background: var(--surface-alt);
  border-radius: var(--r-card);
  display: block;
  object-fit: cover;
  width: 100%;
}
.crail__name {
  font-size: var(--fs-meta);
  font-weight: 650;
  /* Two lines, then clipped: a rail whose cards are different heights because
     one title wrapped is a rail that looks broken. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}
.crail__chapter {
  color: var(--link-chapter);
  font-size: var(--fs-meta);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.crail__foot {
  align-items: center;
  color: var(--text-muted);
  display: flex;
  font-size: var(--fs-meta);
  gap: var(--space-2);
  margin-top: auto;
}


/* ===================================================== METERS ON A CARD === */

/* The shared meter: a track and a fill, the fill being the same three-token
   gradient as the document's top rule. It is a GRAPHIC, which is what lets it
   be --brand and --accent at all. */
.meter {
  background: var(--surface-inset);
  border-radius: var(--r-pill);
  display: block;
  flex: 1 1 auto;
  height: 4px;
  overflow: hidden;
}
.meter__fill {
  background: linear-gradient(90deg, var(--brand), var(--accent));
  border-radius: var(--r-pill);
  display: block;
  height: 100%;
}

/* The card's own copy of it, painted over the FOOT OF THE COVER rather than
   appended under the card. Nothing in the grid moves when it appears, which is
   the whole point: these arrive after load, on a page the visitor is already
   reading, and a listing that reflows under the cursor loses their place
   (main.css rule 4, which is about hover but is the same promise).

   THE SCRIM IS THE TOKEN, NOT A HAND-ROLLED RAMP. This rule used to paint one
   `linear-gradient(to top, var(--scrim), transparent)` and put white text on it,
   and the text does not sit at the bottom of the strip - it is the FIRST child
   of a column that starts a --space-3 inset down from the top edge, which on a
   one-stop ramp is .15-.39 alpha. Over pale cover art that is 1.2-1.6:1, i.e.
   invisible. --grad-cover-scrim is the token built for exactly this: two layers
   compositing to .86 at the foot and out by 34%, measured 11.68:1 light and
   17.93:1 dark over white artwork - the worst case there is - while still
   leaving the cover a cover. background-IMAGE, so the two radius declarations
   below and the (absent) background-colour are untouched by a shorthand. */
.rprog {
  background-image: var(--grad-cover-scrim);
  border-bottom-left-radius: var(--r-card);
  border-bottom-right-radius: var(--r-card);
  bottom: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
  left: 0;
  padding: var(--space-3) var(--space-2) var(--space-2);
  pointer-events: none;
  position: absolute;
  right: 0;
  z-index: 2;
}
/* Ink on a scrim over artwork, so it is the badge ink and not a text token:
   what is behind it is a cover, and no theme controls that. */
.rprog__text {
  color: var(--badge-ink);
  font-size: var(--fs-meta);
  font-weight: 650;
  letter-spacing: var(--track-chip);
  line-height: 1.2;
  text-shadow: 0 1px 2px var(--scrim);
}
/* The track is mixed from the badge ink for the same reason the text above it
   is: this one sits on artwork, so it cannot be a surface token, and rule 1
   says a value that cannot be expressed in tokens is a missing token rather
   than an exception - color-mix expresses it. */
.rprog .meter {
  background: color-mix(in srgb, var(--badge-ink) 35%, transparent);
  height: 3px;
}


/* ========================================================== BREAKPOINTS === */

@media (max-width: 768px) {
  /* No keyboard to name a shortcut for, and the header row needs the width. */
  .search-kbd { display: none; }
  /* A finger scrolls the rail directly, so the arrows are dead weight here -
     and they would be sitting on top of the covers they exist to reveal. */
  .crail__arrow { display: none; }
  .cmdk__panel { margin-top: var(--space-4); max-height: 86vh; }
  .cmdk__foot { display: none; }
  .toasts {
    bottom: var(--space-3);
    left: var(--space-3);
    max-width: none;
    right: var(--space-3);
    width: auto;
  }
}

/* THE 479px BLOCK IS GONE AND MUST NOT COME BACK. It held the rail's narrow card
   and a second value for the reservation token, which is two numbers one
   breakpoint apart that had to agree - and the CLS fix depends on them agreeing
   at every width, not at two. --crail-w is a clamp and --crail-card is a calc off
   it; both are already correct on a 320px phone, on a 1440px monitor and at
   every zoom step in between. */


/* ================================================================ MOTION === */

/* main.css answers prefers-reduced-motion once, for every transition and
   animation on the page, with !important - so everything above is already a
   single frame here and none of it needs restating. What that block CANNOT
   reach is a transform that is not a duration: the 1px press travel would still
   move, instantly, which is a jump rather than a transition. Taking the token
   out is the whole answer, exactly as main.css does with --lift.
   The sliding indicator is left alone deliberately: with the transition clamped
   it simply appears in its new seat, which is the correct still frame of it. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --ix-press: none;
    --ix-press-y: 0px;
  }
}
