/* ==========================================================================
   app.css — page layout for the OSLER application views.
   --------------------------------------------------------------------------
   NOT part of osler-theme. The theme owns how a button looks; this file owns
   where the button sits. Same split the style guide demonstrates with its
   .sg-* classes.

   It reads tokens rather than inventing values, because there is no reason
   not to — but it is free to contain layout literals (grid templates, column
   widths) that would be wrong in the theme.

   Prefix is .app-, never .o-. The .o- namespace belongs to the theme, and a
   layout class wearing it would look like something check_theme.py polices.
   ========================================================================== */

/* ---- generic page furniture --------------------------------------------- */

.app-centred {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-5);
}

.app-page {
  max-width: 900px;
  margin: 0 auto;
  padding: var(--s-7) var(--s-5) var(--s-10);
}

.app-page__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--s-4);
  flex-wrap: wrap;
  margin-bottom: var(--s-6);
}

/* ==========================================================================
   DISPLAY — the room screen.
   Full-viewport, no chrome of ours. The slide surface is a placeholder in S1;
   S2 replaces its contents, not its frame.
   ========================================================================== */

.app-display {
  height: 100dvh;
  display: flex;
  flex-direction: column;
  /* The room machine is a laptop nobody touches mid-lecture. Hide the pointer
     once it has been still for a moment — a cursor parked over a slide is the
     single most common "why is that arrow on the screen" moment. */
  cursor: none;
}

.app-display__slide {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-6);
  padding: var(--s-9);
  text-align: center;
}

.app-display__num {
  font-family: var(--font-mono);
  font-size: var(--fs-lg);
  color: var(--ink-muted);
  letter-spacing: var(--tracking-caps);
}

.app-display__title {
  font-size: var(--fs-4xl);
  max-width: 18ch;
  line-height: var(--lh-tight);
}

/* S1 placeholder marker. Deliberately visible: nobody should mistake this
   build for the real deck renderer. */
.app-display__placeholder {
  font-size: var(--fs-sm);
  color: var(--ink-faint);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}

/* The join takeover, driven by the qr_modal state column. */
.app-display__qr {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-7);
  background: var(--surface-page);
}

.app-display__joinurl {
  font-family: var(--font-mono);
  font-size: var(--fs-lg);
  color: var(--ink-muted);
}

/* ==========================================================================
   REMOTE — the presenter's phone.
   One-handed, in the dark, while talking. Controls are big and pinned to the
   bottom where a thumb actually reaches.
   ========================================================================== */

.app-remote {
  min-height: 100dvh;
  display: grid;
  /* One explicit column the width of the phone: an implicit auto track
     sizes to its items' content contribution and overflowed to 445px on a
     390px phone once the CT thumbnail's aspect-ratio height-derived width
     was the widest thing in the column (round 15). */
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: auto 1fr auto;
  gap: var(--s-3);
  padding-bottom: calc(var(--s-3) + var(--safe-b));
}

/* ONE SCREEN, NO SCROLLING (S6)
   A hard constraint rather than a preference: a presenter is looking at the
   room, not at the phone, and anything below the fold may as well not exist.
   The body may shrink but never scroll, which is why the generators moved
   behind modals and the next-slide thumbnail moved into one. Asserted at
   iPhone dimensions in tests/remote.spec.js. */
.app-remote {
  height: 100dvh;
  min-height: 0;
  overflow: hidden;
}

.app-remote__body {
  min-height: 0;
  overflow: hidden;
  padding: 0 var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

/* THE SLACK HAS AN OWNER. The one-screen rule above says the body never
   scrolls — but it never said which element gives way when the column runs
   out of height, so flexbox squeezed everything equally and overflow
   clipped whatever landed past the edge: the slide counter on the
   not-started view, the Back/Next padding under an iPhone's bottom chrome
   (round 6). Now: nothing shrinks except the thumbnail, which scales —
   remote.js measures the tile and fits the slide to whichever axis binds. */
.app-remote__body > * { flex-shrink: 0; }
.app-remote__body .app-remote__thumbs {
  flex-shrink: 1;
  /* Shrinkable, but never past recognisability: without a floor the
     radiology strip's arrival crushed the tile to 3px. The floor sits HERE,
     on the flex item that shrinks — a floor on the tile inside let the
     container squeeze past it and the tile painted over the counter. */
  min-height: min(5rem, 12dvh);
  /* The tile row must be allowed to shrink with the container — an
     implicit auto row never gives height back. */
  grid-auto-rows: minmax(0, 1fr);
}
.app-remote__thumb { min-height: 0; }
.app-remote__thumb .o-thumb {
  flex: 0 1 auto;
  min-height: 0;
  max-height: 100%;
}

.app-remote__now {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3);
}

.app-remote__counter {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-2xl);
  font-weight: var(--weight-bold);
  color: var(--ink-strong);
}

/* The control deck. Two big targets side by side; everything secondary is a
   row above them so a mis-tap never advances the slide. */
.app-remote__controls {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s-3);
  padding: 0 var(--s-4);
}

/* Secondary controls share the row evenly, however many there are — S3 adds a
   poll launcher here and should not need to come back and change a column
   count. */
.app-remote__secondary {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: var(--s-2);
  padding: 0 var(--s-4);
}

/* ==========================================================================
   ATTENDEE — a phone in a lecture theatre.
   Edge-to-edge, no app chrome of ours (brief §2 PWA stance).
   ========================================================================== */

.app-attendee {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  padding-top: var(--safe-t);
  padding-bottom: var(--safe-b);
}

.app-attendee__slide {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-4);
  padding: var(--s-6);
  text-align: center;
}

.app-attendee__num {
  /* No "Slide X of Y" anywhere on the attendee, either orientation —
     behaviour-contract ruling (round 9), superseding the round-8 keep-it
     judgement. The element stays in the DOM for the state machinery. */
  display: none;
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--ink-muted);
  letter-spacing: var(--tracking-caps);
}

.app-attendee__title {
  font-size: var(--fs-2xl);
  max-width: 20ch;
}

/* Back-paging controls. Present but quiet — self-paced review is an
   accommodation, not the main event, and it must not look like the way to
   drive the room. */
.app-attendee__pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-3);
  padding: var(--s-3);
  /* Above Reveal's slide-background layer: the deck's background div
     covers its whole flex box and, without a stacking context here, ate
     every tap on the pager buttons — visible, enabled, unclickable. */
  position: relative;
  z-index: var(--z-bar);
  /* Room for the return-to-live pill, which floats above this row. Without
     it the pill lands squarely on the forward button and an attendee who has
     paged back cannot page forward again — caught by the sync spec, and it
     would have been maddening on a real phone. */
  margin-bottom: calc(var(--touch-min) + var(--s-4));
}

/* The pill is a theme component and keeps its own look; where it sits is the
   app's business. Lift it clear of the pager row. */
.app-attendee .o-pill {
  bottom: calc(var(--s-4) + var(--safe-b));
}

/* Connection state. A Wi-Fi blip in a lecture theatre is normal and must not
   look like a failure — this is a whisper, not an alert. */
.app-conn {
  position: fixed;
  top: calc(var(--s-3) + var(--safe-t));
  left: 50%;
  transform: translateX(-50%);
  z-index: var(--z-toast);
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-1) var(--s-4);
  font-size: var(--fs-xs);
  color: var(--ink-muted);
  background: var(--surface-card);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-pill);
  box-shadow: var(--shadow-1);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-base) var(--ease);
}
.app-conn.is-visible { opacity: 1; }

/* ==========================================================================
   BLACKOUT — shared by display and attendee.
   ========================================================================== */

.app-blackout {
  position: fixed;
  inset: 0;
  z-index: var(--z-blackout);
  background: var(--blackout);
}

/* ==========================================================================
   Session list / lifecycle pages
   ========================================================================== */

.app-sessionbar {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  flex-wrap: wrap;
}

.app-joinbox {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-6);
}

/* ==========================================================================
   HOME / DECK MANAGER (S2)
   ========================================================================== */

.app-deckgrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: var(--s-5);
}

.app-footer {
  max-width: 900px;
  margin: 0 auto;
  padding: var(--s-7) var(--s-5) var(--s-9);
  display: flex;
  justify-content: space-between;
  border-top: var(--bw-1) solid var(--line);
}

/* ==========================================================================
   DECK EDITOR (S2)
   --------------------------------------------------------------------------
   Three panes: slides, the selected slide, and a live preview in display
   dress. Desktop-first without apology — nobody authors a ten-slide deck on a
   phone, and pretending otherwise would compromise the pane that matters.
   ========================================================================== */

.app-edit {
  display: grid;
  grid-template-columns: 240px minmax(0, 1fr) minmax(320px, 27vw);
  gap: var(--s-5);
  padding: var(--s-5);
  align-items: start;
  min-height: calc(100dvh - 64px);
}

@media (max-width: 1100px) {
  /* Preview drops below rather than shrinking into uselessness. */
  .app-edit { grid-template-columns: 200px minmax(0, 1fr); }
  .app-edit__preview { grid-column: 1 / -1; }
}
@media (max-width: 720px) {
  .app-edit { grid-template-columns: minmax(0, 1fr); }
}

.app-edit__title {
  width: min(28rem, 40vw);
  font-weight: var(--weight-medium);
}

.app-edit__flashes {
  max-width: 900px;
  margin: var(--s-4) auto 0;
  padding: 0 var(--s-5);
}

.app-edit__panehead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  flex-wrap: wrap;
  margin-bottom: var(--s-3);
}

/* ---- slide list ---------------------------------------------------------- */

.app-slidelist {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.app-slidelist__item {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-3);
  background: var(--surface-card);
  border: var(--bw-2) solid var(--line);
  border-radius: var(--r-md);
  cursor: pointer;
  min-height: var(--touch-min);
}
.app-slidelist__item.is-selected {
  border-color: var(--accent);
  background: var(--accent-soft);
}
.app-slidelist__item.is-dragging { opacity: 0.4; }

.app-slidelist__num {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--ink-muted);
  min-width: 1.5rem;
}
.app-slidelist__title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--fs-sm);
}
.app-slidelist__moves { display: flex; gap: var(--bw-2); }
.app-slidelist__moves .o-btn { min-height: var(--s-7); min-width: var(--s-7); padding: 0; }

/* ---- slide pane ---------------------------------------------------------- */

.app-edit__slide {
  background: var(--surface-card);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--s-5);
}

.app-slide-editor { display: flex; flex-direction: column; gap: var(--s-4); }
.app-slide-editor.is-hidden { display: none; }

.app-blocklist {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.app-block {
  background: var(--surface-sunken);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-md);
  overflow: hidden;
}
.app-block.is-dragging { opacity: 0.4; }

.app-block__head {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-3);
  border-bottom: var(--bw-1) solid var(--line);
  cursor: grab;
}
.app-block__head .o-btn { min-height: var(--s-7); min-width: var(--s-7); padding: 0; }

.app-block__body { padding: var(--s-4); }

/* ---- preview ------------------------------------------------------------- */

.app-edit__preview {
  position: sticky;
  top: var(--s-5);
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

/* The stage holds a real 1280x720 slide and scales it, same trick as the
   remote's thumbnails — so the preview cannot disagree with the projector. */
.app-preview {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border: var(--bw-2) solid var(--line-strong);
  border-radius: var(--r-md);
  background: var(--surface-page);
}
.app-preview__stage {
  position: absolute;
  top: 0;
  left: 0;
  width: 1280px;
  height: 720px;
  transform-origin: top left;
}

.app-edit__settings { font-size: var(--fs-sm); }

/* ---- remote thumbnails (S2) --------------------------------------------- */

.app-remote__thumbs {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s-3);
  min-height: 0;
}

/* Only "now" on the phone; "next" is in the notes modal. Two thumbnails is
   the single biggest thing that pushed the controls off the screen. */
/* minmax(0, 1fr), not 1fr: a bare 1fr track is minmax(auto, 1fr) and grows
   to its content's min-content width — the live CT clone's intrinsic
   width pushed the tile to 413px on a 390px phone (round 15). */
.app-remote__thumbs { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.app-remote__thumbs--single { grid-template-columns: minmax(0, 1fr); }
.app-remote__thumb .o-thumb { max-width: 100%; }
/* An aspect-ratio tile whose height is definite reports its transferred
   width as min-content, and a flex item's default min-width:auto lets the
   body grow to it — 445px on a 390px phone once the CT strip no longer
   squeezed the tile (round 15). The column can never be wider than the
   phone; the tile is width-bound and its height follows. */
.app-remote__body,
.app-remote__body .app-remote__thumbs { min-width: 0; max-width: 100%; }
.app-remote__thumb {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  min-width: 0;
}
/* The tile is the clipping window; .o-thumb__scaler inside it holds a
   full-size slide and is transformed down by remote.js. */
.app-remote__thumb .o-thumb {
  position: relative;
  width: 100%;
  overflow: hidden;
}

/* Reveal measures its own container to compute the slide scale, so that
   container must have a real height. As a bare flex child it resolves to 0,
   Reveal falls back to its minimum scale, and the deck renders as a postage
   stamp somewhere off-screen — which looks exactly like "nothing rendered".
   flex: 1 with min-height: 0 gives it the remaining column height. */
.app-display .reveal {
  flex: 1;
  width: 100%;
  min-height: 0;
}

/* ---- attendee deck surface (S2) ----------------------------------------- */

.app-attendee__deck {
  flex: 1;
  min-height: 0;
  display: flex;
}
.app-attendee__deck .reveal { width: 100%; height: 100%; }

/* ---- remote media transport (S2b) --------------------------------------- */

.app-remote__media {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: 0 var(--s-4);
}

/* THE PRIMARY ROW OUTRANKS EVERY OTHER ROW (round 7, item 1). The CT slide
   stacks the most controls of any slide — preview, wheel, presets, window
   row — and unbounded they crushed the body to nothing and pushed Back/Next
   under the phone's chrome. The radiology block is bounded to what the
   viewport can spare (31rem ≈ header + thumbnail floor + counter + title +
   the fixed control rows + padding) and scrolls WITHIN ITSELF: reaching a
   preset may take a deliberate scroll; reaching Next never does. */
/* Round 15 presenter ruling: the block now lives INSIDE the radiology
   modal, so the one-screen rule holds trivially on the main screen and
   the preview can be large — the modal scrolls, the study does not. */
/* THE SLIDE-STUDY MODAL (corrected presenter ruling): fits without
   scrolling at both phone heights. A definite-height flex column all the
   way down — dialog → body → strip → preview → figure → stage — where the
   STAGE is the only thing that flexes; header, preset row, wheel, −/+ row
   and Back/Next are fixed rows. */
.app-remote__modal--study[open] {
  height: min(94dvh, 60rem);
  max-height: 94dvh;
  display: flex;          /* only when open — a closed dialog stays display:none */
  flex-direction: column;
}
/* Fixed rows earn their height: a slimmer wheel and regular-size steppers
   leave the stage > 40% of the modal at both phone heights. */
.app-remote__modal--study .app-remote__modal-head { padding-block: var(--s-2); }
.app-remote__study .o-radwheel { height: 2.75rem; }
.app-remote__modal--study .app-remote__study-body { padding-bottom: calc(var(--s-3) + var(--safe-b)); }
.app-remote__modal--study[open] { max-height: 96dvh; height: min(96dvh, 60rem); }
.app-remote__study-row .o-btn--lg { min-height: 0; padding-block: var(--s-2); font-size: var(--fs-md); }
.app-remote__modal--study .app-remote__modal-head { flex: 0 0 auto; }
.app-remote__modal--study .app-remote__study-body {
  flex: 1 1 auto;
  min-height: 0;
  max-height: none;
  overflow: hidden;               /* asserted: nothing to scroll */
  display: flex;
  flex-direction: column;
  padding-top: var(--s-3);
}
#radiology-inline {
  flex: 1 1 auto;
  min-height: 0;
  padding: 0;
  gap: var(--s-2);
}
#radiology-inline > * { flex: 0 0 auto; }
#radiology-inline > #rb-preview { flex: 1 1 auto; min-height: 0; }
.app-remote__study .app-remote__radiology-preview {
  display: flex;
  flex-direction: column;
}
.app-remote__study .app-remote__radiology-preview .rad-viewer {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;     /* a centring figure shrink-wrapped the stage to 0 wide */
  width: 100%;
  margin: 0;
}
.app-remote__study .app-remote__radiology-preview .rad-viewer__stage {
  flex: 1 1 auto;
  width: 100%;
  height: auto;
  min-height: 0;
  max-height: none;
}
/* The shell's own counter is the n/N in the stepper row here — one of each. */
.app-remote__study .rad-viewer__count { display: none; }
.app-remote__study-row #rb-count {
  font-size: var(--fs-lg);
  padding: var(--s-2) var(--s-3);
  white-space: nowrap;
}
/* One header line: the case/patient string truncates; SIMULATED stays. */
.app-remote__study .rad-viewer__header {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-width: 0;
}
.app-remote__study .rad-viewer__patient {
  flex: 1 1 auto;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.app-remote__study .rad-viewer__bar {
  flex: 0 0 auto;
  /* Stage 4 bakes up to seven presets: ONE row that scrolls sideways,
     so the modal's no-scroll rule and the stage's share both hold. */
  flex-wrap: nowrap;
  overflow-x: auto;
  scrollbar-width: thin;
}
.app-remote__study .rad-viewer__bar .o-btn { flex: 0 0 auto; white-space: nowrap; }
.app-remote__study .o-block__caption { display: none; }
.app-remote__study-row { flex-wrap: nowrap; }
.app-remote__study-row .o-btn { min-width: 0; }
.app-remote__study-row #rb-count { margin-inline: auto; }

/* ---- identifier attestation (S2b) --------------------------------------- */

/* The presenter is the only party who can see burned-in identifiers, so the
   image is shown large enough to actually inspect rather than as a thumbnail. */
.app-attest {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding: var(--s-4);
  background: var(--warn-soft);
  border: var(--bw-2) solid var(--warn);
  border-radius: var(--r-md);
}

.app-attest__img {
  display: block;
  width: 100%;
  max-height: 320px;
  object-fit: contain;
  background: var(--surface-sunken);
  border-radius: var(--r-sm);
}

.app-block__warn {
  padding: var(--s-3);
  background: var(--warn-soft);
  border-left: var(--bw-4) solid var(--warn);
  border-radius: var(--r-sm);
}

/* ---- live polls (S3) ----------------------------------------------------- */

/* The display's poll takeover.
 *
 * Below the QR modal and blackout in the stacking order, deliberately: both of
 * those are "eyes up here" instructions from the presenter, and a poll must
 * never sit on top of one. */
.app-display__poll {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-6);
  padding: var(--s-8);
  background: var(--surface-page);
  text-align: center;
}

.app-display__poll .o-poll,
.app-display__poll .o-poll__cloud { width: min(100%, var(--measure)); }

.app-display__poll .o-poll__question { font-size: var(--fs-3xl); }

/* The attendee's poll takes the screen while it is open.
 *
 * It sat in the flow under the slide to begin with, on the theory that a
 * self-pacing device should still see what it was looking at. That was wrong
 * in the only way that matters: the deck fills the viewport, so the answer
 * buttons landed at y=836 on an 844px phone — off the bottom edge, with the
 * container clipped so they could not be scrolled to either. The poll was
 * unanswerable on exactly the device it exists for.
 *
 * Scrollable, because a ranking with several options is taller than a phone,
 * and below blackout in the stack, because "eyes up here" still outranks it. */
.app-attendee__poll {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  padding: var(--s-4);
  background: var(--surface-page);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.app-attendee__poll[hidden] { display: none; }

/* Poll control on the remote. Sits above the advance buttons with the other
   secondary controls, so a mis-tap while reaching for "next" cannot close a
   poll the room is still answering. */
.app-remote__poll {
  padding: 0 var(--s-4);
}

.app-remote__poll[hidden] { display: none; }

/* ---- gas gallery (S4, development surface) ------------------------------- */

.app-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
  gap: var(--s-6);
  align-items: start;
}

.app-gallery__item {
  padding: var(--s-4);
  background: var(--surface-card);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-lg);
}

/* Bounded so a wall of strips scrolls at a readable size rather than each one
   being a page tall. */
.app-gallery__strip {
  display: flex;
  justify-content: center;
  max-height: 32rem;
  overflow: hidden;
}

/* ---- live blood gas (S4) ------------------------------------------------- */

.app-remote__gas { padding: 0 var(--s-4); }
.app-remote__gas > summary { margin-bottom: var(--s-2); cursor: pointer; }

/* The presenter's preview: small, because it is a check that the right gas
   was generated, not a reading surface. The room screen is for reading. */
/* The dialog preview is an artefact HOST, and hosts owe the strip a
   definite size (the definite-size rule, docs/theme.md). The slide ladder
   is scoped to .o-block--generated_gas and never reaches this dialog, so
   the SVG fell back to each engine's intrinsic guess — a 73px sliver on
   WebKit — and the old max-height/overflow pair then clipped even that.
   The modal body already scrolls; the preview clips nothing. */
.app-remote__gas-preview {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-3);
}
.app-remote__gas-preview:empty { display: none; }
.app-remote__gas-preview .o-gasstrip {
  height: min(var(--artefact-h-md), 42dvh);
  width: auto;
  max-width: 100%;
}
.app-remote__gas-preview .o-ketone {
  height: min(calc(var(--artefact-h-md) / 2), 20dvh);
  width: auto;
}

.app-display__gas {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-4);
  padding: var(--s-6);
  background: var(--surface-page);
}

/* The pushed gas arrives as a pair — strip plus ketone meter — inside one
   injected holder, so the height has to be handed down through both wrappers
   or the strip computes to nothing and the projector shows an empty screen.
   That is exactly what happened when the meter was added: every rule below
   was written for a holder that used to be the stage's direct child. */
.app-display__gas .o-artefact-holder {
  width: 100%;
  height: 100%;
  min-height: 0;
}
/* BOTH MEMBERS OF THE PAIR ARE ALWAYS VISIBLE (behaviour contract, round
   9): the layout answers to the ACTUAL CONTAINER, not the device class. A
   height:100% strip at a narrow window derived a width wider than the
   screen and crushed the meter to 0x0 off-screen — the strip now takes a
   bounded width share so the meter always fits beside it, and a container
   query stacks them only when the window is genuinely too narrow for a
   row. */
.app-display__gas,
.app-attendee__gas,
.app-display .reveal .o-block--generated_gas,
.app-attendee .reveal .o-block--generated_gas,
/* The editor preview is the fifth gas host (round 18): it shows the same
   full-media pair on the same 1280x720 canvas, so it answers to the same
   container system. The narrow branch below deliberately omits it — the
   stage is definitionally --slide-w x --slide-h and can never be narrow. */
.app-preview__stage .o-block--generated_gas { container: gas-stage / size; }
.app-display__gas .o-artefact-pair,
.app-attendee__gas .o-artefact-pair,
.app-display .reveal .o-block--generated_gas .o-artefact-pair,
.app-attendee .reveal .o-block--generated_gas .o-artefact-pair,
.app-preview__stage .o-block--generated_gas .o-artefact-pair {
  height: 100%;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
}
.app-display__gas .o-gasstrip-holder,
.app-attendee__gas .o-gasstrip-holder,
.app-display .reveal .o-block--generated_gas .o-gasstrip-holder,
.app-attendee .reveal .o-block--generated_gas .o-gasstrip-holder,
.app-preview__stage .o-block--generated_gas .o-gasstrip-holder {
  display: flex;
  min-height: 0;
  max-height: 100%;
  justify-content: center;
}
.app-display__gas .o-gasstrip,
.app-attendee__gas .o-gasstrip,
.app-display .reveal .o-block--generated_gas .o-gasstrip,
.app-attendee .reveal .o-block--generated_gas .o-gasstrip,
.app-preview__stage .o-block--generated_gas .o-gasstrip {
  /* A definite container height, not a 100% term: percentages inside
     min() against this auto chain failed to bind and the strip reverted
     to its intrinsic height, straight past the window edge. */
  height: 88cqh;
  max-width: 58cqw;
  width: auto;
}
/* The meter is an object beside the printout, not a second page: it gets a
   share of the width and keeps its own proportions. */
.app-display__gas .o-ketone,
.app-attendee__gas .o-ketone,
.app-display .reveal .o-block--generated_gas .o-ketone,
.app-attendee .reveal .o-block--generated_gas .o-ketone,
.app-preview__stage .o-block--generated_gas .o-ketone {
  width: min(22cqw, 320px);
  max-width: none;
}
@container gas-stage (width < 30rem) {
  .app-display__gas .o-artefact-pair,
  .app-attendee__gas .o-artefact-pair,
  .app-display .reveal .o-block--generated_gas .o-artefact-pair,
.app-attendee .reveal .o-block--generated_gas .o-artefact-pair {
    flex-direction: column;
    /* A wrapping COLUMN of fixed height wraps its overflow into a second
       column beside the first — the meter landed off the right edge of a
       phone. Stacked means stacked. */
    flex-wrap: nowrap;
  }
  /* The holder must hug the strip here, or its theme height:100% parks
     the meter at the far bottom of the column. */
  .app-display__gas .o-gasstrip-holder,
  .app-attendee__gas .o-gasstrip-holder,
  .app-display .reveal .o-block--generated_gas .o-gasstrip-holder,
.app-attendee .reveal .o-block--generated_gas .o-gasstrip-holder {
    height: auto;
    flex: 0 0 auto;
  }
  .app-display__gas .o-gasstrip,
  .app-attendee__gas .o-gasstrip,
  .app-display .reveal .o-block--generated_gas .o-gasstrip,
.app-attendee .reveal .o-block--generated_gas .o-gasstrip {
    height: 70cqh;
    max-width: 92cqw;
  }
  .app-display__gas .o-ketone,
  .app-attendee__gas .o-ketone,
  .app-display .reveal .o-block--generated_gas .o-ketone,
.app-attendee .reveal .o-block--generated_gas .o-ketone {
    width: min(40cqw, 240px);
  }
}
/* On the display canvas the full-media block is already a definite box
   (section height × the full-media 100% rule); it just needs to BE the
   container so the pair answers to its shape rather than to the token
   ladder, which is how a 16:10 window lost the meter below the canvas. */
/* The projector canvas is DEFINITIONALLY --slide-w x --slide-h (the
   projector-view ruling: a fixed 16:9 canvas, never reflowed). Saying so
   explicitly makes every percentage/flex chain below definite in BOTH
   engines — WebKit resolved the section's auto height as content-sized,
   collapsed the slide body to its prose and the gas block to zero. */
.app-display .reveal .slides > section > .o-slide,
/* The editor preview injects the same slide into a 1280x720 stage — and
   round 18 found it had the same collapse: no explicit height on .o-slide,
   so the full-media 100% chain resolved against nothing and the pair
   rendered on SVG attribute fallbacks (strip at attr height, meter gone).
   The preview wears what the projector wears; that includes the canvas. */
.app-preview__stage > .o-slide {
  height: var(--slide-h);
}

.app-display .reveal .o-block--generated_gas,
.app-preview__stage .o-block--generated_gas {
  /* flex fill, not height:100%: WebKit resolves a percentage against a
     flex-derived parent height as 0 (the block collapsed and the pair
     spilled out on a WebKit projector window); a flexed height is
     definite in both engines — the same chain the attendee uses.
     The preview stage is in the list for the same reason as the canvas
     rule above: same markup, same chain, same fix (round 18). */
  flex: 1 1 auto;
  height: auto;
  min-height: 0;
  width: 100%;
}
.app-display .reveal .o-block--generated_gas .o-block__figure,
.app-preview__stage .o-block--generated_gas .o-block__figure {
  height: 100%;
  min-height: 0;
  margin: 0;
}
/* The deck's gas block needs the definite height the container reads:
   in portrait, most of the screen (the pair is the slide); in landscape
   the one-screen slide ladder below hands it the remainder. */
.app-attendee .reveal .o-block--generated_gas {
  height: calc(100dvh - var(--s-10) * 2 - var(--safe-t) - var(--safe-b));
  min-height: 0;
  width: 100%;
}
.app-attendee .reveal .o-block--generated_gas .o-block__figure {
  height: 100%;
  min-height: 0;
  margin: 0;
}

/* On a phone the strip fills the screen and can be tapped to zoom: legible on
   a projector is not legible at reading distance, and the numbers are the
   whole content. */
.app-attendee__gas {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-3);
  padding: var(--s-3);
  background: var(--surface-page);
  overflow: auto;
  overscroll-behavior: contain;
}

/* GAS ROUND (S8, field): the pushed overlay and the deck's gas block use
   the SAME container-sized mechanism as the room display below — one
   rule set, keyed on the actual container (the fixed overlay, or the
   block's definite height in the column), never on the device class or
   a fixed rem. Three separate hand-sized attendee rule sets were the bug:
   a 60dvh landscape cap left half the screen empty, and fixed widths
   meeting the full-size ladder overlapped the meter on an iPad. */
.app-attendee__gas .o-artefact-holder { width: 100%; height: 100%; }

.app-attendee__gas.is-zoomed {
  align-items: flex-start;
  justify-content: flex-start;
}
.app-attendee__gas.is-zoomed .o-artefact-holder { width: 220%; }
.app-attendee__gas.is-zoomed .o-artefact-pair { display: block; }

/* ---- the live ECG (S5b T5) ------------------------------------------------
   Same three surfaces as the gas: a shelf on the remote, full-bleed on the
   projector, full-width and zoomable on a phone. A 12-lead is wider than it
   is tall, so it fits to width everywhere rather than to height — the
   opposite of the strip, which is a tall column. */

.app-remote__ecg-preview {
  display: flex;
  justify-content: center;
  overflow: hidden;
}
.app-remote__ecg-preview:empty { display: none; }
.app-remote__ecg-preview .o-ecg-holder { width: 100%; }
/* The component caps itself at `max-height: 100%`, which resolved against an
   auto-height parent when this shelf sat in normal flow inside a <details>.
   In the modal it sits in a flex column whose height is zero until something
   is in it, so the cap clamped the trace to nothing — a preview that was
   present, correct and 0 px tall. */
.app-remote__ecg-preview .o-ecg { max-height: none; }

.app-display__ecg {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-4);
  padding: var(--s-5);
  background: var(--surface-page);
}

.app-display__ecg .o-ecg-holder { width: 100%; }
.app-display__ecg .o-ecg { width: 100%; height: auto; max-height: 88vh; }

.app-attendee__ecg {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-3);
  padding: var(--s-2);
  background: var(--surface-page);
  overflow: auto;
  overscroll-behavior: contain;
}

.app-attendee__ecg .o-ecg-holder { width: 100%; }
.app-attendee__ecg .o-ecg { width: 100%; height: auto; }

/* Zoomed, a phone shows about a quarter of the paper at a size where small
   squares are countable — which is the only size an ECG is worth showing at
   all. Scrolling both ways is the point, not a failure of the layout. */
.app-attendee__ecg.is-zoomed {
  align-items: flex-start;
  justify-content: flex-start;
}
.app-attendee__ecg.is-zoomed .o-ecg-holder { width: 400%; }

/* Arterial and venous side by side, for the consultant-eye comparison. */
.app-gallery__pair {
  display: flex;
  gap: var(--s-4);
  align-items: flex-start;
  justify-content: center;
}
.app-gallery__pair .app-gallery__strip { flex: 1 1 0; min-width: 0; margin: 0; }

/* ---- ECG gallery (S5b, development surface) ------------------------------ */

/* One trace per row: a 12-lead is wide, and two side by side would be read at
   a size nobody could judge morphology at. */
.app-ecg-gallery {
  display: flex;
  flex-direction: column;
  gap: var(--s-6);
}

.app-ecg-gallery__item {
  padding: var(--s-4);
  background: var(--surface-card);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-lg);
}

.app-ecg-gallery__paper {
  width: 100%;
  overflow-x: auto;
}


/* ---- the room computer's console (S6) ------------------------------------
   Desktop-shaped on purpose: this is the machine plugged into the projector,
   and the phone gets the one-screen treatment instead. */
.app-console__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  gap: var(--s-4);
  align-items: start;
}

.app-console__qr {
  display: flex;
  justify-content: center;
  padding: var(--s-3);
}
.app-console__qr svg { width: 100%; max-width: 14rem; height: auto; }

/* An expired pairing code should not look like a live one. */
.app-console__qr.is-expired { opacity: 0.25; filter: grayscale(1); }


/* ---- the remote's drawer, modals and picker (S6) --------------------------

   Everything that is not one of the four thumb-reachable controls is behind a
   button here. */
.app-remote__drawer {
  display: grid;
  grid-auto-flow: column;
  /* minmax(0, 1fr): equal columns that can shrink below their label —
     the Study button made six, and 1fr's auto minimum pushed Rays off
     the phone (round 15). Labels ellipsise rather than the row overflowing. */
  grid-auto-columns: minmax(0, 1fr);
  gap: var(--s-2);
  padding: 0 var(--s-4);
}
.app-remote__drawer .o-btn {
  min-width: 0;
  padding-inline: var(--s-2);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A native <dialog>, not the theme's .o-modal — that component is a fixed
   full-screen overlay with `display: flex`, which would override the user
   agent's `display: none` on a closed dialog and leave every modal open. The
   platform's open/closed behaviour is the reason for using <dialog> at all.

   On a phone a modal is the whole screen with room to breathe at the bottom,
   because the bottom is where the thumb is. */
.app-remote__modal {
  width: min(30rem, 96vw);
  max-height: 88dvh;
  padding: 0;
  border: 0;
  border-radius: var(--r-lg);
  background: var(--surface-overlay);
  color: var(--ink);
}
.app-remote__modal::backdrop { background: var(--surface-scrim); }

.app-remote__modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-bottom: var(--bw-1) solid var(--line);
  position: sticky;
  top: 0;
  background: var(--surface-overlay);
}
.app-remote__modal-body {
  padding: var(--s-4);
  padding-bottom: calc(var(--s-5) + var(--safe-b));
  overflow-y: auto;
  max-height: calc(88dvh - 4rem);
}

/* The template picker: grouped, searchable, and scrollable *inside* the
   modal — the one place scrolling is right, because it is a list the
   presenter has chosen to open. */
.app-remote__picker {
  max-height: 44vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-md);
  padding: var(--s-2);
}
.app-remote__picker-group {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}
.app-remote__picker .is-chosen {
  border-color: var(--accent);
  color: var(--accent);
}


/* ---- overflow warnings in the editor (S6) --------------------------------
   The author stacks blocks past what a slide can show and finds out in the
   room. The card says so, and the preview draws the line the room stops
   looking at. */
.app-block.is-overflowing {
  border-color: var(--warn);
}
.app-block__overflow {
  color: var(--warn-ink, var(--warn));
}

/* Where the slide's usable area ends. Sits on the preview stage rather than
   inside the slide, so it survives the slide being re-rendered. */
.app-preview__fold {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--bw-2);
  background: repeating-linear-gradient(
    90deg, var(--warn) 0 8px, transparent 8px 16px);
  pointer-events: none;
}


/* ---- explainers (S7) ------------------------------------------------------
   Layout only: the schematic's own look lives in the theme beside the ECG
   paper. The stage puts the mechanism and the trace side by side where the
   width allows, stacked where it does not. */
.app-explain {
  display: grid;
  gap: var(--s-4);
}
.app-explain__stage {
  display: grid;
  gap: var(--s-4);
  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
  align-items: center;
}
@media (max-width: 720px) {
  .app-explain__stage { grid-template-columns: 1fr; }
}
.app-explain__schematic { max-width: 26rem; margin-inline: auto; width: 100%; }
.app-explain__strip { min-width: 0; }
.app-explain__controls { align-items: end; }

/* The reduced-motion stills: a readable strip of moments, each labelled. */
.cs-frames {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--s-3);
  grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
}
.cs-frame {
  display: grid;
  gap: var(--s-1);
  padding: var(--s-2);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-card);
}
.cs-frame__label {
  font-size: var(--fs-xs);
  color: var(--ink-muted);
  margin: 0;
}

/* The explainer's three surfaces, in the ECG's mould: a shelf on the remote,
   a takeover on the projector and on a phone. The stage inside is the
   .app-explain grid above. */
.app-remote__explainer-preview {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.app-remote__explainer-preview:empty { display: none; }
.app-remote__explainer-preview .cs-explainer { width: 100%; }
.app-remote__explainer-preview .o-ecg { max-height: none; }

.app-display__explainer,
.app-attendee__explainer {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-4);
  padding: var(--s-5);
  background: var(--surface-page);
  overflow: auto;
  overscroll-behavior: contain;
}
.app-display__explainer .cs-explainer,
.app-attendee__explainer .cs-explainer { width: 100%; }
.app-attendee__explainer { padding: var(--s-2); gap: var(--s-3); }

/* The axis explainer's page grid: diagram beside the six deflection cells. */
.app-axis {
  display: grid;
  gap: var(--s-4);
}
.app-axis__stage {
  display: grid;
  gap: var(--s-4);
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  align-items: start;
}
@media (max-width: 720px) {
  .app-axis__stage { grid-template-columns: 1fr; }
}
.app-axis__diagram { max-width: 30rem; margin-inline: auto; width: 100%; }
.app-axis__range { width: 100%; max-width: 30rem; }

/* The AV node gallery: mechanism sliders and the fate line. */
.app-avnode__sliders {
  display: grid;
  gap: var(--s-3);
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.app-avnode__pattern {
  letter-spacing: var(--tracking-wide);
  font-size: var(--fs-lg);
  overflow-wrap: anywhere;
}

/* S7.1: the axis page's right-hand region holds stage one (the six leads)
   and stage two (the revealed 12-lead), one at a time. */
.app-axis__right {
  min-width: 0;
  display: grid;
  gap: var(--s-3);
  align-content: start;
}
.app-axis__reveal { min-width: 0; }
.app-axis__revealed {
  margin: 0;
  display: grid;
  gap: var(--s-1);
}
.app-axis__revealed .o-ecg { width: 100%; height: auto; }

/* ---- the live radiology study (S8 T4) -------------------------------------
   The fourth artefact kind, and the one that shipped without this block:
   gas, ECG and explainer are fixed-position takeovers, so an unstyled
   #radiology section sat in normal document flow BELOW the full-viewport
   display stage — injected, technically visible to a DOM assertion, and
   never on the projector. Same three surfaces as the others: a shelf in
   the remote panel, full-bleed on the projector, full-screen on a phone. */

.app-display__radiology {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  padding: var(--s-6);
  background: var(--surface-page);
}
.app-display__radiology[hidden] { display: none; }
/* The height has to be handed down through every wrapper — the gas takeover
   learned this when the meter arrived. Holder and viewer each take exactly
   the space above them; the stage row absorbs what is left; the image
   letterboxes inside the stage. Any link in that chain left unconstrained
   and the intrinsic image height wins and the stack overflows the screen. */
.app-display__radiology .o-artefact-holder {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.app-display__radiology .rad-viewer {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
}
.app-display__radiology .rad-viewer__stage,
.app-attendee__radiology .rad-viewer__stage {
  min-height: 0;
  /* 100% of the grid area, which the minmax(0,1fr) track makes definite —
     with the image out of flow the stage has no in-flow content and would
     otherwise collapse to zero. */
  height: 100%;
}
/* Absolutely filled, not %-height: a percentage height inside an
   auto-sized grid item falls back to the image's intrinsic size, and a
   512px CT at projector width then inflates the whole chain past the
   screen (the -269px stage this section's spec pins). Absolute removes
   the image from flow, so the stage stretches to its track and the
   pixels letterbox inside it. The stage is already position:relative. */
.app-display__radiology .rad-viewer__img,
.app-attendee__radiology .rad-viewer__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.app-attendee__radiology {
  position: fixed;
  inset: 0;
  z-index: var(--z-poll);
  display: flex;
  flex-direction: column;
  padding: var(--s-3);
  background: var(--surface-page);
  overscroll-behavior: contain;
}
.app-attendee__radiology[hidden] { display: none; }
.app-attendee__radiology .o-artefact-holder {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.app-attendee__radiology .rad-viewer {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
}


/* The remote's shelf: the presenter scrolls the real viewer at thumb size.
   Height capped like the gas preview; the step buttons under it are the
   phone's guaranteed way through the stack (wheel is a desktop luxury and
   a drag inside a modal fights the sheet). */
.app-remote__radiology-preview .rad-viewer__stage { max-height: 14rem; }
.app-remote__radiology-preview .rad-viewer__img {
  width: 100%;
  height: 100%;
  max-height: 14rem;
  object-fit: contain;
}
.app-remote__radiology-preview:empty { display: none; }

/* Item 2 of the S8 room feedback: window presets are PRESENTER controls.
   The room display shows the image and nothing interactive — scoped to the
   display page's root, so the same shared shell keeps its buttons for
   attendees (their own windowing is theirs) and in the editor. */
.app-display .rad-viewer__bar { display: none; }
/* The presenter's Now thumbnail is a clone of the projector view and must
   show exactly what the projector shows — no control chips (round 15). */
.app-remote__thumb .rad-viewer__bar { display: none; }

/* Keyboard help on the display machine — local overlay, press "?". */
.app-display__keyhelp {
  position: fixed;
  inset: auto var(--s-6) var(--s-6) auto;
  z-index: var(--z-modal);
  max-width: 24rem;
  padding: var(--s-5);
  border-radius: var(--r-lg);
  background: var(--surface-overlay);
  border: var(--bw-1) solid var(--line-strong);
  display: grid;
  gap: var(--s-3);
}
.app-display__keyhelp[hidden] { display: none; }
.app-display__keys {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--s-2) var(--s-4);
  margin: 0;
}
.app-display__keys dd { margin: 0; }

/* The panel preview, made structural like the takeover: fixed-height
   stage, image out of flow. The in-flow image inflated the chain on a
   phone and pushed the CT partially off-screen — the same class the
   display takeover hit, in the third render context. */
.app-remote__radiology-preview .rad-viewer__stage {
  height: 12rem;
  max-height: none;
  min-height: 0;
}
.app-remote__radiology-preview .rad-viewer__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-height: none;
  object-fit: contain;
}

/* Round 4 CSS. */

/* Item 2 — the thumbnail crop: the scaler clone had no definite height,
   so the media flex chain never bound and a tall study overflowed the
   16:9 clipping window. Give the cloned slide the display's own layout:
   full design height, flex column — the SAME context the room renders in,
   which is the whole promise of the thumbnail. */
.o-thumb__scaler .o-slide {
  height: 100%;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* Item 6 — the detached banner. Loud on purpose: the failure mode is a
   student quietly detached, confused why their phone disagrees with the
   screen. */
.app-attendee__detached {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Above the imaging explorer (z-modal): the "return to live" affordance
     must stay reachable in every detached mode — the spec caught it
     buried beneath the overlay it exists to escape. Same z as the
     overlay, but later in the DOM, so it paints above. */
  z-index: var(--z-modal);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-4);
  padding: var(--s-3) var(--s-4);
  padding-top: calc(var(--s-3) + var(--safe-t));
  background: var(--warn-soft);
  color: var(--warn-ink);
  border-bottom: var(--bw-2) solid var(--warn);
  font-weight: var(--weight-bold);
}
.app-attendee__detached[hidden] { display: none; }

/* The imaging explorer: the attendee's own full-screen study view. */
.app-attendee__explore {
  position: fixed;
  right: var(--s-4);
  bottom: calc(var(--s-8) + var(--safe-b));
  z-index: var(--z-pill);
}
.app-attendee__rad-explore {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex;
  flex-direction: column;
  background: var(--surface-page);
}
.app-attendee__rad-explore[hidden] { display: none; }
/* ROUND 7, ITEM 4: THE CONTROLS FLOAT, THE ANATOMY GETS THE GLASS.
   Landscape is the orientation someone turns to in order to see MORE image,
   so the study view lays out like a PACS viewer in both orientations: the
   stage fills the viewport edge to edge and every control row is a
   semi-transparent overlay at the edges — the explorer bar at the bottom
   (thumb territory; the detached banner owns the top and paints above),
   the window presets and header on a scrim strip at the top. */
.app-attendee__rad-explore-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-3);
  padding-bottom: calc(var(--s-2) + var(--safe-b));
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1;
  /* OPAQUE, not scrim (round 11, item 4): a stage-zoomed image fills the
     letterbox behind these strips, and anatomy bleeding through a 72%
     scrim turned the chrome into a jumble. Same ground as the stage, so
     un-zoomed the strips read as letterbox; zoomed they stay legible. */
  background: var(--rad-ground);
}
.app-attendee__rad-explore-bar .o-eyebrow {
  flex: 1;
  /* On the scrim (portrait) and the letterbox band (landscape) alike, the
     ground behind this text is dark: the radiograph pair, not page ink. */
  color: var(--rad-ink);
}
.app-attendee__rad-explore-body {
  flex: 1 1 auto;
  min-height: 0;
  padding: 0;
}
.app-attendee__rad-explore-body .rad-viewer {
  display: block;
  position: relative;
  height: 100%;
  width: 100%;
}
.app-attendee__rad-explore-body .rad-viewer__stage {
  position: absolute;
  inset: 0;
  height: auto;
  min-height: 0;
}
.app-attendee__rad-explore-body .rad-viewer__header {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1;
  padding: var(--s-1) var(--s-3);
  padding-top: calc(var(--s-1) + var(--safe-t));
  background: var(--rad-ground);
  flex-wrap: nowrap;
}
/* One line in the overlay: a wrapped banner walks down into the preset
   row. The full identity line is on the slide and the display; here it is
   context, and context truncates. */
.app-attendee__rad-explore-body .rad-viewer__patient {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.app-attendee__rad-explore-body .rad-viewer__bar {
  position: absolute;
  /* Below the one-line header, not on top of it (round 11, item 3): s-6
     put the presets inside the header's band once the safe-area inset
     pushed both down together. */
  top: calc(var(--s-9) + var(--safe-t));
  left: 0;
  right: 0;
  z-index: 1;
  justify-content: center;
  padding: var(--s-1) var(--s-3);
  background: var(--rad-ground);
}
/* OVERLAY CONTROLS SIT ON OPAQUE BACKING (round 8, item 3). A translucent
   control over arbitrary CT pixels has no contrast the token graph can
   vouch for — the pair the checker sees is not the pair the eye sees. So
   every chip here is opaque: the radiograph's variant-invariant pair for
   the resting state, the primary-button pair for the selected one. Both
   pairs are verified by tools/check_contrast.py, and the e2e suite
   asserts the COMPUTED pair on the rendered buttons. */
.app-attendee__rad-explore-body .rad-viewer__bar .o-btn {
  background: var(--rad-ground);
  color: var(--rad-ink);
  border-color: var(--rad-ground);
}
.app-attendee__rad-explore-body .rad-viewer__bar .o-btn[aria-pressed="true"] {
  background: var(--accent);
  color: var(--ink-on-accent);
  border-color: var(--accent);
  /* Round 11: while imaging-attached the presets are disabled and the
     disabled fade washed the SELECTED chip to pale-on-pale. Selection is
     STATUS, not an affordance — it stays at full contrast; only the
     unselected chips fade. (The contrast spec now measures effective
     opacity, which is how this slipped through before.) */
  opacity: 1;
}
/* A detached attendee has the loud banner across the top — and windowing
   their own view is exactly why they detached, so in portrait the presets
   step BELOW the banner instead of underneath it. (Landscape presets live
   in the letterbox band, clear of the banner already.) */
@media (orientation: portrait) {
  .app-attendee:has(#detached-banner:not([hidden]))
    .app-attendee__rad-explore-body .rad-viewer__bar {
    top: calc(var(--s-10) + var(--s-6) + var(--safe-t));
  }
}

/* ROUND 8, ITEM 4 — LANDSCAPE: a roughly square CT in a wide viewport
   leaves letterbox bands either side, and that is where the chrome
   belongs. Presets and header down the left band, slice/zoom/follow down
   the right, the anatomy untouched in the middle. Portrait keeps the
   top/bottom strips. */
@media (orientation: landscape) {
  .app-attendee__rad-explore-body .rad-viewer__header {
    right: auto;
    width: max(calc((100vw - 100dvh) / 2 - var(--s-3)), 7rem);
    flex-direction: column;
    align-items: flex-start;
    gap: var(--s-1);
    background: var(--rad-ground);
    padding-left: calc(var(--s-2) + var(--safe-l));
  }
  .app-attendee__rad-explore-body .rad-viewer__bar {
    top: 50%;
    transform: translateY(-50%);
    right: auto;
    width: max(calc((100vw - 100dvh) / 2 - var(--s-3)), 7rem);
    flex-direction: column;
    align-items: stretch;
    background: var(--rad-ground);
    padding-left: calc(var(--s-2) + var(--safe-l));
  }
  .app-attendee__rad-explore-bar {
    top: 0;
    bottom: 0;
    left: auto;
    right: 0;
    width: max(calc((100vw - 100dvh) / 2 - var(--s-3)), 7rem);
    flex-direction: column;
    justify-content: center;
    align-items: stretch;
    background: var(--rad-ground);
    padding-right: calc(var(--s-2) + var(--safe-r));
  }
  .app-attendee__rad-explore-bar .o-eyebrow {
    flex: 0 0 auto;
    color: var(--rad-ink);
  }
  /* iPad-class proportion (round 12 item 5): stretched-to-band controls
     read as enormous slabs on a tablet's wider letterbox — cap them and
     centre, so the band scales without the buttons ballooning. */
  .app-attendee__rad-explore-bar .o-btn,
  .app-attendee__rad-explore-body .rad-viewer__bar .o-btn {
    width: 100%;
    max-width: 11rem;
    align-self: center;
  }
}
.app-attendee__rad-explore-body .o-block__caption { display: none; }
.app-attendee__rad-explore-body .rad-viewer__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* Item 4 — the gas strip that rendered 0px tall beside its meter: in any
   context without a definite flex height (the thumbnail clone before the
   fix above, an auto-height slide), height:100% resolved to nothing and
   the pair became just the meter. The artefact ladder is the definite
   height the chain was missing — the same token the ECG reads. */
.o-block--generated_gas .o-gasstrip-holder {
  height: var(--artefact-h, var(--artefact-h-lg));
  max-height: 100%;
}

/* ==========================================================================
   THE ATTENDEE LAYOUT (S8 round 5, item 1) — a phone in the hand, not a
   small projector. Reveal runs with disableLayout for attendees, so these
   rules OWN the layout: a portrait-first single column that reflows at any
   viewport and orientation. The room display keeps its scaled canvas; this
   is a second layout for a second device class.
   ========================================================================== */

.app-attendee__deck {
  display: block;
  /* NOT a scroll region: since round 6 the PAGE scrolls (the html rule
     below). The auto-overflow this once carried was redundant scrolling —
     and any non-visible overflow is a paint crop, which is how portrait
     zooms stayed pinned inside their band while the box-geometry spec
     passed (round 8 follow-up). */
  overflow: visible;
}
/* disableLayout hands slide LAYOUT to this stylesheet, but Reveal still
   installs its page furniture: html.reveal-full-page { overflow: hidden },
   a clipped 100%-height viewport wrapper, and an absolutely positioned
   .backgrounds layer. On the attendee all three are wrong — the clamp
   killed self-pace scrolling, and the background layer painted OVER the
   static-flow sections, leaving every non-overlay slide blank while all of
   its content held opacity:1 and a real box (round 6; this is why the
   walkthrough spec now hit-tests pixels instead of trusting visibility). */
html.reveal-full-page:has(.app-attendee) {
  overflow: auto;
  height: auto;
}
/* Reveal stamps `reveal-viewport` on the BODY — above .app-attendee, so a
   descendant selector here silently matched nothing for two rounds (the
   html rule above carried the scrolling alone). Target the real element. */
body.reveal-viewport:has(.app-attendee) {
  height: auto;
  overflow: visible;
}
.app-attendee .reveal .backgrounds { display: none; }
/* reveal.css clips the scaled canvas at the .reveal box. The attendee
   column has no canvas — and this was the clip that confined a zoomed
   artefact to its layout slot wherever the slot fell short of the screen
   (portrait), while landscape hid it by reaching the screen edge. */
.app-attendee .reveal { overflow: visible; }

.app-attendee .reveal,
.app-attendee .reveal .slides {
  width: 100%;
  height: 100%;
  position: static;
}

/* One slide at a time; the present section is an ordinary document-flow
   column that scrolls when it is taller than the screen. */
.app-attendee .reveal .slides section {
  display: none;
  /* reveal.css absolutely positions sections for the scaled canvas; in
     the attendee column they are ordinary flow content. */
  position: static;
  top: auto;
  left: auto;
  transform: none;
}
.app-attendee .reveal .slides section.present {
  display: block;
  width: 100%;
  min-height: 100%;
  height: auto;
}

.app-attendee .reveal .o-slide {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  height: auto;
  min-height: 0;
  padding: var(--s-4);
  padding-bottom: calc(var(--s-9) + var(--safe-b)); /* room for the pager */
}

/* Type sized for reading distance, not projection distance. */
.app-attendee .reveal .o-slide__title {
  font-size: clamp(1.375rem, 5.5vw, 2rem);
  line-height: 1.25;
}
.app-attendee .reveal .o-block__prose {
  font-size: clamp(1rem, 4.2vw, 1.25rem);
  max-width: 65ch;
}

/* MEDIA DOMINATES: any artefact takes the practical full width of the
   screen; text scrolls beneath it. The projector's height-ladder tokens
   deliberately do not bind here — width is the constraint on a phone. */
.app-attendee .reveal .o-block--media {
  flex: none;
  width: 100%;
  min-height: 0;
}
.app-attendee .reveal .o-block__img,
.app-attendee .reveal .o-block__video {
  width: 100%;
  max-height: none;
}
/* The YouTube frame has no intrinsic height at all — height:100% of this
   column's auto chain is the definite-size failure again, on video this
   time (round 6, caught by the media spec). Width is the axis the column
   grants; the aspect ratio derives the height. */
.app-attendee .reveal .o-block__yt {
  height: auto;
  width: 100%;
  max-height: none;
}
.app-attendee .reveal .o-block--radiology_study .rad-viewer__stage {
  height: min(75vh, 100vw);
  max-height: none;
}
.app-attendee .reveal .o-ecg {
  width: 100%;
  height: auto;
  max-height: none;
}
/* The strip is tall and narrow: width-bound on a phone, height follows
   its own aspect — the definite-size rule satisfied by WIDTH here, since
   this column has no definite height to grant (see docs/theme.md, "an
   artefact host must establish a definite size on one axis"). */
/* (gas block sizing lives with the gas-stage container rules above) */

/* Landscape: the same column, media capped to the screen's height so the
   image stays whole and the text scrolls beside what remains. Landscape
   must WORK — it lost all content when it was a scaled canvas. */
/* ROUND 7, ITEM 6 — THE GENERAL RULE: every artefact fits entirely within
   the viewport in BOTH orientations, scaling to whichever axis binds, and
   detail artefacts zoom (artefact-zoom.js) so nothing cropped is ever out
   of reach. Landscape is where the portrait column's width-first sizing
   used to overflow the short axis and clip with no way down. */
@media (orientation: landscape) {
  .app-attendee .reveal .o-block--radiology_study .rad-viewer__stage {
    height: min(80vh, 60vw);
  }
  /* Fit to HEIGHT: the short axis binds, the intrinsic ratio (carried as
     width/height attributes on every generated SVG) supplies the width.
     The image only ever scales DOWN (max-height); the ECG takes the
     height outright — its intrinsic size is millimetres-as-pixels, far
     smaller than any phone, and a 156px twelve-lead is not readable.
     Round 8: the artefact gets the WHOLE screen — the title and the
     counter's band are reclaimed below, so the allowance is a margin,
     not a chrome budget. */
  /* ROUND 13, ITEM 3 — the image takes the REMAINING first screen, whole.
     The dvh cap above assumed the image owned the slide; the field's
     slide-1 pairs prose above an unsized image and the cap pushed exactly
     the prose's height of picture below the fold, on every device class.
     The fix is a definite-height ladder: the image-bearing slide is one
     screen tall (scrolling INSIDE itself when prose overflows), the image
     block flexes into what the prose leaves, floored so a prose-heavy
     slide scrolls to a whole image rather than squeezing it to a sliver —
     both contract clauses ("fits entirely, height-bound" and "one
     deliberate scroll away, then owns the screen") at once. */
  .app-attendee .reveal .o-slide:has(.o-block__img, .o-gasstrip, .o-ecg) {
    height: 100dvh;
    min-height: 0;
    overflow-y: auto;
    /* The portrait pager's reserved band is dead space here — landscape
       arrows float in the letterbox corners, so the artefact gets it. */
    padding-bottom: calc(var(--s-4) + var(--safe-b));
  }
  .app-attendee .reveal .o-slide:has(.o-block__img, .o-gasstrip, .o-ecg) .o-slide__body {
    min-height: 0;
  }
  /* The gas block takes the remainder of the one-screen slide. */
  .app-attendee .reveal .o-block--generated_gas {
    height: auto;
    flex: 1 1 auto;
    min-height: 0;
  }
  .app-attendee .reveal .o-block--image {
    flex: 1 1 auto;
    min-height: min(45dvh, 18rem);
    display: flex;
    flex-direction: column;
  }
  .app-attendee .reveal .o-block--image .o-block__figure {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 100%;
    margin: 0;
  }
  .app-attendee .reveal .o-block__img {
    width: auto;
    max-width: 100%;
    flex: 0 1 auto;
    min-height: 0;
    max-height: calc(100dvh - var(--s-6));
    display: block;
    margin-inline: auto;
  }
  /* Round 15 (gallery verdict, cells 6/7): the ECG joins the one-screen
     slide ladder — its block flexes into what the prose leaves and the
     paper fills THAT, so the rhythm strip is on the first screen whatever
     sits above it. A dvh height assumed the paper owned the slide. */
  .app-attendee .reveal .o-block--generated_ecg {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }
  .app-attendee .reveal .o-block--generated_ecg .o-block__figure {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 100%;
    margin: 0;
  }
  .app-attendee .reveal .o-ecg {
    height: auto;
    flex: 1 1 auto;
    min-height: 0;
    width: auto;
    max-width: 100%;
    max-height: calc(100dvh - var(--s-6));
    display: block;
    margin-inline: auto;
  }

  /* ROUND 8, ITEM 5 — the attendee view is a viewing device. In
     landscape the artefact takes the screen the way the full-bleed image
     slide (the reference implementation) always did: the counter's band
     is reclaimed, the pager floats in the letterbox corners, and an
     artefact slide's title gives way to the artefact. */
  .app-attendee__num { display: none; }
  .app-attendee__pager {
    position: fixed;
    bottom: calc(var(--s-3) + var(--safe-b));
    left: 0;
    right: 0;
    /* bottom anchors the MARGIN edge — inherited flow margins would float
       the buttons up the screen. */
    margin: 0;
    justify-content: space-between;
    padding: 0 calc(var(--s-4) + var(--safe-l));
    pointer-events: none;
  }
  .app-attendee__pager .o-btn { pointer-events: auto; }
  .app-attendee .reveal
    .o-slide:has(.o-ecg, .o-block__img, .o-gasstrip) .o-slide__title {
    display: none;
  }
  .app-attendee .reveal .o-slide { justify-content: center; }
}

/* Zoomable artefacts advertise it to the touch system: pinch belongs to
   the artefact, page panning stays with the page until zoomed (the module
   flips touch-action to none only while scale > 1). */
.app-attendee .o-block__img,
.app-attendee .o-ecg,
.app-attendee .o-gasstrip {
  touch-action: pan-x pan-y;
}
/* THE ZOOM SURFACE IS THE VIEWPORT, NOT THE ARTEFACT'S LAYOUT SLOT
   (round 8, item 5). A magnified lead confined to the artefact's original
   box is a keyhole; the zoomed artefact paints over the page — chrome
   included, which is the point of a viewing device. */
.app-attendee .o-block__figure.is-zoomed {
  overflow: visible;
  position: relative;
  z-index: var(--z-modal);
}

/* The drag signpost (round 8, item 2): once per session, fades on its
   own, and can never be in the way of anything. */
.app-attendee__drag-hint {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(var(--s-10) * 2 + var(--safe-b));
  z-index: 2;
  pointer-events: none;
  padding: var(--s-1) var(--s-3);
  border-radius: var(--r-md);
  background: var(--rad-ground);
  color: var(--rad-ink);
  font-size: var(--fs-sm);
  white-space: nowrap;
  animation: osler-drag-hint 6s ease-in-out forwards;
}
@keyframes osler-drag-hint {
  0% { opacity: 0; }
  8% { opacity: 1; }
  75% { opacity: 1; }
  100% { opacity: 0; }
}

/* ==========================================================================
   RADIOLOGY UPLOADER (round 10) — the drop zone is the primary affordance;
   the pickers are the secondary route. Chunk progress is honest, per file.
   ========================================================================== */

.app-dropzone {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-7) var(--s-5);
  border: var(--bw-2) dashed var(--line-control);
  border-radius: var(--r-lg);
  background: var(--surface-sunken);
  text-align: center;
}
.app-dropzone.is-over {
  border-color: var(--accent);
  background: var(--surface-card);
}
.app-dropzone__lead {
  font-size: var(--fs-lg);
  font-weight: var(--weight-bold);
}

.app-upload-progress {
  list-style: none;
  margin: var(--s-3) 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}
.app-upload-progress li {
  display: flex;
  justify-content: space-between;
  gap: var(--s-4);
  font-size: var(--fs-sm);
}
.app-upload-progress__name {
  font-family: var(--font-mono);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

/* The library's working viewer (round 12): a definite stage per the
   artefact-host rule, sized for inspecting slices while entering details. */
.app-library-viewer .rad-viewer__stage {
  height: min(60dvh, 640px);
}
.app-library-viewer .rad-viewer__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.app-library-viewer .rad-viewer { position: relative; }

/* The baseline gallery (round 13 item 4): a development approval surface —
   phone-first grid, cells at natural aspect, readable from the very devices
   the cells describe. */
.app-gallery {
  padding: var(--s-4);
  max-width: 90rem;
  margin-inline: auto;
}
.app-gallery__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
  gap: var(--s-4);
  align-items: start;
}
.app-gallery__cell {
  margin: 0;
  min-width: 0;
}
.app-gallery__img {
  width: 100%;
  height: auto;
  display: block;
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-sunken);
}

/* Round 15 presenter ruling — the editor preview is unmistakably a
   picture: nothing in it responds, no control chips, and on a single-block
   full-size slide it collapses behind "Show slide preview". The working
   viewer is in the block form. */
.app-preview__stage { pointer-events: none; }
.app-preview .rad-viewer__bar { display: none; }
.app-preview .rad-viewer__count { display: none; }
.app-preview.is-collapsed .app-preview__stage {
  height: 0;
  min-height: 0;
  padding: 0;
  border-width: 0;
  overflow: hidden;
}
.app-block__viewer { margin-block: var(--s-3); }
.app-block__viewer .rad-viewer__stage { height: 18rem; }

/* Full-size artefact inspection in the editor (field request): the
   composition preview is honest but tiny; this dialog shows the artefact
   at reading size. Same server-rendered markup as every surface. */
.app-inspect {
  width: min(70rem, 94vw);
  max-height: 94dvh;
  padding: 0;
  border: 0;
  border-radius: var(--r-lg);
  background: var(--surface-overlay);
  color: var(--ink);
}
.app-inspect::backdrop { background: var(--surface-scrim); }
.app-inspect__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--s-3) var(--s-4);
  border-bottom: var(--bw-1) solid var(--line);
}
.app-inspect__body {
  padding: var(--s-4);
  overflow: auto;
  max-height: calc(94dvh - var(--s-10));
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-4);
}
.app-inspect__body .o-gasstrip {
  height: min(78dvh, 640px);
  width: auto;
  max-height: none;
}
.app-inspect__body .o-ketone {
  width: min(20vw, 16rem);
  max-width: none;
}
.app-inspect__body .o-artefact-pair {
  align-items: center;
  flex-wrap: nowrap;
}
.app-inspect__body .o-ecg {
  width: min(66rem, 90vw);
  height: auto;
  max-height: none;
}

/* ---- login portrait (Ward Paper restyle) ---------------------------------
   One new element on the login page — the sanctioned geometry change. The
   credit is the only text over the photograph and it sits on the OPAQUE
   band, so --portrait-credit/--portrait-band is a real, checkable pair
   (tools/check_contrast.py enforces it). */
.app-login__portrait {
  margin: 0;
  line-height: 0; /* no baseline gap under the image */
  border-bottom: var(--bw-1) solid var(--line);
}
.app-login__portrait img {
  width: 100%;
  height: auto;
  display: block;
}
/* On a phone the whole card must fit ONE screen (field, round 21): the
   portrait yields — cropped toward the face, which sits in the plate's
   upper fifth — and the form and quotation never do. Desktop keeps the
   full 4:5 plate. dvh, not vh: the browser chrome collapse is the
   difference between fitting and scrolling on iOS. */
@media (max-width: 40rem) {
  .app-login__portrait img {
    height: min(26dvh, 240px);
    object-fit: cover;
    object-position: 50% 16%;
  }
}
/* Short phones (SE class): the portrait gives a little more and the
   quotation pulls in — everything stays on screen, nothing is hidden. */
@media (max-width: 40rem) and (max-height: 700px) {
  .app-login__portrait img { height: 18dvh; }
  .app-login__quote { margin-top: var(--s-2); padding-top: var(--s-2); }
  .app-login__quote p { font-size: var(--fs-sm); }
}
.app-login__credit {
  background: var(--portrait-band);
  color: var(--portrait-credit);
  font-size: var(--fs-xs);
  line-height: var(--lh-snug);
  padding: var(--s-2) var(--s-4);
}
.app-login__credit a {
  color: var(--portrait-credit);
  text-decoration: underline;
}
/* The quotation: solid paper, clear of the image — and the product's only
   italic (the display serif's italic face exists for this sentence). */
.app-login__quote {
  margin: var(--s-4) 0 0;
  padding-top: var(--s-4);
  border-top: var(--bw-1) solid var(--line-faint);
}
.app-login__quote p {
  margin: 0;
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--fs-md);
  line-height: var(--lh-snug);
  color: var(--ink);
}
.app-login__quote cite {
  display: block;
  margin-top: var(--s-2);
  font-family: var(--font-body);
  font-style: normal;
  font-size: var(--fs-xs);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--ink-muted);
}

/* Card-pass: the demoted study rows lean on their thumbnails for presence;
   a quarantined row keeps a mark — a 3px inset rule in the caution hue,
   drawn INSIDE the reserved border so it cannot move content. */
[data-testid="study-quarantined"].o-card--flat {
  box-shadow: inset 3px 0 0 var(--warn);
}
