/* The Hangar design tokens — ported from DESIGN-SYSTEM.md (the locked
 * language). Neutrals are chrome; saturated instrument colors are reserved
 * for instruments and NEVER appear here. One accent blue. */
:root {
  /* Type */
  --font-sans: "Public Sans", system-ui, -apple-system, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Neutrals (warm) */
  --canvas: #f0eee9;
  --page: #faf9f7;
  --card: #ffffff;
  --ink: #1c1b1a;
  --ink-2: #4a463f;
  --ink-3: #55524c;
  --ink-4: #6e6a64;
  --muted: #8a857d;
  --faint: #a39d94;
  --faint-2: #b0aba3;
  --faint-3: #c9c4bc;
  --hairline: #f2f0ec;
  --card-border: #e5e2dc;
  --fill-2: #efece7;
  --input-border: #d8d4cd;
  --avatar-bg: #dcd8d1;

  /* Accent — the ONE blue. Written as sRGB HEX, not oklch(): a Safari version
     in the field (the field-report screenshot showed white cards + blue accents
     dropped on Safari, structure intact) drops a declaration whose value it can't
     parse, and oklch() was the only non-universally-parsed construct here — so
     every accent color silently vanished. These hex values are the EXACT sRGB the
     modern engines already render the old oklch() to (round-tripped through a
     browser's color engine), so the look is unchanged on a modern browser and
     universally parsed on every Safari. */
  --blue: #2c628d;
  --blue-hover: #09466f;
  --blue-text: #255c87;
  --blue-border: #b0cfea;
  --blue-tint: #f0f8ff;
  --blue-chip: #d3ebff;

  /* Good / confirmed — green (was oklch hue 145) */
  --green-text: #3b723e;
  --green-dot: #49814c;
  --green-border: #b6d9b6;

  /* Caution — amber (was oklch hue 60) */
  --amber-text: #8a4700;
  --amber-dot: #b76b1c;
  --amber-tint: #fff2e8;
  --amber-border: #f1d0b7;

  /* Destructive (was oklch hue 25) */
  --red: #b33736;
  --red-text: #902828;

  /* SYNC GOLD — the BROKEN-mode palette (the footage is a FREE actor: unsynced,
     or frozen while the pilot matches it to the log). Ported VERBATIM from
     apps/replay, which is the shipped visual language for this mode:
       --sync-gold-play    replay.css:1314  .replay-chrome.align .replay-play-btn
       --sync-gold-fill    replay.css:1315  .replay-chrome.align .replay-scrubber-fill
       --sync-gold-handle  replay.css:1316  .replay-chrome.align .replay-scrubber-handle
       --sync-gold-outline replay.css:244   .replay-overlay-frame.paused (inset outline)
     The hangar's ambers (--amber-*) are tuned for the LIGHT canvas; the transport
     bar is dark (#0c0f13), so these stay replay's values rather than remapping.
     sRGB HEX at every value site (Safari drops declarations it can't parse). */
  --sync-gold-play: #b8860b;
  --sync-gold-fill: #d9a62e;
  --sync-gold-handle: #ffe1a1;
  --sync-gold-outline: #ffcc00;
}

* {
  box-sizing: border-box;
}

/* THE NO-HORIZONTAL-SCROLL CONTRACT — site-wide, and structural rather than a
   per-page patch. The PAGE never scrolls sideways; content wider than the
   viewport scrolls INSIDE its own container (the admin/ops/config tables each
   sit in an `overflow-x: auto` card for exactly this reason, and
   tests/no-horizontal-scroll.test.mjs holds every new wide block to the same
   deal).
   It is declared on the ROOT because the root element's overflow PROPAGATES to
   the VIEWPORT (the element itself then computes to `visible`, so this is not the
   non-root scroll container that would break `position: sticky` under it), and
   `overflow-y` still computes to `auto`, so vertical scrolling is untouched. That
   propagation is also what makes it a contract: no page can forget its way past
   the viewport.
   `hidden` and not `clip`, which would be the tidier value: this sheet has
   already lost an entire palette to one engine dropping a construct it could not
   parse (see the accent note above), and the fallback for a dropped `overflow-x`
   is no contract at all. The residual that choice leaves: `hidden` still makes
   the viewport a PROGRAMMATICALLY scrollable box (`clip` would not), so a script
   that scrolls sideways — a scrollIntoView on content wider than the page — can
   strand the viewport there with no scrollbar to come back on. Nothing in the app
   does that today.
   The clip is a FLOOR, not a licence: a page whose content genuinely exceeds the
   viewport now loses those pixels instead of offering a scrollbar, so a wide
   block still owes its own scroll container. */
html {
  overflow-x: hidden;
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: 12.5px;
  line-height: 1.5;
}

.mono {
  font-family: var(--font-mono);
}
.eyebrow {
  font-family: var(--font-mono);
  font-size: 8.5px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}
.fact {
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--ink-4);
}

/* FOR THE READER WHO CANNOT SEE THE HIGHLIGHT. A fact carried by colour alone —
   the tinted row + blue edge that marks which DataMark the playhead is in — is
   not carried at all for a screen-reader user, and `aria-current` on a row with
   no list/grid role is announced inconsistently across engines. This says it in
   words, off screen. The standard recipe: a 1px clipped box that still takes
   part in the accessibility tree (display:none and visibility:hidden do not).
   Never use it to hide something a sighted reader also needs. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.card {
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: 12px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}

.btn {
  font-family: var(--font-sans);
  font-size: 12.5px;
  font-weight: 600;
  border-radius: 7px;
  padding: 10px 16px;
  cursor: pointer;
  border: 1px solid transparent;
}
.btn-primary {
  background: var(--blue);
  color: #fff;
}
.btn-primary:hover {
  background: var(--blue-hover);
}
.btn-primary:disabled {
  opacity: 0.55;
  cursor: default;
}
.btn-secondary {
  background: var(--card);
  color: var(--ink-3);
  border-color: var(--input-border);
}
.btn-quiet {
  background: none;
  border: 0;
  color: var(--muted);
  padding: 0;
  cursor: pointer;
  font-size: 12px;
}
.btn-danger-text {
  background: none;
  border: 0;
  color: var(--red-text);
  padding: 0;
  cursor: pointer;
  font-size: 12px;
  font-weight: 600;
}

.field-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 8.5px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 6px;
}
.input {
  width: 100%;
  font-family: var(--font-sans);
  font-size: 12.5px;
  color: var(--ink);
  background: var(--card);
  border: 1px solid var(--input-border);
  border-radius: 7px;
  padding: 11px 12px;
}
.input.mono {
  font-family: var(--font-mono);
}
.input:focus {
  outline: none;
  border-color: var(--blue-border);
}
.input.ok {
  border-color: var(--green-border);
}
.field-fact {
  margin-top: 6px;
  font-family: var(--font-mono);
  font-size: 10.5px;
}
.field-fact.ok {
  color: var(--green-text);
}
.field-fact.warn {
  color: var(--amber-text);
}
.field-fact.hint {
  color: var(--faint);
}

a {
  color: var(--blue-text);
}
a:hover {
  color: var(--blue-hover);
}

/* An eyebrow-styled breadcrumb link: mono/muted (the comp look, NOT blue),
   no underline at rest, underline only on hover. Used for the flight page's
   entity eyebrow (LIBRARY / TAIL) — the box page's breadcrumb idiom, plus the
   hover underline S3 asks for. Compose with class="eyebrow crumb". */
.crumb {
  color: inherit;
  text-decoration: none;
}
.crumb:hover {
  color: inherit;
  text-decoration: underline;
}

.row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 20px;
  border-top: 1px solid var(--hairline);
}
.row:first-of-type {
  border-top: 0;
}

/* THE SCROLL-CAPPED CARD — a card holding a list long enough to BE a page, and
   refusing to be one. Both DataMarks mounts wear it (the flight page's
   pages/flight/MarksList.ts and the share page's read-only twin
   pages/share/MarksCard.ts): the two lists are twins by design, and this is ONE
   declaration they share rather than a scroll rule copied into each.

   WHAT IT ANSWERS. A real flight-test card runs to ~70 marks, and at their
   natural height those rows are thousands of pixels of column — the page grows
   past everything else on it, and the stage the pilot is reading the marks
   against is far off the top of the screen.

   THE CAP IS ONE SCREEN LESS A STRIP. The strip is what says there is a page
   around this list: a card flush to both viewport edges reads as the page
   itself, and on a phone the fixed tab bar would be the only thing under it.
   `dvh` is the LIVE viewport on iOS Safari, where `100vh` is the height with the
   toolbars RETRACTED — a screen taller than the one the pilot actually has. The
   `vh` line before it is the fallback for an engine that drops the `dvh`
   declaration (the same two-line idiom the pseudo-fullscreen block uses).

   THE AFFORDANCE IS A BAR THAT STAYS. macOS paints OVERLAY scrollbars — they
   fade to nothing a second after the last scroll and reserve no width — and the
   cap can land flush on a complete row, so a card holding 29 marks and showing 7
   reads as the whole list. Styling the scrollbar is what takes the scrollport
   off the overlay track: a scrollport with a styled bar gets a CLASSIC one, which
   occupies its own column and stands there for exactly as long as the content
   overflows. That is the whole mechanism, and it is why this stays a cascade
   question — the bar is a fact about the content, so the card still measures
   nothing and still owns no breakpoint.

   The flight page keys this cap to its RIGHT RAIL instead — `.fl-marks-slot`,
   in the responsive section below, where a second column exists to key to.
   Paper unwinds both caps, in the block that ENDS this sheet. */
.card-scroll {
  overflow-y: auto;
  max-height: calc(100vh - 96px);
  max-height: calc(100dvh - 96px);
  /* Hold the bar's column whether the bar is in it or not, so a card that grows
     past its cap doesn't shift its rows sideways at the moment it starts
     scrolling. Blink reserves it on a card that FITS too (measured: 10px at 3
     marks and at 29); WebKit reserves nothing until the content actually
     overflows, and an engine that doesn't know the property drops one
     declaration — both of which are the bar taking its width out of the row. */
  scrollbar-gutter: stable;
}
/* THE BAR ITSELF, in the two models an engine can have — and it is EITHER/OR,
   not a fallback pair. `scrollbar-width` OUTRANKS these pseudo-elements wherever
   both are understood, and on macOS it resolves back to the system's OVERLAY
   bar: declaring the standard property unconditionally beside these rules
   measures 0px of gutter in both WebKit and Blink, which is this card with no
   fix in it at all. So the standard pair is gated to the engines that have no
   other way to be told — `selector(::-webkit-scrollbar)` is true in WebKit and
   Blink and false in Gecko, which is the discrimination, and an engine too old
   for `selector()` reads the gate as open but is also too old for
   `scrollbar-width`, so it keeps the pseudo-elements by dropping the unknown
   declaration.

   The colors are the sheet's own chrome neutrals — a saturated instrument color
   would read as a state the list doesn't have — and the track is the hairline
   the rows are already separated with, so the bar runs in the line weight the
   card is drawn in. */
.card-scroll::-webkit-scrollbar {
  width: 10px;
}
.card-scroll::-webkit-scrollbar-track {
  background: var(--hairline);
}
.card-scroll::-webkit-scrollbar-thumb {
  background: var(--faint-2);
  border-radius: 5px;
}
.card-scroll::-webkit-scrollbar-thumb:hover {
  background: var(--muted);
}
@supports not selector(::-webkit-scrollbar) {
  .card-scroll {
    scrollbar-width: thin;
    scrollbar-color: var(--faint-2) var(--hairline);
  }
}
/* The card's identity stays on screen while its list moves under it: with 70
   rows in the scrollport, "DataMarks · 70 MARKS" is otherwise gone after the
   first flick. This is a direct child of the scrollport, so it sticks to the
   CARD's top edge rather than the page's.
   The background is the card's own — a transparent sticky header has the rows
   sliding visibly through the title. The z-index is insurance for anything
   positioned a row grows later; a static row already paints below a positioned
   sibling. */
.card-scroll-head {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--card);
  border-bottom: 1px solid var(--hairline);
}

/* THE STRETCHED ROW LINK (src/components/FlightRow.ts). One link owns the row's
   whole tap target without any other control living inside it: the element that
   carries the row's NAME is the anchor, and this overlay grows its hit area to
   the nearest positioned ancestor — the row's own box.

   Why not simply wrap the row in an <a>: a row also carries other links (an
   aircraft chip), buttons (⋯, Delete, Re-upload) and a popup. Nested inside an
   anchor those are invalid HTML that a real parser SHATTERS, and they pour their
   text into the anchor's accessible name. With the overlay they are ordinary
   SIBLINGS that sit above it (position:relative + z-index above this one), so a
   click on any of them can never reach the row link BY CONSTRUCTION — no
   preventDefault anywhere is load-bearing.

   The z-index is deliberate: the overlay must be paintable ABOVE the row's inert
   content (so the whole row is tappable) and BELOW every layered control. */
.row-link::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
}

.brand-dot {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: conic-gradient(#e03131 0 22%, #f4d03f 22% 44%, #2eb04a 44% 66%, #1c1b1a 66% 100%);
}

/* The transport row (over the stage, on the flight + share pages). It carries
 * more controls than any other row, so it wraps at ANY width where its content
 * exceeds the stage — the play/clock/scrub/buttons flow onto extra lines rather
 * than overflowing the stage's right edge. min-width:0 lets the flex children
 * (the clock, the scrubber) actually shrink so the wrap is honest. This is
 * always-on, not breakpoint-gated: a crowded transport overflows a narrow tablet
 * stage (~728px) too, which is above the 720px mobile breakpoint. */
.transport-row {
  /* The transport is a DOCKED BAR in normal flow directly below the stage —
     never an overlay on the video: overlaid controls cover the footage/HUD the
     moment they wrap (a phone-width transport is several rows tall). */
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  padding: 10px 12px 8px;
  margin-top: 2px;
  border-radius: 8px;
  background: #0c0f13;
  font-family: var(--font-mono);
  /* The fade the auto-hide below rides. Declared on the base rule so the bar
     eases in BOTH directions from one place. */
  transition: opacity 180ms ease;
}
.transport-row > * {
  min-width: 0;
}
/* THE FULLSCREEN AUTO-HIDE. In fullscreen the bar is positioned OVER the footage
   (the three blocks further down), which on a phone means it sits on the bottom
   of the shot for the whole flight. replay-engine/useStageChromeIdle decides when
   it goes — never windowed, never while paused, never under an open popover or a
   focused control — and all it does is add this class.
   OPACITY, never `display`: the bar keeps its box, so nothing reflows and nothing
   jumps. `pointer-events: none` is the other half — a faded bar that still
   swallowed taps would make the stage under it dead, and that stage is where the
   tap bringing it back has to land. */
.transport-row.transport-idle {
  opacity: 0;
  pointer-events: none;
}
/* …and the POINTER goes with it, on the platform that has one. The class is on
   the ROOT element (the hook puts it there — the same place, and the same
   reason, as `.stage-fs-lock`), so this one rule reaches the stage whichever of
   the two fullscreen paths is showing it. */
.stage-chrome-idle .stage-fs-host {
  cursor: none;
}
.transport-row > [data-transport-scrub] {
  /* The scrubber's flex lives HERE (not inline) so the mobile full-width
     override below can win — an inline flex would outrank any stylesheet rule. */
  flex: 1;
  accent-color: var(--blue);
}

/* THE CONTROLS. Every transport control's paint lives HERE, never inline: an
   inline declaration outranks any non-!important stylesheet rule, which is
   exactly how the mobile clock-wrap override below sat DEAD for a whole batch
   (the clock carried `white-space:nowrap` inline). The broken-mode `.align`
   flip and the ≤720px overrides below can only win from the stylesheet. */
.transport-play {
  background: var(--blue);
  color: #fff;
  border: none;
  border-radius: 4px;
  padding: 4px 0;
  cursor: pointer;
  font-size: 14px;
  line-height: 1;
  width: 44px;
  text-align: center;
  font-family: inherit;
}
.transport-btn {
  background: rgba(255, 255, 255, 0.14);
  color: #fff;
  border: none;
  border-radius: 4px;
  padding: 4px 10px;
  cursor: pointer;
  font-size: 12px;
  white-space: nowrap;
  font-family: inherit;
}
.transport-btn:disabled {
  cursor: default;
  opacity: 0.7;
}
.transport-btn.attention {
  background: var(--sync-gold-fill);
  color: #16130a;
}
/* A .transport-btn whose whole content is a DRAWN glyph. The padding narrows to
   the optical weight of the one-character buttons beside it, and the SVG is sized
   HERE: an inline <svg> carrying no width/height falls back to 300×150, which
   would blow the bar's height open on the one control meant to be smallest. */
.transport-icon {
  padding: 4px 7px;
  display: inline-flex;
  align-items: center;
}
.transport-icon svg {
  display: block;
  width: 15px;
  height: 15px;
}
.transport-row [data-transport-clock] {
  color: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
  font-size: 13px;
  /* nowrap so the clock never wiggles mid-playback — from the STYLESHEET, so
     the ≤720px `normal` below can actually win (the §5.4 dead-rule fix). */
  white-space: nowrap;
}
.transport-vid {
  color: #5cd6ff;
}

/* A popover anchored to its trigger (volume, the sync zone, the ⋯ overflow).
   The wrapper is the positioning context; the panel floats ABOVE the bar so it
   never covers the footage (the docked-bar rule, tokens above). */
.transport-pop-host {
  position: relative;
  display: inline-flex;
  align-items: center;
}
.transport-pop {
  position: absolute;
  bottom: calc(100% + 8px);
  right: 0;
  z-index: 6;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
  border-radius: 6px;
  background: #161b21;
  border: 1px solid rgba(255, 255, 255, 0.16);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5);
}

/* The sync panel's OVERFLOW placement. Behind the ⋯ the sync levers cannot be a
   popover: the ⋯ panel IS the open popover, and a second floating panel anchored
   inside it would either close its own host or stack a panel on a panel at phone
   width. So there the levers expand IN FLOW, directly under the trigger that
   opened them — a normal flex item of the ⋯ column, set off by its own fill so
   the nesting reads. The trigger keeps its `aria-expanded`, and is also the way
   back out. */
.transport-subpanel {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 8px;
  border-radius: 5px;
  background: rgba(255, 255, 255, 0.07);
}

/* The transient status pill (sidecar "saving…/saved", export messages). Mono
   fact styling, LATEST-WINS, and it fades itself out — the node is keyed on the
   message sequence, so a fresh message remounts it and restarts the animation.
   No page timer owns this; the animation IS the 4 s life.
   OPACITY ONLY. The fade used to land on `visibility: hidden` with `forwards`,
   which is a purely VISUAL disappearance: the flex item and its LAYOUT BOX (plus
   the row's 10px gap) survive forever, and on a phone that invisible item counted
   toward the wrap and pushed the bar to an extra row for the rest of the session.
   Neither `visibility:hidden` nor `opacity:0` removes a box, so the box is
   removed the only way that does: the pill's own `animationend` handler sets
   `display:none` on itself (components/Transport.ts statusPill). This animation
   is now purely the look. */
.transport-status {
  font-family: var(--font-mono);
  font-size: 11px;
  color: #cfd6dd;
  white-space: nowrap;
  animation: transport-status-fade 4s ease-out forwards;
}
@keyframes transport-status-fade {
  0%,
  70% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/* BROKEN MODE (`.align`, replay's class name). The bar goes golden: the play
   button and the scrubber's fill. replay hand-rolls its scrubber as divs, so it
   tints FILL and HANDLE separately; a native <input type=range> takes ONE
   `accent-color`, so the FILL hex drives it and --sync-gold-handle carries the
   mode's text/edge accent on the same dark bar. */
.transport-row.align .transport-play {
  background: var(--sync-gold-play);
}
.transport-row.align > [data-transport-scrub] {
  accent-color: var(--sync-gold-fill);
}
.transport-sync-zone {
  /* The expanded FROZEN zone claims its own full-width line inside the bar: the
     pilot works the scrubber right above it for the whole gesture, so the
     instruction and both commit buttons have to stay on screen. (The collapsed
     trigger is a plain button and claims nothing.) */
  flex-basis: 100%;
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--sync-gold-handle);
}

/* The keyboard-help line — its OWN line below the transport (it was the first
   of five things concatenated into one status line). Hidden on touch: there is
   no Space / ←→ / Shift on a phone, so the line is pure noise there. This is
   the app's ONLY pointer/hover query; the 720px width breakpoint is a different
   question (a narrow desktop window still has a keyboard). */
@media (pointer: coarse) {
  .transport-help {
    display: none;
  }
}

/* FULLSCREEN THE STAGE. Three elements, and which is which is the whole layout:
   `.stage-fs-host` is the fullscreen ELEMENT (it fills the screen and centres what
   it holds); `.stage-fs-frame` is the 16:9 BOX inside it, holding the composite
   AND the transport; `.stage-frame` is the composite itself — the footage, the
   conformal HUD and the M5 insets, absolutely-positioned siblings that must go up
   TOGETHER.
   Declared ABOVE the ≤720px block for the same source-order reason its neighbours
   are: a media query adds no specificity.

   NO SCALING ANYWHERE IN HERE, and that is the load-bearing part. The HUD and the
   insets are Canvas2D with backing stores sized to their measured CSS box ×
   devicePixelRatio, re-measured by a ResizeObserver on their own parent. So
   growing the BOX is what makes them re-allocate at the new size and paint sharp;
   a `transform: scale()` or a `zoom` on the stage would leave every backing store
   at its windowed pixel count and stretch it — a blurry HUD on the one view whose
   whole point is to be big. (Measured, headless Chromium: re-allocating the
   backing store is ~0.1 ms at every size to 14.7 Mpx; the cost is the repaint that
   follows, ~24 ms at 4K×2dpr, once, on the transition.)

   The stage keeps its 16:9 box and is sized off the VIEWPORT height so it fits
   whole on any aspect — on a screen wider than 16:9 the width clamps and the
   stage centres, rather than overflowing the bottom of the screen.

   EVERY RULE IS WRITTEN THREE TIMES: `:fullscreen`, `:-webkit-full-screen`, and
   `.stage-fs-pseudo`. A selector LIST containing a pseudo-class the engine does not
   know is invalid as a whole, so one combined list would be dropped ENTIRELY by
   both engines — one for the prefixed half, the other for the unprefixed.
   Separate blocks are the only spelling that reaches both, and the class rides its
   own for the same reason it cannot be folded into either: it is applied by the
   hook, on a platform where NEITHER pseudo-class will ever match. */
.stage-fs-host:fullscreen {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  background: #000;
}
.stage-fs-host:-webkit-full-screen {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  background: #000;
}
/* THE 16:9 BOX, and the CONTAINING BLOCK the bar is positioned against. Both jobs
   belong to the same element on purpose: `position: relative` here is what makes
   the absolutely-positioned bar below resolve `bottom: 0` to the bottom of the
   STAGE. Anchored to the host instead, the bar would sit at the bottom of the
   SCREEN — and on any display taller than 16:9 (an iPad in portrait, the target
   this care exists for) the host centres this box and leaves a black band the bar
   detaches into. The wrapper's own height IS the composite's, because the bar is
   out of flow. */
.stage-fs-host:fullscreen .stage-fs-frame {
  position: relative;
  width: min(100%, calc(100vh * 16 / 9));
  margin: 0 auto;
}
.stage-fs-host:-webkit-full-screen .stage-fs-frame {
  position: relative;
  width: min(100%, calc(100vh * 16 / 9));
  margin: 0 auto;
}
.stage-fs-host:fullscreen .stage-frame {
  width: 100%;
}
.stage-fs-host:-webkit-full-screen .stage-frame {
  width: 100%;
}
/* The transport OVERLAYS the footage in fullscreen — docking it below would cost
   the stage a bar's worth of height on the one view that exists to be big, and
   the pilot must still be able to scrub, play/pause and leave without dropping
   out of fullscreen first. It sits inside the fullscreen element, so it is
   reachable there; outside it, nothing is. Its containing block is
   `.stage-fs-frame` (above), so it tracks the stage's bottom edge. */
.stage-fs-host:fullscreen .transport-row {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  margin-top: 0;
  border-radius: 0;
  background: rgba(12, 15, 19, 0.86);
}
.stage-fs-host:-webkit-full-screen .transport-row {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  margin-top: 0;
  border-radius: 0;
  background: rgba(12, 15, 19, 0.86);
}

/* PSEUDO-FULLSCREEN — the same stage, on the platform with no element fullscreen
   to ask for (iPhone Safari). The hook adds this class; every declaration is here.
   The layout below the host is IDENTICAL to the native blocks on purpose: the
   difference between the two paths is how the box gets to be the size of the
   screen, and nothing inside it should be able to tell.

   WHAT THE HOST HAS TO DO ITSELF, because no browser is doing it:
   · `position: fixed; inset: 0` in place of the top layer. The z-index clears the
     app's tallest overlay (60 — the global drop zone and the perf probe), so the
     stage covers the page rather than being covered by furniture that scrolled
     underneath it. (This is the one place the fallback is genuinely weaker than
     native: the top layer sits above the BROWSER's chrome too, and z-index cannot.)
   · SAFE-AREA PADDING. A notch or a home indicator would otherwise take a bite out
     of the footage. env() carries a 0px fallback so the declaration stays valid on
     a browser that has no insets to report.
   · `overflow: hidden`, with the page's own scrolling frozen by `.stage-fs-lock`
     below — a fixed box does not stop the document behind it from scrolling, and a
     stage that drifts as the pilot's thumb brushes it is not fullscreen.

   THE HEIGHT IS `dvh`, not `vh`. On iOS Safari `100vh` is the height with the
   toolbars RETRACTED — a viewport taller than the one the pilot has — so a
   vh-sized stage is clipped at the bottom until they scroll, which is exactly what
   the lock above forbids. `dvh` is the live value. The `vh` line before it is the
   fallback for an engine that does not know `dvh` and drops that declaration. */
.stage-fs-host.stage-fs-pseudo {
  position: fixed;
  inset: 0;
  z-index: 70;
  display: flex;
  flex-direction: column;
  justify-content: center;
  overflow: hidden;
  background: #000;
  padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)
    env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
}
/* The 16:9 box, clamped off the height the host actually has — the viewport LESS
   the safe-area padding. The native blocks can say `100vh` because their host IS
   the screen; this one is inset by the notch, and a stage sized to the full
   viewport inside a padded box overflows by exactly that much. */
.stage-fs-host.stage-fs-pseudo .stage-fs-frame {
  position: relative;
  width: min(
    100%,
    calc((100vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)) * 16 / 9)
  );
  width: min(
    100%,
    calc((100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)) * 16 / 9)
  );
  margin: 0 auto;
}
.stage-fs-host.stage-fs-pseudo .stage-frame {
  width: 100%;
}
.stage-fs-host.stage-fs-pseudo .transport-row {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  margin-top: 0;
  border-radius: 0;
  background: rgba(12, 15, 19, 0.86);
}
/* THE SCROLL LOCK, on the root element (the hook puts it there). Both the root and
   the body are frozen: which of the two scrolls depends on where the page put its
   height, and this sheet should not have to know. `overscroll-behavior` is the
   half that stops the rubber-band — `overflow: hidden` alone still lets iOS drag
   the whole page away from under a fixed box. */
.stage-fs-lock,
.stage-fs-lock body {
  overflow: hidden;
  overscroll-behavior: none;
}

/* THE DESKTOP DEFAULTS FOR THE ⋯ SPLIT — declared ABOVE the ≤720px block ON
   PURPOSE, and they must stay there.
   A media query adds NO specificity: `@media (max-width:720px) { .x{display:none} }`
   and a bare `.x{display:contents}` are both (0,1,0), so at 375px BOTH match and
   SOURCE ORDER decides. These two rules sat BELOW the breakpoint block and won it
   outright — the entire mobile layout (⋯ hidden, the desktop-only subtree still
   laid out) was dead in a real browser at 375×700: three wrap rows / a 120px bar
   instead of two rows / 88px. Nothing in the sheet said so, because every rule
   read correctly on its own. tests/transport-css.test.mjs now walks the BUILT
   sheet and fails on any unconditional rule that lands after a media block and
   re-declares the same property for the same target. */
.transport-desktop-only {
  /* A transparent wrapper: its children are the flex row's own items on desktop,
     and the whole subtree goes `display:none` under 720px. Declared HERE (not
     inline) so the media query below can win. */
  display: contents;
}
.transport-overflow {
  /* ⋯ is the MOBILE affordance only — the same controls sit inline on desktop. */
  display: none;
}

@media (max-width: 720px) {
  /* TWO ROWS on a phone. Row 1 is the scrubber alone (`order:-1` pulls it ahead
     of the play button in the wrap order while source order stays honest for
     the keyboard/AT); row 2 is play · clock · mute · marks · ⋯. */
  .transport-row > [data-transport-scrub] {
    flex-basis: 100%;
    order: -1;
    height: 28px;
  }
  /* Let the clock wrap its own text rather than overflow the stage's right edge
     (this rule was dead until the inline nowrap above moved into the sheet). */
  .transport-row [data-transport-clock] {
    white-space: normal;
  }
  /* The compact clock drops the cyan vid readout — the log clock is the one
     that answers "where am I in the flight". */
  .transport-vid {
    display: none;
  }
  /* …and the "/ TOTAL" half with it. THE FLEET'S FLIGHTS ARE LONG: at 1:29:20
     the full "0:31:22 / 1:29:20" is ~133px of a 351px row, and row 2 (play 44 ·
     clock · mute 35 · two marks 32+32 · ⋯ 27, five 10px gaps) then overflows by
     a couple of pixels and the ⋯ wraps to a THIRD row — the stack Sam rejected,
     on his own logs. The total is static, the scrubber already shows
     position-within-whole, both pages carry the duration in their stat strip,
     and the clock keeps it in its `title`. Dropping it is ~78px and takes the
     row to ~275px, so the phone layout stops depending on how long the flight
     was. Position, marks and every control stay exactly where they were. */
  .transport-total {
    display: none;
  }
  /* Marks stay ALWAYS visible (Sam keeps them) but go icon-only. */
  .transport-mark-label {
    display: none;
  }
  /* Secondary controls (the sync zone trigger, the page's own trailing controls)
     move behind the ⋯ overflow. */
  .transport-desktop-only {
    display: none;
  }
  .transport-overflow {
    display: inline-flex;
  }
}

/* THE FLIGHT PAGE'S MARKS COLUMN, KEYED TO ITS RIGHT RAIL — the two-column half
   of the DataMarks scroll. The card machinery itself is `.card-scroll` above;
   this is only where the height comes from when there are two columns.

   The ask, from a 29-mark flight: the marks should bottom out where the right
   rail (footage / flight log / config) does, instead of growing the column for
   thousands of pixels with the video out of sight.

   HOW THE HEIGHT IS KEYED, which is the whole trick: the card is taken OUT OF
   FLOW inside its slot, so it contributes nothing to the body grid's row and the
   row is sized by the RIGHT RAIL alone. The slot then stretches to that row —
   `align-self: stretch` is load-bearing, because the body grid sets
   `align-items: start` on its items and a start-aligned slot holding only an
   out-of-flow card is ZERO tall, which would take the card (capped at 100% of
   it) with it. `max-height`, never `bottom: 0`: a three-mark flight keeps a
   three-mark card instead of a tall empty one.

   The floor is for a SHORT rail (no clips, a lean inventory) — a scroll window
   shorter than a handful of rows is worse than no cap at all.

   GATED TO THE TWO-COLUMN MODE, which is why this is a min-width block rather
   than a default plus a mobile undo: below the breakpoint the grid is ONE
   column, there is no rail to key to, and the slot's own row would be sized by
   the out-of-flow card alone — zero. There the slot is inert and `.card-scroll`'s
   viewport cap governs. (In the 720–721px crack neither block applies: two
   columns, the card in flow under the viewport cap — the old layout, capped.)

   A PRINTED PAGE BOX IS A WIDTH TOO, and a US Letter one is wider than this
   gate — so this block applies on paper and the print unwind at the END of the
   sheet is what takes it back off. Nothing here may move below that block. */
@media (min-width: 721px) {
  .fl-marks-slot {
    position: relative;
    align-self: stretch;
    min-height: 320px;
  }
  .fl-marks-slot > .card-scroll {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    /* Both caps compose: the rail's height AND one screen — a rail taller than
       the viewport must not carry the card past it, or the sticky head leaves
       with the page. vh first so dvh wins where it exists. */
    max-height: min(100%, calc(100vh - 96px));
    max-height: min(100%, calc(100dvh - 96px));
  }
}

/* --- Responsive base (S7) ------------------------------------------------
 * The Hangar's ONE mobile breakpoint: 720px. The chrome (mtop + bottom tab
 * bar) carries its own inline @media (it's a component); these are the shared
 * page utilities the mobile pass leans on — a two-column grid that stacks, and
 * a fixed-min-width row that relaxes so it can wrap. Kept minimal: only what
 * the pages below the breakpoint actually use. */
@media (max-width: 720px) {
  /* A grid that collapses to a single column on mobile. Compose with the
     inline grid-template-columns (the !important wins the specificity race with
     the element's own style attribute). */
  .stack-on-mobile {
    grid-template-columns: 1fr !important;
  }
  /* A row whose children carry fixed min-widths on desktop — relax them so the
     row wraps instead of forcing a horizontal scroll. */
  .wrap-on-mobile {
    flex-wrap: wrap;
  }
  .wrap-on-mobile > * {
    min-width: 0 !important;
  }
  /* THE CHROME'S RESPONSIVE SWITCH — desktop bar ↔ mtop (+ the member chrome's
     bottom tab bar). It lives in the SHARED sheet, not inside one component's
     <style>, because there are two chromes wearing these classes now: the member
     Chrome and the PUBLIC one over a share page. A second copy of these rules
     emitted by the second component is how two chromes drift to two breakpoints.
     `!important` beats the elements' own inline display (the chrome keeps its
     layout inline — the kit's handoff-canvas idiom). The tab bar's body padding
     stays with Chrome: it is the only one that renders a fixed bar. */
  .hangar-desktop-chrome {
    display: none !important;
  }
  .hangar-mtop {
    display: flex !important;
  }
  .hangar-tabbar {
    display: grid !important;
  }
}

/* --- PAPER, and it is the LAST word in this sheet on purpose ---------------
 * A capped card on a printed page silently drops every clipped row: the card's
 * own bottom border draws under the last visible mark, so the page LOOKS
 * complete while the pilot's marks and notes past the fold are never emitted.
 * Print gets the whole list, in flow, on both mounts.
 *
 * THE POSITION IS THE MECHANISM. `.fl-marks-slot > .card-scroll` below is the
 * same selector, at the same specificity (0,2,0), as the two-column keying
 * block's — and a print media block does not outrank a width one: the page BOX
 * has a width, US Letter at Chrome's default margins is ~739px, and the 721px
 * gate applies to it. Equal specificity is settled by source order alone, so
 * this block wins only by being later: move it above the gate and the flight
 * page's card prints capped at the rail's height, in every engine. `!important`
 * would win from anywhere and is deliberately not used: it would also outrank
 * the next print rule this sheet grows, and a tie wants one lever, not two.
 * Any rule that caps or positions this card lands ABOVE here.
 *
 * Measured over the built sheet under print emulation in Blink, WebKit and
 * Gecko at 1280/816/760/739/720/624px, both mounts, 29 marks: position static,
 * max-height none, clientHeight == scrollHeight, all 29 rows in flow. */
@media print {
  .card-scroll {
    max-height: none;
    overflow: visible;
  }
  .fl-marks-slot > .card-scroll {
    position: static;
    max-height: none;
  }
}
