/* ==========================================================================
   osler-theme — components
   --------------------------------------------------------------------------
   Reads ONLY from tokens.css. No literal colour, size, font or duration
   appears below except where the value is structurally fixed (0, 1, 100%,
   50%, aspect-ratio, none) and could never be themed.

   Every class here exists because the brief needs it. The mapping:

     brief §1 roles table   .o-bar .o-thumb .o-notes .o-timer .o-blackout
     brief §3 session model .o-qr .o-code .o-modal--full .o-conn .o-pill--live
     brief §4 S0            .o-btn .o-card .o-modal .o-toast .o-poll
     brief §4 S2/S3         .o-holding .o-poll__row .o-progress
     brief §6 pedagogy      .o-segment (lens switch) .o-switch (a11y toggles)

   Touch-first. Nothing below requires :hover to become usable, and every
   interactive element clears --touch-min.
   ========================================================================== */

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

/* The UA rule for [hidden] is only `display: none`, so any author `display`
   on the same element silently beats it and the "hidden" thing keeps its
   layout box. Make the attribute mean what everyone assumes it means. */
[hidden] { display: none !important; }

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--surface-page);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--fs-md);
  font-weight: var(--weight-normal);
  line-height: var(--lh-body);
  letter-spacing: var(--tracking-normal);
  -webkit-tap-highlight-color: var(--transparent);
  /* Attendees pinch-zoom images; they must never rubber-band the page
     underneath while doing it. */
  overscroll-behavior-y: none;
}

/* Paints any container in the current variant's page colours. This is how a
   subtree becomes "the display variant" visually as well as tokenally. */
.o-surface {
  background: var(--surface-page);
  color: var(--ink);
}

/* ---- focus -------------------------------------------------------------- */
/* One ring, everywhere, defined once. Nothing below may set outline:none
   without restating this. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: var(--r-sm);
}

/* ==========================================================================
   TYPOGRAPHY
   ========================================================================== */

h1, h2, h3, h4, .o-h1, .o-h2, .o-h3 {
  font-family: var(--font-display);
  color: var(--ink-strong);
  font-weight: var(--weight-bold);
  line-height: var(--lh-tight);
  letter-spacing: var(--tracking-tight);
  margin: 0;
  text-wrap: balance;
}

h1, .o-h1 { font-size: var(--fs-3xl); }
h2, .o-h2 { font-size: var(--fs-2xl); }
h3, .o-h3 { font-size: var(--fs-xl); }
h4        { font-size: var(--fs-lg); }

/* Prose gets a measure and honours the dyslexia mode's alignment token. */
.o-prose {
  max-width: var(--measure);
  text-align: var(--text-align-prose);
  line-height: var(--lh-body);
}
.o-prose > * + * { margin-top: var(--s-4); }

/* An image inside markdown prose has no block wrapper to size it, so the
   bound lives here. The Reveal shim used to provide this for everything on a
   slide, which is how it ended up overriding the media blocks' own sizes. */
.o-prose img {
  max-width: 100%;
  max-height: 100%;
}

.o-lede {
  font-size: var(--fs-lg);
  color: var(--ink-muted);
  line-height: var(--lh-snug);
  max-width: var(--measure);
}

.o-muted { color: var(--ink-muted); }
.o-small { font-size: var(--fs-sm); }

.o-eyebrow {
  font-size: var(--fs-xs);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--ink-muted);
}

.o-mono {
  font-family: var(--font-mono);
  /* Slashed zero + tabular figures (Plex Mono): 0 vs O off a projected
     screen is the join-code failure this exists to prevent. */
  font-feature-settings: var(--mono-features);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--tracking-normal);
}

/* Visually hidden, still announced. Used wherever a component conveys
   meaning through a glyph or colour — see .o-poll__mark. */
.o-sr {
  position: absolute;
  width: var(--bw-1);
  height: var(--bw-1);
  padding: 0;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ==========================================================================
   LAYOUT PRIMITIVES
   The only two the theme owns. Page layout (grids, column widths, what sits
   where) belongs to the app's own stylesheet, not here.
   ========================================================================== */

.o-stack { display: flex; flex-direction: column; gap: var(--s-4); }
.o-stack--tight { gap: var(--s-2); }
.o-stack--loose { gap: var(--s-7); }
/* Flex defaults to align-items: stretch, which is right for fields and cards
   and wrong for every inline-sized component — a stacked .o-badge or .o-code
   silently becomes full-bleed. Reach for this whenever the children are
   pill-shaped rather than block-shaped. */
.o-stack--start { align-items: flex-start; }

.o-cluster {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
}
.o-cluster--tight { gap: var(--s-2); }

/* ==========================================================================
   BUTTON
   ========================================================================== */

.o-btn {
  /* layout */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  min-height: var(--touch-min);
  padding: var(--s-2) var(--s-5);

  /* type */
  font-family: var(--font-body);
  font-size: var(--fs-md);
  font-weight: var(--weight-medium);
  line-height: var(--lh-tight);
  letter-spacing: var(--tracking-normal);
  text-align: center;
  text-decoration: none;
  white-space: nowrap;

  /* skin — default is the quiet/secondary button */
  background: var(--surface-card);
  color: var(--ink);
  border: var(--bw-1) solid var(--line-control);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-1);

  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
  transition: background var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              transform var(--dur-fast) var(--ease);
}

.o-btn:hover { background: var(--surface-hover); }
.o-btn:active { background: var(--surface-active); transform: translateY(var(--bw-1)); }

/* A toggle button that is ON.

   Without this, `aria-pressed="true"` was announced to screen readers and
   shown to sighted users not at all — so the remote's Blackout and QR buttons
   looked completely inert when pressed, which is exactly how the first real
   user concluded they were broken. `.o-segment__btn` and `.o-poll__option`
   already had a pressed state; the plain button did not. */
.o-btn[aria-pressed="true"] {
  background: var(--accent-soft);
  border-color: var(--accent);
  color: var(--accent-ink);
  font-weight: var(--weight-bold);
  /* Inset rather than raised: it is held down. */
  box-shadow: none;
}
.o-btn[aria-pressed="true"]:hover { background: var(--accent-soft); }

/* Working on it.
 *
 * A button that has been pressed and is waiting must not look like one that
 * has not been pressed at all — otherwise the natural response to the absence
 * of feedback is to press it again. Caught by the style guide's state
 * catalogue, which is exactly what that catalogue is for.
 *
 * `cursor: progress` rather than `not-allowed`: the action was accepted, it is
 * just not finished. */
.o-btn[aria-busy="true"] {
  color: var(--ink-muted);
  border-color: var(--line);
  box-shadow: none;
  cursor: progress;
}
.o-btn[aria-busy="true"]:hover { background: var(--surface-card); }

.o-btn[disabled],
.o-btn[aria-disabled="true"] {
  color: var(--ink-faint);
  background: var(--surface-sunken);
  border-color: var(--line);
  box-shadow: none;
  cursor: not-allowed;
  transform: none;
}

/* ---- variants ------------------------------------------------------------ */

.o-btn--primary {
  /* Accent means STATE, and a primary button is not a state — it is the
     main verb. Ink-filled in the app variant, accent-filled on the
     projector (ink on near-black would vanish); the pair keeps this file
     unconditional. */
  background: var(--btn-primary-fill);
  color: var(--btn-primary-ink);
  border-color: var(--btn-primary-fill);
}
.o-btn--primary:hover  { background: var(--btn-primary-fill-hover); border-color: var(--btn-primary-fill-hover); }
.o-btn--primary:active { background: var(--btn-primary-fill-active); border-color: var(--btn-primary-fill-active); }

.o-btn--danger {
  background: var(--bad);
  color: var(--ink-on-accent);
  border-color: var(--bad);
}

/* Borderless — for toolbars where a row of outlines would be noise. */
.o-btn--ghost {
  background: var(--transparent);
  border-color: var(--transparent);
  box-shadow: none;
  color: var(--ink);
}
.o-btn--ghost:hover { background: var(--surface-hover); }

/* The remote's primary controls. Operated one-handed, in the dark, by
   someone who is talking to a room at the same time. */
.o-btn--lg {
  min-height: var(--touch-lg);
  padding: var(--s-3) var(--s-7);
  font-size: var(--fs-lg);
  border-radius: var(--r-lg);
}

.o-btn--block { display: flex; width: 100%; }

.o-btn--icon {
  padding: var(--s-2);
  min-width: var(--touch-min);
  aspect-ratio: 1;
}
.o-btn--icon.o-btn--lg {
  min-width: var(--touch-lg);
  padding: var(--s-3);
}

/* ==========================================================================
   CARD
   ========================================================================== */

.o-card {
  background: var(--surface-card);
  color: var(--ink);
  border: var(--border-card) solid var(--line);
  /* Square chrome (Ward Paper): --r-surface, and the CSO ruling keeps the
     deck cards boxed — hairline, square, shadowless. */
  border-radius: var(--r-surface);
  box-shadow: var(--shadow-1);
  overflow: hidden;
}

/* The card-pass demotion: a RECORD in a list rather than an object you
   handle. The border is reserved but transparent (never border: none — the
   1px reservation is what makes the pass provably zero-drift) and the
   ground goes transparent with it. Templates opt records in; a row that
   needs a ledger rule above it turns its reserved top border back on with
   .o-card--ruled. */
.o-card--flat {
  background: var(--transparent);
  border-color: var(--transparent);
  box-shadow: none;
}
.o-card--flat.o-card--ruled { border-top-color: var(--line); }

.o-card__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  padding: var(--s-4) var(--s-5);
  border-bottom: var(--bw-1) solid var(--line-faint);
}

.o-card__title {
  font-family: var(--font-display);
  font-size: var(--fs-lg);
  font-weight: var(--weight-bold);
  color: var(--ink-strong);
  line-height: var(--lh-snug);
  margin: 0;
}

.o-card__body { padding: var(--s-5); }

.o-card__foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--s-3);
  padding: var(--s-4) var(--s-5);
  background: var(--surface-sunken);
  border-top: var(--bw-1) solid var(--line-faint);
}

/* A card that is itself the interactive target (deck picker, library grid). */
.o-card--link {
  display: block;
  text-decoration: none;
  transition: border-color var(--dur-fast) var(--ease),
              box-shadow var(--dur-fast) var(--ease);
}
.o-card--link:hover {
  border-color: var(--accent-line);
  box-shadow: var(--shadow-2);
}

.o-card--accent { border-color: var(--accent-line); background: var(--accent-soft); }

/* ==========================================================================
   FORM FIELDS
   ========================================================================== */

.o-field { display: flex; flex-direction: column; gap: var(--s-2); }

.o-label {
  font-size: var(--fs-sm);
  font-weight: var(--weight-medium);
  color: var(--ink);
}

.o-input,
.o-select,
.o-textarea {
  width: 100%;
  min-height: var(--touch-min);
  padding: var(--s-2) var(--s-3);
  font-family: var(--font-body);
  font-size: var(--fs-md);
  line-height: var(--lh-snug);
  color: var(--ink);
  background: var(--surface-card);
  border: var(--bw-1) solid var(--line-control);
  border-radius: var(--r-md);
  transition: border-color var(--dur-fast) var(--ease);
}

.o-textarea {
  min-height: var(--s-11);
  resize: vertical;
  line-height: var(--lh-body);
}

.o-input::placeholder,
.o-textarea::placeholder { color: var(--ink-faint); }

.o-input:focus,
.o-select:focus,
.o-textarea:focus { border-color: var(--accent); }

.o-input[aria-invalid="true"],
.o-textarea[aria-invalid="true"] { border-color: var(--bad); }

.o-input[disabled],
.o-select[disabled],
.o-textarea[disabled] {
  background: var(--surface-sunken);
  color: var(--ink-faint);
  cursor: not-allowed;
}

.o-hint { font-size: var(--fs-sm); color: var(--ink-muted); }
.o-error {
  font-size: var(--fs-sm);
  color: var(--bad-ink);
  font-weight: var(--weight-medium);
}

/* ==========================================================================
   SWITCH — the accessibility toggles (brief §6) and per-session settings.
   ========================================================================== */

.o-switch {
  /* Positioned so the visually-hidden input below is contained by the label
     rather than escaping to whatever distant ancestor happens to be
     positioned — which is where a stray 1px focus target and a surprising
     scroll-into-view jump come from. */
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  min-height: var(--touch-min);
  cursor: pointer;
  user-select: none;
}

.o-switch__input {
  position: absolute;
  opacity: 0;
  width: var(--bw-1);
  height: var(--bw-1);
}

.o-switch__track {
  position: relative;
  flex: none;
  width: var(--sz-switch-w);
  height: var(--sz-switch-h);
  background: var(--surface-sunken);
  border: var(--bw-2) solid var(--line-control);
  border-radius: var(--r-pill);
  transition: background var(--dur-base) var(--ease),
              border-color var(--dur-base) var(--ease);
}

.o-switch__thumb {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  aspect-ratio: 1;
  background: var(--surface-card);
  border: var(--bw-1) solid var(--line-control);
  border-radius: var(--r-circle);
  transition: transform var(--dur-base) var(--ease);
}

.o-switch__input:checked ~ .o-switch__track {
  background: var(--accent);
  border-color: var(--accent);
}
/* Travel = track width - thumb width - both borders. Expressed in tokens so
   resizing the switch stays a one-line change in tokens.css. */
.o-switch__input:checked ~ .o-switch__track .o-switch__thumb {
  transform: translateX(calc(var(--sz-switch-w) - var(--sz-switch-h)));
}

.o-switch__input:focus-visible ~ .o-switch__track {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}

.o-switch__label { font-size: var(--fs-md); color: var(--ink); }

/* ==========================================================================
   SEGMENTED CONTROL — the case-first / principles-first lens switch
   (brief §6 "dual lenses"), and any other 2-3 way exclusive choice.
   ========================================================================== */

.o-segment {
  display: inline-flex;
  padding: var(--bw-3);
  background: var(--surface-sunken);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-pill);
  gap: var(--bw-2);
}

.o-segment__btn {
  appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--touch-min);
  padding: var(--s-2) var(--s-5);
  font-family: var(--font-body);
  font-size: var(--fs-sm);
  font-weight: var(--weight-medium);
  color: var(--ink-muted);
  background: var(--transparent);
  border: 0;
  border-radius: var(--r-pill);
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--dur-fast) var(--ease),
              color var(--dur-fast) var(--ease);
}

.o-segment__btn[aria-pressed="true"],
.o-segment__btn[aria-selected="true"] {
  background: var(--surface-card);
  color: var(--ink-strong);
  box-shadow: var(--shadow-1);
}

/* ==========================================================================
   BADGE / PILL / STATUS DOT
   ========================================================================== */

.o-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  padding: var(--s-1) var(--s-3);
  font-size: var(--fs-xs);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--ink-muted);
  background: var(--surface-sunken);
  /* A stamped label, not a small box: the border is RESERVED (weight kept,
     colour transparent — border: none would move the text a pixel) and the
     radius is --r-badge, not the pill (card-pass ruling). State reads as
     ink + ground + caps + letterspacing — never colour alone (the glyph and
     sr-label rules are unchanged). */
  border: var(--bw-1) solid var(--transparent);
  border-radius: var(--r-badge);
  white-space: nowrap;
}

.o-badge--ok   { color: var(--ok-ink);   background: var(--ok-soft); }
.o-badge--bad  { color: var(--bad-ink);  background: var(--bad-soft); }
.o-badge--warn { color: var(--warn-ink); background: var(--warn-soft); }
.o-badge--info { color: var(--info-ink); background: var(--info-soft); }
.o-badge--accent {
  color: var(--accent-ink);
  background: var(--accent-soft);
}

.o-dot {
  flex: none;
  width: var(--sz-dot);
  height: var(--sz-dot);
  border-radius: var(--r-circle);
  background: var(--ink-faint);
}
.o-dot--live { background: var(--bad); }
.o-dot--ok   { background: var(--ok); }
.o-dot--warn { background: var(--warn); }

/* The live dot is SOLID (field ruling): the opacity pulse read as
   flicker, and a status marker that blinks looks like a fault. Liveness
   is the pill's wording; steadiness is the point. */

/* "Return to live" — the pill that snaps a self-pacing attendee forward
   (brief §3). Floats above content, clears the home indicator. */
.o-pill {
  position: fixed;
  left: 50%;
  bottom: calc(var(--s-5) + var(--safe-b));
  transform: translateX(-50%);
  z-index: var(--z-pill);

  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--touch-min);
  padding: var(--s-2) var(--s-5);

  font-size: var(--fs-sm);
  font-weight: var(--weight-medium);
  color: var(--ink-on-accent);
  background: var(--accent);
  /* The shadow is gone system-wide; a floating control over arbitrary slide
     content needs an edge instead. bw-1 on a border:0 element grows the pill
     2px total — it floats out of flow, so nothing else moves. */
  border: var(--bw-1) solid var(--line-strong);
  border-radius: var(--r-pill);
  box-shadow: var(--shadow-2);
  cursor: pointer;
}

/* ==========================================================================
   CONNECTION STATUS — the sync spine is a WebSocket; when it drops the
   attendee must know, and rejoin must look deliberate rather than broken
   (brief §3 "silent rejoin").
   ========================================================================== */

.o-conn {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--fs-sm);
  color: var(--ink-muted);
}

.o-spinner {
  flex: none;
  width: var(--sz-spinner);
  height: var(--sz-spinner);
  border: var(--bw-2) solid var(--line);
  border-top-color: var(--accent);
  border-radius: var(--r-circle);
  animation: o-spin var(--dur-slow) linear infinite;
}
@keyframes o-spin { to { transform: rotate(1turn); } }

/* ==========================================================================
   APP BAR — the chrome on the remote and authoring views. Safe-area aware
   so it clears the notch in the installed PWA (brief §2).
   ========================================================================== */

.o-bar {
  position: sticky;
  top: 0;
  z-index: var(--z-bar);

  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: calc(var(--s-3) + var(--safe-t)) calc(var(--s-4) + var(--safe-r))
           var(--s-3) calc(var(--s-4) + var(--safe-l));

  background: var(--surface-card);
  border-bottom: var(--bw-1) solid var(--line);
}

.o-bar__brand {
  font-family: var(--font-display);
  font-size: var(--fs-lg);
  font-weight: var(--weight-bold);
  color: var(--ink-strong);
  letter-spacing: var(--tracking-tight);
  text-decoration: none;
}

.o-bar__spacer { flex: 1 1 auto; }

/* ==========================================================================
   MODAL
   ========================================================================== */

.o-modal {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-5);
  background: var(--surface-scrim);
}

.o-modal__box {
  width: 100%;
  max-width: var(--measure);
  max-height: 100%;
  overflow: auto;
  background: var(--surface-overlay);
  color: var(--ink);
  /* Shadowless system: the border does the separating now, at the SAME
     weight it always reserved (a weight change moves content). */
  border: var(--border-card) solid var(--line-strong);
  border-radius: var(--r-surface);
  box-shadow: var(--shadow-3);
}

.o-modal__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  padding: var(--s-5);
  border-bottom: var(--bw-1) solid var(--line-faint);
}

.o-modal__title {
  font-family: var(--font-display);
  font-size: var(--fs-xl);
  color: var(--ink-strong);
  margin: 0;
}

.o-modal__body { padding: var(--s-5); }

.o-modal__foot {
  display: flex;
  justify-content: flex-end;
  gap: var(--s-3);
  padding: var(--s-5);
  border-top: var(--bw-1) solid var(--line-faint);
}

/* Full-bleed variant — the join-QR takeover on the room display. No box, no
   chrome, maximum contrast, because it is read from the back row by someone
   holding a phone camera at arm's length (brief §3). */
.o-modal--full { padding: 0; background: var(--surface-page); }
.o-modal--full .o-modal__box {
  max-width: none;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-7);
  border: 0;
  border-radius: 0;
  box-shadow: none;
  background: var(--surface-page);
}

/* ==========================================================================
   JOIN QR + CODE (brief §3)
   ========================================================================== */

.o-qr {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--qr-size);
  height: var(--qr-size);
  padding: var(--s-4);
  /* The quiet zone must be near-white for reliable camera decode even on the
     dark display variant — this is the one place a bright ground is correct,
     and it is bounded to the QR itself rather than flooding the room. */
  background: var(--qr-quiet);
  color: var(--qr-ink);
  border-radius: var(--r-md);
}
.o-qr > svg, .o-qr > img { width: 100%; height: 100%; display: block; }

/* The human-typeable fallback: osler.../j/ABCD. Tabular, wide-tracked,
   unambiguous glyphs — attendees key this in from the back of the room. */
.o-code {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-6);
  font-family: var(--font-mono);
  /* THE pair that matters: this renders the join code on the projector. */
  font-feature-settings: var(--mono-features);
  font-size: var(--fs-2xl);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-caps);
  color: var(--ink-strong);
  background: var(--surface-sunken);
  border: var(--bw-2) dashed var(--line-strong);
  border-radius: var(--r-md);
}

/* ==========================================================================
   TOAST
   ========================================================================== */

.o-toast {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  padding: var(--s-4);
  max-width: var(--measure);
  background: var(--surface-inverse);
  color: var(--ink-inverse);
  /* bw-1, matching the weight the status variants below always carried, so
     base and status toasts share one box size. */
  border: var(--bw-1) solid var(--line-strong);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-2);
  font-size: var(--fs-sm);
}

.o-toast--ok   { background: var(--ok-soft);   color: var(--ok-ink);   border: var(--bw-1) solid var(--ok); }
.o-toast--bad  { background: var(--bad-soft);  color: var(--bad-ink);  border: var(--bw-1) solid var(--bad); }
.o-toast--warn { background: var(--warn-soft); color: var(--warn-ink); border: var(--bw-1) solid var(--warn); }

/* The live region wrapper — fixed, stacked, clears the home indicator. */
.o-toaster {
  position: fixed;
  left: 50%;
  bottom: calc(var(--s-5) + var(--safe-b));
  transform: translateX(-50%);
  z-index: var(--z-toast);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  width: max-content;
  max-width: calc(100% - var(--s-8));
}

/* ==========================================================================
   POLL (brief S3)
   --------------------------------------------------------------------------
   Correctness is NEVER carried by colour alone. Every correct/incorrect row
   also gets a glyph (.o-poll__mark, aria-hidden) plus an .o-sr text label,
   and a thickened left border. Red/green is the single most common
   colour-blind failure in teaching software and this component is the one
   place in the suite where it would actually change what a learner takes
   away from the session.
   ========================================================================== */

.o-poll { display: flex; flex-direction: column; gap: var(--s-3); }

.o-poll__row {
  position: relative;
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--s-3);
}

.o-poll__mark {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--sz-icon);
  height: var(--sz-icon);
  font-size: var(--fs-sm);
  font-weight: var(--weight-bold);
  color: var(--ink-faint);
}

.o-poll__label {
  font-size: var(--fs-md);
  color: var(--ink);
  line-height: var(--lh-snug);
}

.o-poll__count {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-sm);
  color: var(--ink-muted);
  white-space: nowrap;
}

/* The trough spans all three columns on the row below the label. */
.o-poll__track {
  grid-column: 1 / -1;
  position: relative;
  height: var(--poll-bar-h);
  background: var(--surface-sunken);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-sm);
  overflow: hidden;
}

/* Width is set inline by the app as --o-poll-pct; the fallback keeps the
   component renderable in a static style guide. */
.o-poll__fill {
  height: 100%;
  width: var(--o-poll-pct, 0%);
  background: var(--accent);
  border-radius: 0;
  transition: width var(--dur-slow) var(--ease-out);
}

.o-poll__row.is-correct .o-poll__fill { background: var(--ok); }
.o-poll__row.is-correct .o-poll__mark { color: var(--ok-ink); }
.o-poll__row.is-correct .o-poll__track { border-color: var(--ok); border-left-width: var(--bw-4); }

.o-poll__row.is-incorrect .o-poll__fill { background: var(--bad); }
.o-poll__row.is-incorrect .o-poll__mark { color: var(--bad-ink); }
.o-poll__row.is-incorrect .o-poll__track { border-color: var(--bad); border-left-width: var(--bw-4); }

/* The attendee's own selection, before results are revealed. */
.o-poll__row.is-chosen .o-poll__label { font-weight: var(--weight-bold); }
.o-poll__row.is-chosen .o-poll__track { border-color: var(--accent); border-left-width: var(--bw-4); }

/* Voting mode: the whole row is the tap target, and it is a big one. */
.o-poll__option {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  min-height: var(--touch-lg);
  padding: var(--s-3) var(--s-4);
  font-family: var(--font-body);
  font-size: var(--fs-md);
  text-align: left;
  color: var(--ink);
  background: var(--surface-card);
  border: var(--bw-2) solid var(--line-control);
  border-radius: var(--r-md);
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease);
}
/* Not open yet.
 *
 * An attendee sees the question and its options from the moment the slide
 * appears, so they can read and start thinking — but must not be able to
 * answer before the presenter opens it. Quietened and unmistakably inert,
 * rather than hidden: "you will be asked this" is worth knowing.
 *
 * `not-allowed` rather than `default` on the cursor, and full opacity on the
 * text: an option nobody can read is not a preview of anything. */
.o-poll__option:disabled,
.o-poll__option[aria-disabled="true"] {
  background: var(--surface-sunken);
  border-color: var(--line);
  border-style: dashed;
  color: var(--ink-muted);
  cursor: not-allowed;
}

.o-poll__option[aria-pressed="true"] {
  border-color: var(--accent);
  background: var(--accent-soft);
  font-weight: var(--weight-medium);
}

/* ---- word cloud (S3) -----------------------------------------------------
   Weighted in four steps, not on a continuous scale. This is read from the
   back of a lecture theatre, where the difference between two adjacent
   continuous sizes carries no information — but four clearly distinct tiers
   do. The count also goes to screen readers as text, because size is not
   available to them at all. */

.o-poll__cloud {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: center;
  gap: var(--s-3) var(--s-5);
  list-style: none;
  margin: 0;
  padding: 0;
}

.o-poll__word {
  color: var(--ink);
  line-height: var(--lh-snug);
  font-size: var(--fs-md);
}
.o-poll__word[data-weight="2"] { font-size: var(--fs-lg); }
.o-poll__word[data-weight="3"] {
  font-size: var(--fs-2xl);
  font-weight: var(--weight-medium);
}
.o-poll__word[data-weight="4"] {
  font-size: var(--fs-3xl);
  font-weight: var(--weight-bold);
  color: var(--ink-strong);
}

.o-poll__word-form { display: flex; gap: var(--s-2); }
.o-poll__word-form .o-input { flex: 1 1 auto; min-width: 0; }

/* ---- ranking (S3) --------------------------------------------------------
   Drag is an addition, never the only way: a ranking you can only express by
   dragging is one a switch user cannot express at all, so every item also
   carries up/down buttons at full touch size. */

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

.o-poll__rank-item {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  min-height: var(--touch-lg);
  padding: var(--s-2) var(--s-3);
  background: var(--surface-card);
  border: var(--bw-2) solid var(--line-control);
  border-radius: var(--r-md);
  cursor: grab;
}

.o-poll__rank-pos {
  flex: none;
  width: var(--sz-icon);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-sm);
  color: var(--ink-muted);
}

.o-poll__rank-label { flex: 1 1 auto; min-width: 0; color: var(--ink); }
.o-poll__rank-controls { flex: none; display: flex; gap: var(--s-1); }

/* ---- word-cloud moderation, remote only (S3) -----------------------------
   The presenter taps to hide an entry before the room ever sees it. Hidden
   rows stay legible rather than being dimmed into unreadability — the
   presenter has to be able to find one again to un-hide it. */

.o-poll__mod {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  list-style: none;
  margin: 0;
  padding: 0;
}

.o-poll__mod-row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  min-height: var(--touch-min);
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-sm);
  background: var(--surface-card);
}

.o-poll__mod-text { flex: 1 1 auto; min-width: 0; color: var(--ink); }

.o-poll__mod-count {
  flex: none;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-sm);
  color: var(--ink-muted);
}

.o-poll__mod-row.is-hidden-entry {
  background: var(--surface-sunken);
  border-left: var(--bw-4) solid var(--bad);
}
.o-poll__mod-row.is-hidden-entry .o-poll__mod-text {
  text-decoration: line-through;
  color: var(--ink-muted);
}

.o-poll__question {
  font-size: var(--fs-xl);
  font-weight: var(--weight-bold);
  color: var(--ink-strong);
  line-height: var(--lh-snug);
  margin: 0;
}

.o-poll__opts-static {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  list-style: none;
  margin: 0;
  padding: 0;
  color: var(--ink);
}

.o-poll__status { color: var(--ink-muted); font-size: var(--fs-sm); }

/* ==========================================================================
   PROGRESS — slide position, poll countdown.
   ========================================================================== */

.o-progress {
  height: var(--bw-6);
  width: 100%;
  background: var(--surface-sunken);
  border-radius: var(--r-pill);
  overflow: hidden;
}
.o-progress__fill {
  height: 100%;
  width: var(--o-progress-pct, 0%);
  background: var(--accent);
  transition: width var(--dur-base) var(--ease-out);
}

/* ==========================================================================
   REMOTE — thumbnails, timer, speaker notes (brief §1 roles table)
   ========================================================================== */

.o-thumb {
  display: block;
  width: var(--sz-thumb-w);
  aspect-ratio: 16 / 9;
  background: var(--surface-sunken);
  border: var(--bw-2) solid var(--line);
  border-radius: var(--r-md);
  overflow: hidden;
}
.o-thumb--current { border-color: var(--accent); }
.o-thumb > img { width: 100%; height: 100%; object-fit: cover; display: block; }

.o-thumb__caption {
  font-size: var(--fs-xs);
  color: var(--ink-muted);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  font-weight: var(--weight-bold);
}

/* Elapsed-session clock. Tabular so the digits do not jitter, which is
   distracting in peripheral vision while presenting. */
.o-timer {
  font-family: var(--font-mono);
  font-feature-settings: var(--mono-features);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-2xl);
  font-weight: var(--weight-bold);
  color: var(--ink-strong);
  letter-spacing: var(--tracking-normal);
  line-height: var(--lh-tight);
}
.o-timer--over { color: var(--bad-ink); }

/* Speaker notes. Read at a glance, in a dark room, mid-sentence — so the
   measure is short and the leading is generous even outside dyslexia mode. */
.o-notes {
  padding: var(--s-4);
  background: var(--surface-sunken);
  border-left: var(--bw-4) solid var(--accent-line);
  border-radius: var(--r-sm);
  font-size: var(--fs-lg);
  line-height: var(--lh-body);
  color: var(--ink);
  max-width: var(--measure);
  text-align: var(--text-align-prose);
}

/* ==========================================================================
   BLACKOUT — the presenter's "look at me, not the screen" button. Sits above
   every other layer including modals, deliberately.
   ========================================================================== */

.o-blackout {
  position: fixed;
  inset: 0;
  z-index: var(--z-blackout);
  background: var(--blackout);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ==========================================================================
   HOLDING CARD — what attendees see on their own device while media plays on
   the room display only (brief S2 accept criterion).
   ========================================================================== */

.o-holding {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-4);
  padding: var(--s-9) var(--s-5);
  text-align: center;
  background: var(--surface-sunken);
  border: var(--bw-2) dashed var(--line-strong);
  border-radius: var(--r-lg);
  color: var(--ink-muted);
}
.o-holding__title {
  font-family: var(--font-display);
  font-size: var(--fs-xl);
  color: var(--ink);
  margin: 0;
}

/* ==========================================================================
   FORCED COLOURS — keep structural borders visible when the OS overrides
   our palette entirely.
   ========================================================================== */

@media (forced-colors: active) {
  .o-card, .o-modal__box, .o-poll__track, .o-thumb, .o-btn, .o-badge {
    border-style: solid;
  }
  .o-poll__fill, .o-progress__fill { forced-color-adjust: none; }
}

/* ==========================================================================
   SLIDES AND BLOCKS (S2)
   --------------------------------------------------------------------------
   The rendered deck. One markup shape serves the room display, the attendee
   follower view, the remote's thumbnails and the authoring preview — a
   thumbnail is the real slide at 0.2 scale, not a screenshot, so it cannot
   show something the display will not.
   ========================================================================== */

.o-slide {
  display: flex;
  flex-direction: column;
  gap: var(--s-6);
  width: 100%;
  height: 100%;
  padding: var(--slide-pad);
  padding-bottom: var(--slide-pad-bottom);
  text-align: left;
}

.o-slide__title {
  /* Explicitly the display serif (restyle): the slide chrome must not lean
     on inheritance for the face that carries the deck's voice. */
  font-family: var(--font-display);
  font-size: var(--fs-3xl);
  line-height: var(--lh-tight);
  margin: 0;
  text-wrap: balance;
}

.o-slide__body {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
  flex: 1;
  min-height: 0;
}

/* ---- blocks -------------------------------------------------------------- */

.o-block { min-width: 0; }

.o-block__prose { font-size: var(--fs-xl); max-width: var(--measure); }
.o-block__prose > * + * { margin-top: var(--s-4); }
.o-block__prose :where(h1, h2, h3) { font-size: var(--fs-2xl); }
.o-block__prose :where(ul, ol) { padding-left: var(--s-6); }
.o-block__prose :where(li) + :where(li) { margin-top: var(--s-2); }
.o-block__prose :where(code) {
  font-family: var(--font-mono);
  background: var(--surface-sunken);
  border-radius: var(--r-sm);
  padding: 0 var(--s-1);
}
.o-block__prose :where(strong) { font-weight: var(--weight-bold); }
.o-block__prose :where(blockquote) {
  border-left: var(--bw-4) solid var(--accent-line);
  padding-left: var(--s-4);
  color: var(--ink-muted);
}

.o-block__figure {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  /* Bounded by the flex line, so a tall image shrinks rather than overflowing;
     the img's own max-height then applies the authored size on top. */
  max-height: 100%;
  min-height: 0;
  min-width: 0;
  /* The caption sits under the media and is centred with it, so the figure
     shrinks to its content rather than stretching across the slide. */
  align-items: center;
}

/* object-fit: contain — a clinical image must never be cropped to fit a box.
   A chest film with the costophrenic angles cut off is a different film.

   Images and video are replaced elements, so max-width/max-height preserve
   their intrinsic aspect for free — no aspect-ratio needed, and no distortion
   when the width constraint binds. */
.o-block__img,
.o-block__video {
  max-width: 100%;
  /* Two independent caps: never taller than the space, never bigger than the
     authored size. Replaced elements keep their aspect under both for free. */
  max-height: var(--media-h, var(--media-h-lg));
  min-height: 0;
  object-fit: contain;
  border-radius: var(--r-md);
  background: var(--surface-sunken);
}

.o-block__audio { width: 100%; }

.o-block__caption {
  font-size: var(--fs-sm);
  color: var(--ink-muted);
  margin: 0;
}

/* Media blocks size themselves to FIT, and centre by default.
 *
 * The previous rule was `width: 100%; aspect-ratio: 16/9`, which on a 1280-wide
 * slide demands ~675px of height in ~520px of content height. The box
 * overflowed the slide and took the player out of view with it — audio kept
 * playing, which is how it was found, in front of a room.
 *
 * Sizing by HEIGHT and deriving width is what makes fitting structural rather
 * than lucky: a 16:9 box on a 16:9 slide is always height-limited. */
.o-block--media {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Grows to claim the slide's spare height. Without this the block is
     content-sized, its height is indefinite, and a percentage height on the
     media inside resolves against nothing — which collapsed the player to a
     couple of hundred pixels. */
  flex: 1 1 auto;
  min-height: 0;
  min-width: 0;
}

.o-block__yt {
  position: relative;
  /* Take the height the flex line offers, capped at the authored size. A
     crowded slide therefore shrinks the player instead of pushing it off the
     bottom, and width follows from the aspect ratio either way. */
  height: 100%;
  max-height: var(--media-h, var(--media-h-lg));
  aspect-ratio: 16 / 9;
  max-width: 100%;
  background: var(--surface-sunken);
  border-radius: var(--r-md);
  overflow: hidden;
}
.o-block__yt-mount,
.o-block__yt iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

.o-block__poll {
  padding: var(--s-5);
  background: var(--surface-sunken);
  border: var(--bw-2) solid var(--line);
  border-radius: var(--r-lg);
}
.o-block__poll-q {
  font-size: var(--fs-xl);
  margin: var(--s-2) 0 var(--s-4);
}
.o-block__poll--waiting {
  border-style: dashed;
}

.o-block__poll-opts {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}
.o-poll__option-static {
  padding: var(--s-3) var(--s-4);
  background: var(--surface-card);
  border: var(--bw-2) solid var(--line-control);
  border-radius: var(--r-md);
  font-size: var(--fs-md);
}

.o-block__stub,
.o-block__missing {
  padding: var(--s-5);
  border: var(--bw-2) dashed var(--line-strong);
  border-radius: var(--r-lg);
  background: var(--surface-sunken);
}

.o-holding__glyph { font-size: var(--fs-3xl); }

/* ---- thumbnails ---------------------------------------------------------- */
/* The remote's current/next previews. A real slide, scaled — no screenshots,
   so it can never disagree with what the room is seeing. The inner element is
   rendered at display dimensions and transformed down; the outer clips. */

.o-thumb__scaler {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--slide-w);
  height: var(--slide-h);
  transform-origin: top left;
  pointer-events: none;
  overflow: hidden;
  background: var(--surface-page);
}
/* The clone arrives wrapped in its template div, and that wrapper is part
   of the height chain: left at auto, every 100% below it resolves to
   nothing — which is how the full-media rung (the only percentage rung)
   rendered a 0x0 gas strip in the thumbnail while its px-rung siblings
   looked fine. The seventh missing-artefact report, and the reason the
   definite-size rule now names WRAPPERS as part of the host. */
.o-thumb__scaler > [data-slide-index] { height: 100%; }
.o-thumb__scaler .o-slide { padding: var(--s-6); }
.o-thumb__scaler .o-slide__title { font-size: var(--fs-2xl); }
.o-thumb__scaler .o-block__prose { font-size: var(--fs-lg); }

/* ---- small button, and the autosave indicator (S2) ---------------------- */

/* Compact variant for dense toolbars (the block-type row in the editor).
   Still clears --touch-min vertically; only the padding tightens. */
.o-btn--sm {
  padding: var(--s-1) var(--s-3);
  font-size: var(--fs-sm);
}

/* Autosave state. There is no Save button, so this is the only thing telling
   an author their work is safe — it has to be legible and it must never say
   "Saved" optimistically. */
.o-save {
  font-size: var(--fs-sm);
  color: var(--ink-muted);
  white-space: nowrap;
}
.o-save.is-saving { color: var(--ink-muted); }
.o-save.is-saved  { color: var(--ok-ink); }
.o-save.is-error  { color: var(--bad-ink); font-weight: var(--weight-bold); }

/* ---- media size and position (S2c) -------------------------------------- */
/* Tokenised sizes, not free pixels: the theme owns how big "large" is, so a
   deck cannot be authored to a width that only works on the laptop it was
   written on. The same classes drive the display, the attendee view, the
   remote's thumbnails and the authoring preview — one renderer, four
   consumers. */

.o-block--media.is-size-small  { --media-h: var(--media-h-sm); }
.o-block--media.is-size-medium { --media-h: var(--media-h-md); }
.o-block--media.is-size-large  { --media-h: var(--media-h-lg); }
.o-block--media.is-size-full   { --media-h: var(--media-h-full); }

/* THE SIZE VOCABULARY HAS TO REACH THE ARTEFACTS TOO.
   `--media-h` is consumed by .o-block__img, .o-block__video and .o-block__yt
   and by nothing else, so a generated ECG or gas strip carried the size class
   on its wrapper and dropped it: "full" rendered exactly like "small". The
   artefacts read their own ladder, keyed the same way. */
.o-block--media.is-size-small {
  --artefact-w: var(--artefact-w-sm);
  --artefact-h: var(--artefact-h-sm);
}
.o-block--media.is-size-medium {
  --artefact-w: var(--artefact-w-md);
  --artefact-h: var(--artefact-h-md);
}
.o-block--media.is-size-large {
  --artefact-w: var(--artefact-w-lg);
  --artefact-h: var(--artefact-h-lg);
}
.o-block--media.is-size-full {
  --artefact-w: var(--artefact-w-full);
  --artefact-h: var(--artefact-h-full);
}

/* A 12-lead is wide: width binds, height follows the aspect ratio. */
.o-block--generated_ecg .o-block__figure { width: 100%; }
.o-block--generated_ecg .o-ecg {
  width: var(--artefact-w, var(--artefact-w-lg));
  max-width: 100%;
  height: auto;
  margin-inline: auto;
}

/* A strip is a tall column: height binds, and the meter beside it follows. */
.o-block--generated_gas .o-block__figure { height: 100%; min-height: 0; }
.o-block--generated_gas .o-artefact-pair,
.o-block--generated_gas .o-gasstrip-holder { height: 100%; min-height: 0; }
.o-block--generated_gas .o-gasstrip {
  height: var(--artefact-h, var(--artefact-h-lg));
  max-height: 100%;
  width: auto;
}
.o-block--generated_gas .o-ketone {
  width: calc(var(--artefact-h, var(--artefact-h-lg)) * 0.55);
  max-width: 40%;
}

/* FULL MEANS THE WHOLE CANVAS, AND THE TITLE GOES.
   A 12-lead at full size wants every millimetre of the slide, and a title
   above it is the difference between twelve readable leads and twelve nearly
   readable ones. Suppressed on *every* consumer rather than only on the
   display, because a preview that showed a title the room will not show would
   be lying about the thing it exists to preview. */
.o-slide--full-media {
  /* Near edge to edge (gallery second pass revised "edge to edge"): the
     media padding keeps the artefact large but never flush — content at
     the tile's bottom edge read as a crop in every preview cell. */
  padding: var(--slide-pad-media);
  gap: 0;
}
.o-slide--full-media .o-slide__title { display: none; }
.o-slide--full-media .o-slide__body { padding: 0; gap: 0; }
.o-slide--full-media .o-block--media { width: 100%; height: 100%; }
/* Round 15 (gallery verdict, cell 2): FULL means contain-fit to the whole
   canvas — scale until the first axis binds and letterbox only the other.
   The --media-h-full token is "the largest that fits under a title", but a
   full-media slide has no title, so capping at it left margin on BOTH
   axes at once, which is never correct. */
/* Images and video only — NOT the radiology figure: its stage is a block
   whose content is absolutely positioned, so a centring flex column
   shrinks it to zero width (a dark sliver with the figure background
   showing beside it — the round-15 regression on size:full CT slides). */
.o-slide--full-media .o-block--image .o-block__figure,
.o-slide--full-media .o-block--video .o-block__figure {
  height: 100%;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin: 0;
}
.o-slide--full-media .o-block__img,
.o-slide--full-media .o-block__video {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  min-height: 0;
  flex: 0 1 auto;
}
.o-slide--full-media .o-block--generated_ecg .o-ecg {
  width: 100%;
  max-height: 100%;
}

.o-block--media.is-pos-left   { justify-content: flex-start; }
.o-block--media.is-pos-centre { justify-content: center; }
.o-block--media.is-pos-right  { justify-content: flex-end; }


/* ==========================================================================
   GENERATED BLOOD GAS STRIP (S4)
   --------------------------------------------------------------------------
   An imitation of a physical object, so it gets its own small palette in the
   semantic layer rather than borrowing the UI surfaces. Everything here is
   inside an <svg> the server rendered from osler/gas_strip.py; the classes
   are on SVG elements, which is why fills are set rather than backgrounds.
   ========================================================================== */

.o-gasstrip {
  display: block;
  width: 100%;
  height: auto;
  max-height: 100%;
  /* Barely off square to the page. Enough to read as a torn strip that was
     put down on a desk, not enough to make anyone tilt their head. */
  transform: rotate(-0.5deg);
  filter: var(--strip-shadow);
}

.o-gasstrip .s-paper { fill: var(--strip-paper); }
.o-gasstrip .s-paper-cut { fill: var(--strip-paper); }

.o-gasstrip text {
  /* --font-machine, NEVER --font-mono: the printout's lettering is part of
     the artefact, and the UI mono restyling must not reach it (Ward Paper
     restyle; enforced by check_theme.py). */
  font-family: var(--font-machine);
  /* px, not rem — inside a viewBox this is user units and scales with the
     paper. See the token definition for what rem did here. */
  font-size: var(--strip-fs-body);
  fill: var(--strip-ink);
  /* The dot-matrix face: real thermal print is slightly heavy and slightly
     spaced, and the spacing does more of the work than the weight. */
  letter-spacing: var(--tracking-print);
}

.o-gasstrip .s-head {
  font-size: var(--strip-fs-head);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-machine);
}
.o-gasstrip .s-sub {
  fill: var(--strip-ink-muted);
  letter-spacing: var(--tracking-banner);
}
.o-gasstrip .s-meta { fill: var(--strip-ink-muted); }
.o-gasstrip .s-section {
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-machine);
  fill: var(--strip-ink);
}
.o-gasstrip .s-label { fill: var(--strip-ink); }
.o-gasstrip .s-val {
  font-weight: var(--weight-bold);
  font-variant-numeric: tabular-nums;
}
.o-gasstrip .s-unit { fill: var(--strip-ink-muted); }
.o-gasstrip .s-range { fill: var(--strip-ink-faint); }
.o-gasstrip .s-flag { font-weight: var(--weight-bold); }

/* The "simulated" line is the one thing on the strip that must never be
   mistaken for print noise. It is a governance statement, not decoration. */
.o-gasstrip .s-warn {
  fill: var(--strip-warn);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-caps);
}

.o-gasstrip .s-rule {
  stroke: var(--strip-rule);
  stroke-width: var(--bw-1);
}
.o-gasstrip .s-rule--faint { stroke-dasharray: 2 3; }

/* Reduced motion does not apply to a static strip, but the rotation is a
   vestibular irritant for some readers and costs nothing to drop. */
[data-motion="reduced"] .o-gasstrip { transform: none; }


/* ==========================================================================
   ECG PAPER (S5)
   --------------------------------------------------------------------------
   One SVG user unit is one millimetre (osler/ecg_paper.py), so nothing here
   may scale the geometry — the calibration is the instrument. Only colour and
   line weight live in this file.
   ========================================================================== */

/* ---- the handheld ketone meter ------------------------------------------
   Sized in px inside the viewBox, like the strip: user units, so the type
   scales with the device instead of with the page's root font size. */
.o-ketone {
  display: block;
  width: 100%;
  height: auto;
  max-width: var(--meter-w);
  filter: var(--strip-shadow);
}
.o-ketone .k-body { fill: var(--meter-case); }
.o-ketone .k-bezel { fill: var(--meter-bezel); }
.o-ketone .k-screen { fill: var(--meter-screen); }
.o-ketone .k-port { fill: var(--meter-bezel); }
.o-ketone text { font-family: var(--font-machine); } /* artefact, not UI */
.o-ketone .k-value {
  font-size: var(--meter-fs-value);
  font-weight: var(--weight-bold);
  fill: var(--meter-screen-ink);
}
.o-ketone .k-units {
  font-size: var(--meter-fs-units);
  fill: var(--meter-screen-ink);
}
.o-ketone .k-screen-label,
.o-ketone .k-screen-flag {
  font-size: var(--meter-fs-label);
  letter-spacing: var(--meter-tracking);
  fill: var(--meter-screen-ink);
}
.o-ketone .k-screen-flag { font-weight: var(--weight-bold); }
.o-ketone .k-brand {
  font-size: var(--meter-fs-label);
  letter-spacing: var(--meter-tracking-brand);
  fill: var(--meter-brand);
}
.o-ketone .k-time {
  font-size: var(--meter-fs-small);
  fill: var(--meter-brand);
}
.o-ketone .k-sim {
  font-size: var(--meter-fs-small);
  letter-spacing: var(--meter-tracking-sim);
  fill: var(--meter-sim);
}

/* The pair: an instrument beside a printout. They sit side by side where
   there is room and stack where there is not — the meter is never on its own
   line above the strip, because that reads as a heading. */
.o-artefact-pair {
  display: flex;
  gap: var(--s-4);
  align-items: flex-start;
  justify-content: center;
  flex-wrap: wrap;
}
.o-artefact-pair .o-gasstrip-holder { flex: 0 1 auto; }
.o-artefact-pair .o-ketone { align-self: center; }

.o-ecg {
  display: block;
  width: 100%;
  height: auto;
  max-height: 100%;
  filter: var(--strip-shadow);
}

.o-ecg .g-paper { fill: var(--ecg-paper); }

/* Stroke widths are in millimetres, because the whole drawing is. A real
   trace is about 0.3 mm; the grid is finer than that or it competes. */
.o-ecg .g-minor {
  fill: none;
  stroke: var(--ecg-grid-minor);
  stroke-width: var(--ecg-stroke-minor);
}
.o-ecg .g-major {
  fill: none;
  stroke: var(--ecg-grid-major);
  stroke-width: var(--ecg-stroke-major);
}

.o-ecg .e-trace {
  fill: none;
  stroke: var(--ecg-trace);
  stroke-width: var(--ecg-stroke-trace);
  stroke-linejoin: round;
  stroke-linecap: round;
}

.o-ecg .e-cal {
  fill: none;
  stroke: var(--ecg-trace);
  stroke-width: var(--ecg-stroke-trace);
}

.o-ecg text {
  /* Artefact lettering — --font-machine, never --font-mono (see .o-gasstrip). */
  font-family: var(--font-machine);
  fill: var(--ecg-ink);
  font-size: var(--ecg-fs-meta);
  letter-spacing: var(--tracking-print);
}
.o-ecg .e-head {
  font-size: var(--ecg-fs-head);
  font-weight: var(--weight-bold);
}
.o-ecg .e-lead {
  font-size: var(--ecg-fs-lead);
  font-weight: var(--weight-bold);
}
.o-ecg .e-meta { fill: var(--ecg-ink); }

/* The one thing on the page that must never be mistaken for part of the
   trace: it is a governance statement, not a lead label. */
.o-ecg .e-warn {
  fill: var(--ecg-warn);
  font-weight: var(--weight-bold);
  font-size: var(--ecg-fs-lead);
}

/* The animator's furniture on a rhythm strip: a veil over the trace not yet
   reached, and the playhead. Injected by explain.js into the strip's own
   millimetre coordinate space. */
.o-ecg .cs-veil { fill: var(--ecg-paper); fill-opacity: 0.72; }
.o-ecg .cs-playhead {
  stroke: var(--accent);
  stroke-width: var(--ecg-stroke-major);
}

/* ---- conduction schematic (S7 explainers) --------------------------------

   A generated artefact like the ECG paper, so it lives beside it. Structure
   state is carried by `is-*` classes on each `data-structure` group — set by
   the animation clock, or baked in by the stills renderer. Two rules encode
   accessibility here: a blocked or failing structure always shows a cross
   glyph as well as a colour, and the only animations are opacity/dash
   shimmers on duration tokens, so reduced motion collapses them to a steady
   state rather than to nonsense. */

.cs-schematic {
  display: block;
  width: 100%;
  height: auto;
}

.cs-schematic .cs-chamber {
  fill: var(--surface-sunken);
  stroke: var(--line);
  stroke-width: 1.4;
  transition: fill var(--dur-fast) var(--ease), stroke var(--dur-fast) var(--ease);
}
.cs-schematic .cs-wire {
  fill: none;
  stroke: var(--ink-faint);
  stroke-width: 2.4;
  stroke-linecap: round;
  transition: stroke var(--dur-fast) var(--ease);
}
.cs-schematic .cs-wire--twig { stroke-width: 1.4; }
.cs-schematic .cs-wire--ap { stroke-dasharray: 3 3; }
.cs-schematic .cs-node {
  fill: var(--ink-faint);
  transition: fill var(--dur-fast) var(--ease);
}
.cs-schematic .cs-label {
  fill: var(--ink-muted);
  font-family: var(--font-body);
  font-size: var(--ecg-fs-head);
  font-weight: var(--weight-medium);
}
.cs-schematic .cs-lead {
  fill: none;
  stroke: var(--line-strong);
  stroke-width: 1.8;
  stroke-linecap: round;
}
.cs-schematic .cs-lead-tip { fill: var(--line-strong); }
.cs-schematic .cs-cross {
  display: none;
  stroke: var(--bad);
  stroke-width: 2.4;
  stroke-linecap: round;
}
.cs-schematic .cs-wire--burst { stroke-width: 1.4; }

/* Active conduction. */
.cs-part.is-conducts .cs-wire,
.cs-part.is-paced .cs-wire { stroke: var(--accent); }
.cs-part.is-conducts .cs-node,
.cs-part.is-paced .cs-node { fill: var(--accent); }
.cs-part.is-conducts .cs-chamber,
.cs-part.is-paced .cs-chamber {
  fill: var(--accent-soft);
  stroke: var(--accent);
}
.cs-part.is-paced .cs-lead { stroke: var(--accent); }
.cs-part.is-paced .cs-lead-tip { fill: var(--accent); }

/* Slow or decremental conduction: warn hue plus a dashed stroke, so the
   state survives without its colour. */
.cs-part.is-delayed .cs-wire {
  stroke: var(--warn);
  stroke-dasharray: 4 3;
}
.cs-part.is-delayed .cs-node { fill: var(--warn); }
.cs-part.is-delayed .cs-chamber {
  fill: var(--warn);
  fill-opacity: 0.22;
  stroke: var(--warn);
}

/* Conduction failing: the cross carries the state, the hue seconds it. */
.cs-part.is-blocked .cs-wire,
.cs-part.is-failed .cs-wire,
.cs-part.is-failed .cs-lead { stroke: var(--bad); }
.cs-part.is-blocked .cs-node { fill: var(--bad); }
.cs-part.is-failed .cs-lead-tip { fill: var(--bad); }
.cs-part.is-blocked .cs-cross,
.cs-part.is-failed .cs-cross { display: block; }

/* An impulse from somewhere it shouldn't come from. */
.cs-part.is-ectopic .cs-wire { stroke: var(--info); }
.cs-part.is-ectopic .cs-node { fill: var(--info); }
.cs-part.is-ectopic .cs-chamber {
  fill: var(--info);
  fill-opacity: 0.22;
  stroke: var(--info);
}

/* Backwards up the system: accent, but dashed. */
.cs-part.is-retrograde .cs-wire {
  stroke: var(--accent);
  stroke-dasharray: 4 3;
}
.cs-part.is-retrograde .cs-chamber {
  fill: var(--accent-soft);
  stroke: var(--accent);
  stroke-dasharray: 4 3;
}

/* Continuous disorder: a shimmer on duration tokens, so reduced motion
   leaves a steady warn state rather than a frozen flicker. */
.cs-part.is-fibrillating .cs-chamber {
  fill: var(--warn);
  fill-opacity: 0.3;
  stroke: var(--warn);
  animation: cs-shimmer var(--dur-slow) var(--ease) infinite alternate;
}
.cs-part.is-circuit .cs-chamber {
  fill: var(--accent-soft);
  stroke: var(--accent);
  stroke-dasharray: 6 4;
  animation: cs-march var(--dur-slow) linear infinite;
}

@keyframes cs-shimmer {
  from { fill-opacity: 0.16; }
  to { fill-opacity: 0.4; }
}
@keyframes cs-march {
  from { stroke-dashoffset: 0; }
  to { stroke-dashoffset: 10; }
}

/* ---- ladder diagram (S7) --------------------------------------------------

   Atria, node, ventricles against time, sharing the ECG paper's time scale
   so it stacks under a strip in alignment. Blocked stubs carry a terminal
   bar as well as their hue; the re-entrant direction is dashed. */

.o-ladder {
  display: block;
  width: 100%;
  height: auto;
}
.o-ladder .ld-rule { stroke: var(--line); stroke-width: 0.2; }
.o-ladder .ld-label {
  fill: var(--ink-muted);
  /* The ladder annotates the trace on the same panel — its lettering follows
     the artefact, not the UI. */
  font-family: var(--font-machine);
  font-size: var(--ecg-fs-lead);
  font-weight: var(--weight-bold);
}
.o-ladder .ld-line {
  fill: none;
  stroke: var(--ink);
  stroke-width: 0.45;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.o-ladder .ld-line--blocked { stroke: var(--bad); }
.o-ladder .ld-bar {
  stroke: var(--bad);
  stroke-width: 0.6;
  stroke-linecap: round;
}
.o-ladder .ld-line--ectopic { stroke: var(--info); }
.o-ladder .ld-line--paced { stroke: var(--accent); }
.o-ladder .ld-line--retro { stroke-dasharray: 1.4 1.0; }

/* ---- axis explainer (S7) --------------------------------------------------

   Einthoven's triangle, the hexaxial reference and the six deflection cells.
   The client rotates the arrow, redraws the loop and fills the waveforms;
   the isoelectric lead is marked with a class that carries a border change
   as well as a hue, so the state survives without its colour. */

.ax-diagram {
  display: block;
  width: 100%;
  height: auto;
}
.ax-diagram .ax-side {
  stroke: var(--line-strong);
  stroke-width: 1.6;
}
/* Prominence is deliberate: the lead axes are the teaching object, the
   electrodes are context — so spokes and their labels read strong, and the
   RA/LA/LL marks read faint. */
.ax-diagram .ax-electrode { fill: var(--ink-faint); }
.ax-diagram .ax-electrode-label {
  fill: var(--ink-faint);
  font-family: var(--font-body);
  font-size: var(--ecg-fs-lead);
}
.ax-diagram .ax-label,
.ax-diagram .ax-plus {
  fill: var(--ink);
  font-family: var(--font-body);
  font-size: var(--ecg-fs-head);
  font-weight: var(--weight-medium);
}
.ax-diagram .ax-plus { font-weight: var(--weight-bold); }
.ax-diagram .ax-hex {
  fill: none;
  stroke: var(--line-faint);
}
.ax-diagram .ax-spoke {
  stroke: var(--ink-muted);
  stroke-width: 1.4;
  transition: stroke var(--dur-fast) var(--ease);
}
.ax-diagram .ax-spoke.is-isoelectric {
  stroke: var(--warn);
  stroke-width: 2.4;
  stroke-dasharray: 3 3;
}
.ax-diagram .ax-spoke-label {
  fill: var(--ink-strong);
  font-family: var(--font-body);
  font-size: var(--ecg-fs-head);
  font-weight: var(--weight-bold);
}

/* The named axis zones: faint sectors, each named on the sector and bounded
   by printed degrees — the region is never its tint alone. */
.ax-diagram .ax-zone {
  stroke: none;
  fill-opacity: 0.12;
  transition: fill-opacity var(--dur-fast) var(--ease);
}
.ax-diagram .ax-zone[data-zone="normal"] { fill: var(--ok); }
.ax-diagram .ax-zone[data-zone="left"] { fill: var(--info); }
.ax-diagram .ax-zone[data-zone="right"] { fill: var(--warn); }
.ax-diagram .ax-zone[data-zone="extreme"] { fill: var(--bad); }
.ax-diagram .ax-zone-name {
  fill: var(--ink-muted);
  font-family: var(--font-body);
  font-size: var(--ecg-fs-lead);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}
.ax-diagram .ax-boundary {
  fill: var(--ink-muted);
  font-family: var(--font-mono);
  font-size: var(--ecg-fs-lead);
}
.ax-diagram .ax-loop {
  fill: none;
  stroke: var(--accent);
  stroke-width: 1;
  opacity: 0.55;
}
.ax-diagram .ax-arrow-shaft {
  stroke: var(--accent);
  stroke-width: 2.6;
  stroke-linecap: round;
}
.ax-diagram .ax-arrow-head { fill: var(--accent); }
.ax-diagram .ax-readout {
  fill: var(--ink);
  font-family: var(--font-mono);
  font-size: var(--ecg-fs-head);
}

.ax-panel {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-2);
}
.ax-cell {
  display: grid;
  gap: var(--s-1);
  padding: var(--s-2);
  border: var(--bw-1) solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-card);
  transition: border-color var(--dur-fast) var(--ease);
}
/* Each cell is real ECG paper at the engine's calibration — the .o-ecg
   classes carry the grid and the trace, exactly as they do everywhere else
   an ECG is drawn. */
.ax-cell__paper { max-height: none; }
.ax-cell__sign { font-size: var(--fs-sm); color: var(--ink-muted); }
.ax-cell.is-isoelectric {
  border-color: var(--warn);
  border-style: dashed;
}

/* ---------------------------------------------------------------------------
   Radiology frame-stack viewer (S8 T3)
   The stage owns all touch input (touch-action: none) so a vertical drag
   scrolls the STACK, not the page — the page is scrolled from outside the
   stage. Radiographs are viewed on black in every reporting room; the stage
   ground is --surface-inverse — the deliberate dark island — in both
   variants.
--------------------------------------------------------------------------- */

/* header / stage / presets / caption. The minmax(0, 1fr) stage row is what
   lets a height constraint from outside (the slide ladder, a display
   takeover) actually reach the stage; with height unconstrained it sizes
   to content as before. */
.rad-viewer {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto auto;
  gap: var(--s-2);
}

.rad-viewer__header {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--ink-muted);
}

.rad-viewer__patient { overflow-wrap: anywhere; }

.rad-viewer__stage {
  position: relative;
  overflow: hidden;
  border-radius: var(--r-md);
  /* --rad-ground, NOT --surface-inverse: inverse flips light under the
     display variant, and the projector letterboxed films in white. The
     radiograph ground is variant-invariant by declaration in tokens.css. */
  background: var(--rad-ground);
  touch-action: none;
  cursor: ns-resize;
  user-select: none;
  -webkit-user-select: none;
}

.rad-viewer__img {
  display: block;
  width: 100%;
  height: auto;
  transform-origin: center center;
}

.rad-viewer__count {
  position: absolute;
  right: var(--s-3);
  bottom: var(--s-2);
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  /* Variant-invariant like the ground it sits over — --ink-inverse is
     dark ink under the display variant and vanished into the scrim. */
  color: var(--rad-ink);
  background: var(--surface-scrim);
  padding: var(--s-1) var(--s-2);
  border-radius: var(--r-sm);
}

.rad-viewer__bar {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

.rad-viewer__bar .o-btn[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--accent);
}

/* THE SIZE VOCABULARY HAS TO REACH THE RADIOLOGY VIEWER TOO — the same
   lesson the generated ECG and gas strip learned above, relearned by the
   first real CT in a room: the wrapper carried is-size-*, the viewer read
   none of it, and its intrinsic width-100% sizing both ignored the ladder
   and overflowed the slide's bottom edge.

   Height binds (a CT slice is square-ish on a wide slide), width follows.
   Containment is structural, the .o-block__yt pattern: take the height the
   flex line offers, capped at the authored rung — a crowded slide shrinks
   the viewer instead of pushing it off the bottom. */
.o-block--radiology_study.is-size-small  { --rad-h: var(--rad-h-sm); }
.o-block--radiology_study.is-size-medium { --rad-h: var(--rad-h-md); }
.o-block--radiology_study.is-size-large  { --rad-h: var(--rad-h-lg); }
.o-block--radiology_study.is-size-full   { --rad-h: var(--rad-h-full); }

.o-block--radiology_study .o-block__figure.rad-viewer {
  height: 100%;
  min-height: 0;
  max-width: 100%;
}
.o-block--radiology_study .rad-viewer__stage {
  flex: 1 1 auto;
  height: 100%;
  max-height: var(--rad-h, var(--rad-h-lg));
  min-height: 0;
}
/* The image fills the stage box and letterboxes inside it — a radiograph
   on the dark ground, which is how every reporting room shows one. The
   zoom/pan transform applies to the box; contain keeps the pixels inside
   it at rest. */
.o-block--radiology_study .rad-viewer__img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* The emulated PACS scroll wheel (S8 room feedback round 3): a ridged
   strip the presenter drags vertically to scrub slices. Grip lines are
   drawn with the line token so both variants dress it; touch-action none
   so the drag is the wheel's, not the page's; selection and callout are
   suppressed because a long press on a control must never start iOS text
   selection — that hijack is exactly what caused the stuck-slice bug. */
.o-radwheel {
  position: relative;
  height: var(--sz-radwheel);
  border: var(--bw-1) solid var(--line-control);
  border-radius: var(--r-md);
  background: repeating-linear-gradient(
    to bottom,
    var(--surface-sunken) 0,
    var(--surface-sunken) var(--sz-radwheel-ridge),
    var(--line) var(--sz-radwheel-ridge),
    var(--line) calc(var(--sz-radwheel-ridge) + var(--sz-radwheel-groove)));
  touch-action: none;
  cursor: ns-resize;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
.o-radwheel:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.o-radwheel__grip {
  position: absolute;
  inset: 30% 40%;
  border-radius: var(--r-sm);
  background: var(--surface-card);
  border: var(--bw-1) solid var(--line-strong);
  pointer-events: none;
}

/* No control's label is selectable text: a long press on a button must
   press the button, not begin a selection. (The stuck-slice diagnosis:
   iOS selection hijacked the gesture and the terminal pointer event never
   came back.) */
.o-btn {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

/* The image PORT (round 12 stage 2): the contain-fit region the image
   occupies at scale 1 — and therefore at every scale. The clip lives here,
   not on the stage: zoomed content may fill its own letterbox inside this
   region and may never leave it. Sized by the viewer script. */
.rad-viewer__port {
  position: absolute;
  overflow: hidden;
}
.rad-viewer__port .rad-viewer__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* Transient zoom-factor readout beside the slice counter. */
.rad-viewer__zoomfx {
  position: absolute;
  right: var(--s-3);
  bottom: var(--s-9);
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--rad-ink);
  background: var(--rad-ground);
  padding: 0 var(--s-2);
  border-radius: var(--r-sm);
  pointer-events: none;
}

/* Free windowing (imaging-fidelity stage 3) — authoring surfaces only.
   The canvas paints the int16 frame through a live window LUT in place of
   the baked JPEG; the panel holds centre/width and "save as preset". */
.rad-viewer__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  image-rendering: auto;
}
.rad-viewer__wl {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
  border-top: var(--bw-1) solid var(--line);
  background: var(--surface-card);
  color: var(--ink);
}
.rad-viewer__wl-row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  font-size: var(--fs-sm);
}
.rad-viewer__wl-row input[type="range"] { flex: 1 1 auto; min-width: 0; }
.rad-viewer__wl-row output {
  font-family: var(--font-mono);
  min-width: var(--s-8);
  text-align: right;
}
.rad-viewer__wl-save .o-input { flex: 1 1 10rem; min-width: 0; }
