/* interactive.css -- The four things a reader operates rather than reads: the guess panel and its mask, the shuffle control, and the pinned scene with its stepped text and progress rail.
 *
 * Part 8 of 9. These files were one style.css and are still one
 * stylesheet: equal-specificity rules resolve by document order, so the
 * <link> tags in every page must stay in this order and nothing may move
 * between files without checking what it lands after.
 *   tokens.css -> layout.css -> type.css -> prose.css -> charts.css -> sources.css -> paper.css -> interactive.css -> home.css
 *
 * See site/README.md for the rules that govern all of them. */

/* --- The guess ------------------------------------------------------------- */

/* One panel, on one section, on one page. See guess.js for why it is rare on
 * purpose; this file only has to make it look like a question rather than a
 * form. Reads as a raised card in the section's own accent, which is the same
 * treatment the callouts get -- it is an aside in the argument, not a control
 * panel bolted to the side of it. */
.guess {
  margin: 0 0 var(--space-5);
  padding: var(--space-4);
  border: 1px solid color-mix(in oklab, var(--accent) calc(22% * var(--tint-strength)), var(--tint-base));
  /* Rounded like the callouts: same treatment, same reason -- content, not chrome. */
  border-radius: 0.5rem;
  background: color-mix(in oklab, var(--accent) calc(7% * var(--tint-strength)), var(--tint-base));
}
.guess[hidden] { display: none; }

.guess-ask {
  margin: 0 0 var(--space-3);
  font-size: var(--size-3);
  color: var(--text-primary);
}

/* The reader's own number, set at the size the answer will be. Seeing their
 * guess in the same type as the finding is most of what makes the comparison
 * land -- a guess in small print beside a headline in large print has already
 * conceded the argument before the answer arrives. */
.guess-row {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin-bottom: var(--space-1);
}
.guess-value {
  font: 700 var(--size-5)/var(--lh-tight) var(--font-display);
  letter-spacing: -0.03em;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  /* Fixed width for four digits, so the panel does not twitch sideways as the
   * number grows and shrinks under the reader's own thumb. */
  min-width: 3.2ch;
  text-align: right;
}
.guess-unit {
  font: 400 var(--size-3)/var(--lh-tight) var(--font-display);
  color: var(--text-secondary);
}

.guess-slider {
  width: 100%;
  margin: var(--space-1) 0 var(--space-1);
  accent-color: var(--accent);
  cursor: grab;
}
.guess-slider:active { cursor: grabbing; }
.guess-slider:disabled { cursor: default; opacity: 0.55; }
.guess-slider:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }

/* Three landmarks, not a full ruler. The scale is logarithmic and the middle
 * label is what says so -- 20 sitting at the midpoint between 1 and 500 is the
 * only warning a reader gets that the travel is not linear, and without it the
 * slider quietly lies about where their guess is going. */
.guess-scale {
  display: flex;
  justify-content: space-between;
  font: 400 var(--size-1)/var(--lh-tight) var(--font-display);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

.guess-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-4);
}
.guess-submit, .guess-skip {
  font: 500 var(--size-2)/var(--lh-body) var(--font-display);
  border-radius: var(--radius);
  padding: var(--space-1) var(--space-3);
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
}
.guess-submit {
  color: var(--surface);
  background: var(--accent);
  border: 1px solid var(--accent);
}
.guess-submit:hover:not(:disabled) { filter: brightness(1.08); }
/* Visibly inert rather than merely unresponsive: the button is disabled until
 * the slider has been moved, and a reader who presses a live-looking control
 * and gets nothing concludes the page is broken. */
.guess-submit:disabled { opacity: 0.4; cursor: default; }
.guess-skip {
  color: var(--text-muted);
  background: none;
  border: 1px solid transparent;
}
.guess-skip:hover { color: var(--text-primary); border-color: var(--border); }
.guess-submit:focus-visible,
.guess-skip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.guess-verdict {
  margin: var(--space-3) 0 0;
  font-size: var(--size-2);
  color: var(--text-primary);
}
.guess-verdict:empty { display: none; }

/* Answered: the controls have said what they had to say and stop competing with
 * the verdict for attention. */
.guess.is-answered .guess-slider,
.guess.is-answered .guess-scale { opacity: 0.45; }
/* Skipped: the whole panel steps back. The reader declined; leaving a bright
 * card sitting above the answer they asked for is nagging. */
.guess.is-skipped { opacity: 0.5; }

/* --- The mask -------------------------------------------------------------- */

/* Blurred, not hidden. The number is in the DOM, in the accessibility tree, and
 * findable by find-in-page -- a screen reader announces it as it always did.
 * This is a courtesy to the eye of a reader who wants to guess first, and it
 * must never become a lock on the content.
 *
 * `user-select: none` alongside, so a reader dragging across the heading does
 * not lift the answer out of a blur they had not chosen to remove yet. Applied
 * only to the ratio itself, never to the words around it: the heading still has
 * to read as a heading while it is masked. */
.is-masked .guess-mask,
/* The chart's own hero number is the same answer in larger type. It sits below
 * the panel and is normally out of sight while the question is being asked, but
 * "normally" is doing too much work on a tall monitor -- so it is masked by the
 * same rule rather than by the accident of where the fold happens to fall. */
.is-masked .hero-stat .ratio {
  filter: blur(0.42em);
  user-select: none;
  transition: filter 0.45s ease;
}
.guess-mask, .hero-stat .ratio { transition: filter 0.45s ease; }

/* The one place the reveal genuinely is motion rather than a state change, so
 * it is the one place reduced motion needs an answer: the number appears
 * sharp instead of coming into focus. */
@media (prefers-reduced-motion: reduce) {
    .guess-mask, .is-masked .guess-mask,
  .hero-stat .ratio, .is-masked .hero-stat .ratio { transition: none; }
}

/* --- The shuffle control --------------------------------------------------- */

/* The one chart on the site the reader operates rather than reads. It sits
 * inside the diversity section and inherits that section's accent, so pressing
 * it does not feel like leaving the argument to use a widget. */
.shuffle {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-3);
  margin-top: var(--space-3);
}

/* Deliberately the most inviting control on the page -- filled rather than
 * outlined, unlike the figure tools beside it. Those are for the reader who
 * wants to take the chart away; this one is part of the argument, and a reader
 * who never presses it has missed the section's point. */
.shuffle-button {
  font: 500 var(--size-2)/var(--lh-body) var(--font-display);
  color: var(--surface);
  background: var(--accent);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  padding: var(--space-1) var(--space-3);
  cursor: pointer;
  flex: none;
  transition: filter 0.15s;
}
.shuffle-button:hover { filter: brightness(1.08); }
.shuffle-button:active { filter: brightness(0.94); }
.shuffle-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* A live region, so the count is announced as it changes rather than silently
 * updating for anyone not watching the pixels. */
.shuffle-readout {
  margin: 0;
  font: 400 var(--size-2)/var(--lh-body) var(--font-display);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* The reader's own draws, on the axis under the distribution they came from.
 * Slightly translucent so a stack of them at the mode reads as a stack rather
 * than as one dot. */
.shuffle-dot { opacity: 0.85; }

/* On paper the button does nothing, so it goes; the histogram and the two rules
 * carry the whole argument without it, which is the test of whether the
 * interaction was decoration. It passes. */
@media print {
  .shuffle-button { display: none; }
}

/* --- The pinned figure ----------------------------------------------------- */

/* One chart on the site is pinned while the paragraphs arguing from it are
 * stepped through. The ordination earns it because the section makes three
 * claims about the same picture in a row -- the size of the effect, the evenness
 * of the spread, and the conclusion -- and a reader checking the third has
 * scrolled the evidence for it off the screen.
 *
 * Nothing else gets this. A pinned figure costs the reader a permanent slice of
 * their window, and it is only worth it where the prose genuinely keeps pointing
 * at the same object. Every other chart on the page is discussed once and then
 * left behind, which is what ordinary flow is for.
 *
 * The default here is ordinary flow: figure, then three paragraphs, stacked and
 * scrolling like any other section. `is-stepped` is added by initScrolly() only
 * once it has a chart with stages to drive, so a reader with no JavaScript, or a
 * browser where the observer never fires, gets a plain section rather than three
 * paragraphs piled on top of each other -- which is what the overlapping layout
 * below would look like with nothing to choose between them.
 */
.scrolly { position: relative; }

.scrolly-figure {
  position: sticky;
  /* Clear of the bar, with a little air. The bar is opaque, so a figure that
   * stuck flush against it would have its caption cut in half. */
  top: calc(var(--topbar-h) + 0.75rem);
  /* Above the steps, below the bar, the section map and the tooltip -- the same
   * order everything else on the page observes. */
  z-index: 3;
  /* The figure must be opaque -- prose scrolls beneath it while it is pinned --
   * but a bare white rectangle over the backdrop photograph read as a fault,
   * not a decision. So it is dressed as what it is: a card, in the same
   * language as the page cards on the front page. */
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  box-shadow: var(--shadow-soft);
  padding: var(--space-3) var(--space-3) var(--space-2);
  margin-bottom: var(--space-4);
}

/* --- The stepped arrangement ---------------------------------------------- */

/* The text does not move.
 *
 * This is the whole point of the layout and it took two attempts to get right.
 * The obvious build -- pinned figure, paragraphs scrolling past underneath --
 * has the prose slide up and disappear behind an opaque chart, which in a single
 * column reads as text being eaten rather than as a considered transition.
 * Pinning each paragraph in turn fixed the disappearing but left them sliding
 * into place, so the text was still in motion while the reader was trying to
 * read it.
 *
 * So the paragraphs are stacked in one grid cell, pinned in a slot directly
 * under the chart, and only the current one is shown. Scrolling changes which
 * paragraph is there and which stage the chart is in; it moves neither. The
 * reader's eye can stay in exactly one place for the length of the argument,
 * which is the arrangement's only justification for existing.
 */
.scrolly-steps.is-stepped {
  position: sticky;
  /* Directly under the figure. The offset depends on the chart's rendered
   * height, so initScrolly() measures it and publishes it here; there is no CSS
   * expression for "the bottom edge of my previous sibling". */
  top: var(--scrolly-step-top, 0px);
  /* All steps in one cell, so the slot is as tall as the tallest of them and
   * never changes height as they swap -- a slot that resized would move the
   * page under the reader, which is the thing this layout exists to prevent. */
  display: grid;
}
.scrolly-steps.is-stepped > * {
  grid-area: 1 / 1;
  margin: 0;
  opacity: 0;
  /* Not `visibility` or `display`: the paragraphs that are not showing are still
   * read, in order, by a screen reader, and still found by find-in-page. This is
   * a visual arrangement for a reader who is scrolling, and it must not become a
   * way of hiding two thirds of an argument from anybody who is not. */
}

/* The fade is switched on a frame after the layout is, and only then.
 *
 * Until `is-stepped` is applied the three paragraphs are ordinary prose at full
 * opacity, and the payload arrives late enough that the page has already painted
 * them that way. Applying the class with the transition already live therefore
 * animates two of them from 1 to 0 -- a quarter-second on every load with all
 * three paragraphs stacked on top of each other, which is unreadable and looks
 * like a fault. Deferring by a frame makes the first application instant and
 * every later one a fade. */
.scrolly-steps.is-stepped.is-live > * { transition: opacity 0.25s ease; }
.scrolly-steps.is-stepped > .is-current { opacity: 1; }

/* --- The progress rail ----------------------------------------------------- */

/* Down the left of the pinned scene, in the gutter between the section map and
 * the prose column.
 *
 * It exists because a pinned scene stops the page from moving, and a page that
 * has stopped moving is indistinguishable from a page that has stuck. The rail
 * is the only thing on screen during the sequence that answers "am I still
 * getting somewhere" -- so it fills continuously with scroll rather than
 * stepping with the paragraphs, which would leave it motionless for most of
 * each step and reintroduce the very doubt it is there to remove.
 *
 * Only above 1280px, and that number is arithmetic rather than taste. The gutter
 * is the distance between the section map's right edge and the column's left
 * edge: the map sits at `max(1.5rem, 50% - measure/2 - 12.5rem)` and is 10.5rem
 * wide, so once the first branch of that max stops winning there is a clear 2rem
 * of gutter, and below roughly 1250px the map's box already reaches the column.
 * At 1280px the gap is about 34px against the 24px this needs. Below that the
 * rail is simply not drawn -- there is nowhere to put it that is not on top of
 * something else, and a progress indicator overlapping the navigation is worse
 * than none.
 */
.scrolly-rail { display: none; }

@media (min-width: 1280px) {
  .scrolly-figure .scrolly-rail {
    display: block;
    position: absolute;
    /* Clear of the column's own left edge, so the figure keeps the alignment
     * every other block on the page shares. The rail hangs outside the measure;
     * it is an instrument, not content. */
    left: -1.4rem;
    top: 0;
    width: 3px;
    /* Chart plus the slot below it, measured by initScrolly() -- the rail is a
     * picture of progress through the whole scene, not through the chart. */
    height: var(--scrolly-rail-h, 100%);
    border-radius: var(--radius);
    background: var(--grid);
  }

  /* The filled part. Driven straight off the same progress fraction that
   * chooses the step, so the two can never disagree about how far along the
   * reader is. */
  .scrolly-rail__fill {
    position: absolute;
    inset: 0 0 auto 0;
    height: calc(var(--scrolly-progress, 0) * 100%);
    border-radius: var(--radius);
    background: var(--accent);
  }

  /* One dot per step, centerd in its own band. This is the part that says how
   * many of these there are -- a bare fill answers "am I moving" but not "how
   * much of this is left", and a reader who cannot see the end of a pinned
   * sequence tends to scroll hard through it. */
  .scrolly-rail__dot {
    position: absolute;
    left: 50%;
    width: 9px;
    height: 9px;
    margin: -4.5px 0 0 -4.5px;
    border-radius: 50%;
    background: var(--surface);
    box-shadow: 0 0 0 2px var(--grid);
    transition: background 0.25s, box-shadow 0.25s, transform 0.25s;
  }
  .scrolly-rail__dot.is-done {
    background: var(--accent);
    box-shadow: 0 0 0 2px var(--accent);
  }
  /* The current one is larger as well as colored, so it is not color alone
   * carrying which step you are on -- the same rule the charts follow. */
  .scrolly-rail__dot.is-current {
    background: var(--accent);
    box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) calc(30% * var(--tint-strength)), var(--tint-base));
    transform: scale(1.35);
  }
}

/* Nothing to be progressing through on paper. */
@media print {
  .scrolly-rail { display: none !important; }
}

/* The chart's annotation layers fade rather than switch.
 *
 * An instantaneous change is the one thing a reader who is scrolling will not
 * attribute to their own scrolling -- it has to be seen happening, or the chart
 * reads as an animation running on its own schedule beside the text rather than
 * as something the text is driving. Slower than the paragraph swap, so the
 * picture is still settling as the new sentence lands on it. */
.stage-layer { transition: opacity 0.35s ease; }

/* The scroll distance the stepping happens over is set by initScrolly(), as a
 * `min-height` in pixels, and it has to be that rather than a padding.
 *
 * Padding was the first attempt and it fails in a way worth recording. A sticky
 * element is held inside its containing block, and a containing block is the
 * ancestor's *content* box -- padding is outside it. So `padding-bottom: 165vh`
 * made the scene look right, gave it the scroll length it needed, and left the
 * chart with about a hundred pixels of sticky range: it pinned for a moment and
 * then slid away with the page. `min-height` grows the content box, which is
 * the box sticky actually measures against.
 *
 * In pixels rather than viewport units because it is the sum of two measured
 * heights and a viewport fraction, and only script knows the first two. */

/* Below this the arrangement stops paying. A pinned chart is roughly 60% of a
 * short window, which leaves no room for the slot beneath it and turns a
 * considered layout into a peephole. Everything falls back to ordinary flow, and
 * the stages still change as the reader scrolls -- they simply happen to a chart
 * that is moving with them. */
@media (max-height: 720px), (max-width: 640px) {
  .scrolly-figure { position: static; }
  /* The min-height is an inline style and would win over anything written here,
   * so it is initScrolly() that removes it -- it stands the whole arrangement
   * down whenever the figure is not sticky, which is exactly these cases. */
  .scrolly-steps.is-stepped { position: static; display: block; }
  .scrolly-steps.is-stepped > * { opacity: 1; margin-bottom: var(--space-4); }
}

/* Paper has no scroll position, so there is no current step and no stage. Every
 * paragraph prints, in order, under a chart that is where it was written --
 * which is the plain section this layout was built on top of. charts.js sets
 * stage 0 on `beforeprint` so the ordination prints unannotated. */
@media print {
  .scrolly-figure { position: static; }
  /* The min-height is an inline style and would win over anything written here,
   * so it is initScrolly() that removes it -- it stands the whole arrangement
   * down whenever the figure is not sticky, which is exactly these cases. */
  .scrolly-steps.is-stepped { position: static; display: block; }
  .scrolly-steps.is-stepped > * { opacity: 1; margin-bottom: var(--space-3); }
}


/* --- The robotics page: a season on a spine, prints on a table ------------

   Two treatments for two kinds of photograph. The competition photos are a
   sequence with an order that means something, so they hang off a vertical
   timeline: a spine, a stop per photo, an oversized ghost index, and the
   event named in the bar's own small-caps voice. The workshop photos are not
   a sequence at all, so they sit like prints left on a table, slightly
   askew, and straighten when picked up.

   Both kinds open in the lightbox below (js/gallery.js). Everything here
   reads its colors from tokens, its faces from the two families, and its
   reveals from scroll-driven animations -- the same @supports the reading
   progress bar uses, so browsers without them simply see everything at
   once, which is a page, not a bug. */

/* --- The mosaic -----------------------------------------------------------
 *
 * A gallery that fills the page: one dense grid in the breakout measure,
 * tiles spanning by size class, no numbering and no order beyond the
 * markup's own. It replaced a numbered timeline on 2026-08-31, at the
 * author's request: a wall of photographs, not a sequence.
 *
 * The tiles crop in the browser (object-fit), which the prose photographs
 * deliberately do not -- see prose.css. The difference is the job: a tile
 * is a preview whose whole frame is one click away in the lightbox, and
 * this wall is still growing, so cropping at build time would mean
 * recutting files every time a new photograph rearranges the layout. */
.mosaic {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  grid-auto-rows: 4.6rem;
  grid-auto-flow: dense;
  gap: var(--space-2);
  margin: var(--space-5) 0 var(--space-6);
}
.mosaic__item {
  position: relative;
  margin: 0;
  grid-column: span 2;
  grid-row: span 3;
  /* The tile clips itself as well as trusting the frame inside it, so the
   * rounding survives no matter what the frame is doing. */
  border-radius: var(--radius-photo);
  overflow: hidden;
}
.mosaic__item--feature { grid-column: span 4; grid-row: span 6; }
.mosaic__item--wide { grid-column: span 4; grid-row: span 3; }
.mosaic__item--tall { grid-row: span 6; }
.mosaic__item .photo__open,
.mosaic__item .photo__frame { height: 100%; }
.mosaic__item .photo__frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The label sits on the photograph, so its scrim and ink are photo-relative
 * tokens rather than theme ones -- see tokens.css. pointer-events off, so
 * the whole tile stays one click. */
.mosaic__label {
  position: absolute;
  inset: auto 0 0 0;
  z-index: 1;
  pointer-events: none;
  padding: var(--space-5) var(--space-3) var(--space-2);
  border-radius: 0 0 var(--radius-photo) var(--radius-photo);
  background: linear-gradient(180deg, transparent, var(--label-scrim));
  color: var(--label-ink);
  font: 600 var(--size-1)/var(--lh-tight) var(--font-display);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
/* A label in two registers, for prints that have both an event and a
 * result to state (2026-09-01, "some of them are a tiny bit lengthy...
 * I want to emphasize it all"): <small> is the event, a step smaller and
 * dimmer -- on robotics it shares the figure-number's line -- and the
 * text after its <br> is the result, full ink beneath it. Labels without
 * a <small> are untouched. */
.mosaic__label small {
  font-size: 0.82em;
  letter-spacing: 0.08em;
  /* 0.75 at first; raised 2026-09-01 ("a bit more visible for all
   * photos") -- the size still ranks the two lines without the dimming. */
  opacity: 0.88;
}

/* A seat held for photographs that are still coming. Decorative, so pages
 * mark it aria-hidden; it disappears entirely on a phone, where an empty
 * frame in a single column is just a gap with a border. */
.mosaic__soon {
  display: grid;
  place-content: center;
  text-align: center;
  border: 1px dashed var(--border);
  border-radius: var(--radius-photo);
  background: var(--surface-raised);
  color: var(--text-secondary);
  font: 600 var(--size-1)/var(--lh-snug) var(--font-display);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* The clickable photograph. A real button, so the keyboard gets it. */
.photo__open {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: zoom-in;
  border-radius: var(--radius-photo);
}
.photo__open:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.photo__open .photo__frame {
  border-radius: var(--radius-photo);
  transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.photo__open:hover .photo__frame,
.photo__open:focus-visible .photo__frame {
  box-shadow: var(--shadow-lift);
}
/* The frame-level lift, only where the tilt script is not serving it. */
.photo__open:not(.is-tilty):hover .photo__frame,
.photo__open:not(.is-tilty):focus-visible .photo__frame {
  transform: translateY(-3px);
}
.photo__open.is-tilty .photo__frame { transition: box-shadow 0.25s ease; }
/* Inside a mosaic the whole tile is the moving object: photograph, scrim
 * and label lift together, so nothing inside the tile shifts against the
 * rest of it. The inner frame/img hover transforms are disabled here for
 * the same reason (the audit's ~-sibling patch tried to move the label in
 * step and still let the img scale under a fixed scrim; whole-tile motion
 * is the honest fix). */
.mosaic__item { transition: transform 0.25s ease; }
.mosaic__item:has(.photo__open:hover),
.mosaic__item:has(.photo__open:focus-visible) { transform: translateY(-3px); z-index: 2; }
/* Where the tilt script runs it marks the button .is-tilty and drives the
 * whole tile's transform itself, damped, frame by frame -- easing it here
 * as well made every move rubbery and the mouse leaving a snap. The
 * stylesheet lift stands down for those tiles (the script lifts too, and
 * settles back along the same curve); its z-index bump stays, so a moving
 * tile still draws over its neighbors. */
.mosaic__item:has(.photo__open.is-tilty) { transition: none; }
.mosaic__item:has(.photo__open.is-tilty):has(.photo__open:hover),
.mosaic__item:has(.photo__open.is-tilty):has(.photo__open:focus-visible) { transform: none; }
.mosaic .photo__open:hover .photo__frame,
.mosaic .photo__open:focus-visible .photo__frame { transform: none; }
.mosaic .photo__open:hover img { transform: none; }
/* Same selection guard as the bar: light label ink on the UA's pale
 * light-mode highlight was near-invisible. */
.mosaic__label::selection,
.mosaic__label ::selection { background: var(--selection-on-dark); color: var(--label-ink); }
.photo__open img { transition: transform 0.6s cubic-bezier(0.22, 0.68, 0.3, 1); }
.photo__open:hover img { transform: scale(1.025); }
@media (prefers-reduced-motion: reduce) {
  .photo__open .photo__frame, .photo__open img, .mosaic__item { transition: none; }
  .photo__open:hover img { transform: none; }
}

/* Each tile rises as it enters. Scroll-driven, so nothing runs per frame;
 * without support, or with motion reduced, every tile is simply there. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .mosaic__item {
      animation: mosaic-rise linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 35%;
    }
  }
}
@keyframes mosaic-rise {
  from { opacity: 0; transform: translateY(1rem); }
}

/* One column on a phone: the mosaic's spans stop meaning anything at two
 * columns' width, so each photograph simply takes the line at 4:3. */
@media (max-width: 700px) {
  .mosaic { display: block; }
  .mosaic__item { margin: 0 0 var(--space-2); aspect-ratio: 4 / 3; }
  .mosaic__soon { display: none; }
}

/* The lightbox. One native dialog, built by js/gallery.js, shared by every
 * photograph on the page. The backdrop dims by filtering what is behind it
 * rather than naming a darkness of its own, so both palettes keep their
 * scrim. */
.lightbox {
  border: 0;
  padding: 0;
  background: none;
  max-width: none;
  max-height: none;
  overflow: visible;
}
.lightbox::backdrop {
  background: var(--scrim, rgb(0 0 0 / 0.4));
  backdrop-filter: blur(5px) brightness(0.55);
}
.lightbox figure { margin: 0; }
.lightbox img {
  display: block;
  margin: 0 auto;
  max-width: min(92vw, 72rem);
  max-height: 78vh;
  width: auto;
  height: auto;
  border-radius: var(--radius-photo);
  box-shadow: var(--shadow-lift);
}
.lightbox figcaption {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-4);
  max-width: min(92vw, 72rem);
  margin: var(--space-3) auto 0;
  padding: var(--space-2) var(--space-3);
  border-radius: 0.5rem;
  background: var(--surface);
  box-shadow: var(--shadow-soft);
  font: italic 400 var(--size-2)/var(--lh-snug) var(--font-body);
  color: var(--text-primary);
}
.lightbox__btn {
  position: fixed;
  z-index: 2;
  width: 2.6rem;
  height: 2.6rem;
  border: 0;
  border-radius: var(--radius-round);
  background: var(--surface);
  color: var(--text-primary);
  font: 700 1.05rem/1 var(--font-display);
  cursor: pointer;
  box-shadow: var(--shadow-soft);
}
.lightbox__btn:hover { box-shadow: var(--shadow-lift); }
.lightbox__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.lightbox__close { right: max(0.8rem, 3vw); top: 0.9rem; }

/* The timeline and the prints step out of the prose measure: photographs at
 * column width were living on a page sized for sentences. Centered escape,
 * clamped to the viewport minus the body padding. */
.breakout {
  position: relative;
  left: 50%;
  transform: translateX(-50%);
  width: min(62rem, calc(100vw - 2 * var(--space-4)));
}

/* --- The clips (newspapers page) -------------------------------------------
 *
 * One card per published piece. The headline is the link and it leads OFF
 * the site, which the arrow says plainly; the deck below it is written for
 * this page rather than quoted from the article. The rule down the left is
 * the same gesture as the study's caveat ledger: a piece filed, marked. */
.clip {
  margin: var(--space-5) 0;
  padding: var(--space-2) 0 var(--space-2) var(--space-4);
  border-left: 3px solid var(--accent);
}
/* The first clip sits close under the lede: the section is already doing
 * the separating, and the stacked margins read as a hole (2026-08-31, at
 * the author's request). */
.clip:first-child { margin-top: 0; }
.clip__eyebrow {
  margin: 0 0 var(--space-2);
  font: 600 var(--size-1)/var(--lh-tight) var(--font-display);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-secondary);
}
/* The Post's name is its actual wordmark (2026-09-09, at the author's
 * request; it replaced a blackletter approximation the same day): the
 * public-domain masthead SVG from Wikimedia, drawn as a mask filled with
 * the page's own ink so it reads in both palettes. The inner span is the
 * name in plain text -- what a browser without mask support shows, and
 * what a copy-paste of the eyebrow yields; role="img" on the wrapper
 * keeps the name announced when the mask hides it. */
.clip__eyebrow .clip__paper--post {
  font-weight: 600;
  color: var(--text-primary);
}
@supports (mask: url("")) or (-webkit-mask: url("")) {
  .clip__eyebrow .clip__paper--post {
    display: inline-block;
    /* Taller than the line on purpose: the wordmark is wide and short, and
     * at text height it shrinks to a whisper. This is a nameplate. */
    height: 1.8em;
    aspect-ratio: 474.061 / 71.725;
    vertical-align: -0.52em;
    background: currentColor;
    -webkit-mask: url(../media/wapo-masthead.svg) no-repeat center / contain;
    mask: url(../media/wapo-masthead.svg) no-repeat center / contain;
  }
  .clip__eyebrow .clip__paper--post > span { display: none; }
}
/* The Examiner's, quieter (2026-09-09, "different font, less loud"): its
 * nameplate is spaced capitals in a high-contrast serif, so the eyebrow's
 * own uppercase stays and only the face, a small step of size and the
 * ink change. */
.clip__eyebrow .clip__paper--examiner {
  font: 600 1.18em/1 'Playfair Display', 'Lora', serif;
  letter-spacing: 0.1em;
  color: var(--text-primary);
}
.clip__head {
  margin: 0 0 var(--space-2);
  font-size: var(--size-4);
  line-height: var(--lh-snug);
}
.clip__head a {
  color: var(--text-primary);
  text-decoration-color: color-mix(in oklab, var(--accent) 45%, transparent);
}
.clip__head a::after {
  /* The marker the bar uses for links that leave the site. */
  content: "\2197";
  font-size: 0.7em;
  margin-left: 0.3em;
  vertical-align: 0.3em;
  opacity: 0.65;
}
.clip__deck {
  margin: 0;
  max-width: 38rem;
  font: 400 var(--size-3)/var(--lh-body) var(--font-body);
  color: var(--text-secondary);
}
