@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');/* ── Base ── */
body {
  font-family: 'Crimson Text', Georgia, serif;
  font-size: 18px;
  background-color: #F9F5EC;
  color: #2C1F0E;
  padding: 50px 0px 0px 0px;
  z-index: 0;
}h1, h2, h3, h4, h5, h6 {
  font-family: 'Cinzel', 'Palatino Linotype', serif;
  letter-spacing: 0.06em;
}/* ── Navbar ── */
.navbar-inverse {
  background: #1C130A;
  border-bottom: 3px solid #C9A84C;
  color: #ffffff;
}/* The hamburger opts out of iOS double-tap-to-zoom (issue #683). Two presses in
   quick succession on the toggle — open, then close — are a double-tap candidate,
   so WebKit holds the second one back while it waits to see whether a zoom is
   starting, and the press that was meant to close the menu lands late or not at
   all. This is the same delay #616 removed from .mt-nav-backdrop, where the
   reasoning is written out in full; it was never extended to the button that
   raises the menu in the first place.
   `manipulation` is `pan-x pan-y pinch-zoom`, so pinch-to-zoom still reaches the
   browser from the toggle — only the double-tap shortcut is given up, on a 44px
   button that has nothing to zoom into. */
.navbar-fixed-top .navbar-toggle { touch-action: manipulation; }/* Hamburger overlay height cap — applies globally to ANY viewport where
   the Bootstrap collapse is in its open state (.in). Without this, a
   signed-in admin on phone landscape sees 6 stacked items (Home, Puzzles,
   About, Admin, My Account, Sign Out) at ~55-60px each = ~360px, which
   exceeds the ~340px below the navbar on a 390-460px-tall landscape
   viewport — the bottom item (Sign Out) was clipped below the fold and
   the body scrollbar didn't help because the navbar is position:fixed.
   max-height: calc(100dvh - 50px) + overflow-y:auto lets the overlay
   scroll INTERNALLY so every menu item is reachable on any viewport.
   Desktop expanded state doesn't carry the .in class, so this rule is
   a no-op there. */
.navbar-fixed-top .navbar-collapse.collapse.in,
.navbar-fixed-top .navbar-collapse.collapsing {
  max-height: calc(100dvh - 50px) !important;
  overflow-y: auto !important;
  overflow-x: hidden !important;
  /* #590: stops the gesture being handed to the page once the menu hits its
     end. DEFENCE IN DEPTH ONLY — js/nav-modal.js is the real fix, because this
     property is consulted only for boxes that are scroll containers WITH
     scrollable overflow, and the menu's 4-6 items usually do not exceed its
     max-height, so there is nothing to scroll and the property is skipped. It
     is iOS Safari 16+ regardless. Every collapse variant now scrolls
     internally — this rule, ~1192 (portrait), ~1243 (mt-nav-forced), ~2231
     (pz-mobile-fold) and ~2577 (pz-mobile-fold-ads) — so the clamp below can
     never produce a box that overflows but cannot scroll. */
  overscroll-behavior: contain;
}/* #590: backdrop behind the open dropdown — the visual half of "modal", and
   the surface that catches a tap-outside to close.
   z-index 1005 sits above page content (the rotate overlay is the highest at
   999) and below Bootstrap's fixed navbar (1030). It does NOT need to duck
   under the phone routes' collapse z-index:1010: .navbar-fixed-top is
   position:fixed with z-index 1030, which ESTABLISHES A STACKING CONTEXT, so
   that 1010 is scoped inside the navbar and the whole navbar subtree paints at
   1030 against the root. A body-level backdrop can therefore never cover the
   menu at any value below 1030 (the .mt-rotate-overlay comment ~1969 documents
   the same reasoning). Two things do sit above this and are meant to: the
   navbar subtree itself (1030) and .mt-skip-link:focus (2000, which must stay
   reachable for keyboard users); .mt-rotate-overlay is 999, i.e. below.
   Known escape: .pz-celebrate-actions is appended to <body> at z-index 1040
   (~1685), so the win-celebration action bar stays undimmed and clickable if
   the menu is opened during a celebration — cosmetic, and the buttons are
   harmless.
   touch-action:manipulation lets a two-finger pinch reach the browser so a
   zoomed-in user who opens the menu can pinch back OUT (#613, matching
   nav-modal.js's blockOutside, which lets the two-finger touchmove through) AND
   disables iOS double-tap-to-zoom, whose tap-recognition delay otherwise made
   tap-to-close flaky (#616); single-finger scroll that starts on the backdrop is
   still cancelled by nav-modal.js's blockOutside preventDefault, and other
   gestures are handled there too. */
.mt-nav-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1005;
  background: rgba(13, 27, 62, 0.45);
  touch-action: manipulation;
  /* iOS Safari only synthesises a `click` on a tap when the element is
     "clickable" — a native control, or one carrying an onclick/bound handler or
     `cursor: pointer`. The document-level tap-outside-to-close in nav-modal.js
     (#616) delegates `click`, so without this a tap on the plain backdrop div
     fires NO click on iOS and the menu wouldn't close. cursor:pointer restores
     it (and reads as "tap to dismiss" on desktop too). */
  cursor: pointer;
  opacity: 0;
  /* Only the open state takes pointer events. While fading out the element is
     still full-viewport for another 0.18s, and without this it silently eats
     the first tap after the menu closes.
     No reduced-motion override is needed here: the site-wide
     prefers-reduced-motion block near the end of this file already forces
     transition-duration to 0.01ms on everything. */
  pointer-events: none;
  transition: opacity 0.18s ease;
}.mt-nav-backdrop.is-open { opacity: 1; pointer-events: auto; }.navbar-inverse .navbar-nav > li > a {
  color: #E8D48B;
  font-family: 'Cinzel', serif;
  font-size: 0.82em;
  letter-spacing: 0.08em;
  padding-top: 18px;
  padding-bottom: 18px;
}.navbar-inverse .navbar-nav > li > a:hover {
  color: #FFD700;
  background: #2C1F0E;
}.navbar-inverse .navbar-nav > .active > a,
.navbar-inverse .navbar-nav > .active > a:hover,
.navbar-inverse .navbar-nav > .active > a:focus {
  background: #2C1F0E;
  color: #FFD700 !important;
}.navbar-inverse .navbar-nav > .open > a,
.navbar-inverse .navbar-nav > .open > a:hover,
.navbar-inverse .navbar-nav > .open > a:focus {
  background: #2C1F0E;
  color: #FFD700 !important;
}.navbar-inverse .navbar-nav .open .dropdown-menu > li > a {
  background-color: #1C130A;
  color: #E8D48B;
}.navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover {
  color: #FFD700;
  background-color: #2C1F0E;
}.navbar-inverse .navbar-nav .open .dropdown-menu {
  background-color: #1C130A;
  color: #E8D48B;
}.navbar-brand {
  padding-top: 0;
  padding-bottom: 0;
  display: flex;
  align-items: center;
  height: 100%;
}.navbar-brand img {
  vertical-align: middle;
}.navbar-inverse .navbar-toggle {
  border-color: #C9A84C;
}.navbar-inverse .navbar-toggle .icon-bar {
  background-color: #C9A84C;
}/* ── Jumbotron / Hero ── */
.jumbotron {
  margin: 0px auto;
  padding: 70px 30px 60px;
  background: linear-gradient(160deg, #0D1B3E 0%, #162347 60%, #1a2a5e 100%);
  color: #F0E6C8;
  border-bottom: 4px solid #C9A84C;
  position: relative;
  overflow: hidden;
}.jumbotron::before {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 6px;
  background: repeating-linear-gradient(
    90deg,
    #C9A84C 0, #C9A84C 10px,
    #1C130A 10px, #1C130A 16px,
    #C9A84C 16px, #C9A84C 22px,
    #1C130A 22px, #1C130A 38px
  );
}.jumbotron h1 {
  font-family: 'Cinzel', serif;
  font-size: 3.2em;
  font-weight: 900;
  color: #E8D48B;
  text-shadow: 2px 3px 10px rgba(0,0,0,0.7);
  letter-spacing: 0.2em;
  margin-bottom: 0;
}.jumbotron p {
  font-family: 'Crimson Text', Georgia, serif;
  font-size: 1.35em;
  color: #D4C494;
  font-style: italic;
  letter-spacing: 0.04em;
  margin-top: 10px;
}/* ── Section rows ── */
.row-header {
  margin: 0px auto;
  padding: 0px 0px;
}.row-content {
  margin: 0px auto;
  padding: 6px 0px;
  border-bottom: 2px solid #C9A84C;
  min-height: 200px;
  background: #F9F5EC;
}/* ── Greek ornament divider ── */
.greek-divider {
  text-align: center;
  margin: 25px 0;
  color: #C9A84C;
  letter-spacing: 0.5em;
  font-size: 1.2em;
  line-height: 1;
}/* ── Section heading underline ── */
h2.section-heading {
  color: #0D1B3E;
  display: inline-block;
  position: relative;
  padding-bottom: 8px;
  font-size: 26px;
}h2.section-heading::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  width: 70%;
  height: 2px;
  background: linear-gradient(to right, #C9A84C, transparent);
}/* ── Feature boxes ── */
.feature-box {
  padding: 25px 20px;
  margin: 15px 0;
  border: 1px solid #C9A84C;
  border-top: 4px solid #C9A84C;
  background: #FFFDF5;
  text-align: center;
  transition: box-shadow 0.2s;
}.feature-box:hover {
  box-shadow: 0 4px 18px rgba(201,168,76,0.25);
}.feature-box i {
  color: #C9A84C;
  font-size: 2.4em;
  margin-bottom: 12px;
  display: block;
}.feature-box h4 {
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  letter-spacing: 0.05em;
  margin-bottom: 10px;
}.feature-box p {
  color: #5C3D11;
  font-size: 0.95em;
}/* ── Greek quote ── */
blockquote.greek-quote {
  border-left: 5px solid #C9A84C;
  background: #F5EED8;
  font-family: 'Crimson Text', Georgia, serif;
  font-style: italic;
  font-size: 1.25em;
  color: #3D2C0E;
  padding: 18px 24px;
  margin: 12px 0;
  border-radius: 0;
}blockquote.greek-quote footer {
  color: #8B6914;
  font-family: 'Cinzel', serif;
  font-size: 0.72em;
  letter-spacing: 0.08em;
  margin-top: 8px;
}/* ── Panels (accordion) ── */
.panel-default {
  border: 1px solid #C9A84C;
  border-radius: 0;
  background: #FFFDF5;
  margin-bottom: 8px;
  box-shadow: 2px 2px 8px rgba(0,0,0,0.07);
}.panel-heading {
  background: linear-gradient(to right, #1C130A, #2E200E) !important;
  color: #E8D48B !important;
  border-radius: 0 !important;
  border-bottom: 1px solid #C9A84C !important;
}.panel-title > a {
  color: #E8D48B !important;
  font-family: 'Cinzel', serif;
  font-size: 0.95em;
  letter-spacing: 0.05em;
}.panel-title > a:hover {
  color: #FFD700 !important;
  text-decoration: none;
}.panel-title small {
  color: #C9A84C;
  font-family: 'Crimson Text', serif;
  font-style: italic;
  font-size: 0.85em;
}.panel-body {
  font-family: 'Crimson Text', Georgia, serif;
  font-size: 1.1em;
  color: #2C1F0E;
  background: #FFFDF5;
}/* ── Breadcrumb ── */
.breadcrumb {
  background: transparent;
  font-family: 'Cinzel', serif;
  font-size: 0.78em;
  color: #8B7355;
  border-bottom: 1px solid #C9A84C;
  border-radius: 0;
  padding: 8px 0;
  letter-spacing: 0.05em;
}.breadcrumb > li + li::before {
  color: #C9A84C;
  content: "✦ ";
}.breadcrumb a {
  color: #8B6914;
}/* Breadcrumb in "title role" — replaces the h3 + greek-divider title block
   on pages that lead with the breadcrumb as the page heading. */
.breadcrumb.breadcrumb-title {
  font-size: 1.4em;
  padding: 12px 0;
  margin-bottom: 4px;
  letter-spacing: 0.06em;
}.breadcrumb.breadcrumb-title > .active {
  color: #0D1B3E;
}/* ── Footer ── */
.row-footer {
  margin: 0px auto;
  padding: 30px 0px 15px;
  background: #1C130A;
  color: #C9A84C;
  border-top: 4px solid #C9A84C;
}.row-footer h5 {
  font-family: 'Cinzel', serif;
  color: #E8D48B;
  border-bottom: 1px solid #C9A84C;
  padding-bottom: 7px;
  letter-spacing: 0.12em;
}.row-footer a {
  color: #C9A84C;
}.row-footer a:hover {
  color: #FFD700;
  text-decoration: none;
}.row-footer address {
  color: #B89A50;
  font-size: 88%;
  margin: 0px;
}.row-footer .list-unstyled li a {
  font-family: 'Cinzel', serif;
  font-size: 0.82em;
}.row-footer p {
  color: #8B7355;
  font-size: 0.88em;
}/* ── Form controls ── */
.form-control {
  border: 1px solid #C9A84C;
  border-radius: 0;
  background: #FFFDF5;
  font-family: 'Crimson Text', Georgia, serif;
  font-size: 1em;
  color: #2C1F0E;
}.form-control:focus {
  border-color: #8B6914;
  box-shadow: 0 0 6px rgba(201,168,76,0.4);
}.control-label {
  font-family: 'Cinzel', serif;
  font-size: 0.82em;
  letter-spacing: 0.04em;
  color: #3D2C0E;
}/* ── Buttons ── */
.btn-primary {
  background-color: #0D1B3E;
  border-color: #C9A84C;
  color: #E8D48B;
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  letter-spacing: 0.06em;
  border-radius: 0;
}.btn-primary:hover, .btn-primary:focus {
  background-color: #162347;
  border-color: #FFD700;
  color: #FFD700;
}.btn-info {
  background-color: #6B4C11;
  border-color: #C9A84C;
  color: #E8D48B;
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  border-radius: 0;
}.btn-success {
  background-color: #1E3A16;
  border-color: #7CB87A;
  color: #B8D4B6;
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  border-radius: 0;
}/* ── Modal ── */
.modal-content {
  border-radius: 0;
  border: 2px solid #C9A84C;
  background: #F9F5EC;
}.modal-header {
  background: #1C130A;
  border-bottom: 2px solid #C9A84C;
  border-radius: 0;
}.modal-header h4 {
  font-family: 'Cinzel', serif;
  color: #E8D48B;
  letter-spacing: 0.08em;
}.modal-header .close {
  color: #C9A84C;
  opacity: 1;
  text-shadow: none;
  font-size: 1.6em;
}.modal-header .close:hover {
  color: #FFD700;
}/* ── Carousel ── */
.carousel {
  background: #0D1B3E;
}.carousel .item {
  height: 300px;
}.carousel .item img {
  left: 0;
  min-height: 300px;
  position: absolute;
  top: 0;
}/* ── Tab content ── */
.tab-content {
  border-bottom: 1px solid #C9A84C;
  border-left: 1px solid #C9A84C;
  border-right: 1px solid #C9A84C;
  padding: 10px;
  background: #FFFDF5;
}/* ── Hero header layout ── */
.header-hero {
  padding: 0 !important;
}.header-hero::before {
  z-index: 2;
}.header-inner {
  display: flex;
  align-items: stretch;
  min-height: 230px;
}.header-logo-panel {
  background: #FFFFFF;
  flex: 0 0 185px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px 15px;
}.header-logo-panel img {
  height: 300px;
  width: auto;
  display: block;
}.header-text-panel {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 55px 20px 45px 28px;
}.header-orion {
  flex: 0 0 175px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding-right: 18px;
  gap: 6px;
}.header-orion svg {
  width: 145px;
  height: auto;
}.constellation-scroll {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  background: linear-gradient(to right, #6B4F0A, #C9A84C 18%, #E8D48B 50%, #C9A84C 82%, #6B4F0A);
  border: 1px solid #5A3F08;
  border-radius: 3px;
  box-shadow: 0 2px 5px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.15);
  font-family: 'Cinzel', serif;
  font-size: 0.65em;
  color: #1C130A;
  letter-spacing: 0.07em;
  white-space: nowrap;
  max-width: 145px;
}.mt-constellation-name {
  flex: 1;
  text-align: center;
  padding: 3px 6px;
}.scroll-end {
  display: inline-block;
  width: 14px;
  height: 20px;
  background: radial-gradient(ellipse at center, #C9A84C 40%, #8B6914 100%);
  border-radius: 50%;
  border: 1px solid #5A3F08;
  box-shadow: inset 0 1px 3px rgba(0,0,0,0.4);
  flex-shrink: 0;
}

.scroll-end-l { margin-left: -1px; }
.scroll-end-r { margin-right: -1px; }

/* All three own no zoom of their own, so the browser may have every gesture the
   puzzle does not claim: the pinch, and a one-finger pan in either axis. Each
   page declares the same value for its own canvas; this is the narrow-width
   override that used to withhold the pan axes. What keeps a tap from being
   swallowed as a scroll is the 10 CSS-px travel slop in each puzzle's touchend,
   not the touch-action. */
@media (max-width: 767px) {
  #hanoi-canvas,
  #lamplighter-canvas,
  #trial-of-numbers-canvas { touch-action: pan-x pan-y pinch-zoom; }
}

/* ── Affix ── */
.affix {
  top: 100px;
}#carouselButtons {
  bottom: 0px;
  position: absolute;
  right: 0px;
}/* ══════════════════════════════════════════════════
   NEW 4-PANEL LAYOUT
   ══════════════════════════════════════════════════ */

/* Outer wrapper — fills viewport below navbar */
.mt-layout {
  display: flex;
  height: calc(100vh - 50px);
  height: calc(100dvh - 50px);
  max-width: 1600px;
  margin: 0 auto;
  overflow: hidden;
}/* Side panels */
.mt-panel-left,
.mt-panel-right {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
  overflow: hidden;
}/* Center panel — scrollable content area */
.mt-panel-center {
  flex: 3;
  display: flex;
  flex-direction: column;
  min-width: 0;
  overflow-x: hidden;
  overflow-y: auto;
  background: #F9F5EC;
}/* Side-panel ICON width (#364). Locked to the soldier logo, the reference:
   logo-transparent.png is 763×1200 (w/h 0.636), height-bound to the 300px top
   region → renders ≈ 300 × 0.636 ≈ 191px wide. The constellation is capped to
   this so the two columns' TOP ICONS match (logo ≈ constellation). The no-ads
   decorative panels deliberately take the FULL slot width instead. */
:root { --mt-side-img-w: 191px; }/* Top section of each side panel (logo / constellation) — fixed equal height on both sides */
.mt-side-top {
  display: flex;
  flex-direction: row;
  flex-shrink: 0;
  height: 300px;
  overflow: hidden;
}/* Logo area in left panel */
.mt-left-logo {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 12px 8px;
  background: #F9F5EC;
  overflow: hidden;
}.mt-left-logo img {
  max-width: 100%;
  max-height: 300px;
  width: auto;
  height: auto;
  display: block;
  object-fit: contain;
}/* Constellation area in right panel — overrides .header-orion where needed */
.mt-right-constellation {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 10px 8px;
  gap: 6px;
  background: linear-gradient(160deg, #0D1B3E 0%, #162347 60%, #1a2a5e 100%);
  overflow: hidden;
}/* Constellation sized to MATCH the soldier (#364): fill the 300px top region
   like the logo does, instead of the old small 170px cap. Explicit height =
   region (300) − vertical padding (20) − the name-scroll + gap (~34) so the svg
   fills the space above the scroll without overflowing; width follows the
   viewBox ratio (100/175) → ≈ 246 × 0.571 ≈ 140px, well under the soldier's
   ~191px column. */
.mt-right-constellation svg {
  height: 246px;
  width: auto;
  max-width: var(--mt-side-img-w);
}/* Left-panel constellation — hidden by default, shown only in landscape-small */
.mt-left-constellation { display: none; }/* Ads placeholder */
.mt-ads-placeholder {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px dashed #C9A84C;
  /* NO MARGIN, and the breathing space is what pays for it. A rail is a fifth of the layout width,
     so on a small landscape phone it is already close to Google's 120px floor and every pixel of
     inset comes straight off the ad: 8px each side is 16 of the ~121 the rail has at 667. Slots
     under the floor cannot hold an ad AT ALL (js/ad-slots.js declines to mount and paints the box
     red on beta), so an inset that reads as tidy is the difference between a slot that earns and a
     slot that is decoration. The dashed border stays — it is what separates the ad from the page,
     and it costs 1px rather than 8. */
  margin: 0;
  color: #C9A84C;
  font-family: 'Cinzel', serif;
  font-size: 0.7em;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  background: #FFFDF5;
}/* AN ACCOUNT/AUTH PAGE HAS NO AD SURFACES, SO ITS RAIL IS JUST PANEL (#733).
   Attraction earns, administration does not — header.php clears $mtShowAds on those pages and
   publishes body[data-ads-off]. There is no ad, no mounter and no CMP there, so the box above
   would otherwise sit on a login page as a dashed rectangle promising something that is never
   coming.

   THE ELEMENT STAYS; ONLY ITS PAINT GOES. .mt-ads-placeholder is `flex: 1` and is what pushes
   .mt-panel-footer to the bottom of the panel — .mt-panel-footer has no `margin-top: auto` to fall
   back on, so `display: none` here would float the legal + copyright strip up under the logo.

   SCOPED AWAY FROM [data-no-ads] because paid/staff are unchanged on these pages as everywhere
   else: js/no-ads-fill.js keeps painting the #348 art into this same box for them. And a free
   viewer deliberately does NOT get that art — it is the paid/staff signal ("your panel is not an
   ad"), which #709 already refused to lend to an ad-seeing viewer; borrowing it here would hollow
   out the signal for the sake of filling a gap. Empty is more honest than borrowed.

   THIS RULE IS NOW REDUNDANT WITH THE ONE BELOW, and is kept rather than deleted. Since #825 the
   blanking rule gates on `:not([data-no-ads])` instead of `[data-ads-live]`, so it matches an auth
   page too and would blank the box on its own. They agree — equal specificity (0,2,1), the later one
   wins for the properties they share, and `border: none` here plus `border-color: transparent` there
   both end at no visible border. What this one still carries is the REASON: an auth page has no ad
   surfaces at all, which is a different claim from "no mounter is running" and would survive even if
   the rule below changed again. Deleting it would leave #733's decision recorded nowhere. */
body[data-ads-off]:not([data-no-ads]) .mt-ads-placeholder {
  border: none;
  background: none;
}/* ─────────────────────────────────────────────────────────────────────────
   THE SLOT IS INVISIBLE; ONLY THE AD IS VISIBLE (#709)
   ─────────────────────────────────────────────────────────────────────────
   Every rule above draws a BOX — a dashed gold border and a cream fill — and that box was right
   while the scaffolding was inert, because an empty outlined rectangle is how you see that a slot
   exists. It is wrong once a real ad mounts: the creative is Google's rectangle, centred in a box
   the layout engine sized, and our chrome around it frames somebody else's content and announces
   the empty margin either side of a winner smaller than the box. So for a viewer who gets ads the
   slot blends into the page and the ad is the only thing on it.

   NOT KEYED ON `[data-ads-live]` ANY MORE (#825), AND THE REVERSAL IS THE POINT. #709 gated this on
   "a mounter is running" deliberately, to avoid what blanking on `:not([data-no-ads])` produced: a
   free viewer on a PRODUCTION with no network code got "invisible empty reserves and a 110px blank
   strip pinned to the bottom of every page", so the placeholder was left to stay "until something is
   actually going to fill it".

   THAT TRADE WAS TAKEN BEFORE THE SITE WAS PUBLIC, and going live decided it the other way. The
   state it optimised for — prod, launched, no ads yet — is not a brief pre-flight window; it is
   every visitor, for as long as the account takes to approve, and what they see is a dashed gold
   rectangle on a cream fill that reads as unfinished scaffolding. Reported from production, which is
   the only place it was ever visible: beta and local set `[data-ads-live]` too (simulation counts as
   a mounter), so the box was blanked everywhere EXCEPT the one channel the public can see.

   THE 110px STRIP OBJECTION IS ANSWERED PROPERLY RATHER THAN ACCEPTED. It was the strongest half of
   #709's argument and blanking alone does not address it — a fixed cream band reserving a tenth of a
   phone screen for something that can never arrive is worse than an outline, not better. So
   includes/footer.php no longer RENDERS the bar when nothing can fill it, which is #624's own rule
   ("the element is ABSENT rather than hidden so nothing downstream re-derives it") extended from
   paid/staff to the unconfigured case. The engine then asks the DOM, finds no bar, and gives the
   height to the board — the same composition a paid viewer already gets.

   WHAT REMAINS IS AN EMPTY SIDE PANEL, and that is honest: the rail is `flex: 1` and still does its
   layout job of pushing the footer down, it simply has nothing in it. An empty panel looks like an
   empty panel; a dashed rectangle looks like a bug.

   `[data-no-ads]` IS NOW EXCLUDED EXPLICITLY rather than falling out of `$mtShowAds` being false for
   paid/staff. There the box IS the content (the classical panel of #348) and keeps every bit of its
   chrome. Border WIDTH lives in the base rules and the no-ads rules only restyle it, so blanking the
   base for them would have taken the panel's border with it.

   THE UNFILLED STATE IS UNAFFECTED. js/ad-slots.js paints the ornament as a background-IMAGE when
   Ad Manager answers with nothing, and a transparent background-COLOR does not hide it — so an
   empty slot still shows art rather than a hole, which is the whole point of that state.

   ...AND THE BOX CAN BE PUT BACK, one attribute away, because inspecting a layout means seeing the
   boxes. `body[data-ads-outline]` restores the outline on every surface at once; js/ad-slots.js
   sets it from `localStorage['mt_ads_outline']`, so it survives navigation and needs no query
   argument (those are miserable to type on the phone this most needs checking on):
       localStorage.setItem('mt_ads_outline', '1')   // then reload — outlines on
       localStorage.removeItem('mt_ads_outline')     // off again
   The RED under-minimum marking is deliberately NOT part of this: a slot too small to ever hold an
   ad is a defect and must stay visible whether or not anyone asked to see the boxes.
   ───────────────────────────────────────────────────────────────────────── */
body:not([data-ads-outline]):not([data-no-ads]) .mt-ads-placeholder,
body:not([data-ads-outline]):not([data-no-ads]) .mt-col-ads {
  border-color: transparent;
  background-color: transparent;
  color: transparent;          /* the "Ads" word, wherever one is still rendered */
}
/* ...BUT THE FIXED BAR BLENDS RATHER THAN VANISHES, and the difference is not cosmetic. Those two
   sit IN FLOW, so transparent means the panel behind them shows and that is exactly right. This one
   is `position: fixed` over the page, and its background is the only thing stopping the page from
   scrolling THROUGH it — measured on an iPad Mini portrait, a puzzle card came up through the bar
   and sat around the creative. So it keeps an opaque fill and takes the PAGE's own cream, which is
   what "blend into the background" means for an element that has to occlude one.

   AND IT KEEPS AN EDGE, for the same reason it keeps the fill. A bar that is exactly the page colour
   with nothing marking its top is invisible until something disappears under it — prose scrolls up
   and simply stops existing part-way through a line, which reads as a rendering fault rather than as
   a boundary. So there is a hairline: the site's gold at low alpha, SOLID rather than the dashed
   placeholder rule (a dashed line at 45% reads as a dotted leftover, not as an edge), with a whisper
   of shadow above it so the content meets the bar rather than being cut by it. One pixel and a soft
   6px lift — enough to say "the page continues under here", not enough to be a second footer. */
body:not([data-ads-outline]):not([data-no-ads]) .mt-portrait-ads-bar {
  border-top-style: solid;
  border-top-color: rgba(201, 168, 76, 0.45);
  background-color: #F9F5EC;
  color: transparent;
  box-shadow: 0 -6px 10px -8px rgba(28, 19, 10, 0.18);
}
/* ...AND AGAIN, ONE CLASS HEAVIER, FOR THE PUZZLE ROUTES. The in-column slot is drawn by rules that
   key on the placement (`body.pz-layout2[data-mt-ads="column"|"band"|"top"]`, 0-3-1) and the band
   restyles it a second time, so the selector above (0-2-2) loses to both and the maze kept its
   dashed box. Written as its own rule rather than folded into the list above, because the two are
   different claims: that one says "a slot is invisible", this one says "and it stays invisible
   through the placement chrome". */
body.pz-layout2:not([data-ads-outline]):not([data-no-ads]) .mt-col-ads,
body.pz-layout2:not([data-ads-outline]):not([data-no-ads]) .mt-ads-placeholder {
  border-color: transparent;
  background-color: transparent;
  color: transparent;
}
body.pz-layout2:not([data-ads-outline]):not([data-no-ads]) .mt-portrait-ads-bar {
  border-top-style: solid;
  border-top-color: rgba(201, 168, 76, 0.45);
  background-color: #F9F5EC;    /* opaque, and edged — see the bar's note above */
  color: transparent;
  box-shadow: 0 -6px 10px -8px rgba(28, 19, 10, 0.18);
}
/* ─────────────────────────────────────────────────────────────────────────
   No-ads accounts (issue #348): paid (active club/executive) + staff see a
   subtle decorative fill instead of ads; header.php sets body[data-no-ads].
   STRICTLY layout-preserving — only the slot FILL changes (border style/color,
   background, label hidden). Box-model props (size, margin, padding,
   border-WIDTH, display, position) are untouched, so every layout variant is
   pixel-identical to the ads-shown state. The fill is a random CLASSICAL FRIEZE
   image (img/no-ads/*, cream/gold) tiled to the slot SHAPE. js/no-ads-fill.js picks the
   random image per slot + sets background-image inline. The "Ads" label is
   hidden via color:transparent (the node stays, so nothing reflows).
   ───────────────────────────────────────────────────────────────────────── */
/* Shared: hide the "Ads" label. SAME picture repeated HORIZONTALLY (portrait bar
   + nav) is a CSS background; DIFFERENT pictures stacked VERTICALLY (tall side
   panels) are <img> tiles built by js/no-ads-fill.js. */
body[data-no-ads] .mt-ads-placeholder,
body[data-no-ads] .mt-portrait-ads-bar {
  color: transparent;
  background-position: center;
}/* Tall side slots: a column of DIFFERENT friezes (js/no-ads-fill.js appends a
   .mt-noads-fill layer of .mt-noads-tile <img>s). The slot clips them and is a
   positioning context. The trimmed friezes (no white margin) stack edge-to-edge
   so there are no white gaps between them (review note). */
body[data-no-ads] .mt-ads-placeholder {
  position: relative;
  overflow: hidden;
  background-color: #FBF7EC;           /* cream fallback before the images load */
  border-style: solid;                 /* base border is 1px all sides → soften it */
  border-color: rgba(201, 168, 76, 0.25);
}body[data-no-ads] .mt-ads-placeholder .mt-noads-fill {
  position: absolute;
  inset: 0;
  overflow: hidden;                /* a too-tall vertical panel is CLIPPED at the bottom (#364) */
  display: flex;
  flex-direction: column;
}/* Full slot width, natural height (#364): the vertical panel fills the slot
   width; if it's taller than the slot the .mt-noads-fill overflow:hidden clips
   the bottom; if it's shorter the friezes + flex band below fill the rest. */
body[data-no-ads] .mt-ads-placeholder .mt-noads-tile {
  display: block;
  width: 100%;
  height: auto;
  flex: 0 0 auto;
}/* Bottom band (issue #354): takes the EXACT leftover under the stacked tiles and
   shows a frieze scaled to that height + repeated horizontally (so the column
   never ends on a clipped partial frieze; same scale-to-height + repeat-x as the
   portrait bar). js/no-ads-fill.js blanks the image → cream when the band lays
   out shorter than 1/3 of a full frieze. */
body[data-no-ads] .mt-ads-placeholder .mt-noads-band {
  flex: 1 1 0;
  min-height: 0;
  background-repeat: repeat-x;
  background-size: auto 100%;
  background-position: left top;
}/* Wide bars (portrait + nav): a strip scaled to the slot HEIGHT, repeated across.
   .mt-portrait-ads-bar has ONLY a top border in its base rule, so style just
   that edge — a blanket border-style:solid would activate 3 stray ~3px borders
   at the initial `medium` width (earlier review finding, issue #348). */
body[data-no-ads] .mt-portrait-ads-bar {
  background-color: #FBF7EC;
  background-repeat: repeat-x;
  background-size: auto 100%;
  border-top-style: solid;
  border-top-color: rgba(201, 168, 76, 0.25);
}/* Panel footer strips — both pinned to the same height so the two side columns'
   bottom bars match (#364 symmetric geometry). Centered as a flex row; the
   right panel adds its own gap rule below. */
.mt-panel-footer {
  flex-shrink: 0;
  min-height: 38px;
  padding: 7px 10px;
  border-top: 1px solid #C9A84C;
  background: #1C130A;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
}.mt-copyright {
  /* Crimson Text (the body serif) renders true mixed-case "Mathturion" — Cinzel
     draws lowercase as wide small-caps ("MATHTURION"), which overran the footer.
     Camel-case + no letter-spacing keeps the brand compact (#374). */
  font-family: 'Crimson Text', serif;
  font-size: 0.9em;
  color: #B89A6A;
}.mt-panel-footer a {
  color: #C9A84C;
  font-size: 1.1em;
}.mt-panel-footer a:hover {
  color: #FFD700;
  text-decoration: none;
}/* The desktop right-panel footer strip now holds ONLY the version chip (issue
   #325 — legal + copyright moved to the LEFT panel footer in header.php).
   CENTER it, mirroring the left-panel footer's centered legal+copyright link so
   the two panel footers read as a symmetric pair. */
.mt-panel-right .mt-panel-footer {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}/* Build/version stamp (issue #127) — small, muted chip in both footer slots
   (desktop right .mt-panel-footer + portrait/landscape-small .mt-content-footer).
   The compound a.mt-build-stamp selector beats the container's `a` rule (which
   would otherwise force 1.1em / gold), mirroring the .mt-copyright override. */
.mt-panel-footer a.mt-build-stamp,
.mt-panel-footer a.mt-build-stamp:visited,
.mt-content-footer a.mt-build-stamp,
.mt-content-footer a.mt-build-stamp:visited {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-family: Consolas, 'Liberation Mono', monospace;
  font-size: 0.78em;
  color: #8f7a52;
  letter-spacing: 0.02em;
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;   /* keep 'v1.0.0 · beta' on one line (single-line footer, #325) */
  flex: 0 0 auto;
}.mt-panel-footer a.mt-build-stamp:hover,
.mt-content-footer a.mt-build-stamp:hover {
  color: #FFD700;
  text-decoration: none;
}/* Site chip uses fa-tag (#325), the staff chip still uses fa-refresh — size both. */
.mt-build-stamp .fa-tag,
.mt-build-stamp .fa-refresh { font-size: 0.95em; }/* Center panel title section */
.mt-center-title {
  flex-shrink: 0;
  background:
    linear-gradient(to right,  #F9F5EC 0%, transparent 70px),
    linear-gradient(to left,   #0D1B3E 0%, transparent 70px),
    linear-gradient(160deg, #0D1B3E 0%, #162347 60%, #1a2a5e 100%);
  padding: 28px 30px 22px;
  border-bottom: 4px solid #C9A84C;
  position: relative;
  overflow: hidden;
  text-align: center;
}.mt-center-title::before {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 5px;
  background: repeating-linear-gradient(
    90deg,
    #C9A84C 0, #C9A84C 10px,
    #1C130A 10px, #1C130A 16px,
    #C9A84C 16px, #C9A84C 22px,
    #1C130A 22px, #1C130A 38px
  );
}.mt-center-title h1 {
  font-family: 'Cinzel', serif;
  font-size: 2.6em;
  font-weight: 900;
  color: #E8D48B;
  text-shadow: 2px 3px 10px rgba(0,0,0,0.7);
  letter-spacing: 0.2em;
  margin: 0 0 6px 0;
}/* Constrain Bootstrap container inside center panel */
.mt-panel-center .container {
  width: 100%;
  max-width: 100%;
  padding-left: 20px;
  padding-right: 20px;
}/* Shield icon in Home nav link */
.mt-nav-home > a {
  display: flex !important;
  align-items: center;
  gap: 7px;
  padding-top: 4px !important;
  padding-bottom: 4px !important;
}.mt-nav-shield {
  flex-shrink: 0;
  opacity: 0.9;
  width: 40px;
  height: 48px;
  object-fit: cover;
  object-position: center;
}/* Navbar inner — constrain to layout max-width */
.mt-navbar-inner {
  max-width: 1600px;
  margin: 0 auto;
  padding-left: 15px;
  padding-right: 15px;
}.mt-navbar-brand {
  font-family: 'Cinzel', serif;
  font-size: 1em !important;
  letter-spacing: 0.1em;
  color: #E8D48B !important;
  padding-top: 15px;
  padding-bottom: 15px;
}/* Avoid the doubled wordmark: when the hero title band (.mt-center-title)
   is actually rendered, hide the navbar brand. Driven by body.mt-hero-visible,
   toggled in header.php from the hero's real rendered state (covers every
   layout-variant × page-type combination, incl. live tablet rotation). When
   the hero is hidden (portrait, puzzle pages, auth-mobile) the class is
   absent so the brand stays as the sole title. */
body.mt-hero-visible .mt-navbar-brand { display: none; }/* Lone-hamburger navbar: on normal (non-puzzle/non-auth) pages in
   landscape-small at collapsed width, the compact hero is shown so the
   brand is hidden (rule above) and there's no ad slot — leaving just the
   3-bar. Anchor the hamburger left and add the shield on the opposite
   (right) side so the bar reads [hamburger] … [shield], mirroring the
   pz-mobile-fold treatment. Scoped to body.mt-hero-visible (set only when
   the hero actually renders) so it can't reach desktop, portrait
   (brand stays centred), puzzle, or auth navbars. Shield is absolutely
   positioned so the existing collapse/dropdown behaviour is untouched. */
/* WIDTH MUST TRACK THE COLLAPSE THRESHOLD ABOVE (#844). This block IS the collapsed
   landscape bar's design — toggle anchored left, shield painted right — and it stopped
   at 767 while the collapse moved to 949, so 768-949 landscape collapsed into a bar with
   NEITHER half: no wordmark (hidden by the .mt-hero-visible rule above), no crest, just a
   hamburger against an empty band. That is every common phone landscape - 844x390 is an
   iPhone 12/13/14, 915x412 a Pixel 7. Found by review, not by the sweep, which compared
   box heights and never asked whether the bar still had anything IN it.
   Still gated on .mt-hero-visible + max-height 599 + landscape, so widening the width
   cannot reach desktop, portrait, puzzle or auth navbars. */
@media (max-width: 949px) and (max-height: 599px) and (orientation: landscape) {body.mt-hero-visible .navbar-fixed-top .mt-navbar-inner { position: relative; }body.mt-hero-visible .navbar-fixed-top .navbar-toggle {
    float: left;
    margin-left: 15px;
  }body.mt-hero-visible .navbar-fixed-top .mt-navbar-inner::after {
    content: '';
    position: absolute;
    top: 0;
    bottom: 0;
    right: 12px;
    margin: auto 0;
    width: 40px;
    height: 40px;
    background: url('../img/logo-small.png') center / contain no-repeat;
    pointer-events: none;
  }}/* Hide the navbar shield (::after on .mt-navbar-inner) while the hamburger
   dropdown is open. Bootstrap 3 toggles `in`/`collapsing` only on the
   descendant #navbar.navbar-collapse, not on .mt-navbar-inner, so the
   pseudo-element can't react on its own. js/nav-modal.js (bound to Bootstrap
   collapse events, id-agnostic so the staff console's #staff-navbar gets it
   too) toggles body.mt-nav-open; this rule covers all three shield contexts:
   pz-mobile-fold, pz-mobile-fold-ads, and the lone-hamburger hero navbar.
   Cross-browser (no :has() dependency — iOS Safari < 15.4 lacks :has()). */
body.mt-nav-open .navbar-fixed-top .mt-navbar-inner::after {
  display: none !important;
}/* Minimum size guard — touch devices below 320×480 (in either orientation).
   Orientation-scoped so phones in landscape (which always have height < 480)
   don't get falsely classified as too small. Landscape height threshold is
   199 px (not 319) because iOS Safari in landscape shrinks the visible
   viewport height below 320 px when the URL bar + bottom toolbar are
   showing — a 320 threshold would falsely flag real iPhone SE landscape. */
@media (hover: none) and (pointer: coarse) and (orientation: portrait)  and (max-width: 319px),
       (hover: none) and (pointer: coarse) and (orientation: portrait)  and (max-height: 479px),
       (hover: none) and (pointer: coarse) and (orientation: landscape) and (max-width: 479px),
       (hover: none) and (pointer: coarse) and (orientation: landscape) and (max-height: 199px) {.navbar, .mt-layout, .mt-portrait-ads-bar { display: none; }.mt-too-small {
    display: flex !important;
    align-items: center;
    justify-content: center;
    height: 100vh;
    height: 100dvh;
    padding: 20px;
    text-align: center;
    font-family: 'Cinzel', serif;
    color: #5C3D11;
    background: #F9F5EC;
  }}.mt-too-small { display: none; }/* ── Content footer — hidden on desktop, shown in portrait/landscape-small ── */
.mt-content-footer {
  display: none;
  align-items: center;
  justify-content: center;
  /* Centered (#374 follow-up), wrapping to TWO lines when the legal+copyright
     link and the version chip don't fit on one line: they sit centered side by
     side (column-gap apart) while there is room, and drop to stacked centered
     lines (legal above, version below) when the bar would otherwise be too wide
     — content-driven, no fixed breakpoint. row-gap separates the two lines; on
     one line it has no effect. */
  flex-wrap: wrap;
  row-gap: 3px;
  column-gap: 14px;
  padding: 8px 12px;
  background: #1C130A;
  border-top: 1px solid #C9A84C;
  flex-shrink: 0;
}.mt-content-footer .mt-copyright {
  font-family: 'Crimson Text', serif;   /* camel-case brand, compact (#374) */
  font-size: 0.9em;
  color: #B89A6A;
}.mt-content-footer a {
  color: #C9A84C;
  font-size: 1.1em;
}.mt-content-footer a:hover { color: #FFD700; }/* THE AD NOTICE (#733) — the END OF THE SCROLLABLE CONTENT, on every page that shows ads.
   It is the last element inside .mt-panel-center (footer.php closes </main> right after it), so a
   reader who scrolls to the bottom finds it there in EVERY variant: the centre panel's own scroll on
   desktop, the body scroll in portrait. It is content, not chrome — which is why it carries NO
   display rules at all and is never hidden. An earlier draft mirrored .mt-content-footer's five
   display sites, which hid it on desktop and on the large/ puzzle routes and put a copy in the left
   panel column instead; that is the column placement this exists not to be.
   WRAPS RATHER THAN SQUEEZES: three items on a 320px phone become two or three centred lines. */
/* CONTACT US IS NOT A PRIVACY LINK (#837). It first reused .mt-privacy-link for the look, and
   scripts/smoke_test.php §3c refused it — that check asserts a paid/staff page carries NO
   mt-privacy-link, and a contact link wearing the class made the markup claim a role it does not
   have. The class was a styling shortcut; the assertion was reading it as a statement. Sharing the
   LOOK in a selector list is fine, because that is a claim about appearance rather than about what
   the element is.

   CONTACT US TAKES ITS OWN ROW. The band is a wrapping flex row, so without this the link
   trails the two privacy links and reads as a third item in the same list — which it is not: those
   two are about advertising and this one is about reaching a person. flex-basis: 100% makes it wrap
   whatever sits beside it, and justify-content: center on the strip then centres it on its own line.
   When it is the ONLY thing in the band (a member, or an account page) this changes nothing, since
   it was already alone on a line. */
/* THE BAND STANDS ASIDE ON ONE COMPOSITION (#837), AND IS HIDDEN UNTIL THE ENGINE SAYS SO.
   On the compositions that pin .mt-content-footer into the strip a portrait ad bar would have held
   (#828/#831) the guarantee is ZERO gap below it at every scroll position, and a band above the
   footer opens one.

   THE DEFAULT IS HIDDEN, AND THAT IS THE WHOLE FIX. Writing this as a single
   `[data-mt-pin-footer] { display: none }` rule looks equivalent and is not: the engine writes that
   attribute during apply(), so BEFORE the first compose it is absent, the rule does not match, and
   the band is visible on a route that is about to pin. tests/pinbar.html measured exactly that —
   green 3/3 run alone, red inside a loaded full run, always reporting this band's own height as the
   gap. A transient that only appears under load is still a transient.

   So: hidden on any pz-layout2 body, and revealed only once the engine has published a route AND
   has not published a pin. data-mt-route is written on every compose and survives apply()'s
   attribute sweep (KEEP_PUBLISHED), so it is a reliable "composition has happened" signal.

   BEING PINNED IS NOT THE QUESTION — being MOVED by the band is. Two rules pin this bar and they
   behave oppositely. data-mt-pin-footer (the #828/#831 freed-ad-strip route) is `position: fixed`,
   so it is out of flow and a band above it cannot move it: measured on cheese 375x553, nine-tokens
   390x740 and tablet 768x1024, the band renders, nothing overlaps (band bottom 487 against footer
   top 488) and every board is unchanged. Those routes SHOW the band, which is what a reader
   reasonably expects — cheese in SE portrait had no footer contact link while SE landscape did,
   purely because one composition pinned and the other did not.

   The panels-off large/portrait route is `position: sticky`, which a band above it DOES move:
   removing this clause reproduces a gap under the bar that grows with scroll — [0, 17, 47]px across
   a 60px range on mystery-masterpiece and gift-from-india at 744x1133 — against a guarantee of zero
   at every scroll position. A dark bar floating 47px above the bottom at the end of the page is a
   worse defect than a link that is still one hamburger tap away, so that one composition keeps the
   exclusion. It is a judgement, not a law: the two routes it affects are the roomy-portrait maze and
   masterpiece, and if the link is wanted there the fix is in the engine (it relocates this band next
   to the footer) rather than in this selector. */
body.pz-layout2 .mt-privacy-strip { display: none; }
body.pz-layout2[data-mt-route]:not([data-mt-panels="none"]) .mt-privacy-strip { display: flex; }
/* ...AND THE PANELS-OFF ROUTE ONLY PINS WHEN IT HAS NO STATIC AD BAR. The pin above is
   `[data-mt-panels="none"]:not([data-mt-ads="bottom"])` (see the .mt-content-footer rule), so
   excluding the whole panels-off route hid the band on a composition where nothing is pinned.
   Measured on escape-maze — free tier, and the only puzzle declaring barOnPanelsOff — at
   744x1133 with ads live: route=large/portrait panels=none ads=bottom, footer position STATIC,
   and the band gone. That is a free viewer looking at a real ad bar with no "Your Privacy
   Choices" and no "Privacy Policy" — the control legal.php promises in writing, which master
   rendered there. An over-broad exclusion is not a safe direction when what it hides is a
   compliance control. */
body.pz-layout2[data-mt-route][data-mt-panels="none"][data-mt-ads="bottom"]:not([data-mt-pin-footer]) .mt-privacy-strip { display: flex; }
.mt-band-contact {
  /* flex-basis:100% makes this the whole row, so the band's justify-content has nothing left to
     centre — the row centres its own contents instead. */
  flex-basis: 100%;
  /* A FLEX ROW, NOT text-align, BECAUSE THE TWO CHILDREN DO NOT SHARE A BASELINE. The link is
     display:inline-flex (it carries an icon beside its text), and an inline-flex box does not
     expose the baseline of its text — the browser synthesises one from its first item, which here
     is the envelope glyph, and its border-bottom + padding-bottom shift it further. Set against a
     plain inline <span>, that left the label sitting a couple of pixels off the link. Centring both
     boxes sidesteps the synthesis entirely and holds whatever the icon's metrics are. */
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 8px;
}
/* Shares .mt-privacy-why's look by SELECTOR rather than by a second copy of the three
   declarations — the same convention the link rule above states and then, in its first draft, did
   not follow here. Two labels in one band whose colour or size could silently diverge is the exact
   drift that convention exists to prevent. */
.mt-band-why {
  /* AND IT MATCHES THE LINK'S BOTTOM EXTRAS so the two TEXTS line up. The row centres its children
     by box, and the link's box is 2px taller at the bottom than its text (border-bottom 1px +
     padding-bottom 1px for the underline), so centring lifted the link's text 1px above the label's.
     Measured with Range rects over the actual text nodes — the earlier attempt at this compared the
     label's BOX to the link's inner SPAN and so could not see whether it had worked. */
  padding-bottom: 2px;
}
.mt-privacy-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: center;
  column-gap: 14px;
  row-gap: 2px;
  padding: 10px 14px 12px;
  border-top: 1px solid #E4D9BE;
  flex-shrink: 0;
}/* The band's optional RIGHT half on an auth page (#806): the page's own recovery
   links, one row, opposite "Questions? Contact Us". Hidden by default and revealed
   only by the landscape-small auth rule — the card shows them everywhere else, and
   both copies visible at once would be two live copies of the same link.
   It carries the label colour so the separator between the two anchors matches
   .mt-band-why; the anchors themselves take the shared link look below. */
.mt-band-auth {
  display: none;
  align-items: center;
  gap: 6px;
  font-family: 'Crimson Text', serif;
  font-size: 0.84em;
  color: #8F7A52;
}
.mt-privacy-why,
.mt-band-why {
  font-family: 'Crimson Text', serif;
  font-size: 0.84em;
  color: #8F7A52;
}.mt-privacy-link,
.mt-privacy-link:visited,
.mt-band-link,
.mt-band-link:visited,
.mt-band-auth a,
.mt-band-auth a:visited {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-family: 'Crimson Text', serif;
  font-size: 0.84em;
  color: #8B6914;
  text-decoration: none;
  border-bottom: 1px solid #DCCFB0;   /* matches .mt-legal-toc a — the same kind of link */
  padding-bottom: 1px;
  white-space: nowrap;
}
/* AFTER the shared rule, not before it (#806). That rule's selector list includes
   `.mt-band-auth a` at the identical specificity, so an override written ABOVE it
   loses on source order — which is where this one sat, silently doing nothing. The
   wrapper has already applied 0.84em and em COMPOUNDS, so the anchors came out
   0.71em: measured 12.7px against "Contact Us" at 15.1px, in the same band, at the
   one width where the band is the only way to reach them. */
.mt-band-auth a,
.mt-band-auth a:visited { font-size: inherit; }
.mt-privacy-link:hover,
.mt-privacy-link:focus,
.mt-band-link:hover,
.mt-band-link:focus,
.mt-band-auth a:hover,
.mt-band-auth a:focus {
  color: #0D1B3E;
  border-bottom-color: #0D1B3E;
}.mt-privacy-link .fa,
.mt-band-link .fa { font-size: 0.95em; opacity: 0.75; }/* Tiny phones (≈320px, iPhone SE): trim the content-footer text + padding so the
   legal+copyright link and the version chip stay compact (#325). They now wrap to
   two lines when still too wide (#374, .mt-content-footer flex-wrap), so this is
   just a size trim, not an overflow guard; roomier phones keep the larger size. */
@media (max-width: 360px) {.mt-content-footer { padding-left: 8px; padding-right: 8px; }.mt-content-footer .mt-copyright { font-size: 0.72em; }.mt-content-footer a.mt-build-stamp { font-size: 0.7em; }}/* Footer legal+copyright link (issue #325): the scales icon + the copyright
   string, together ONE link → legal.php, shown in every footer slot beside the
   version chip. Replaces the old standalone scales icon + the separate
   non-clickable copyright label. The copyright is now a <span> inside this link
   (it inherits the click), keeping its muted camel-case Crimson Text styling from
   the .mt-copyright rules above (#374); the whole link brightens to gold on hover. */
.mt-footer-legal {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  color: #C9A84C;
  text-decoration: none;
  white-space: nowrap;   /* never wrap the copyright to a 2nd line (single-line footer on small devices, #325) */
  flex: 0 0 auto;        /* don't let flex squeeze the link in the content-footer bar */
}.mt-footer-legal:hover,
.mt-footer-legal:focus { color: #FFD700; text-decoration: none; }.mt-footer-legal .fa-balance-scale { font-size: 1.05em; line-height: 1; }.mt-footer-legal:hover .mt-copyright,
.mt-footer-legal:focus .mt-copyright { color: #E8D48B; }/* Content-page "← Back" link (shared by legal.php + release-notes.php, #325 —
   promoted out of legal.php's inline <style> so both render it identically). */
.mt-legal-back {
  display: inline-block;
  margin-bottom: 12px;
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  letter-spacing: 0.04em;
  color: #8B6914;
  text-decoration: none;
  border-bottom: 1px solid #C9A84C;
  padding-bottom: 1px;
}.mt-legal-back:hover { color: #0D1B3E; border-bottom-color: #0D1B3E; }/* "Release Notes →" link on aboutus.php + chronicle.php (#325) — on-theme gold
   underline link (matches the legal TOC / back-link style, not Bootstrap blue). */
.mt-release-link {
  color: #8B6914;
  text-decoration: none;
  border-bottom: 1px solid #C9A84C;
  padding-bottom: 1px;
}.mt-release-link:hover { color: #0D1B3E; border-bottom-color: #0D1B3E; }/* ── Portrait ads bar — fixed at bottom, hidden on desktop ── */
.mt-portrait-ads-bar {
  display: none;
  position: fixed;
  bottom: 0; left: 0; right: 0;
  height: 110px;
  background: #FFFDF5;
  border-top: 1px dashed #C9A84C;
  align-items: center;
  justify-content: center;
  font-family: 'Cinzel', serif;
  font-size: 0.7em;
  color: #C9A84C;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  z-index: 500;
}/* ══════════════════════════════════════════════════
   PORTRAIT LAYOUT  (width < 1024px, height > width)
   ══════════════════════════════════════════════════ */

/* Phone, tablet, and any narrow window: fixed navbar (overlay dropdown), hamburger
   menu, brand centered.

   THE THRESHOLD IS 949px AND NOT 768, AND IT IS NO LONGER SCOPED TO PORTRAIT (#844).
   Five left-hand items (Home / Puzzles / About / Chronicle / Contact) plus the
   signed-in account group do not fit one row below ~934 in either orientation, and
   the bar then stands at 115px against its 50px reserve. The FOUR-item bar already
   did this at 800 signed in, so the old 768 cutoff was leaving a real defect
   uncovered — widening it fixes that as well as making room for the fifth item.

   949 AND NOT THE MEASURED BOUNDARY. Driven with the REAL signed-in markup, the
   bar wraps at 930-933 and is clean from 934, so 933 is the exact answer and 949
   is 16px of deliberate headroom. Two reasons it is not gratuitous: a media query
   is evaluated against a FRACTIONAL viewport width, so a browser at 110% zoom or a
   hidpi rounding can sit at 933.5 — outside an exact `max-width: 933px` and inside
   the wrap — and the boundary is a function of the rendered WIDTH OF SIX LABELS,
   which any future wording change moves. The cost is that windows 934-949 collapse
   when they need not; the cost of being 1px short is a two-line navbar over the
   content, so the asymmetry decides it.

   THE FIRST VERSION OF THIS RULE SAID 929, and it was measured wrong rather than
   reasoned wrong: the probe built its signed-in group with `fa` icons where the
   real markup uses glyphicons, so it measured a narrower bar and reported 930 as
   clean. An independent review measured the shipped page and found the wrap. The
   lesson is in the method, not the number — a probe that BUILDS the markup it
   measures is testing the probe.

   Why a media query rather than the body.mt-nav-forced measurer below, which
   already collapses the bar whenever the menu wraps: that measurer is JS, and with
   a fifth item it stops being a safety net and becomes the only thing standing
   between a normal window and a two-line navbar over the content. A media query
   re-evaluates itself on resize and rotation with nothing to go stale, and it is
   right before a single line of script has run. The measurer stays for what it was
   always for — the cases above 929 nobody enumerated.

   The rules inside are unchanged, and they are not new to landscape: body.mt-nav-forced
   applies this identical set at any width and orientation already, so dropping the
   portrait scoping runs a rule set that has been shipping in landscape all along. */
@media (max-width: 949px) {.navbar .navbar-header { position: relative; min-height: 50px; float: none; width: 100%; }.navbar .navbar-brand {
    position: absolute;
    top: 0;
    left: 50%;
    float: none;
    margin: 0;
    transform: translateX(-50%);
  }/* Force Bootstrap navbar to collapse (overrides its min-width: 768px rule).
     Cap open-overlay height to the viewport below the 50px navbar + internal
     scroll: signed-in admin (Home/Puzzles/About + Admin/My Account/Sign Out
     = 6 stacked items × ~55-60px) overflows phone landscape ~390-460px tall
     and the bottom item (Sign Out) was previously below the fold. */
  .navbar-toggle { display: block !important; }.navbar-collapse.collapse { display: none !important; height: 0 !important; overflow: hidden !important; }.navbar-collapse.collapse.in,
  .navbar-collapse.collapsing {
    display: block !important;
    height: auto !important;
    max-height: calc(100dvh - 50px) !important;
    overflow-y: auto !important;
    overflow-x: hidden !important;
  }/* !important on the three float/display declarations (#844): below 768 they never
     competed, because Bootstrap's own rules are @media (min-width: 768px). Across
     769-949 they now do, at equal specificity, and this file wins ONLY because it is
     loaded after bootstrap.min.css. The mt-nav-forced block below carries !important
     for exactly this reason and says so; this block needed the same and lacked it.
     Stack dropdown items vertically (overrides Bootstrap's min-width: 768px horizontal layout) */
  .navbar-nav { float: none !important; margin: 7.5px -15px; }.navbar-nav > li { float: none !important; }.navbar-nav.navbar-right { float: none !important; }/* Unified icon column so text labels align across all menu items */
  .navbar-nav > li > a {
    display: flex;
    align-items: center;
    gap: 10px;
    padding-top: 10px;
    padding-bottom: 10px;
  }.navbar-nav > li > a > .glyphicon,
  .navbar-nav > li > a > .fa {
    width: 40px;
    text-align: center;
    flex-shrink: 0;
  }.mt-nav-home > a { gap: 10px; }}/* ── Content-driven navbar collapse (any width / orientation) ──────────────
   When the expanded horizontal menu would wrap to a second line — it is
   longer when signed in, longer still for an admin, or simply in a narrow
   window — the inline measurer in includes/header.php sets body.mt-nav-forced
   and we collapse to the hamburger overlay regardless of viewport. The rules
   mirror the ≤768px-portrait block above but are keyed on the class, so they
   need !important to beat Bootstrap's own min-width:768px navbar rules. The
   measurer only fires in Bootstrap's un-collapsed mode, so this never fights
   the portrait / mobile-fold / landscape-small hamburgers. */
body.mt-nav-forced .navbar .navbar-header { position: relative; min-height: 50px; float: none; width: 100%; }body.mt-nav-forced .navbar .navbar-brand {
  position: absolute;
  top: 0;
  left: 50%;
  float: none;
  margin: 0;
  transform: translateX(-50%);
}body.mt-nav-forced .navbar-toggle { display: block !important; }body.mt-nav-forced .navbar-collapse.collapse { display: none !important; height: 0 !important; overflow: hidden !important; }body.mt-nav-forced .navbar-collapse.collapse.in,
body.mt-nav-forced .navbar-collapse.collapsing {
  display: block !important;
  height: auto !important;
  max-height: calc(100dvh - 50px) !important;
  overflow-y: auto !important;
  overflow-x: hidden !important;
}body.mt-nav-forced .navbar-nav { float: none !important; margin: 7.5px -15px; }body.mt-nav-forced .navbar-nav > li { float: none !important; }body.mt-nav-forced .navbar-nav.navbar-right { float: none !important; }body.mt-nav-forced .navbar-nav > li > a {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-top: 10px;
  padding-bottom: 10px;
}body.mt-nav-forced .navbar-nav > li > a > .glyphicon,
body.mt-nav-forced .navbar-nav > li > a > .fa {
  width: 40px;
  text-align: center;
  flex-shrink: 0;
}body.mt-nav-forced .mt-nav-home > a { gap: 10px; }@media (max-width: 1023px) and (orientation: portrait) {body[data-ads-live] { padding-bottom: 110px; }/* KEYED ON [data-ads-live], WHICH IS "A BAR IS RENDERED" (#829 review). It was a BARE `body`
     rule for a long time — no class, no attribute, no channel — so ANY document in portrait
     reserved 110px, and the staff console reserved it too. The console has no ad surfaces at all
     and never will; it inherits this stylesheet because build_staff.py copies css/, so its verbatim
     pages (bare <body>, no class, no attributes) held a strip for a bar that cannot exist. Nothing
     downstream cleared it, because every clearing rule names an attribute only the SITE emits.

     THE POSITIVE FORM IS THE ONLY ONE THAT WORKS HERE. Excluding by absence — :not([data-no-adbar])
     and friends — matches a body carrying no ad attributes at all, which is exactly the staff case,
     so it excludes everything except the thing you meant. data-ads-live is emitted precisely when
     $mtShowAds, and $mtShowAds already implies !$mtHideAds and $mtAdsPage, so it is the same
     condition footer.php renders the element on. A document that never heard of ads reserves nothing.

     (data-no-adbar still earns its place for the PIN and the measurer below: those want "a SITE page
     with no bar", and being an attribute only header.php emits, they cannot reach the console either.)

     ...and the reserve belongs to the BAR, so a page with no bar reserves nothing. footer.php
     renders .mt-portrait-ads-bar only for a viewer who gets ads, and header.php marks that same
     viewer with body[data-no-ads] from the one authority (mt_user_shows_ads) — so this is the exact
     complement of the element being there, not a second opinion about who is paid. Without it a
     paid/staff member got 110px of blank cream held at the foot of every portrait page for a bar
     that was never going to arrive, which is the same "0 is an answer, absent is not" the layout
     engine already applies on the puzzle routes (it writes the body reserve itself, and
     a portrait fitter used to zero it inline on the unmigrated puzzles — so this rule is for the
     ORDINARY pages: home, the gallery, About, the Chronicle, the account area).
     The URL-bar-reclaim below is unaffected: it stands on `.mt-layout { min-height: 100vh }` plus
     the 50px navbar, which still overflows the collapsed viewport without the bar's 110. */
  body[data-no-ads] { padding-bottom: 42px; }
  /* ...AND THE THIRD NO-BAR STATE (#825 follow-up): an AD-SEEING viewer whose bar cannot be
     filled. header.php publishes body[data-no-adbar] as the exact complement of footer.php
     rendering the element, so this keys on the BAR rather than on the VIEWER -- which is what
     the paragraph above already says the reserve should do. Without it, removing the bar left
     its 110px behind and every ordinary portrait page ended in blank cream. */
  body[data-no-adbar]:not(.mt-auth-page) { padding-bottom: 42px; }/* ...and #733 adds the THIRD no-bar state, which is what the two above did not anticipate. An
     account/auth page renders no bar for ANYONE, so an AD-SEEING viewer there reserves nothing
     either. The two rules that already clear this reserve both key on .mt-auth-page (see the
     ≤767 and 768-1023 portrait blocks), and payment-history.php deliberately does NOT carry that
     class — being outside it is the entire reason $mtPageNoAds exists — so without this it kept
     the full 110px for a bar footer.php no longer renders. Measured: 110px of blank cream at the
     foot of the page, on the one page this PR suppressed by FLAG rather than by class, i.e. the
     one page whose behaviour actually changed.
     Worth stating plainly, because footer.php's comment claims absence means nothing downstream
     re-derives who gets a bar: CSS still does, and it was re-deriving it from [data-no-ads], which
     is a question about the VIEWER where this one is about the PAGE.
     0, not the 42px floor above: that floor exists because [data-no-ads] also PINS
     .mt-content-footer into the strip, and that pin is scoped to [data-no-ads] — here nothing is
     pinned, the footer stays in normal flow, so there is nothing to reserve for. */
  body[data-ads-off]:not([data-no-ads]) { padding-bottom: 0; }/* 42px, NOT 0 — and it is a FLOOR, not the answer. js/no-ads-footer.js measures the bar and writes
     the exact reserve inline, which is the number that matters (the bar wraps to two centred lines at
     some widths, #374, so it runs 38-66px). But the PIN is unconditional CSS while the RESERVE was
     JS-only, so a script that failed to load left a fixed bar overlapping the last 40-70px of every
     ordinary portrait page — a state the old 110px constant could not reach, i.e. this change made a
     new failure mode possible rather than inheriting one. A floor makes the JS a refinement instead
     of a dependency: without it the page still reserves one line and the worst case is a two-line bar
     covering ~24px; with it the reserve is exact. 42px IS the one-line height (38 at 360, 42 at 412
     and up), so at the widths where the bar does not wrap the floor is already correct. */

  /* ...and the strip the bar used to hold becomes the LEGAL + VERSION bar (#624 follow-up). Portrait
     has always ENDED IN A PINNED BOTTOM BAND; for a viewer with no ad there was nothing left to pin,
     so the page simply stopped and the copyright sat wherever the prose happened to end.
     FIXED, AND STICKY WAS TRIED FIRST. This file already pins this same bar with `position: sticky`
     on the tablet route that drops its side panels (`body.pz-layout2[data-mt-panels="none"]`), and
     sticky is the better tool where it works — it stays IN FLOW, so it needs no reserve. It does not
     work here, and the reason is structural rather than incidental: sticky can only shift an element
     WITHIN its containing block, and this bar is the LAST CHILD of .mt-panel-center, so it already
     sits flush against that block's bottom edge and has nothing to slide over. Measured at 390x844
     on home.php: `bottom: 0` moved it 0px at every scroll offset, pinning only where it already was.
     The tablet route escapes that because there the panel is a FIXED-HEIGHT INTERNAL SCROLLER whose
     content is taller — scrollport and containing block are different boxes, so there is room. In
     portrait the body scrolls and the two coincide.
     So: fixed, and the body must reserve the height. That reserve is NOT a constant — the bar wraps
     to two centred lines when the legal link and the version chip will not fit (#374), measuring
     38px at 360 and 66px at 390, and moving again with the version chip's channel suffix (`v1.0.0`
     on prod, `v1.1.0 · beta` on beta). So it is MEASURED at runtime by js/no-ads-footer.js.
     ONE ELEMENT, MOVED — not a second copy — so there is no duplicated copyright to remove.
     z-index sits under the navbar (1030) and the nav backdrop (1005), so an open hamburger covers
     it, and above page content the way the ads bar's 500 did.

     NOT ON A PUZZLE PAGE. There the bottom strip is PLAY AREA, and a pinned bar can only be paid for
     out of the board — which is the height dropping the ad bar just gave back, handed straight to
     chrome. So a puzzle page keeps the copyright and version where the layouts already put them:
     BELOW THE FOLD, after the description (the engine lists them in `belowFold` in that order, and
     the phone routes flow them the same way). The bar is then in normal flow and costs the board
     nothing. The only thing a puzzle page loses for a no-ads viewer is the ad bar itself.

     NOR ON AN AUTH PAGE. Those place this same bar themselves — `margin-top: auto` at the end of a
     flex column that is either the fixed-height centred card box or the long-page variant — and they
     force `body { padding-bottom: 0 !important }`, which an inline reserve cannot outrank. Pinning it
     there would float the bar over the bottom of the card with nothing reserved beneath it. */
  body[data-no-ads]:not(.mt-puzzle-page):not(.mt-auth-page) .mt-content-footer,
  body[data-no-adbar]:not(.mt-puzzle-page):not(.mt-auth-page):not([data-ads-off]) .mt-content-footer {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 500;
  }/* Layout: side panels side-by-side at top, center full-width below */
  .mt-layout {
    flex-wrap: wrap;
    height: auto;
    min-height: auto;
    overflow: visible;
  }/* URL-bar-reclaim for SHORT non-puzzle pages (issue #204). A page only
     marginally taller than the viewport overflows ONLY while the mobile URL
     bar is showing; scrolling down collapses the bar, the viewport grows past
     the content, the overflow vanishes, and the browser snaps scrollTop back
     to 0 — the reported "try to scroll down, page jumps back to the top" (most
     visible on a short list like Your Puzzle Progress with few solved rows).
     Forcing the layout to at least one viewport keeps the body taller than the
     URL-bar-COLLAPSED viewport (body = 50 navbar + 100dvh + 110 ads bar), so
     there is always overflow and the scroll never resets. align-content keeps
     the wrapped panels at their natural height with the slack at the bottom
     (default `stretch` would balloon them). Same intent as the landscape-small
     reclaim in the media query below, but that single-row layout uses
     align-items while this wrapped two-line layout needs align-content. Puzzle
     pages keep their own portrait layouts. */
  body:not(.mt-puzzle-page) .mt-layout {
    min-height: 100vh;
    min-height: 100dvh;
    align-content: flex-start;
  }.mt-panel-left {
    flex: 0 0 50%;
    height: auto;
    order: 1;
    border-bottom: 2px solid #C9A84C;
  }.mt-panel-right {
    flex: 0 0 50%;
    height: auto;
    order: 2;
    border-bottom: 2px solid #C9A84C;
  }.mt-panel-center {
    flex: 0 0 100%;
    order: 3;
    height: auto;
    overflow-y: visible;
    overflow-x: hidden;
  }/* Side-top areas: height proportional to panel width (each panel is 50vw) */
  .mt-side-top { height: 35vw; }.mt-left-logo { padding: 8px; }.mt-left-logo img { height: 100%; max-height: 100%; }.mt-right-constellation { padding: 8px; }/* SVG cap = container (35vw) − 16 padding − 6 gap − ~22 scroll plate − 8 buffer
     Guarantees the scroll plate stays visible on every phone width. */
  /* height:auto resets the desktop base rule's explicit 246px (#364) so portrait
     stays driven by max-height below. */
  .mt-right-constellation svg { height: auto; max-height: calc(35vw - 52px); width: auto; max-width: none; }/* Hide desktop-only side panel elements. The right-panel footer needs a
     2-class selector to win: `.mt-panel-right .mt-panel-footer { display: flex }`
     (#127, non-media-query) has higher specificity than a bare
     `.mt-panel-footer { display: none }`, so without the explicit right-panel
     selector the version chip leaks into portrait below the constellation /
     Become-a-Supporter CTA AND duplicates the center content-footer's chip.
     Landscape-small already hides it this way (.mt-panel-right .mt-panel-footer). */
  .mt-ads-placeholder { display: none; }.mt-panel-footer,
  .mt-panel-right .mt-panel-footer { display: none; }/* Hide desktop title, show content footer */
  .mt-center-title { display: none; }.mt-content-footer { display: flex; }/* Show fixed ads bar */
  .mt-portrait-ads-bar { display: flex; }/* Portrait entry CTAs (issue #11): the landscape hero band (.mt-center-title)
     is hidden above, so on home the two CTAs are tucked under the soldier logo
     (left panel) and the constellation (right panel). Both .mt-side-top blocks
     are a fixed 35vw tall, so the buttons start at the same y and read as a
     side-by-side pair across the wrapped top row. Buttons only — no tagline /
     intro / band. */
  /* Scoped to the panels so this beats the base `.mt-portrait-cta {display:none}`
     (which sits later in the file — media queries add no specificity, so an
     unscoped rule here would lose on source order and the buttons would stay
     hidden). */
  .mt-panel-left .mt-portrait-cta,
  .mt-panel-right .mt-portrait-cta {
    display: flex;
    justify-content: center;
    padding: 10px 8px 12px;
  }.mt-portrait-cta .mt-cta {
    width: 100%;
    max-width: 220px;
    text-align: center;
    font-size: 0.82em;
    padding: 10px 12px;
  }}/* ══════════════════════════════════════════════════
   LANDSCAPE-SMALL  (height < 600px, landscape)
   ══════════════════════════════════════════════════ */
@media (max-height: 599px) and (orientation: landscape) {/* Left panel: logo on top, constellation at bottom */
  .mt-panel-left .mt-side-top { height: auto; }.mt-panel-left .mt-left-logo { padding: 6px 8px; }.mt-panel-left .mt-left-logo img { max-height: 100px; }.mt-panel-left .mt-ads-placeholder { display: none; }.mt-panel-left .mt-panel-footer { display: none; }.mt-panel-left .mt-left-constellation {
    display: flex;
    margin-top: auto;
    flex: 0 0 auto;
    padding: 6px 4px 8px;
    gap: 4px;
    background: linear-gradient(160deg, #0D1B3E 0%, #162347 60%, #1a2a5e 100%);
  }.mt-panel-left .mt-left-constellation svg {
    width: 100%;
    max-width: 110px;
    /* Cap SVG so the scroll plate fits even on short landscape viewports.
       Budget: 50 navbar + 112 logo block + 14 const padding + 4 gap + 22 scroll + 8 buffer = 210. */
    max-height: min(160px, calc(100dvh - 210px));
    height: auto;
  }/* Right panel: ads only */
  .mt-panel-right .mt-side-top { display: none; }.mt-panel-right .mt-panel-footer { display: none; }/* The base rule's margin: 0 stands here too — this is the variant where the rail is narrowest
     (a fifth of 568 on the smallest phone in landscape), so it is the last place to spend width
     on an inset. See the base rule for why. */
  .mt-panel-right .mt-ads-placeholder { margin: 0; }/* Center panel: compact title */
  .mt-center-title { padding: 12px 24px; }.mt-center-title h1 { font-size: 1.5em; margin-bottom: 2px; }/* Sticky content footer */
  .mt-content-footer { display: flex; margin-top: auto; }/* Body-level scrolling on non-puzzle pages so mobile Chrome / Safari
     auto-hide the URL bar after the first scroll. Puzzle pages are excluded:
     non-mobile-fold puzzles fit-to-viewport; mobile-fold variants already body-scroll.
     min-height: 100dvh (not calc(100dvh - 50px)) ensures body = 50 navbar + 100dvh,
     which always exceeds viewport — needed for the browser to register scroll and
     trigger the URL-bar auto-hide. The trailing ~50 px of empty layout below the
     side panels is the cost of the URL-bar reclaim (acceptable trade-off). */
  body:not(.mt-puzzle-page) .mt-layout {
    height: auto;
    min-height: 100dvh;
    overflow: visible;
    align-items: flex-start;
  }body:not(.mt-puzzle-page) .mt-panel-left,
  body:not(.mt-puzzle-page) .mt-panel-right {
    position: sticky;
    top: 50px;
    height: calc(100dvh - 50px);
    align-self: flex-start;
  }body:not(.mt-puzzle-page) .mt-panel-center {
    height: auto;
    overflow-y: visible;
  }}/* ══════════════════════════════════════════════════
   TABLET (touch device that falls through to the DESKTOP layout)
   ══════════════════════════════════════════════════ */
/* An iPad-class device lands on the DESKTOP rules — which scroll
   .mt-panel-center INTERNALLY inside a fixed-height .mt-layout (overflow:hidden)
   — whenever it matches NEITHER mobile media query, i.e.:
     · landscape and TALLER than the 599px landscape-small cutoff (every iPad in
       landscape: Mini 1024×768, Air 1180×820, Pro …), AND
     · portrait and WIDER than the 1023px portrait cutoff (only iPad Pro 12.9",
       1024px wide in portrait — smaller iPads' portrait width is ≤ 1023 so they
       already get the wrapped portrait layout).
   Rotating a content page across that boundary FLIPS the scroll model
   (body-scroll ↔ inner-panel-scroll), and iOS Safari — holding a stale 100dvh
   from a collapsed URL bar — strands the page mid-text with touch-scroll frozen
   (#490). Cover BOTH fall-through cases with the same body-scroll treatment the
   portrait / landscape-small variants use, so the scroll model never flips on
   rotation for any iPad. (Supersedes the earlier landscape-only #488 tablet
   block — this adds the portrait arm + the auth-page exclusion.) TOUCH-GATED
   (hover:none + pointer:coarse) so real
   desktops keep the internal-scroll layout; the landscape arm's min-height:600px
   keeps phones (shorter in landscape) on landscape-small, and the portrait arm's
   min-width:1024px keeps ≤ 1023px-wide portrait tablets/phones on the wrapped
   portrait variant. AUTH pages are excluded (:not(.mt-auth-page)) — they are
   vertically-centred, fixed-height-box + js/auth-fit.js scaled, and rely on the
   desktop layout on iPad landscape (see the .mt-auth-centered blocks below);
   body-scroll would top-strand the card. Puzzle pages excluded too (their own
   desc-flow / mobile-fold routes own tablet layout). */
@media (hover: none) and (pointer: coarse) and (orientation: landscape) and (min-height: 600px),
       (hover: none) and (pointer: coarse) and (orientation: portrait)  and (min-width: 1024px) {body:not(.mt-puzzle-page):not(.mt-auth-page) .mt-layout {
    height: auto;
    min-height: 100vh;
    min-height: 100dvh;
    overflow: visible;
    align-items: flex-start;
  }body:not(.mt-puzzle-page):not(.mt-auth-page) .mt-panel-left,
  body:not(.mt-puzzle-page):not(.mt-auth-page) .mt-panel-right {
    position: sticky;
    top: 50px;
    height: calc(100vh - 50px);
    height: calc(100dvh - 50px);
    align-self: flex-start;
  }body:not(.mt-puzzle-page):not(.mt-auth-page) .mt-panel-center {
    height: auto;
    overflow-y: visible;
  }}/* ══════════════════════════════════════════════════
   PUZZLE PAGES  (shared layout — applied on body.mt-puzzle-page)
   ══════════════════════════════════════════════════ */

body.mt-puzzle-page .mt-center-title { display: none; }body.mt-puzzle-page .mt-panel-center {
  padding: 0;
  overflow: hidden;
}/* Anti-copy deterrent on puzzle pages (#328 Part 1) — FRICTION, NOT PREVENTION.
   Raises the bar against casual copying of puzzle text + art; bypassable via
   DevTools / screenshots (see issue #328; js/puzzle-protect.js handles the
   right-click / drag / copy events). Form fields stay selectable so any input
   keeps working, and user-select:none does NOT stop assistive tech reading the
   DOM — it only blocks manual selection/copy. */
body.mt-puzzle-page {
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;   /* suppress the iOS long-press save/copy menu */
}body.mt-puzzle-page input,
body.mt-puzzle-page textarea,
body.mt-puzzle-page [contenteditable]:not([contenteditable="false"]) {
  -webkit-user-select: text;
  -moz-user-select: text;
  -ms-user-select: text;
  user-select: text;
}body.mt-puzzle-page img,
body.mt-puzzle-page canvas {
  -webkit-user-drag: none;   /* the only real "user-drag" property (WebKit/Blink) */
}/* Faint per-user watermark (#328 Part 3a). Diagonal, full-coverage tiled mark
   over the play area, painted by js/puzzle-watermark.js (background-image set
   there). mix-blend-mode:difference + a tiny gray ink make it sit a few grades
   off whatever's behind it (cream canvas OR dark painted scene), so it's
   barely-there in play but survives cropping. pointer-events:none → never
   blocks gameplay. */
.pz-watermark {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-repeat: repeat;
  mix-blend-mode: difference;
  z-index: 3;
}/* "© Mathturion" copyright line after the description prose. */
.pz-desc-copyright {
  margin-top: 10px;
  text-align: right;
  font-family: 'Crimson Text', serif;
  font-size: 0.85em;
  color: rgba(28, 19, 10, 0.4);
}body.mt-puzzle-page .mt-panel-center > .container {
  padding: 0;
  margin: 0;
  width: 100%;
  max-width: none;
  flex: 1;
  display: flex;
  min-height: 0;
}.pz-area {
  flex: 1;
  display: flex;
  flex-direction: column;
  position: relative;
  overflow: hidden;
  padding: 12px 18px;
  gap: 10px;
  min-height: 0;
  min-width: 0;
  background: #F9F5EC;
}.pz-description {
  flex: 0 0 auto;
  max-height: 40%;
  overflow: hidden;
  font-size: 0.95em;
  color: #5C3D11;
  font-family: 'Crimson Text', serif;
  line-height: 1.45;
}.pz-description h2 {
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  margin: 0 0 6px;
  font-size: 1.25em;
  letter-spacing: 0.05em;
}.pz-description blockquote {
  border-left: 3px solid #C9A84C;
  padding: 2px 12px;
  margin: 6px 0;
  font-style: italic;
  color: #5C3D11;
  font-size: 0.92em;
}.pz-description p { margin: 4px 0; }.pz-header {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 44px;   /* the back link's line box (~22) + its 11px of padding each side */
}.pz-desc-tab {
  padding: 4px 16px;
  background: #1C130A;
  color: #E8D48B;
  border: 1px solid #C9A84C;
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  letter-spacing: 0.08em;
  cursor: pointer;
  transition: background 0.18s, color 0.18s;
}.pz-desc-tab:hover { background: #2C1F0E; color: #FFD700; }.pz-bottom {
  flex: 1 1 auto;
  display: flex;
  gap: 14px;
  min-height: 0;
  min-width: 0;
}/* ============================================================
   SHARED PUZZLE CONTROL BUTTONS

   Canonical look for every puzzle's action buttons — Undo / Reset /
   Rotate / Move / Zoom / Flip / Clear / Play Another, etc. Each button
   carries BOTH `.pz-btn` and its own puzzle-prefix class (.cm-btn,
   .uph-btn, …); the prefix class is kept only as a hook for per-puzzle
   compaction (mobile-fold font sizes) and accent variants. Base look,
   hover, and disabled state live here so they can't drift between
   puzzles. Text/border is the dark brown #5C3D11 for readability
   (issue #9). Per-puzzle overrides (font-size, accent colors) win on
   source order / specificity as documented in puzzles/CLAUDE.md.
   ============================================================ */
.pz-btn {
  font-family: 'Cinzel', serif;
  background: transparent;
  color: #5C3D11;
  border: 2px solid #5C3D11;
  padding: 4px 14px;
  font-size: 0.9em;
  letter-spacing: 0.06em;
  cursor: pointer;
  transition: background 0.18s, color 0.18s;
  border-radius: 2px;
  white-space: nowrap;
}.pz-btn:hover:not(:disabled) { background: #8B5A2B; color: #F9F5EC; }.pz-btn:disabled { opacity: 0.4; cursor: not-allowed; }/* Reset stands apart with a soft-rose fill (issue #8). Higher pseudo-
   class specificity on :hover keeps the rose hover above any puzzle
   prefix :hover rule. */
.pz-btn--reset {
  background: #EDB4AC;
  color: #7A1F17;
  border-color: #B23A2E;
}.pz-btn--reset:hover:not(:disabled) { background: #E4A39A; color: #5A140E; }/* ── Win-overlay action buttons (issue #225) ─────────────────────────────────
   A real-DOM bar that celebration.js shows over the canvas once the celebration
   settles (replacing the old "click anywhere to play again"). position:fixed and
   centred on the canvas by JS (left/top set inline; transform centres on that
   point). Its own button look — light fills on a dark pill — because the plain
   .pz-btn (dark-on-transparent) would vanish against the celebration's dark
   veil. z-index sits above the navbar (1030) so it's never occluded. */
/* During a win celebration the play area is inert — only the fixed-position
   action buttons above are clickable. Force a default cursor over the play area
   (canvas / SVG / scene) so it doesn't show each puzzle's own clickable cursor
   and read as if the whole celebration were clickable (issue #361). The action
   buttons live in <body> (not inside .pz-play), so they keep their own pointer
   cursor. */
body.pz-celebrating .pz-play,
body.pz-celebrating .pz-play * { cursor: default !important; }/* Let the browser's own pinch-zoom through while the celebration is up. The six
   zoom-owning puzzles keep touch-action:none during PLAY so they can implement
   pinch themselves — but once the board is solved it is inert (taps swallowed,
   no dragging), so that block buys nothing and costs the only gesture that can
   recover a lost view. It made the win-overlay a TRAP: if the action bar ended
   up outside the visible area (a win taken while pinch-zoomed), the sole
   zoomable surface was the bar itself — the very thing out of reach.
   This rule is only half the fix: those puzzles ALSO cancel the gesture in
   their own capture-phase touch handlers, which a CSS relaxation cannot undo.
   The shared guard in the layout engine reads this computed value and
   swallows the multi-touch ahead of them, which is what makes it real.
   The bar is also clamped to the VISUAL viewport (puzzles/celebration.js), so
   it should not go missing in the first place; this is the guarantee that the
   player can always zoom out and find it anyway. Reverted on reset() with the
   class.
   pinch-zoom, not auto (#600): the celebration is a modal now, so the page
   must not PAN under it — but the zoom half is exactly what this rule exists
   for and stays. The shared guard treats both values the same. */
body.pz-celebrating .pz-play,
body.pz-celebrating .pz-play canvas,
body.pz-celebrating .pz-play * { touch-action: pinch-zoom !important; }/* Once the BROWSER itself is pinch-zoomed in (visual viewport scale > 1), let the
   zoom-owning puzzles' play surface hand the pinch to the browser so the user can
   pinch back OUT — otherwise touch-action:none makes the play area the one surface
   that can't be un-zoomed, trapping the page (issue #613). The shared guard in
   the engine swallows the TWO-finger gesture off the puzzle AND toggles
   body.mt-browser-zoomed — and js/mt-layout.js (the layout2 engine) does the same,
   since the guard lives in the engine now and this would otherwise be lost
   protection entirely; this rule is the other half (touch-action:none alone
   would still block the browser's pinch).

   `pan-x pan-y` is here for the OTHER half of the gesture, and it is a
   correction. This rule was written to RELAX the five zoom-owning puzzles from
   `touch-action: none`, but it is `!important` over `.pz-play *`, so it also
   TIGHTENED the puzzles that had already been given a pan axis (#600): trains,
   eight-chests, lamplighter, twelve-bowls and the Trial declare
   `pan-y pinch-zoom` and scroll fine at 1x, and this rule took that away the
   moment the page was zoomed in — the one state where there is most to scroll
   to and least of it on screen. `pinch-zoom` alone means "the browser owns
   two-finger gestures and NOTHING else", so a zoomed-in player could pinch out
   but could not pan to the part of the board they had just zoomed in on.

   BOTH axes, not just pan-y: a zoomed page is scrolled sideways as well as
   down, and the puzzles that had a pan axis only ever had the vertical one.

   This does NOT hand the browser single-finger PLAY, which is what the earlier
   wording feared. Which gestures the puzzle keeps is decided per touch by the
   puzzle itself — it cancels the touchstart only when the finger landed on
   something it owns (a car, a token, a piece), so a drag that starts on a piece
   is still a drag and a drag that starts on empty scene scrolls the page. See
   the `pickCar` comment in trains.php for the pattern. Inert at scale 1.
   `auto`, NOT `pan-x pan-y pinch-zoom` — and the difference is not cosmetic, it is
   the whole bug. Those three keywords ARE `manipulation`, and the device said so:
   the diagnostic printed `ta=manipulation` next to `cancelable=false` on both
   touchstart and touchmove, with `pd=false`. `manipulation` tells Chrome there is
   no double-tap to disambiguate, so it may commit to the pan immediately and hand
   the page non-cancelable events — at which point preventDefault is not merely
   ignored, it is never permitted to run, and NO amount of JS can keep a drag. With
   `auto` the browser must still consider a double-tap, so the events stay
   cancelable and the puzzle's claim is honoured.
   This is #619's value, arrived at on a device; it was ported here after three
   theories derived from first principles each failed on that same device. Prefer
   the measurement. */
body.mt-browser-zoomed .pz-play,
body.mt-browser-zoomed .pz-play canvas,
body.mt-browser-zoomed .pz-play * { touch-action: auto !important; }

/* #600 — the celebration is MODAL. A full-viewport layer dims the page, eats
   every tap that is not a win button, and locks scrolling; puzzles/celebration.js
   adds it on start() and drops it on reset(). Before this the board went inert
   on a win but nothing else did: the puzzle's own Reset/Undo, the navbar and
   the links below the fold all stayed live, and the page could be scrolled
   clean away from the win screen.
   Stack: page → .pz-celebrate-backdrop (1035) → the celebration canvas
   (.pz-celebrate-stage, 1038) → .pz-celebrate-actions (1040). The canvas is
   raised ABOVE the backdrop on purpose — drawOverlay() already veils the board
   to 0.60 black on that canvas, so leaving it underneath would dim the
   confetti and the text along with everything else.
   The fade is delayed 700ms to land with that in-canvas veil, which starts at
   t=700 and reaches full at t=1400 — the element itself is present (and
   blocking) from the instant of the win.
   touch-action: pinch-zoom keeps the one gesture that can recover a win screen
   left off-screen by a zoomed-in solve; the JS lock stands down for
   multi-touch for the same reason. */
.pz-celebrate-backdrop {
  position: fixed;
  top: 0; right: 0; bottom: 0; left: 0;
  z-index: 1035;
  display: none;               /* JS flips to block when shown */
  background: rgba(0, 0, 0, 0.60);
  opacity: 0;
  transition: opacity 700ms ease 700ms;
  touch-action: pinch-zoom;
  -webkit-tap-highlight-color: transparent;
}.pz-celebrate-backdrop--show { opacity: 1; }/* z-index ONLY. `position` is deliberately not set here: five puzzles overlay
   the board with an absolutely-positioned celebration canvas (top/left/
   transform centring, e.g. #celebration-canvas in gift-from-india), and
   forcing `relative` would drop it back into flow. celebration.js gives the
   canvas an inline `position: relative` only when it computes to `static`,
   and removes that inline value again on reset. */
body.pz-celebrating canvas.pz-celebrate-stage { z-index: 1038; }@media (prefers-reduced-motion: reduce) {/* The overlay is painted once with no animation (#262), so the backdrop has
     nothing to fade in with. */
  .pz-celebrate-backdrop { transition: none; }}.pz-celebrate-actions {
  position: fixed;
  z-index: 1040;
  display: none;               /* JS flips to flex when shown */
  transform: translate(-50%, -50%);
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 10px 14px;
  max-width: 92vw;
  background: rgba(13, 27, 62, 0.72);
  border: 1px solid rgba(201, 168, 76, 0.55);
  border-radius: 8px;
  box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
  opacity: 0;
  transition: opacity 0.32s ease;
}.pz-celebrate-actions--show { opacity: 1; }/* The per-puzzle flavour note (#918) — a paragraph, a picture and a reference,
   in a CARD. celebration.js writes that card's width and its height CAP, framed by
   the middle column where the layout has three and by the visible viewport where it
   has one, which is what lets everything inside be bounded AGAINST THE CARD rather
   than in fixed pixels: a picture in px is either too small to read on a desktop or
   too tall for a phone, and there is no one number that is both. Those bounds are
   written as px by _sizeCard and NOT expressible here, because the card's height is
   a cap over an auto height, under which a `max-height: 100%` is indefinite and
   does not apply at all.

   IT COVERS THE PAINTED VICTORIA! / verdict lines, deliberately — the celebration
   has two acts, the confetti for the first five seconds and then the card. An
   earlier pass lifted those lines by the panel's measured growth so nothing was
   covered; a card this size has nothing behind it left to protect.

   The panel is a COLUMN while it is a card (note above, buttons below) and stays
   the ordinary centred wrap-row otherwise. */
.pz-celebrate-btnrow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px;
}
.pz-celebrate-actions--note {
  /* The card's width and height cap are written as px by celebration.js, so the
     box has to BE that size rather than that size plus its padding and border —
     30px over on both axes otherwise. Bootstrap's global reset already makes it
     border-box on every real page; saying so here keeps the geometry from
     depending on that, which is how tests/celebrate-note.html found it. */
  box-sizing: border-box;
  flex-direction: column;
  flex-wrap: nowrap;
  justify-content: center;
  padding: 12px 14px;
}
.pz-celebrate-actions--note .pz-celebrate-btnrow { flex: 0 0 auto; }
.pz-celebrate-note {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
}
/* SIDE BY SIDE wherever the CARD is wide enough, stacked where it is not - and it
   is the CARD's own width, not the viewport's, so this is a class celebration.js
   publishes rather than a media query. It has to be: since the card is the middle
   COLUMN in a three-column layout rather than the whole viewport, a query keyed on
   the viewport put a 444px-wide card into a row and let flex shrink squeeze the
   picture to 146 x 184 while the prose kept its measure. */
.pz-celebrate-actions--row .pz-celebrate-note { flex-direction: row; gap: 16px; }
.pz-celebrate-note-img {
  display: block;
  flex: 0 1 auto;
  min-height: 0;
  /* BOTH DIMENSIONS AUTO, bounded by three maxima — the card's height, its width,
     and the picture's own resolution — so the BOX hugs the picture whichever binds.
     `height: 100%` + object-fit was tried and is the trap: on a tall card the box
     took the full height and letterboxed the engraving inside its own gold border,
     measured at 313 x 808 for a picture that is 313 x 395. */
  width: auto;
  height: auto;
  max-height: min(var(--pz-img-max-h, 100%), var(--pz-nat-h, 100%));
  max-width: min(var(--pz-img-max-w, 46%), var(--pz-nat-w, 46%));
  border: 1px solid rgba(201, 168, 76, 0.55);
  border-radius: 2px;
  background: #F9F5EC;
}
/* Stacked is the DEFAULT (the row above is the opt-in): the prose and the buttons
   take what they need and the picture takes everything left, growing until its
   width binds or it reaches its own resolution. */
.pz-celebrate-actions--note:not(.pz-celebrate-actions--row) .pz-celebrate-note-text { flex: 0 0 auto; }
.pz-celebrate-actions--note:not(.pz-celebrate-actions--row) .pz-celebrate-note-img {
  max-width: min(var(--pz-img-max-w, 100%), var(--pz-nat-w, 100%));
}
/* The picture links to the same article as the line under the prose (#918) — a
   thumbnail of a thing is the most obvious place to press for the thing. The anchor
   shrink-wraps it (line-height:0, so no descender strip under the image), which is
   also why both of the picture's maxima are published in px: a percentage would
   resolve against this anchor, which has no definite width. */
.pz-celebrate-note-fig {
  display: block;
  flex: 0 1 auto;
  min-height: 0;
  line-height: 0;
  border-radius: 2px;
}
.pz-celebrate-note-fig:focus-visible {
  outline: 2px solid #E8D48B;
  outline-offset: 3px;
}
.pz-celebrate-note-text {
  margin: 0;
  max-width: 34em;
  min-width: 0;
  font-family: 'Crimson Text', serif;
  font-size: 0.95em;
  line-height: 1.45;
  color: #F2E6B8;
  text-align: center;
  /* A safety valve, not a scrollbar the player is meant to meet: the prose gets
     whatever the card has left after the picture. It was `40vh`, which bit at
     ordinary phone-landscape heights and put a scroll thumb on four lines
     (reported on an iPhone SE) — and `vh` is the wrong unit inside
     debug-mobile.php's iframe besides, where it measures the frame rather than
     the phone being simulated. */
  max-height: 100%;
  overflow-y: auto;
}
.pz-celebrate-note-text em { font-style: italic; }
/* The reference, on its own line under the prose. INSIDE the paragraph so it
   scrolls with it, and PADDING rather than a margin so it cannot collapse out.
   Gold-on-navy at the site's own hover colour, underlined so it reads as a link
   without a second colour, and a visible focus ring: it sits before the buttons,
   so shift+Tab from Play Again is how a keyboard reaches it. */
.pz-celebrate-note-link {
  display: block;
  padding-top: 10px;
  font-size: 0.95em;
  color: #E8D48B;
  text-decoration: underline;
}
.pz-celebrate-note-link:hover,
.pz-celebrate-note-link:focus {
  color: #FFF0A0;
  text-decoration: underline;
}
.pz-celebrate-note-link:focus-visible {
  outline: 2px solid #E8D48B;
  outline-offset: 2px;
}
/* A SHORT viewport has the least prose room of any shape, so the type tightens
   rather than the card scrolling. */
@media (max-height: 520px) {
  .pz-celebrate-note-text { font-size: 0.86em; line-height: 1.32; }
  .pz-celebrate-note-link { padding-top: 6px; }
}
.pz-celebrate-btn {
  font-family: 'Cinzel', serif;
  display: inline-block;
  background: #F9F5EC;
  color: #0D1B3E;
  border: 2px solid #C9A84C;
  padding: 6px 16px;
  font-size: 0.92em;
  letter-spacing: 0.06em;
  line-height: 1.1;
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
  border-radius: 3px;
  transition: background 0.18s, color 0.18s, box-shadow 0.18s;
}.pz-celebrate-btn:hover,
.pz-celebrate-btn:focus {
  background: #FFF8E8;
  color: #0D1B3E;
  text-decoration: none;
  box-shadow: 0 0 0 2px rgba(201, 168, 76, 0.45);
  outline: none;
}/* Primary "Play Again" inverts to a gold fill so it leads the eye. */
.pz-celebrate-btn--primary {
  background: #C9A84C;
  color: #0D1B3E;
  border-color: #E8D48B;
}.pz-celebrate-btn--primary:hover,
.pz-celebrate-btn--primary:focus {
  background: #E8D48B;
  color: #0D1B3E;
}/* Stop the page "jumping" when a confirm dialog opens. Bootstrap adds
   .modal-open to <body> — overflow:hidden plus a compensating padding-right on
   the body AND the fixed navbar — to account for the removed scrollbar. On the
   body-scrolling (desc-flow) puzzle pages that reflows the centered .mt-layout
   and the centered navbar brand by ~15px. Here we keep the scrollbar present
   and drop the padding so the layout width is unchanged while the dialog is up.
   Scoped to puzzle pages; the backdrop still covers the play area, and being
   able to scroll the page behind a quick confirm is harmless. Resting (no-modal)
   layout is untouched. */
body.mt-puzzle-page.modal-open {
  overflow: auto !important;
  padding-right: 0 !important;
}body.mt-puzzle-page.modal-open .navbar-fixed-top {
  padding-right: 0 !important;
}/* Accident guard (small devices): give Reset a little breathing room so a stray
   tap is less likely, for the puzzles where a mis-tapped Reset is costly. Where
   the control column uses justify-content: space-between (mystery-masterpiece, broken-
   chessboard) the margin is absorbed from the surrounding gaps with no height
   change; on the others the layout fitter scales the few extra px away. Either
   way Reset ends up with visibly more space around it than its neighbours.
   Pairs with the Reset confirmation in puzzles/confirm-action.js. */
@media (max-height: 599px) and (orientation: landscape),
       (max-width: 767px) and (orientation: portrait) {body.pz-motif-dissection  .pz-controls .pz-btn--reset,
  body.pz-mystery-masterpiece            .pz-controls .pz-btn--reset,
  body.pz-gift-from-india   .pz-controls .pz-btn--reset,
  body.pz-cats-and-mice     .pz-controls .pz-btn--reset,
  body.pz-trains            .pz-controls .pz-btn--reset,
  body.pz-eight-chests      .pz-controls .pz-btn--reset {
    margin-top: 8px;
    margin-bottom: 8px;
  }}.pz-controls {
  flex: 1;
  min-width: 0;
  min-height: 0;
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: flex-start;
}.pz-controls-inner {
  transform-origin: left center;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 2px;
}.pz-play {
  flex: 2;
  min-width: 0;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
}.pz-play canvas {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
}.pz-overlay {
  position: absolute;
  top: 0; left: 0; right: 0; bottom: 0;
  background: rgba(28, 19, 10, 0.97);
  color: #F9F5EC;
  padding: 54px 32px 24px;
  overflow-y: auto;
  z-index: 10;
  font-size: 1em;
  line-height: 1.55;
  display: none;
  font-family: 'Crimson Text', serif;
}.pz-overlay.pz-open { display: block; }.pz-overlay h2 {
  font-family: 'Cinzel', serif;
  color: #E8D48B;
  margin: 0 0 14px;
  letter-spacing: 0.05em;
}.pz-overlay blockquote {
  border-left: 3px solid #C9A84C;
  padding: 4px 14px;
  margin: 10px 0;
  font-style: italic;
  color: #E8D48B;
}.pz-overlay .pz-overlay-close {
  position: absolute;
  top: 14px;
  right: 20px;
  background: transparent;
  border: 2px solid #C9A84C;
  color: #E8D48B;
  font-family: 'Cinzel', serif;
  padding: 4px 14px;
  cursor: pointer;
  letter-spacing: 0.06em;
  z-index: 1;
}.pz-overlay .pz-overlay-close:hover { background: #C9A84C; color: #1C130A; }.pz-back-link {
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  color: #8B5A2B;
  letter-spacing: 0.06em;
  text-decoration: none;
  white-space: nowrap;
  /* Breathing room above and below — roughly half the link's own line box on each side. It sits
     between the play area and the description on the desktop routes, and at the top of the
     below-fold block on the phone ones, and in both it read as glued to its neighbours.
     PADDING, not margin, deliberately: the link's line box is ~22px, which is half the 44px
     platform minimum for a touch target, so the same change that gives it room also makes it
     properly tappable. Vertical only — a horizontal pad would shift the text off the area's own
     padding edge and break its alignment with the description below it.
     NB the engine reserves this in G.HEADER_H; the two must move together. */
  display: inline-block;
  padding: 11px 0;
}.pz-back-link:hover { color: #0D1B3E; text-decoration: none; }.pz-back-link i { margin-right: 5px; }/* ── Rotate-device overlay (shown on puzzle pages in narrow portrait) ── */
.mt-rotate-overlay {
  display: none;
  position: fixed;
  top: 50px;
  left: 0;
  right: 0;
  bottom: 0;
  /* Below Bootstrap's fixed navbar (z-index 1030) so the navbar's
     hamburger dropdown — which drops into the area below the navbar
     that the overlay also covers — can render on top of the overlay
     instead of being hidden behind it. */
  z-index: 999;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  text-align: center;
  background: #F9F5EC;
  padding: 24px;
  font-family: 'Cinzel', serif;
  color: #5C3D11;
  font-size: 1.1em;
  letter-spacing: 0.05em;
  line-height: 1.5;
}.mt-rotate-icon {
  font-size: 4em;
  color: #C9A84C;
  margin-bottom: 20px;
  transform: rotate(90deg);
}/* Actions inside the rotate overlay (#267) — so it is never a dead end:
   a Reload escape hatch for a stale-orientation report, and a way out for
   a rotation-locked user who genuinely can't turn the device. */
/* Constrain text width so it wraps within a narrow phone instead of sizing to
   its unwrapped max-content (the flex column centers children on the cross
   axis, which otherwise lets a long line overflow the viewport). A definite
   width (not just max-width) is what actually caps the flex item. */
.mt-rotate-overlay p {
  width: 100%;
  max-width: 300px;
  box-sizing: border-box;
  overflow-wrap: break-word;
  letter-spacing: 0.02em;
}.mt-rotate-actions {
  margin-top: 28px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  width: 100%;
  max-width: 300px;
}.mt-rotate-btn {
  font-family: 'Cinzel', serif;
  font-size: 0.8em;
  letter-spacing: 0.05em;
  color: #E8D48B;
  background: #0D1B3E;
  border: 1px solid #C9A84C;
  border-radius: 6px;
  padding: 11px 22px;
  cursor: pointer;
  white-space: normal;
}.mt-rotate-btn:hover,
.mt-rotate-btn:focus { background: #16285a; color: #F9F5EC; }.mt-rotate-back {
  font-family: 'Cinzel', serif;
  font-size: 0.8em;
  letter-spacing: 0.05em;
  color: #8B6914;
  text-decoration: none;
  /* Vertical padding so this phone-only text link is a comfortable tap
     target, not just the height of its text. */
  padding: 10px 16px;
}.mt-rotate-back:hover,
.mt-rotate-back:focus { color: #0D1B3E; text-decoration: underline; }/* Narrow portrait on puzzle pages — show rotate prompt, hide layout */
@media (max-width: 767px) and (orientation: portrait) {body.mt-puzzle-page .mt-rotate-overlay { display: flex; }body.mt-puzzle-page .mt-layout,
  body.mt-puzzle-page .mt-portrait-ads-bar { display: none; }}/* Medium-width portrait on puzzle pages (≥768 — iPad Mini and up):
   force the desktop landscape layout instead of the portrait wrap. */
@media (min-width: 768px) and (max-width: 1023px) and (orientation: portrait) {body.mt-puzzle-page { padding-bottom: 0; }body.mt-puzzle-page .mt-layout {
    flex-wrap: nowrap;
    height: calc(100vh - 50px);
    height: calc(100dvh - 50px);
    min-height: auto;
    overflow: hidden;
  }body.mt-puzzle-page .mt-panel-left,
  body.mt-puzzle-page .mt-panel-right {
    flex: 1;
    height: auto;
    order: 0;
    border-bottom: none;
  }body.mt-puzzle-page .mt-panel-center {
    flex: 3;
    order: 0;
    height: auto;
    overflow-y: hidden;
  }body.mt-puzzle-page .mt-side-top { height: 300px; }body.mt-puzzle-page .mt-left-logo { padding: 12px 8px; }body.mt-puzzle-page .mt-left-logo img { height: auto; max-height: 300px; }body.mt-puzzle-page .mt-right-constellation { padding: 10px 8px; }body.mt-puzzle-page .mt-right-constellation svg { height: auto; max-height: none; width: 100%; max-width: 170px; }body.mt-puzzle-page .mt-ads-placeholder { display: flex; }body.mt-puzzle-page .mt-panel-footer { display: block; }body.mt-puzzle-page .mt-content-footer { display: none; }body.mt-puzzle-page .mt-portrait-ads-bar { display: none; }}/* Wide-portrait puzzle layout (≥768 px width, portrait orientation):
   stack controls on top of play area — center panel is much taller than
   wide on iPad Mini/Air/Pro portrait, so side-by-side would waste space.
   justify-content: center clusters controls + canvas in the middle of
   .pz-bottom with equal empty space above and below. */
@media (min-width: 768px) and (orientation: portrait) {body.mt-puzzle-page .pz-bottom {
    flex-direction: column;
    justify-content: center;
    gap: 12px;
  }body.mt-puzzle-page .pz-controls {
    flex: 0 0 auto;
    width: 100%;
    align-items: center;
    justify-content: center;
  }body.mt-puzzle-page .pz-controls-inner {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 16px;
    transform-origin: center center;
  }body.mt-puzzle-page .pz-play {
    flex: 0 1 auto;
  }}/* Phone landscape (landscape-small) on puzzle pages: hide .mt-panel-left
   and the content footer (copyright + contact email) so the puzzle play
   area gets every pixel. Desktop, iPad portrait, and iPad landscape keep
   all three panels and the standard footer. The right panel (ads) is
   preserved everywhere. With left hidden, the flex math gives center 3/4
   (75%) and right 1/4 (25%). Phone portrait is unaffected (rotate overlay
   still shown). */
@media (max-height: 599px) and (orientation: landscape) {body.mt-puzzle-page .mt-panel-left,
  body.mt-puzzle-page .mt-content-footer {
    display: none;
  }}/* ══════════════════════════════════════════════════
   PUZZLE CARD (shared by puzzles.php grid AND home.php
   Today's Challenge). Page-specific layout (grid,
   coming-soon figure) stays inline in puzzles.php.
   ══════════════════════════════════════════════════ */
.puzzle-card {
  display: flex;
  flex-direction: column;
  border: 1px solid #C9A84C;
  border-top: 4px solid #C9A84C;
  background: #FFFDF5;
  padding: 16px 18px 6px;
  margin: 0;
  text-decoration: none;
  color: inherit;
  transition: box-shadow 0.2s, background 0.2s;
}a.puzzle-card { cursor: pointer; }a.puzzle-card:hover {
  box-shadow: 0 4px 16px rgba(201,168,76,0.28);
  background: #FFF8E8;
  text-decoration: none;
  color: inherit;
}/* "Back to Puzzles" returns land on the puzzle's card (#pz-<slug>, issue #609).
   The id lives on the CARD WRAPPER (a non-focusable <div>), so the fragment jump
   scrolls to it but focuses nothing — no focus ring/underline on the card. This
   keeps it clear of the fixed 50px navbar so it lands just below the bar. */
.puzzle-grid [id^="pz-"] { scroll-margin-top: 66px; }/* Landing highlight: JS in puzzles.php sets .mt-card-lit on the landed card so it
   shows the SAME flyover (hover) colour — gold glow + warm bg. It holds while the
   pointer is over no card; when the pointer is over ANOTHER card the JS swaps it
   to .mt-card-fading. */
a.puzzle-card.mt-card-lit {
  box-shadow: 0 4px 16px rgba(201,168,76,0.28);
  background: #FFF8E8;
}/* The ~2s fade of the returned card's highlight to base — a keyframe ANIMATION,
   not a transition, so it (a) always plays from the explicit glow `from` keyframe
   with no dependence on the lit state having painted first (the transition version
   silently ran from base ~80% of the time and showed nothing), and (b) overrides
   :hover on its own, so the fade can't be interrupted by the mouse without needing
   to exclude the card from :hover. `forwards` holds base until the class is dropped. */
a.puzzle-card.mt-card-fading {
  animation: pzCardFade 2s ease forwards;
}@keyframes pzCardFade {from { box-shadow: 0 4px 16px rgba(201,168,76,0.28); background: #FFF8E8; }to { box-shadow: 0 0 0 rgba(201,168,76,0);         background: #FFFDF5; }}.puzzle-card-header {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 6px;
}/* UNIFORM icon slot (#369): every card's icon sits in the same fixed-width box,
   centered, so icons read at a comparable size and the titles line up
   card-to-card — FA glyphs vary in natural width (fa-train wide, fa-circle-o
   narrow). 1.3em (in the icon's own font-size) fits the widest glyph; em-based so
   it scales with the 1.6em base / 3.2em signed-in (.puzzle-grid .puzzle-icon). */
.puzzle-card .puzzle-icon {
  color: #C9A84C;
  font-size: 1.6em;
  flex-shrink: 0;
  display: inline-block;
  width: 1.3em;
  text-align: center;
}/* Image puzzle icon (#369 — for a glyph Font Awesome 4.6.3 lacks, e.g. the
   Twelve Bowls bowl). Fills the SAME 1.3em slot as the glyph icons (so it's
   comparable in size, not the ~1.5× wider it was), capped in height since it's
   wide-aspect, centered + aspect-preserved. */
.puzzle-card .puzzle-icon-img {
  width: 1.3em;
  height: auto;
  max-height: 1.3em;
  object-fit: contain;
  vertical-align: middle;
}.puzzle-card h3 {
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  font-size: 1.05em;
  letter-spacing: 0.05em;
  margin: 0;
}.puzzle-card .puzzle-tag {
  display: inline-block;
  background: #0D1B3E;
  color: #F1E0A0; /* brighter gold + bold strokes — thin Cinzel read faint on navy (#55 legibility) */
  font-family: 'Cinzel', serif;
  font-size: 0.65em;
  font-weight: 700;
  letter-spacing: 0.08em;
  padding: 2px 8px;
  margin-bottom: 8px;
}.puzzle-card p {
  color: #5C3D11;
  font-size: 0.9em;
  margin: 0;
}/* Per-user progress modifier (signed-in only) — a small bare glyph on the
   top-right corner of the card's puzzle icon: green check = solved, green
   trophy = optimal, gold eye = visited. The s/T variant count lives separately
   on the category line (.puzzle-progress-count). A soft cream text-shadow keeps
   the glyph legible over the gold icon. */
.puzzle-icon-wrap { position: relative; display: inline-flex; align-items: center; flex-shrink: 0; }.ppb {
  position: absolute;
  top: -6px;
  right: -8px;
  z-index: 2;
  line-height: 1;
  text-shadow: 0 0 3px #F9F5EC, 0 0 2px #F9F5EC;
}.ppb .ppb-check { color: #2A6B2A; font-size: 1.5em; }/* green check  = solved (not optimal) */
.ppb .ppb-trophy { color: #2A6B2A; font-size: 1.5em; }/* green trophy = optimal              */
.ppb .fa-eye { color: #8B6914; font-size: 1.4em; }/* dark gold eye = visited (#264 AA)    */

/* My-Account → Puzzle Progress table */
.mt-progress-table {
  width: 100%;
  border-collapse: collapse;
  font-family: 'Crimson Text', serif;
  font-size: 0.95em;
  margin-bottom: 8px;
}.mt-progress-table th,
.mt-progress-table td {
  padding: 6px 10px;
  border-bottom: 1px solid #E6DCC4;
  text-align: left;
  white-space: nowrap;
}.mt-progress-table th {
  font-family: 'Cinzel', serif;
  font-size: 0.8em;
  letter-spacing: 0.04em;
  color: #0D1B3E;
  border-bottom: 2px solid #C9A84C;
}.mt-progress-table .mt-prog-name { font-weight: 600; color: #1C130A; white-space: normal; }.mt-progress-table .mt-prog-num { font-variant-numeric: tabular-nums; color: #5C3D11; }.mt-prog-star { color: #C9A84C; }.mt-prog-dash { color: #B7A98A; }.mt-prog-status {
  display: inline-block;
  font-family: 'Cinzel', serif;
  font-size: 0.72em;
  letter-spacing: 0.04em;
  padding: 1px 9px;
  border-radius: 10px;
  border: 1px solid transparent;
}.mt-prog-status.ps-none { color: #6E6249; }/* #174 — was #9A8E72 (2.98:1); now 5.5:1 */
.mt-prog-status.ps-progress { background: #F0E9D8; color: #7A5A0C; border-color: #D8C9A0; }/* #174 — text was #8B6914 (4.20:1); now 5.3:1 */
.mt-prog-status.ps-solved { background: #EAF5EA; color: #1A4A1A; border-color: #2A6B2A; }.mt-prog-status.ps-optimal { background: #C9A84C; color: #1C130A; border-color: #B08F38; }.mt-prog-caption {
  font-family: 'Crimson Text', serif;
  font-size: 0.85em;
  color: #777;
  margin: 4px 0 0;
}/* puzzles.php header "Your Progress" link */
.mt-progress-link {
  float: right;
  margin-top: 4px;
  font-family: 'Cinzel', serif;
  font-size: 0.8em;
  letter-spacing: 0.04em;
  color: #0D1B3E;
  text-decoration: none;
  border: 1px solid #C9A84C;
  border-radius: 4px;
  padding: 4px 12px;
  background: #FFFDF5;
}.mt-progress-link:hover { background: #C9A84C; color: #1C130A; }/* puzzle-progress.php toolbar (summary + Solved|All toggle) */
.mt-prog-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 12px;
}.mt-prog-summary { margin: 0; font-family: 'Crimson Text', serif; color: #5C3D11; font-size: 1.05em; }.mt-prog-toggle {
  display: inline-flex;
  border: 1px solid #C9A84C;
  border-radius: 4px;
  overflow: hidden;
}.mt-prog-toggle a {
  font-family: 'Cinzel', serif;
  font-size: 0.8em;
  letter-spacing: 0.04em;
  padding: 5px 16px;
  color: #8B5A2B;
  text-decoration: none;
  background: transparent;
}.mt-prog-toggle a + a { border-left: 1px solid #C9A84C; }.mt-prog-toggle a:hover:not(.active) { background: #F0E4C4; }.mt-prog-toggle a.active { background: linear-gradient(180deg, #E8D48B, #C9A84C); color: #0D1B3E; }/* ══════════════════════════════════════════════════
   HOME PAGE (body.mt-home)
   Hero extras inside .mt-center-title + content
   sections: Today's Challenge, How It Works,
   Why Mathturion?, Join the Journey.
   ══════════════════════════════════════════════════ */

/* Hero extras (tagline + intro + CTAs) injected via $titleExtras */
body.mt-home .mt-center-title { padding-bottom: 30px; }/* Portrait-only entry CTAs under the soldier / constellation (issue #11).
   Hidden by default — only the portrait media query reveals them; desktop
   landscape and landscape-small already show the CTAs inside the hero band. */
.mt-portrait-cta { display: none; }.mt-hero-tagline {
  font-family: 'Cinzel', serif;
  font-size: 1.15em;
  color: #E8D48B;
  letter-spacing: 0.06em;
  font-style: italic;
  margin: 14px 0 0 0;
}.mt-hero-intro {
  font-family: 'Crimson Text', Georgia, serif;
  font-size: 1.02em;
  color: #D4C494;
  font-style: normal;
  letter-spacing: 0.02em;
  margin: 12px auto 18px;
  max-width: 620px;
  line-height: 1.45;
}/* Hide intro when the two CTA buttons wrap onto separate lines.
   Class toggled by inline JS in home.php on load / resize / orientationchange. */
body.mt-hero-stacked .mt-hero-intro { display: none; }.mt-hero-ctas {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: 16px;
  margin-top: 8px;
}/* Shared CTA button — dark-on-cream by default; primary inverts. */
.mt-cta {
  display: inline-block;
  font-family: 'Cinzel', serif;
  font-size: 0.88em;
  letter-spacing: 0.1em;
  padding: 10px 22px;
  border: 2px solid #C9A84C;
  background: #F9F5EC;
  color: #0D1B3E;
  text-decoration: none;
  transition: background 0.18s, color 0.18s, box-shadow 0.18s;
  cursor: pointer;
}.mt-cta:hover, .mt-cta:focus {
  text-decoration: none;
  color: #0D1B3E;
  background: #FFF8E8;
  box-shadow: 0 3px 12px rgba(201,168,76,0.35);
}.mt-cta-primary {
  background: #0D1B3E;
  color: #E8D48B;
}.mt-cta-primary:hover, .mt-cta-primary:focus {
  background: #162347;
  color: #FFD700;
}/* Section heading variant with an inline icon on the left
   (Today's Challenge — temple/column icon) */
.section-heading.mt-home-heading-icon {
  display: inline-flex;
  align-items: center; /* center the icon vertically across the heading height — sits centered when the title wraps to two lines on a narrow phone */
  gap: 14px;
  white-space: normal; /* allow a long heading (e.g. "Latest from the Chronicle") to wrap on a narrow phone instead of clipping off the right edge */
  max-width: 100%;
}.section-heading.mt-home-heading-icon .fa {
  color: #C9A84C;
  font-size: 0.9em;
}.mt-home-heading-img {
  height: 1.3em; /* visually matches Cinzel cap-height now that the icon is cropped flush */
  width: auto;
  /* centered vertically by the heading's align-items: center (handles the 2-line wrap case) */
}/* Today's Challenge — gold-bordered panel (mock) with laurel wreath on the
   left and the puzzle card (puzzles.php styling) on the right. */
.mt-challenge-frame {
  display: flex;
  align-items: center;
  gap: 24px;
  border: 1px solid #C9A84C;
  background: #FAF6EE; /* matches laurel-wreath.png background so wreath blends seamlessly */
  padding: 22px 26px;
  margin-top: 10px;
}.mt-challenge-wreath {
  flex: 0 0 auto;
  width: 150px;
  display: flex;
  align-items: center;
  justify-content: center;
}.mt-challenge-wreath img {
  display: block;
  width: 100%;
  height: auto;
}.mt-challenge-card-wrap {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
}.mt-challenge-card-wrap .puzzle-card {
  flex: 1 1 auto;
  width: 100%;
}@media (max-width: 575px) {.mt-challenge-frame {
    flex-direction: column;
    align-items: center;
    gap: 16px;
    padding: 18px;
  }.mt-challenge-wreath { width: 120px; }.mt-challenge-card-wrap { width: 100%; }}/* How It Works — 3 circular gold-bordered steps separated by vertical gold dividers */
.mt-how-col {
  position: relative;
}/* Vertical gold divider on the left edge of cols 2 and 3 (skips col 1).
   Only on viewports wide enough for the 3-column layout — phone stacks vertically
   and shouldn't show side dividers. */
@media (min-width: 768px) {.mt-how-col + .mt-how-col::before {
    content: '';
    position: absolute;
    left: 0;
    top: 14%;
    bottom: 14%;
    width: 1px;
    background: #C9A84C;
  }}.mt-how-step {
  text-align: center;
  padding: 18px 14px;
}.mt-how-icon {
  width: 72px;
  height: 72px;
  border: 2px solid #C9A84C;
  border-radius: 50%;
  margin: 0 auto 14px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #FFFDF5;
}.mt-how-icon .fa {
  color: #C9A84C;
  font-size: 1.8em;
}.mt-how-step h4 {
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  letter-spacing: 0.06em;
  margin: 0 0 8px;
}.mt-how-step p {
  color: #5C3D11;
  font-size: 0.95em;
  margin: 0;
}/* Why Mathturion — body copy */
.mt-home-why {
  color: #2C1F0E;
  font-size: 1.02em;
  line-height: 1.55;
  margin: 6px 0 0;
}/* Join the Journey — bottom CTA panel */
.mt-journey-panel {
  display: flex;
  align-items: center;
  gap: 24px;
  flex-wrap: wrap;
  border: 1px solid #C9A84C;
  background: #FAF6EE; /* matches temple.png + laurel-wreath.png background */
  padding: 22px 28px;
  margin: 8px 0 4px;
}.mt-journey-icon {
  flex: 0 0 auto;
  width: 70px;
  height: auto;
}.mt-journey-text {
  flex: 1 1 280px;
  min-width: 0;
}.mt-journey-text h2 {
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  font-size: 1.4em;
  letter-spacing: 0.06em;
  margin: 0 0 6px;
}.mt-journey-text p {
  color: #5C3D11;
  font-size: 0.98em;
  margin: 0;
  line-height: 1.45;
}.mt-journey-cta {
  flex: 0 0 auto;
  align-self: center;
}/* Hero — phone tweaks (stack buttons, shrink padding) */
@media (max-width: 575px) {.mt-hero-ctas { flex-direction: column; align-items: stretch; gap: 10px; }.mt-hero-ctas .mt-cta { text-align: center; }.mt-hero-intro { font-size: 0.95em; padding: 0 8px; }.mt-hero-tagline { font-size: 1.02em; }.mt-journey-panel { padding: 18px; }.mt-journey-cta { width: 100%; text-align: center; }}/* About Us — section headings with a leading FA icon (variant of
   .mt-home-heading-icon without nowrap, so longer headings can wrap on
   narrow phones). The <span> child gets min-width:0 so the text wraps
   inside the inline-flex container instead of overflowing. */
.section-heading.mt-about-heading {
  display: inline-flex;
  align-items: baseline;
  gap: 14px;
}.section-heading.mt-about-heading .fa {
  color: #C9A84C;
  font-size: 0.9em;
  flex: 0 0 auto;
}.section-heading.mt-about-heading > span {
  min-width: 0;
}/* About Us — image-based section icon (e.g. Our Mission compass).
   Larger than the FA icons and vertically centered against the heading. */
.section-heading.mt-about-heading .mt-about-icon {
  width: 96px;
  height: 96px;
  flex: 0 0 auto;
  align-self: center;
}.section-heading.mt-about-heading:has(.mt-about-icon) {
  align-items: center;
}/* Phone-only: tighten the gap between the breadcrumb-title's gold divider
   and the first section heading. Targets only the first .row-content
   (the one immediately preceded by a plain .row, not another .row-content).
   Desktop / iPad keep Bootstrap's default h2 margin-top. */
@media (max-width: 767px) and (orientation: portrait),
       (max-height: 599px) and (orientation: landscape) {.row:not(.row-content) + .row-content .section-heading.mt-about-heading {
    margin-top: 4px;
  }}/* About Us — phone-sized viewports: shrink icons + heading + breadcrumb-title
   so the first section's heading + intro paragraph fit above the fold on
   phone landscape (max-height: 599px) and phone portrait (max-width: 767px).
   iPad portrait and up keep the desktop sizes. */
@media (max-width: 767px) and (orientation: portrait),
       (max-height: 599px) and (orientation: landscape) {.section-heading.mt-about-heading {
    font-size: 22px;
  }.section-heading.mt-about-heading .mt-about-icon {
    width: 56px;
    height: 56px;
  }.breadcrumb.breadcrumb-title {
    font-size: 1.15em;
  }}/* About Us — closing tagline: decorative inscription panel with a gold
   rule above + below, each centered on a small gold diamond. */
.mt-tagline-block {
  position: relative;
  max-width: 760px;
  margin: 36px auto 12px;
  padding: 34px 0 26px;
  text-align: center;
}.mt-tagline-block::before,
.mt-tagline-block::after {
  content: "";
  position: absolute;
  left: 8%;
  right: 8%;
  height: 1px;
  background: linear-gradient(
    to right,
    transparent 0%,
    #C9A84C 18%,
    #C9A84C 44%,
    transparent 48%,
    transparent 52%,
    #C9A84C 56%,
    #C9A84C 82%,
    transparent 100%
  );
}.mt-tagline-block::before { top: 12px; }.mt-tagline-block::after { bottom: 12px; }.mt-tagline-diamond {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  color: #C9A84C;
  font-size: 14px;
  line-height: 1;
}.mt-tagline-diamond.top { top: 5px; }.mt-tagline-diamond.bottom { bottom: 5px; }.mt-about-tagline {
  text-align: center;
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  font-size: 1.15em;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  margin: 14px 0;
}@media (max-width: 575px) {.mt-tagline-block { padding: 28px 0 22px; }.mt-tagline-block::before { top: 10px; }.mt-tagline-block::after { bottom: 10px; }.mt-tagline-diamond { font-size: 12px; }.mt-about-tagline { font-size: 0.95em; letter-spacing: 0.08em; }}/* ─────────────────────────────────────────────────────────────────────────────
   Auth pages  (body.mt-auth-page)
   ───────────────────────────────────────────────────────────────────────────── */

/* Phone portrait + landscape-small: simplified layout — no side panels,
   navbar brand only (no nav links or hamburger). Center title hidden
   (brand already shows "Mathturion"). Shield crest shown on brand.

   SIGNED-IN EXCEPTION: when header.php detects mt_auth_user() it adds
   data-signed-in="1" on <body>. We scope the navbar simplifications to
   :not([data-signed-in]) so signed-in visitors (account.php / admin.php /
   change-* / etc.) keep the hamburger + My Account + Sign Out — they
   need the menu to leave / log out / navigate. Pre-auth flow pages
   (login / register / forgot-* / etc.) are unaffected because they
   render without a session set, so the simplified bar still applies. */
@media (max-width: 767px) and (orientation: portrait),
       (max-height: 599px) and (orientation: landscape) {/* Reclaim the 110px body bottom-padding that the portrait MQ (line ~1008)
     reserves for the fixed .mt-portrait-ads-bar — that bar is hidden on
     every auth page (see body.mt-auth-page .mt-portrait-ads-bar), so the
     reserved band would otherwise leave the footer floating ~110px above
     the bottom on long auth pages (Tester Tools, Admin, …) in phone
     portrait. Mirrors the equivalent clear in the iPad-portrait block. */
  body.mt-auth-page { padding-bottom: 0 !important; }body.mt-auth-page .mt-panel-left,
  body.mt-auth-page .mt-panel-right { display: none !important; }body.mt-auth-page:not([data-signed-in]) .navbar-toggle { display: none !important; }body.mt-auth-page:not([data-signed-in]) .navbar-collapse { display: none !important; }body.mt-auth-page .mt-center-title { display: none !important; }/* Shield crest (img/logo-small.png) before the wordmark — sized to match
     the landscape home-page Home-nav shield (.mt-nav-shield, 40×48,
     object-fit:cover to crop the PNG's transparent padding). No height
     cap is needed: .navbar-brand is display:flex; align-items:center
     (see line ~68), so the crest is vertically centred against the
     wordmark on the navbar's 50px centre line and the bar neither grows
     nor reflows (same as how the home-page navbar carries .mt-nav-shield).
     Hidden when signed in — the hamburger reappears and the crest would
     compete with it on the cramped 50px bar. */
  body.mt-auth-page:not([data-signed-in]) .mt-brand-home-icon {
    display: inline-block !important;
    width: 40px;
    height: 48px;
    object-fit: cover;
    object-position: center;
    margin-right: 7px;
  }}/* Shield crest in navbar brand: hidden everywhere except auth-page phones.
   !important is required because Bootstrap ships `.navbar-brand > img {
   display: block }` (specificity 0,1,1) which outranks a bare
   `.mt-brand-home-icon` (0,1,0) now that this element is an <img> — without
   !important the crest leaks onto every page (home, puzzles, …) at its
   natural 192×192 size. The auth-phone show rule still wins over this:
   `body.mt-auth-page .mt-brand-home-icon` is 0,2,1 !important > 0,1,0
   !important, so the crest only ever appears on auth-page phones (where
   the hamburger is hidden — never alongside a 3-bar). */
.mt-brand-home-icon { display: none !important; }/* Portrait ads bar: not relevant on auth pages */
body.mt-auth-page .mt-portrait-ads-bar { display: none !important; }/* Auth landscape-small: absolutely-centre the brand, same as the portrait
   rule (line ~969). Without this the brand is an in-flow float, so the
   48px shield crest + 15+15 padding grows the navbar to ~78px (taller
   than portrait, and breaking the body padding-top:50px / .container
   calc(100dvh - 50px) math → spurious scrollbar, footer can't fit).
   Absolute + height:100% pins the brand to the 50px navbar-header so
   flex-centring fits the crest inside a stable 50px bar. Auth pages have
   no hamburger / nav links here, so a centred brand is correct; scoped to
   body.mt-auth-page so non-auth landscape-small navbars are untouched.
   Skipped when signed in (data-signed-in) so the hamburger + collapsed
   menu items can sit alongside an in-flow brand normally. */
@media (max-height: 599px) and (orientation: landscape) {body.mt-auth-page:not([data-signed-in]) .navbar .navbar-header { position: relative; min-height: 50px; float: none; width: 100%; }body.mt-auth-page:not([data-signed-in]) .navbar .navbar-brand {
    position: absolute;
    top: 0;
    left: 50%;
    float: none;
    margin: 0;
    transform: translateX(-50%);
  }}/* All viewports: flex column so card can be vertically centered.
   margin: auto on the card centers it when shorter than the container;
   collapses to top-align when the card overflows (admin/account pages). */
body.mt-auth-page .mt-panel-center {
  display: flex;
  flex-direction: column;
}body.mt-auth-page .container {
  flex: 1;
  display: flex;
  flex-direction: column;
}/* Login only: let flex:1 actually bound the container. Without this,
   min-height:auto grows it to the card's content height, so auth-fit.js
   reads the wrong available height and never scales — the panel then
   scrolls (e.g. iPad Mini landscape, which uses the desktop layout).
   overflow-y:auto is a no-JS fallback; with auth-fit active the scaled
   card fits and it never shows. Scoped to login so long pages
   (account / admin) keep their legitimate scroll. */
body.mt-auth-login .container { min-height: 0; overflow-y: auto; }body.mt-auth-page .mt-auth-card {
  margin: auto;
}/* CTA and privacy: hidden only in landscape (no room); portrait keeps them */
@media (max-height: 599px) and (orientation: landscape) {body.mt-auth-page .mt-auth-cta { display: none !important; }body.mt-auth-page .mt-auth-privacy { display: none !important; }}/* Form card */
.mt-auth-card {
  max-width: 480px;
  margin: 32px auto 40px;
  background: #F9F5EC;
  border: 1px solid #C9A84C;
  border-radius: 4px;
  padding: 32px 40px;
  box-shadow: 0 2px 10px rgba(0,0,0,.12);
}.mt-auth-card-wide { max-width: 720px; }/* Embedded Stripe Checkout (issue #186): Stripe's payment form mounts as a
   plain white iframe. Frame it in an on-brand gold-bordered white card so it
   reads as an intentional panel on the cream auth card / inside the account
   modal rather than a bare white block. Applied to #mt-checkout on both
   register.php step 2 and the account.php Membership modal. */
.mt-checkout-frame {
  background: #fff;
  border: 2px solid #C9A84C;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 4px 16px rgba(13, 27, 62, .12);
}@media (max-width: 480px) {.mt-checkout-frame { padding: 10px; }}/* Loading placeholder shown in the checkout mount while the Checkout Session is
   created and Stripe.js loads its iframe (issue #268). The text carries the
   meaning; the spinner is decorative (and frozen under prefers-reduced-motion). */
.mt-checkout-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  min-height: 120px;
  padding: 24px 16px;
  font-family: 'Crimson Text', serif;
  /* Fixed px (not em) so it renders the same in both mount contexts — the
     register auth-card and the account.php modal body. */
  font-size: 16px;
  color: #5C3D11;
}.mt-checkout-loading .fa-spinner {
  font-size: 22px;
  color: #C9A84C;
}/* "← Back to options" under the mounted form — gold/Cinzel to match the site's
   other back links (the card's top .mt-auth-back + bottom .mt-auth-links are
   hidden while the embedded form shows, so this is the only back link then). */
#mt-checkout-back {
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  letter-spacing: 0.04em;
  color: #8B6914;                /* #264: AA on the cream checkout card */
  text-decoration: none;
}#mt-checkout-back:hover,
#mt-checkout-back:focus { color: #0D1B3E; text-decoration: none; }/* Cancel/Back link at the top-left of an auth card (#155) — mirrors legal.php's
   "← Back". Sends the user to the flow's origin (?next=) or home. */
.mt-auth-back {
  display: inline-block;
  margin-bottom: 14px;
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  letter-spacing: 0.04em;
  color: #8B6914;
  text-decoration: none;
  border-bottom: 1px solid #C9A84C;
  padding-bottom: 1px;
}.mt-auth-back:hover,
.mt-auth-back:focus { color: #0D1B3E; border-bottom-color: #0D1B3E; text-decoration: none; }/* ── Both phone orientations: short cards = vh sticky-footer, long pages scroll ──
   SHORT cards (body.mt-auth-centered): a sticky-footer layout sized to the
   URL-bar-COLLAPSED viewport via `100vh` (in this codebase 100vh = the
   largest / bar-collapsed viewport — see the Known Quirks note). The flow:
     • URL bar SHOWN (first paint): the visible window is smaller than
       100vh, so the 100vh-tall layout overflows by ~the bar height → the
       page is scrollable → the first scroll auto-collapses the bar. (Only
       the bottom footer strip is briefly behind the bar — that IS the
       reclaim, by design; the card is centred well above it.)
     • URL bar COLLAPSED: visible window == 100vh == layout height exactly
       → no residual scroll, footer flush at the bottom, card centred.
       "After the scroll it looks like a no-scroll page." Because vh is
       static (unlike dvh) the layout is sized once to the final state —
       no reflow/jank on collapse, auth-fit scales the card once.
   Mechanics: .mt-panel-center is a flex column of min-height 100vh-50px;
   .container is flex:1 (the centred card region, overflow:hidden, auth-fit
   scales the card to it); .mt-content-footer is flex:0 0 auto so it is
   PINNED to the very bottom. Works regardless of bar position (Chrome top
   / iOS Safari bottom) since it only relies on vh = collapsed height.
   LONG pages (mt-auth-page WITHOUT mt-auth-centered — Account Settings,
   Admin, Tester, register steps 1-2, accept-invitation form): never
   clipped, never auto-fit-scaled — they body-scroll like a normal page.
   Both: full-width card with comfortable mobile padding.                 */
@media (max-width: 767px) and (orientation: portrait),
       (max-height: 599px) and (orientation: landscape) {body.mt-auth-centered .mt-layout {
    height: calc(100vh - 50px) !important;
    overflow: visible !important;
    align-items: stretch !important;
  }body.mt-auth-centered .mt-panel-center {
    height: calc(100vh - 50px) !important;
    overflow: visible !important;
    display: flex !important;
    flex-direction: column !important;
  }/* Centred card region. The panel is a FIXED 100vh-50px box, so this
     flex:1 child is CLAMPED (it shrinks to the space above the pinned
     footer — it can't grow with a tall card). That clamp is what lets
     auth-fit.js measure real overflow (scrollHeight − clientHeight, valid
     under overflow-y:auto exactly as under hidden) and scale the card to
     fit: card centred (margin:auto), fully visible. (With min-height
     instead, the panel grew with the card, clientHeight==scrollHeight,
     auth-fit saw 0 overflow and never scaled → page > 100vh and the footer
     was pushed off.)

     STAYS `hidden` HERE, and that is a deliberate reversal (#806). Making it
     `auto` unconditionally looked harmless — a container with nothing to
     scroll shows no scrollbar — but on iOS it is not: a drag that starts
     inside a nested overflow:auto element is handed to THAT element, and a
     nested scroller with nothing to scroll does not reliably chain to the
     document. The auth strip deliberately keeps a small residual document
     scroll so the first drag collapses the URL bar, and an inner scroller
     silently swallows the gesture that was supposed to do it. Reported on a
     real iPhone SE: the bar stopped hiding. So the clip is kept for the case
     that FITS, and the scroll is granted only where it is actually needed —
     see the :not([data-fit="ok"]) rule directly below. */
  body.mt-auth-centered .container {
    flex: 1 1 auto !important;
    min-height: 0 !important;
    height: auto !important;
    display: flex !important;
    flex-direction: column !important;
    overflow: hidden !important;
    padding-top: 8px !important;
    padding-bottom: 8px !important;
  }/* …and the escape hatch, for a card auth-fit could NOT fit (#806). Its scale
     was floored at 0.4 and with JS off it does not run at all, so `hidden`
     alone meant the remainder was cut off unreachably. THE HATCH CARRIES MORE
     TRAFFIC NOW than that sentence implies: since 76df1f1 the fitter REFUSES any
     shrink below 0.75 rather than clamping at 0.4, so every card that would have
     been drawn between those two figures now arrives here instead of being
     squashed. Which is the point — a 0.4 form is legible in a screenshot and
     unusable in the hand — but it means this rule is the ordinary path for a
     tall card on the fallback route, not the rare one. The attribute is set
     only when the height auth-fit DREW fits, so its absence means "did not
     fit" — including "JS never ran", which is when this matters most.
     Landscape does not rely on this any more (it scrolls the document
     instead — see the block further down); portrait still does. */
  body.mt-auth-centered .container:not([data-fit="ok"]) {
    overflow-x: hidden !important;
    overflow-y: auto !important;
    align-items: start !important;
  }body.mt-auth-centered .mt-auth-card {
    margin: auto !important;
  }/* flex:0 0 auto after the flex:1 region → pinned to the very bottom of
     the 100vh-50px column (sticky footer). */
  body.mt-auth-centered .mt-content-footer {
    flex: 0 0 auto !important;
  }/* Long pages: undo any inherited clip/height-lock so the document flows
     and the body scrolls normally. */
  body.mt-auth-page:not(.mt-auth-centered) .mt-layout,
  body.mt-auth-page:not(.mt-auth-centered) .mt-panel-center,
  body.mt-auth-page:not(.mt-auth-centered) .container {
    overflow: visible !important;
    height: auto !important;
  }/* …and hold the black bar on the viewport, in BOTH cases.
     The center panel is ≥ the viewport below the 50px navbar; .container keeps
     its natural height at the top and the footer's margin-top:auto eats the
     leftover space, so a short page pins the bar to the bottom with no help.
     The .mt-layout floor matches the panel so landscape-small's
     min-height:100dvh (line ~1111) can't leave an empty band below the footer.

     WHEN THE CONTENT OVERFLOWS the auto margin has nothing left to eat, and
     until now the bar simply flowed after the content and left the screen —
     measured 415px below the fold on register.php at 375x667, 316 on
     accept-invitation, 468 on reactivate. That was not a decision so much as an
     assumption that outlived its ground: the rule was written when a card was
     always fitted to the viewport, so "overflows" was a case that barely
     happened. #806 made these cards scroll rather than compress, and it happens
     on every one of them now.

     STICKY, NOT FIXED — the opposite of the choice the landscape-small block
     makes for the CENTRED bar a few hundred lines down, and the difference is
     the containing block. There the panel is locked to the viewport height and
     the 50px of URL-bar travel lives BELOW it in .mt-layout, so a sticky bar
     runs out of containing block and rises with the page (measured at six phone
     landscape sizes, all delta=50). Here the panel is height:auto and grows with
     the content, so its end IS the document's end and sticky reaches all the way.
     Measured 0 at the bottom of the scroll on all three pages at 375x667,
     390x844, 430x932, 744x1133 and both phone landscape sizes. Sticky also keeps
     the bar in flow, so it needs no padding reserve and cannot cover the last
     line of the card the way the fixed one has to be padded around.

     This media block is a LIST — phone portrait and landscape-small — so the one
     rule covers both orientations. */
  body.mt-auth-page:not(.mt-auth-centered) .mt-layout,
  body.mt-auth-page:not(.mt-auth-centered) .mt-panel-center {
    min-height: calc(100dvh - 50px) !important;
  }body.mt-auth-page:not(.mt-auth-centered) .container {
    flex: 0 0 auto !important;
  }body.mt-auth-page:not(.mt-auth-centered) .mt-content-footer {
    flex: 0 0 auto !important;
    margin-top: auto !important;
    position: sticky !important;
    bottom: 0 !important;
    z-index: 5;
  }body.mt-auth-page .mt-auth-card {
    width: 100% !important;
    box-sizing: border-box !important;
    padding: 16px !important;
  }}/* ── Landscape-small short cards: pin to ONE strip + virtual scrollbar ────
   Root cause of the "footer won't pin / always scrolls even with no URL
   bar" bug on the short workflow cards: the global landscape-small rule
   `body:not(.mt-puzzle-page) .mt-layout { min-height: 100dvh }` (line
   ~1111) still reaches body.mt-auth-centered (an auth page is not a puzzle
   page). The shared block above only overrides `height` to
   calc(100vh - 50px) — it never touched `min-height`, so the used height
   resolved to 100dvh and, stacked on body's 50px navbar padding-top, the
   document was ALWAYS 50px taller than the viewport (even with the URL bar
   collapsed) → a permanent scrollbar and a 50px dead band that pushed the
   footer off the true bottom.

   Matching `min-height` to the intended strip neutralises the global
   floor: the layout becomes exactly one calc(100vh - 50px) strip, so
   body == 50 + (100vh - 50) == 100vh == the URL-bar-collapsed viewport →
   footer flush, ZERO scroll when the bar is gone. When the URL bar IS
   shown the strip is taller than the smaller visible window, so a scroll
   still exists (the first drag collapses the bar) — but the scrollbar is
   hidden while press-and-scroll keeps working, so it reads as a clean
   no-scroll page. Mirrors the min-height fix the long-page branch already
   has (line ~2902). vh-only (no dvh pair) on purpose, matching this
   block's "sized once, no reflow on collapse" model — and since
   100vh ≥ 100dvh always, this min-height can never exceed `height`, so it
   only ever cancels the rogue global floor and never re-introduces jank.
   Scoped landscape-small only (the confirmed repro); phone portrait is
   intentionally left as-is. */
@media (max-height: 599px) and (orientation: landscape) {body.mt-auth-centered .mt-layout,
  body.mt-auth-centered .mt-panel-center {
    min-height: calc(100vh - 50px) !important;
  }/* Virtual scrollbar: invisible, but press-and-scroll still processed.
     The strip's overflow bubbles to the document (layout + center are
     overflow:visible), so the ROOT is the scroller — cover both the
     "body is the scroller" and "html is the scroller" engines. Scoped to
     auth-centered pages so no other page's scrollbar is affected. */
  body.mt-auth-centered { scrollbar-width: none; -ms-overflow-style: none; }body.mt-auth-centered::-webkit-scrollbar { width: 0; height: 0; }html:has(body.mt-auth-centered) { scrollbar-width: none; }html:has(body.mt-auth-centered)::-webkit-scrollbar { width: 0; height: 0; }}/* ── Phone-portrait short cards: same residual-scroll fix as landscape-small ──
   The global portrait floor `body:not(.mt-puzzle-page) .mt-layout {
   min-height: 100dvh }` (line ~1144) reaches body.mt-auth-centered too. The
   portrait auth block above overrides `height` to calc(100vh - 50px) but, like
   the landscape-small case once did, never touched `min-height` — so the used
   height resolves to 100dvh and, stacked on body's 50px navbar padding-top, the
   document is ~50px taller than the viewport → a permanent residual scrollbar
   (and the URL-bar-shown transient adds a little more). This stayed invisible
   while portrait cards fit naturally; a taller card (e.g. forgot-password's
   user-ID + email pair) makes it show. Mirror the landscape-small remedy:
   neutralise the rogue 100dvh floor to the intended strip, then hide the
   residual scrollbar (auth-fit guarantees the card fits, and touch
   press-and-scroll still works, so nothing reachable is hidden). Scoped to
   auth-centered so no other portrait page is affected. */
@media (max-width: 767px) and (orientation: portrait) {body.mt-auth-centered .mt-layout,
  body.mt-auth-centered .mt-panel-center {
    min-height: calc(100vh - 50px) !important;
  }body.mt-auth-centered { scrollbar-width: none; -ms-overflow-style: none; }body.mt-auth-centered::-webkit-scrollbar { width: 0; height: 0; }html:has(body.mt-auth-centered) { scrollbar-width: none; }html:has(body.mt-auth-centered)::-webkit-scrollbar { width: 0; height: 0; }}/* ── Tablet portrait (iPad Mini 768 / Air 820 / Pro) ─────────────────────
   Root cause: portrait CSS uses flex-wrap:wrap — left+right in row-1
   (order 1,2), center in row-2 (order 3). Any height limit on the layout
   clips row-2 entirely, making the form invisible.

   For ALL auth pages: hide side panels (logo/constellation not needed on
   auth forms), undo the wrap so the center panel is full-width, clear the
   110px body bottom-padding reserved for the now-hidden portrait ads bar.

   SHORT cards (body.mt-auth-centered) — vh sticky-footer (same model as
   the phone block): .mt-panel-center is a flex column of min-height
   100vh-50px; .container is flex:1 (its resolved flex height is definite,
   so the centurion grid below still resolves its 2fr/3fr tracks and
   auth-fit.js scales the card to it); .mt-content-footer is flex:0 0 auto
   so it is pinned to the very bottom. Sized to the bar-collapsed viewport
   so one reclaim scroll then leaves a clean no-scroll page. LONG pages
   (mt-auth-page WITHOUT mt-auth-centered): no height lock — body-scroll
   normally.                                                            */
@media (min-width: 768px) and (max-width: 1023px) and (orientation: portrait) {body.mt-auth-page {
    padding-bottom: 0 !important;
  }body.mt-auth-page .mt-panel-left,
  body.mt-auth-page .mt-panel-right { display: none !important; }body.mt-auth-page .mt-layout {
    flex-wrap: nowrap !important;
    align-items: stretch !important;
  }body.mt-auth-page .mt-panel-center {
    flex: 1 0 100% !important;
    order: 1 !important;
  }body.mt-auth-centered .mt-layout {
    height: calc(100vh - 50px) !important;
    overflow: visible !important;
  }body.mt-auth-centered .mt-panel-center {
    height: calc(100vh - 50px) !important;
    overflow: visible !important;
    display: flex !important;
    flex-direction: column !important;
  }/* Centred card region. The panel is a FIXED 100vh-50px box, so this
     flex:1 child is CLAMPED (shrinks to the space above the pinned footer,
     can't grow with a tall card). That definite, clamped height lets the
     centurion grid (display:grid in the later block) resolve its 2fr/3fr
     rows AND lets auth-fit.js measure real overflow and scale the card.
     overflow:hidden while the card fits, for the phone block's reason (#806):
     an inner scroller swallows the drag that is supposed to collapse the URL
     bar. The escape hatch below grants a scroll only when auth-fit could not
     fit the card. Tablets measured clean on every page in both orientations,
     so that hatch is the invariant being made true everywhere rather than a
     fix for an observed break here. */
  body.mt-auth-centered .container {
    flex: 1 1 auto !important;
    min-height: 0 !important;
    height: auto !important;
    overflow: hidden !important;
    padding-top: 12px !important;
    padding-bottom: 12px !important;
  }body.mt-auth-centered .container:not([data-fit="ok"]) {
    overflow-x: hidden !important;
    overflow-y: auto !important;
    align-items: start !important;
  }body.mt-auth-centered .mt-content-footer {
    flex: 0 0 auto !important;
  }/* Long pages: explicitly unclip so the document flows + body scrolls. */
  body.mt-auth-page:not(.mt-auth-centered) .mt-layout,
  body.mt-auth-page:not(.mt-auth-centered) .mt-panel-center,
  body.mt-auth-page:not(.mt-auth-centered) .container {
    overflow: visible !important;
    height: auto !important;
  }/* The black bar holds the viewport whether or not the content overflows —
     margin-top:auto pins it when there is slack, position:sticky when there is
     not. Same rule, and the same reasoning, as the phone block above. */
  body.mt-auth-page:not(.mt-auth-centered) .mt-layout,
  body.mt-auth-page:not(.mt-auth-centered) .mt-panel-center {
    min-height: calc(100dvh - 50px) !important;
  }body.mt-auth-page:not(.mt-auth-centered) .container {
    flex: 0 0 auto !important;
  }body.mt-auth-page:not(.mt-auth-centered) .mt-content-footer {
    flex: 0 0 auto !important;
    margin-top: auto !important;
    position: sticky !important;
    bottom: 0 !important;
    z-index: 5;
  }}/* ── Portrait: compact single-column, full content ── */
/* COMPACT PORTRAIT TYPOGRAPHY — up to 1023, matching the LAYOUT's own portrait
   boundary rather than Bootstrap's phone one.
   It used to stop at 767, and the result was a page that got TALLER on a BIGGER
   device: an iPad Mini is 744 CSS px wide, so it fell under the old cutoff and
   got these compact sizes, while an iPad Air at 820 did not. Measured on
   register.php — the worst case, seven fields plus the terms block — the card
   was 857px on the Mini and 958px on the Air, and the boundary was exactly one
   pixel of width (767 -> 768 flips 857 -> 958). The Air's usable height is only
   47px greater, so the larger tablet ended up with 54px LESS breathing room than
   the smaller one, which is not a trade anyone chose.
   1023 is not a new number: it is where .mt-layout already switches to the
   wrapped portrait variant, so the auth type now changes where the page around
   it changes. */
@media (max-width: 1023px) and (orientation: portrait) {body.mt-auth-page .mt-auth-heading { font-size: 22px; margin-bottom: 14px; padding-bottom: 10px; }body.mt-auth-page .mt-auth-card .form-group { --mt-fieldgap: 10px; margin-bottom: var(--mt-fieldgap); }body.mt-auth-page .mt-auth-links { margin-top: 10px; font-size: 15px; }}/* …and a slightly wider column where the width exists to give. A tablet in
   portrait has 288px of it spare beside a 480px card; a little of that back
   shortens every wrapped line — the intro, the field hints, the terms sentence —
   which is height, not just looks. Held to 560 rather than filling the strip:
   a text column much past that reads badly, and the cards are a fixed family.
   .mt-auth-card-wide (reactivate, 720) is left alone — it already asked to be
   wider and is not the page under pressure. */
@media (min-width: 768px) and (max-width: 1023px) and (orientation: portrait) {
  body.mt-auth-page .mt-auth-card:not(.mt-auth-card-wide) { max-width: 560px; }
  /* VERTICAL padding matched to the phone block, horizontal left generous. The
     phone rule a few blocks up sets the whole padding to 16px (it also sets
     width: 100%, which is why that rule cannot simply be widened to cover
     tablets — it would stretch the card across the strip). Without this, crossing
     768 still cost 32px of card height at an identical viewport, purely in
     padding — the same "bigger means tighter" step this change is undoing,
     just smaller. Horizontal stays at 32 because the column is 560 here and a
     text block wants its side margins. */
  body.mt-auth-page .mt-auth-card:not(.mt-auth-card-wide) { padding: 16px 32px; }
}/* ── Landscape small: 2-column label-left / input-right, very compact ── */
@media (max-height: 599px) and (orientation: landscape) {body.mt-auth-page .mt-auth-card { padding: 10px 16px !important; }body.mt-auth-page .mt-auth-heading { font-size: 18px; margin-bottom: 10px; padding-bottom: 8px; }body.mt-auth-page .mt-auth-card .form-group {
    /* COLUMN GAP ONLY. `gap: 8px` is a row-gap too, and this row wraps (see the
       description rule below), so the shorthand was also spacing a field from its
       own description — putting the description 12px from the field it describes
       and 8px from the next one. The 8 between label and input is unchanged. */
    --mt-fieldgap: 8px;
    display: flex; align-items: center; column-gap: 8px; row-gap: 0; margin-bottom: var(--mt-fieldgap);
  }body.mt-auth-page .mt-auth-card .control-label {
    flex: 0 0 90px; margin-bottom: 0; text-align: right; font-size: 13px;
  }body.mt-auth-page .mt-auth-card .form-control {
    flex: 1; height: 34px; padding: 4px 10px; font-size: 16px;
  }/* A password field is NOT a direct flex child — js/password-toggle.js (#266)
     wraps it in .mt-pw-wrap so the eye button can sit at the input's right
     edge. So the wrap, not the input, is the flex item, and without this it
     falls back to flex: 0 1 auto and shrinks to content width — leaving the
     Password field visibly shorter than the User ID field beside it (#599).

     GROW ONLY — deliberately not the sibling's `flex: 1` (= 1 1 0). This row
     can hold a THIRD flex item: js/auth-validate.js appends its .mt-fieldmsg
     to the .form-group (not next to the input), and the row is nowrap, so a
     live validation message competes for the same width. With flex-basis 0
     the wrap would then get only the leftover after the message's own base,
     making the field NARROWER than it is today whenever a message shows; and
     with min-width: 0 it can collapse to 0 while the input (min-width: auto)
     spills out of it — which drags the absolutely-positioned eye button off
     the input onto the label. Keeping basis/min-width at auto matches the
     plain .form-control's shrink behaviour, so the two rows stay in step in
     every state. (The message squeezing the row at all is a separate,
     pre-existing problem that hits the User ID row identically.) */
  /* ── A FIELD DESCRIPTION IS NOT A COLUMN (#806) ───────────────────────────
     .form-group is a flex ROW here, and a description written after the input
     is a third flex item on it — so it does not sit under the field, it takes
     the field's width away. Measured on edit-profile at 844x390: "3-64
     characters: letters, numbers, dots, underscores, or hyphens." wrapped to
     two lines and left the User ID input 25px wide, a box too small to show one
     character, while Display Name (which carries no description) had the whole
     row. The Current Password row was squeezed by its own shorter description
     in the same way.

     The row wraps now and anything that is not the label or the field takes a
     line of its own. That covers the live validation message too
     (js/auth-validate.js appends .mt-fieldmsg to the .form-group, not beside
     the input) — the same squeeze, noted as pre-existing in the .mt-pw-wrap
     comment above, and fixed here by the same declaration rather than by a
     second rule that could drift from this one.

     BELOW THE FIELD, not above it. `order: -1` would put the description over
     the row and match how a reader might scan it, but the DOM order stays
     label-input-description, so a screen reader would then read it in an order
     nobody sees — and every other viewport of the same page already prints it
     under the input, because there .form-group is an ordinary block. One
     reading order everywhere.

     FULL WIDTH rather than indented to the input's column: the label track is
     90px here and 110 under .mt-auth-compact, so an indent would be a third
     copy of a number that already exists twice, and the description reads
     perfectly well starting at the card's edge. */
  body.mt-auth-page .mt-auth-card .form-group { flex-wrap: wrap; }
  body.mt-auth-page .mt-auth-card .form-group > p,
  body.mt-auth-page .mt-auth-card .form-group > .mt-fieldmsg { flex: 0 0 100%; }
  body.mt-auth-page .mt-auth-card .mt-pw-wrap { flex-grow: 1; }body.mt-auth-page .mt-auth-btn { padding: 8px; font-size: 15px; margin-top: 4px; }body.mt-auth-page .mt-auth-links { margin-top: 8px; font-size: 14px; }body.mt-auth-page .mt-auth-sub { font-size: 15px; margin-bottom: 10px; }body.mt-auth-page .mt-auth-error,
  body.mt-auth-page .mt-auth-success,
  body.mt-auth-page .mt-auth-hint,
  body.mt-auth-page .mt-auth-info,
  body.mt-auth-page .mt-auth-warn {
    padding: 4px 8px;
    margin-bottom: 6px;
    font-size: 13px;
    line-height: 1.3;
  }/* THE HINT SHOWS IN LANDSCAPE TOO (#806), reversing a `display: none`
     that used to sit here. Its reason was that the hint is "informational
     filler that competes with auth-fit's scaling budget when a flash banner
     is ALSO shown" — measured, it competes for nothing. .mt-auth-msg
     reserves 36px in this very media query WHETHER OR NOT the hint renders
     (the override is a few rules below, and is unconditional), and all
     thirteen hint strings come out 27px — one line — at the narrowest card
     inner width on the site, 446px. So hiding it bought no height at all;
     what it bought was a 36px band of blank card directly under the
     heading, which on a phone in landscape is the most conspicuous empty
     space on the page. And a flash banner is a SIBLING ABOVE the slot,
     paying its own height either way, so the two never shared a budget. */

  /* RECOVERY LINKS MOVE TO THE BAND (#806). Stacked under Sign In in the 172px
     actions column they wrap to two lines ~4px apart — two tap targets a thumb
     cannot separate on real hardware, which is the whole reason for the move.
     The band below the card carried "Questions? Contact Us" alone on a centred
     own-row, so the second half was empty: contact goes left, the links right,
     one row, and the card spends nothing.

     .mt-auth-links-BANDED, not .mt-auth-links — the card copy marks itself, so
     hiding it cannot strip the links off the auth pages that have not opted in.
     .mt-band-contact drops its flex-basis:100% here (that rule exists to make it
     the whole row and centre itself; with a second item the band's own
     space-between does the placing), and the band stops wrapping so the two
     halves cannot fall onto two rows — measured at 320px wide, the narrowest
     landscape viewport in the device list, they still fit one line. */
  /* GATED ON THE BAND ACTUALLY BEING THERE (#806 review). The three rules below
     presuppose a footer band carrying a SECOND item -- the comment above says so
     itself, "with a second item the band's own space-between does the placing".
     Keyed on body.mt-auth-page they applied wherever that precondition did not
     hold, and it failed in two directions.

     THE STAFF CONSOLE is the serious one. It gets the site's CSS and the site's
     pages -- forgot-password, forgot-user-id and resend-verification ship
     verbatim in staff.zip and set $mtAuthBandLinks -- but its OWN footer:
     staff/staff_footer.php renders no .mt-privacy-strip and no .mt-band-auth at
     all. So the hide fired with nowhere for the links to reappear. On a phone in
     landscape forgot-password lost "Forgot your user ID?" and "Back to Sign In",
     and resend-verification lost its ONLY link, leaving a card with no way out --
     the same class of defect #806 exists to remove, shipped into the console by
     the fix for it. Verified from the built bundle, not from the source tree.

     ON THE SITE, space-between with a single item is flex-start, so "Questions?
     Contact Us" sat left-aligned on the fifteen auth pages that never opt in --
     a regression from master, where that band was a centred own row.

     THE GATE ASKS WHETHER THE BAND IS THERE, which is the condition itself and
     so cannot drift from it. A body attribute set by includes/header.php was
     tried first and is INERT: all four pages assign $mtAuthBandLinks 100+ lines
     AFTER their `require includes/header.php` (login.php:304 against :178), so
     the header cannot see it. Moving four assignments above the require would
     work and would put the correctness back into an ordering somebody has to
     maintain.

     :has() DEGRADES THE RIGHT WAY. Where it is unsupported the whole selector
     fails, so the hide and the sizing fail together with the reveal below
     staying inert for want of an element: the in-card links stay visible and the
     page behaves as it did on master. The failure mode is the pre-#806 layout,
     never a card with its links removed and nowhere to find them.

     Measured at 667x331: banded pages match, take space-between and spread two
     items; verify-email does not match, keeps justify-content:center with
     flex-basis:100%, and its contact line is centred (offset 0). Guarded by
     scripts/smoke_test.php, because nothing else here looks at the console. */
  body.mt-auth-page:has(.mt-band-auth) .mt-auth-links-banded { display: none !important; }
  body.mt-auth-page:has(.mt-band-auth) .mt-privacy-strip {
    flex-wrap: nowrap;
    justify-content: space-between;
  }
  body.mt-auth-page:has(.mt-band-auth) .mt-band-contact { flex-basis: auto; }
  body.mt-auth-page .mt-band-auth { display: flex; }
}.mt-auth-heading {
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  text-align: center;
  font-size: 26px;
  margin: 0 0 20px;
  padding-bottom: 14px;
  border-bottom: 1px solid #C9A84C;
}/* The account hub's breadcrumb (#741), sitting INSIDE the card above the heading.

   INSIDE, not above the card, because the card is a fixed max-width (480, or 720 when
   wide) centred in a panel that is far wider on desktop — a breadcrumb spanning the
   container would sit detached from the box it belongs to, and matching the width would
   mean tracking which card variant is in use. In the card it aligns itself.

   IT GIVES UP ITS OWN GOLD RULE, which is the whole of this rule's reason to exist:
   .breadcrumb draws a bottom border and .mt-auth-heading directly below draws another, so
   the pair rendered two gold lines a heading apart. The heading's is the one that means
   something (it closes the card's title block), so this defers to it. Top padding goes for
   the same reason — the card already has its own 32px, and 8 more read as a gap rather
   than as a header.

   NOT hidden in landscape-small, unlike .mt-auth-cta / .mt-auth-privacy just above. Those
   are optional invitations and this is the way out; the phone is the case where the navbar
   is collapsed behind a hamburger, so it is where the height is most worth spending. MEASURED
   at ~40px (28.1 box + 12 margin), constant at every viewport including landscape-small — an
   earlier draft of this comment guessed ~20 and so priced the trade at half. It is still the
   right trade on a card whose whole content is one error line, but it is 40px, not 20. */
body.mt-auth-page .mt-auth-card > .mt-auth-crumb {
  border-bottom: none;
  padding-top: 0;
  margin-bottom: 12px;
}/* ...and the OTHER shape, where the breadcrumb IS the card's title (#741). On the hub the
   heading would have read "My Account" directly under a crumb ending "My Account" — a
   duplication rather than a trail — so .breadcrumb-title takes the title role, exactly as
   aboutus.php and legal.php already use it in place of a heading. Here it KEEPS
   .breadcrumb's gold rule, because that rule is now the one closing the card's title block,
   and the spacing is .mt-auth-heading's (0 0 20px / 14px) so the card's rhythm is unchanged
   by which of the two shapes it wears — AT EVERY BREAKPOINT, which took a second pass: the
   heading is compressed by the phone-portrait and landscape-small blocks above, this matched
   neither, and the claim held only at >=768px while the hub's title block came out 26px
   TALLER than the heading it replaced on the smallest screen. Both compressions are mirrored
   there now. Left-aligned, which is both what .breadcrumb-title does everywhere else and what
   the hub's own section headings do. */
body.mt-auth-page .mt-auth-card > .mt-auth-crumb-title {
  margin: 0 0 20px;
  padding: 0 0 14px;
  line-height: 1.15;
}/* ...and the two compressions .mt-auth-heading gets, mirrored onto this shape (#741).

   THEY LIVE HERE, NOT IN THE TWO AUTH MEDIA BLOCKS ABOVE beside the heading rules they copy,
   and that placement is load-bearing rather than tidy: those blocks come EARLIER in the file,
   a media query adds no specificity, and this selector is identical in both — so source order
   decides and the base rule directly above would win. Measured with them up there: font-size
   compressed (that property is not set here, so the media rule outranked .breadcrumb-title)
   while margin-bottom stayed 20px and padding-bottom 14px. Half-applied, and silently.

   `line-height` is on the BASE rule above rather than in here, because the two elements do
   not agree at equal font-size — an <ol>'s line box is taller than an <h2>'s, which left the
   title block ~7px over the heading's at EVERY width even once the box values matched, so it
   is one declaration for all three breakpoints. The values are .mt-auth-heading's own —
   22/14/10 portrait, 18/10/8 landscape-small — and px not em, because .breadcrumb-title's
   phone size otherwise comes from an UNSCOPED `.breadcrumb.breadcrumb-title` rule in the
   About Us block, so scoping that rule one day would silently resize the account hub. */
@media (max-width: 767px) and (orientation: portrait) {
  body.mt-auth-page .mt-auth-card > .mt-auth-crumb-title {
    font-size: 22px; margin: 0 0 14px; padding: 0 0 10px;
  }
}@media (max-height: 599px) and (orientation: landscape) {
  body.mt-auth-page .mt-auth-card > .mt-auth-crumb-title {
    font-size: 18px; margin: 0 0 10px; padding: 0 0 8px;
  }
}/* THE TRAIL IS NOT THE TITLE. In the title role both crumbs render at 1.4em, so "Home"
   arrives at the same weight as the page you are actually on and the two read as a pair of
   headings rather than as a path leading to one. The ancestor steps down; the current page
   keeps the title size. The separator goes with it — it is .active's own ::before, so
   without this it stays full size and a large ✦ sits between a small "Home" and a large
   "My Account", which looks more deliberate than either. Baseline alignment is the
   inline-block default, so the smaller crumb sits on the title's baseline unaided. */
body.mt-auth-page .mt-auth-card > .mt-auth-crumb-title > li:not(.active),
body.mt-auth-page .mt-auth-card > .mt-auth-crumb-title > li.active::before {
  font-size: 0.72em;
}/* The active crumb IS the page's heading in this shape (see account.php) — an <h2> that must
   look like nothing at all, since the <ol> around it already carries the type. `font: inherit`
   takes size, family, weight and line-height from the <li> in one declaration, so the heading
   tracks .breadcrumb-title and its two phone compressions without a second copy of any of them. */
body.mt-auth-page .mt-auth-card > .mt-auth-crumb-title .mt-auth-crumb-h {
  display: inline;
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  margin: 0;
  padding: 0;
}.mt-auth-sub {
  font-family: 'Crimson Text', serif;
  color: #666;
  text-align: center;
  margin-bottom: 18px;
  font-size: 18px;
}.mt-auth-card .control-label {
  font-family: 'Cinzel', serif;
  font-size: 15px;
  color: #0D1B3E;
  letter-spacing: 0.03em;
}.mt-auth-card .form-control {
  font-family: 'Crimson Text', serif;
  font-size: 18px; /* ≥16px — prevents iOS auto-zoom on field tap */
}/* Messages */
.mt-auth-error {
  background: #fdf0f0; border: 1px solid #d44; border-radius: 4px;
  color: #a00; padding: 10px 14px; margin-bottom: 16px;
  font-family: 'Crimson Text', serif; font-size: 17px;
}.mt-auth-success,
.mt-auth-hint {  /* neutral guiding text shown when the slot has no status message */
  background: #f0f7f0; border: 1px solid #4a4; border-radius: 4px;
  color: #2a6; padding: 10px 14px; margin-bottom: 16px;
  font-family: 'Crimson Text', serif; font-size: 17px;
}/* Neutral hint: identical box geometry to a real message (so the
   reserved slot never shifts) but lower-contrast colours so it reads as
   calm guidance, not a success confirmation. Only the colours change —
   warnings / errors / success / info keep their full contrast. */
.mt-auth-hint {
  background: #f4f8f4;
  border-color: #d8e6da;
  color: #6a8a72;
}.mt-auth-info {
  background: #F9F5EC; border: 1px solid #C9A84C; border-radius: 4px;
  color: #0D1B3E; padding: 10px 14px; margin-bottom: 16px;
  font-family: 'Crimson Text', serif; font-size: 17px;
}.mt-auth-warn {
  background: #fdf6ec; border: 1px solid #e0a030; border-radius: 4px;
  color: #7a4a00; padding: 10px 14px; margin-bottom: 16px;
  font-family: 'Crimson Text', serif; font-size: 17px;
}/* A long unbreakable token in a status/feedback message — e.g. an email address
   echoed back in an error — must wrap inside the box instead of spilling past the
   card edge (#244). overflow-wrap breaks such a word ONLY when it would otherwise
   overflow; the boxes have no max-height, so they just grow to fit (no scroll
   needed). word-break is a legacy WebKit fallback. */
.mt-auth-error,
.mt-auth-success,
.mt-auth-hint,
.mt-auth-info,
.mt-auth-warn {
  overflow-wrap: break-word;
  word-break: break-word;
}/* Reserved status slot — always rendered above the form on the form-bearing
   auth pages (login, login-verify, reset-password, accept-invitation,
   register) so a message toggling in/out never reflows the card: card
   height stays constant across states, protecting the no-scroll /
   viewport-fit layouts. Empty = transparent reserved space; the inner
   message keeps its own box, the slot owns the spacing to the form.
   PORTRAIT / desktop reserve = 1 line (~46px = one 17px line + 10px·2
   padding + border). Covers every slot message except the single
   longest one ("Please fix the errors below before continuing.",
   46 chars), which can wrap to 2 lines only on the narrowest phones —
   there the slot grows and that one page reflows slightly when that
   message shows. Accepted trade: it removes ~24px of dead space in the
   common no-message state, which was pushing the lower card content
   (Forgot / CTA) onto the fold on iPhone 14. Landscape is also 1 line
   (override below). */
.mt-auth-msg {
  min-height: 46px;
  margin-bottom: 16px;
  box-sizing: border-box;
}.mt-auth-msg > * { margin-bottom: 0 !important; }/* Landscape-small (phones): 1-line reserve — justified separately by
   content. Here the message box uses the compact 14 px font in the
   wider centurion column, where even the longest slot message stays on
   ONE line, so a 2-line reserve would just be dead space. Still zero
   reflow (every message is ≤ 1 line in this context) and auth-fit.js
   keeps it no-scroll. ~36 px = 1 compact line + 6 px·2 padding + border. */
@media (max-height: 599px) and (orientation: landscape) {.mt-auth-msg { min-height: 36px; margin-bottom: 8px; }.mt-auth-msg > * { margin-bottom: 0 !important; }}/* 2FA digit boxes */
.mt-2fa-inputs {
  display: flex;
  gap: 8px;
  justify-content: center;
  margin-bottom: 16px;
}.mt-2fa-digit {
  width: 46px;
  height: 56px;
  font-size: 26px;
  font-family: 'Cinzel', serif;
  font-weight: bold;
  text-align: center;
  border: 2px solid #C9A84C;
  border-radius: 4px;
  background: #fff;
  color: #0D1B3E;
  outline: none;
  padding: 0;
  -moz-appearance: textfield;
}.mt-2fa-digit:focus {
  border-color: #0D1B3E;
  box-shadow: 0 0 0 3px rgba(13,27,62,.12);
}.mt-2fa-digit:disabled {
  background: #f0ede6;
  border-color: #d9c88a;
  color: #aaa;
}@media (max-width: 400px) {.mt-2fa-digit { width: 38px; height: 48px; font-size: 22px; }.mt-2fa-inputs { gap: 6px; }}/* ── 2FA verify (login-verify.php) two-column landscape layout ───────────
   Default everywhere: a single column in the ORIGINAL stacked order.
   Content is physically grouped into an .mt-2fa-info / .mt-2fa-action
   wrapper, but the wrappers are display:contents so their children are
   the flex items of .mt-2fa-grid and `order: var(--ord)` restores the
   original interleaved sequence (status, sub, inputs, timer, trust,
   verify, links) — desktop / portrait are pixel-unchanged.

   Landscape-small only: the wrappers materialise as two real flex
   columns side by side (Option A — context left, action right) so the
   tall digit grid and the button stack run in parallel instead of
   stacked. That roughly halves the card's natural height, so auth-fit.js
   barely has to scale it down on short landscape phones.

   Scoped to body.mt-auth-2fa so no other auth page is affected. The
   expired / maxed states render no .mt-2fa-grid, so they keep the plain
   stack untouched. */
body.mt-auth-2fa .mt-2fa-grid {
  display: flex;
  flex-direction: column;
}body.mt-auth-2fa .mt-2fa-info,
body.mt-auth-2fa .mt-2fa-action {
  display: contents;
}body.mt-auth-2fa .mt-2fa-info > *,
body.mt-auth-2fa .mt-2fa-action > * {
  order: var(--ord, 0);
}@media (max-height: 599px) and (orientation: landscape) {body.mt-auth-2fa .mt-auth-card { max-width: 720px; }/* The status slot sits in the narrow half-width info column here, so
     the ~38–39-char 2FA messages can wrap to 2 lines on the tightest
     landscape phones (≈ iPhone SE, 667-wide → ~297px column). Reserve
     2 lines so switching between hint / error / info never reflows the
     card. The global landscape 1-line reserve (≈36px) stays for
     login.php, whose centurion layout gives the message the full wide
     card width where 1 line always suffices. Costs nothing here: the
     info column has viewport slack, so auth-fit.js doesn't shrink more. */
  body.mt-auth-2fa .mt-auth-msg { min-height: 56px; }body.mt-auth-2fa .mt-2fa-grid {
    flex-direction: row;
    align-items: flex-start;
    column-gap: 24px;
  }body.mt-auth-2fa .mt-2fa-info,
  body.mt-auth-2fa .mt-2fa-action {
    display: flex;
    flex-direction: column;
    flex: 1 1 0;
    min-width: 0;
  }body.mt-auth-2fa .mt-2fa-info { order: 1; }body.mt-auth-2fa .mt-2fa-action { order: 2; }/* wrappers are real flex columns now — children flow in DOM order
     (info DOM: sub, status, timer, links / action DOM: inputs, trust,
     verify), which is exactly the Option-A column order */
  body.mt-auth-2fa .mt-2fa-info > *,
  body.mt-auth-2fa .mt-2fa-action > * { order: 0; }/* 6 boxes must fit a half-width column on the narrowest landscape phone */
  body.mt-auth-2fa .mt-2fa-digit { width: 40px; height: 46px; font-size: 22px; }body.mt-auth-2fa .mt-2fa-inputs { gap: 6px; margin-bottom: 12px; }body.mt-auth-2fa .mt-2fa-action .mt-auth-btn { margin-top: 4px; }}/* Primary button */
.mt-auth-btn {
  display: block; width: 100%; padding: 12px;
  background: #0D1B3E; color: #C9A84C;
  border: 1px solid #C9A84C; border-radius: 3px;
  font-family: 'Cinzel', serif; font-size: 17px; letter-spacing: 0.05em;
  text-align: center; cursor: pointer;
  transition: background .15s, color .15s;
}.mt-auth-btn:hover, .mt-auth-btn:focus {
  background: #C9A84C; color: #0D1B3E; text-decoration: none;
}.mt-auth-btn[disabled], .mt-auth-btn:disabled { opacity: .5; cursor: not-allowed; }.mt-auth-btn + .mt-auth-btn { margin-top: 8px; }.mt-auth-btn-secondary {
  background: transparent; color: #0D1B3E; border-color: rgba(201,168,76,.6);
}.mt-auth-btn-secondary:hover { background: rgba(201,168,76,.12); color: #0D1B3E; }/* admin-invite.php delivery-action row (#85): a compact content-sized row when it
   fits on one line (Cancel pushed to the right), collapsing to full-width stacked
   buttons with a centered Cancel below once the window is too narrow for one line. */
.mt-invite-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }body.mt-auth-page .mt-invite-actions .mt-auth-btn {
  width: auto; margin: 0; padding: 8px 14px; font-size: 14px; flex: 0 1 auto;
}.mt-invite-actions .mt-inv-cancel { margin-left: auto; color: #888; }@media (max-width: 560px) {.mt-invite-actions { flex-direction: column; align-items: stretch; }body.mt-auth-page .mt-invite-actions .mt-auth-btn { width: 100%; }.mt-invite-actions .mt-inv-cancel { margin-left: 0; align-self: center; margin-top: 6px; }}/* Links row */
.mt-auth-links {
  text-align: center; margin-top: 14px;
  font-family: 'Crimson Text', serif; font-size: 17px; color: #666;
}.mt-auth-links a { color: #8B6914; }/* #264: AA on cream */
.mt-auth-links a:hover { color: #0D1B3E; }/* CTA panel below card */
.mt-auth-cta {
  margin-top: 20px; padding-top: 16px;
  border-top: 1px solid rgba(201,168,76,.3);
  font-family: 'Crimson Text', serif; font-size: 18px;
  color: #666; text-align: center;
}.mt-auth-cta a { color: #8B6914; }/* #264: AA on cream */
.mt-auth-cta a:hover { color: #0D1B3E; }/* Section heading inside wide cards */
.mt-auth-section-heading {
  font-family: 'Cinzel', serif; color: #0D1B3E;
  font-size: 15px; letter-spacing: 0.05em; text-transform: uppercase;
  margin: 24px 0 12px; padding-bottom: 6px;
  border-bottom: 1px solid rgba(201,168,76,.4);
}.mt-auth-section-heading:first-child { margin-top: 0; }/* (The old .mt-inv-create label-alignment block was removed with the inline
   Create-Invitation form — that form now lives on admin-invite.php, issue #85.) */

/* Pending-invitation row actions: POST buttons (Resend/Revoke) that need to
   look like the inline links they replaced (forms can't be plain <a>). Colour
   is set inline per action (gold resend, red revoke). */
.mt-inv-link {
  background: none; border: 0; padding: 0; margin: 0 8px 0 0;
  font: inherit; cursor: pointer; text-decoration: underline;
}.mt-inv-link:hover { text-decoration: none; }/* Privacy disclaimer */
.mt-auth-privacy {
  margin-top: 18px;
  font-family: 'Crimson Text', serif; font-size: 14px;
  color: #aaa; text-align: center; line-height: 1.5;
}/* My Account — read-only profile fields. View/edit separation: this page
   only displays values; edits happen on dedicated pages (edit-profile.php
   etc.), so the fields are static text, not inputs. */
.mt-acct-fields { margin-bottom: 20px; }.mt-acct-field { padding: 11px 0; border-bottom: 1px solid #efe7d2; }.mt-acct-field:last-child { border-bottom: none; }.mt-acct-label {
  font-family: 'Cinzel', serif; font-size: 12px; color: #0D1B3E;
  letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 3px;
}.mt-acct-value {
  font-family: 'Crimson Text', serif; font-size: 19px; color: #1C130A; margin: 0;
}.mt-acct-badge { font-family: 'Crimson Text', serif; font-size: 14px; color: #2a6; margin-left: 8px; }.mt-acct-note { font-family: 'Crimson Text', serif; font-size: 14px; color: #999; margin-left: 8px; }/* Account-role badge (issue #372) — a small colour-coded pill so the role
   (admin / executive / club / tester / free) reads at a glance instead of as
   plain text. Shared by accept-invitation.php, account.php, and the staff
   console's account.php (the staff bundle copies css/); markup + label come
   from includes/role_badge.php so they can't drift. Base pill = the neutral
   'free' look; per-role modifiers override the colours below. */
.mt-role-badge {
  display: inline-block;
  font-family: 'Cinzel', serif;
  font-size: 12px; font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase;
  line-height: 1; white-space: nowrap;
  padding: 5px 12px; border-radius: 999px;
  border: 1px solid transparent;
  vertical-align: middle;
  /* neutral / free fallback */
  background: #6B6B6B; color: #fff;
}/* Admin matches the staff console's .mt-staff-badge (gold pill) — admins are
   exactly who the /staff/ console is for, so the badge echoes that identity. */
.mt-role-admin { background: #E8D48B; color: #1C130A; border-color: #C9A84C; }.mt-role-executive { background: #C9A84C; color: #1C130A; border-color: #1C130A; }.mt-role-club { background: #34579E; color: #F4E7BE; border-color: #C9A84C; }.mt-role-tester { background: #2E6B4F; color: #F9F5EC; border-color: #1f4a37; }.mt-role-free { background: #6B6B6B; color: #fff; border-color: #555; }/* Step-by-step de-mocking: sections not yet wired stay on screen as dimmed,
   non-interactive previews so the full account vision is visible while we
   convert them one at a time. The "preview" pill marks them clearly so the
   placeholder data isn't mistaken for live info. */
.mt-acct-soon { opacity: .55; }.mt-acct-soon button, .mt-acct-soon input, .mt-acct-soon select, .mt-acct-soon a {
  pointer-events: none; cursor: default;
}.mt-acct-preview {
  display: inline-block; margin-left: 10px; padding: 1px 9px;
  font-family: 'Cinzel', serif; font-size: 11px; letter-spacing: 0.06em;
  color: #8a6d1f; background: #f6efda; border: 1px solid #e0cd95;
  border-radius: 10px; vertical-align: middle; text-transform: uppercase;
}/* Live per-field validation messages (js/auth-validate.js): password match /
   strength / user-ID format feedback shown under the field before submit. */
.mt-fieldmsg { margin: calc(var(--mt-fieldgap, 15px) / 2) 0 0; font-family: 'Crimson Text', serif; font-size: 14px; color: #a00; }.mt-fieldmsg:empty { display: none; }/* ── A FIELD'S DESCRIPTION BELONGS TO THE FIELD (#806) ─────────────────────
   The standing gray line under an input ("3-64 characters: letters, numbers,
   dots, underscores, or hyphens.") — the constant counterpart to .mt-fieldmsg,
   which only appears when validation has something to say. One class rather
   than the inline styles these carried, so the spacing rule below has one place
   to live and cannot drift between the pages that use it.

   PROXIMITY IS THE WHOLE POINT, at a ratio of 1:2 — the gap to its own field is
   half the gap to the next one, so the description reads as part of the field
   above it rather than as a loose line between two. Expressed as half of
   --mt-fieldgap rather than as a number, because that gap is 15px on desktop,
   10 in portrait and 8 in landscape: three constants, one ratio, and the ratio
   written once.

   Measured on edit-profile before this, landscape had it exactly BACKWARDS —
   12px above, 8px below, so the User ID rule sat closer to Current Password
   than to the field it describes. The 12 was the .form-group's own `gap: 8px`,
   which is a ROW gap as well as a column gap once the row wraps, and the
   landscape block splits the two for that reason. The other layouts were the
   right way round but loose, at 4 against 10 and 15; all three are 1:2 now
   (4:8, 5:10, 7.5:15).

   :first-child IS THE "PREVIOUS" CASE. A note written BEFORE its field
   describes the field below it, so the margin goes the other way. Nothing ships
   in that order today; the rule is here so that the next page to want it does
   not have to rediscover the proximity argument. */
/* ONE FIELD-GAP PER LAYOUT, published so the description can be half of it.
   Bootstrap's own .form-group is 15px and the two auth media blocks tighten it to
   10 and 8; each now states the number as a variable and spends it, so the ratio
   below is written once instead of three times and cannot drift from the spacing
   it describes. */
.mt-auth-card .form-group { --mt-fieldgap: 15px; margin-bottom: var(--mt-fieldgap); }
.mt-fieldnote {
  margin: calc(var(--mt-fieldgap, 15px) / 2) 0 0;
  font-family: 'Crimson Text', serif;
  font-size: 14px;
  line-height: 1.35;
  color: #888;
}
.mt-fieldnote:empty { display: none; }
.form-group > .mt-fieldnote:first-child { margin: 0 0 calc(var(--mt-fieldgap, 15px) / 2); }/* Persistent "why the submit button is disabled" helper (issue #266) — beside
   the grayed data-submit-guard button so it never reads as broken; role="status"
   announces the reason to assistive tech (a disabled button is unfocusable). */
.mt-submit-help { margin: 6px 0 0; font-family: 'Crimson Text', serif; font-size: 14px; color: #7a6a3a; }.mt-submit-help:empty { display: none; }/* Password show/hide toggle (issue #266) — js/password-toggle.js wraps each auth
   password field in .mt-pw-wrap and drops an eye button at its right edge. The
   wrap is block-level (matches the .form-control it contains) so the absolutely
   positioned button aligns to the input's full height regardless of the label
   above or validation message below. */
.mt-pw-wrap { position: relative; display: block; }.mt-pw-wrap .form-control { padding-right: 42px; }.mt-pw-toggle {
  position: absolute;
  top: 0;
  right: 0;
  width: 40px;
  height: 100%;
  padding: 0;
  border: 0;
  background: transparent;
  color: #8a7a4a;
  cursor: pointer;
  font-size: 15px;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}.mt-pw-toggle:hover,
.mt-pw-toggle:focus { color: #0D1B3E; }.mt-pw-toggle:focus { outline: 2px solid #C9A84C; outline-offset: -2px; }/* Skip-to-content link (issue #263, WCAG 2.4.1). Hidden via Bootstrap .sr-only
   until focused; when focused, pin it visibly at the top-left ABOVE the fixed
   navbar (z-index 1030). main:focus outline is suppressed — it's a
   programmatic focus target (tabindex=-1), not a user-styled control. */
.mt-skip-link.sr-only-focusable:focus {
  position: fixed;
  top: 8px;
  left: 8px;
  z-index: 2000;
  width: auto;
  height: auto;
  margin: 0;
  padding: 10px 16px;
  background: #0D1B3E;
  color: #E8D48B;
  border: 2px solid #C9A84C;
  border-radius: 4px;
  font-family: 'Cinzel', serif;
  font-size: 14px;
  text-decoration: none;
  clip: auto;
}.mt-panel-center:focus { outline: none; }/* Reduced-motion (issue #262, WCAG 2.3.3 / 2.2.2). For users who ask the OS to
   minimise motion, neutralise all decorative CSS animation/transition (hover
   slides, modal fades, etc.). The constellation twinkle (SMIL) and the win
   confetti (rAF) are gated in JS instead — see js/constellation-widget.js and
   puzzles/celebration.js. Near-zero (not 0) keeps any animationend/transitionend
   listeners firing. Scoped to the media query, so motion-OK users are untouched. */
@media (prefers-reduced-motion: reduce) {*, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }}/* Admin → Users roster. Rule: data cells NEVER wrap (one line each); only the
   header text may wrap (so "Paid Through" stacks and frees width for Joined).
   Long User ID / Display Name / Email values ellipsize (.mt-trunc) with the
   full string in a hover tooltip (title=). min-width + .table-responsive make
   the horizontal scrollbar appear as soon as the row content would overflow,
   rather than ever compressing a cell. Rows are clickable → detail modal. */
.mt-user-table { min-width: 720px; }.mt-user-table th { white-space: normal; vertical-align: bottom; }.mt-user-table td { white-space: nowrap; }.mt-user-row, .mt-inv-row, .mt-locked-row { cursor: pointer; }.mt-trunc {
  display: inline-block; max-width: 200px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  vertical-align: bottom;
}/* Admin panel (body.mt-admin-panel, the live "panel" view only — NOT the
   denied/unavailable centered cards, NOT account.php/tester.php which share
   .mt-auth-card-wide). A wide, full-height working surface instead of a narrow
   centered form card:
     • width  — fills the center strip (overrides the 720px .mt-auth-card-wide
       cap); the ~15px .container padding is the breathing room from the edge.
     • height — flex:1 grows the card to the full available column height.
   Placed AFTER `body.mt-auth-page .mt-auth-card { margin:auto }` so the margin
   override (which also disables the vertical auto-centering) wins on equal
   specificity. */
body.mt-admin-panel .mt-auth-card {
  max-width: none;
  width: 100%;
  margin: 16px 0;
  flex: 1 1 auto;
}/* The Users table opts into a min-width to keep its no-wrap columns legible;
   confine that overflow to a horizontal scrollbar INSIDE the responsive wrapper
   at every width (Bootstrap 3 only enables this below 768px), so a narrow strip
   never forces the card past .mt-panel-center's clipped (overflow-x:hidden)
   right edge. */
body.mt-admin-panel .table-responsive { overflow-x: auto; }
/* ── Admin panel furniture (#863) ────────────────────────────────────────────
   The eighth tab arrived on a bar whose styling was copied per item: every tab
   carried its own inline font-family:'Cinzel',serif;font-size:14px, and the
   panels below had drifted where nothing held them together — the three "Click
   a row for full details." hints disagreed on margin (0 0 8px against
   -8px 0 8px) and the empty states were split between #aaa and #888. These are
   the named classes those copies collapse into; the drift is not a tidiness
   complaint, it is what copying always produces given enough time. */

/* THE BAR IS A TAB STRIP, and the active tab OWNS the rule under it. A row of chips
   reads as a row of buttons — every one equally pressable, the selected one merely a
   darker button — which is the wrong metaphor for "you are looking at this panel". So
   the inactive tabs are recessed and closed (their own bottom border), the strip is a
   gold rule across the whole bar, and the ACTIVE tab is filled, a little taller, and
   pulled DOWN over the rule (margin-bottom:-2px) so it visually breaks through into
   the panel below rather than sitting beside its neighbours.

   FLEX, NOT Bootstrap's .nav-tabs, because eight tabs can wrap and a folder tab's
   bottom edge IS the strip's border — wrap that and the upper row floats above a line
   it no longer touches. Here the tabs are closed shapes with their own borders, so a
   wrapped row still reads as tabs; only the active one needs the rule, and it is on
   the last row wherever it lands. An overflow-x scroller was the other option and is
   worse: a tab you cannot see is a tab you do not know exists. */
.mt-admin-tabs::before, .mt-admin-tabs::after { content: none; }
.mt-admin-tabs {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: 3px;
  margin: 0 0 22px; padding: 0;
  border-bottom: 2px solid #C9A84C;
  list-style: none;
}
.mt-admin-tabs > li { margin: 0; }
.mt-admin-tabs > li > a {
  display: block;
  font-family: 'Cinzel', serif; font-size: 13px; letter-spacing: 0.02em;
  color: #7a6320;
  background: rgba(13, 27, 62, 0.045);
  border: 1px solid rgba(201, 168, 76, 0.55);
  border-radius: 4px 4px 0 0;
  padding: 6px 13px 5px;
  margin-bottom: -1px;          /* closed tabs rest ON the rule */
  line-height: 1.2;
}
.mt-admin-tabs > li > a:hover,
.mt-admin-tabs > li > a:focus {
  background: rgba(201, 168, 76, 0.20);
  border-color: #C9A84C;
  color: #0D1B3E; text-decoration: none;
}
/* The selected tab: filled, taller, and pulled down THROUGH the rule. */
.mt-admin-tabs > li.active > a,
.mt-admin-tabs > li.active > a:hover,
.mt-admin-tabs > li.active > a:focus {
  background: #0D1B3E;
  border-color: #0D1B3E;
  color: #E8D48B;
  padding: 9px 16px 7px;
  margin-bottom: -2px;
  box-shadow: 0 -2px 0 0 #C9A84C inset;   /* a gold cap, so it reads as lit */
}

/* The three lines that sit under a panel heading. One margin each, defined once,
   so a fourth panel cannot invent a fourth spacing. */
.mt-admin-note  { font-family: 'Crimson Text', serif; color: #888; font-size: 14px; margin: 0 0 4px; }
.mt-admin-hint  { font-family: 'Crimson Text', serif; color: #aaa; font-size: 13px; margin: 0 0 8px; }
.mt-admin-empty { font-family: 'Crimson Text', serif; color: #888; margin: 0; }

/* Table body + header, replacing the pair of inline declarations every admin
   table repeated. .mt-user-table keeps its own no-wrap/ellipsis rules above —
   this is only the typography those tables all agreed on by hand. */
.mt-admin-table { font-family: 'Crimson Text', serif; font-size: 15px; }
.mt-admin-table > thead > tr { font-family: 'Cinzel', serif; font-size: 13px; color: #0D1B3E; }

/* ── Operations journal (#863) ──────────────────────────────────────────────
   The console's read of includes/logging.php's day files. Monospace for the
   instant and the key=value pairs, because they are read by SCANNING a column
   for a change rather than by reading prose, and a proportional font destroys
   the column. The action label stays in the site's own face: it is the part a
   human reads as a sentence. */
.mt-ops-when { font-family: 'Courier New', monospace; font-size: 12.5px; white-space: nowrap; color: #555; }
.mt-ops-fields { font-family: 'Courier New', monospace; font-size: 12.5px; color: #444; word-break: break-word; }
.mt-ops-fields b { color: #6b5518; font-weight: 600; }

/* The severity chip. Keyed on what the act DID rather than on whether the script
   succeeded: deploy.refused and deploy.fail both mean the tooling behaved, and
   only one of them is a problem. A takedown is amber and not red because it is
   usually deliberate — colouring every one as a fault teaches the eye to skip
   the colour, which is the one thing a chip must not do. */
.mt-ops-act {
  display: inline-block; font-family: 'Cinzel', serif; font-size: 12px;
  padding: 2px 8px; border-radius: 2px; white-space: nowrap;
  border: 1px solid rgba(13, 27, 62, 0.25); color: #0D1B3E;
}
.mt-ops-act.good { border-color: #2e7d32; color: #1b5e20; background: rgba(46, 125, 50, 0.08); }
.mt-ops-act.warn { border-color: #a97a1a; color: #7a4a00; background: rgba(201, 168, 76, 0.14); }
.mt-ops-act.bad  { border-color: #b23b3b; color: #a12020; background: rgba(178, 59, 59, 0.08); }

/* A run that opened and never closed (#850). Amber, not red, and in the body face
   rather than the chip's Cinzel: it sits BESIDE a neutral "Deploy started" chip and
   qualifies it, so making it a second chip would read as a second event. The three
   things that produce it are not all faults — one of them is a deploy still running —
   which is why it says what is missing rather than what went wrong. */
.mt-ops-open {
  font-size: 11.5px; color: #7a4a00; white-space: nowrap; margin: 0 4px 0 2px;
  border-bottom: 1px dotted rgba(169, 122, 26, 0.7); cursor: help;
}

/* A line the reader could not parse is SHOWN, in full, marked — never dropped.
   A skipped line is invisible, and an operator reading a log during an incident
   would have no way to know the file held something the page decided not to
   render. Red-on-cream so it cannot be mistaken for an event. */
.mt-ops-raw {
  font-family: 'Courier New', monospace; font-size: 12.5px;
  color: #a12020; background: rgba(178, 59, 59, 0.06);
  word-break: break-all;
}
/* User detail modal — two-column label/value layout (caption left, value right,
   both aligned into clean columns). CSS grid: column 1 sizes to the widest
   label (max-content), column 2 takes the rest; rows baseline-aligned. Values
   may wrap freely here (the no-wrap rule is the table's, not the modal's), so a
   long email breaks within its column. */
.mt-um-list {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 20px;
  row-gap: 10px;
  align-items: baseline;
  margin: 0;
}.mt-um-list dt {
  font-family: 'Cinzel', serif; font-size: 12px; letter-spacing: 0.04em;
  text-transform: uppercase; color: #0D1B3E; font-weight: normal;
  text-align: left;
}.mt-um-list dd {
  font-family: 'Crimson Text', serif; font-size: 16px; color: #1C130A;
  margin: 0;
  /* Don't wrap a long value (e.g. email) — keep it on one line and let the
     value cell scroll horizontally instead, so the caption column never gets
     pushed and stays aligned/visible. min-width:0 lets the grid track shrink
     below the content's intrinsic width so overflow-x can actually scroll. */
  white-space: nowrap;
  overflow-x: auto;
  min-width: 0;
}/* Inline banner (email-change pending, etc.) */
.mt-auth-banner {
  background: #fdf6ec; border: 1px solid #e0a030; border-radius: 4px;
  padding: 8px 12px; margin-bottom: 14px;
  font-family: 'Crimson Text', serif; font-size: 16px; color: #7a4a00;
}.mt-auth-banner a { color: #7a4a00; text-decoration: underline; }/* Phone logo above card */
.mt-auth-logo { display: none; text-align: center; padding-top: 14px; }.mt-auth-logo img { width: 40px; height: 48px; object-fit: cover; object-position: center; }@media (max-width: 767px) and (orientation: portrait),
       (max-height: 599px) and (orientation: landscape) {body.mt-auth-page .mt-auth-logo { display: block; }}/* ── Login page only: phone landscape — proportional logo / frame split ──
   Placement algorithm: take the common width (container content box), split
   it between logo and frame in proportion to each element's own intrinsic
   width, then centre each element inside its allocated slot.

   CSS Grid fr units implement this directly — a track of `Nfr` receives
   N / Σfr of the available width. Intrinsic widths:
     • frame ≈ 480px  (its designed max-width)
     • logo  ≈ 191px  (centurion is 763×1200; bounded to the landscape
                        height ≈ calc(100dvh−50−24) ≈ 300px and
                        `contain`-scaled → width ≈ 300 × 763/1200 ≈ 191px)
   → columns 200fr 480fr  (logo ≈ 29%, frame ≈ 71% of the common width;
     the ~9px shift from the prior 600×900 logo is immaterial to the
     proportional split, so the track values are left unchanged).
   Each element is centred in its own track (logo image via background
   center/contain; card via justify-self:center). Login-only via
   body.mt-auth-login; placed after the shared landscape-small rules so it
   wins on equal specificity + source order. Portrait is untouched (this MQ
   matches landscape only).                                                 */
@media (max-height: 599px) and (orientation: landscape) {body.mt-auth-login .container {
    display: grid !important;
    grid-template-columns: 200fr 480fr;
    /* One row exactly the height of .container's content box (it has a
       definite height: the explicit calc(100dvh − 50px) set by the
       mobile-fold-mirror block, minus its own padding). minmax(0,1fr)
       stops content from expanding it. The card fits this row; the
       footer (sibling after .container) sits below the fold so the
       page is minimally scrollable → mobile URL-bar reclaim. */
    grid-template-rows: minmax(0, 1fr) !important;
    align-items: center !important;
    column-gap: 0 !important;
    /* The row is a bounded fit and auth-fit.js scales the visible card to
       it; hide overflow so a grid item whose border-box is still taller
       than its track (transform/negative-margin don't shrink that in
       grid) can't surface a scrollbar. Overrides the shared
       landscape-small `overflow-y:auto`. */
    overflow: hidden !important;
  }/* …UNLESS auth-fit could not actually fit the card (#806). The clip above
     is right while the DRAWN card fits its track — it exists only to suppress
     the residual border-box overflow that transform:scale cannot remove in
     grid. It is wrong when the card genuinely does not fit, which is every
     phone in landscape on `login`: the scale is floored at 0.4 and the
     leftover was cut off with no way to reach it (22px on a Galaxy S23, 28px
     on an S20 — the "← Home" link and both recovery links).
     auth-fit sets data-fit="ok" only when the height it drew fits, so the
     attribute's ABSENCE means "did not fit" — including when JS never ran at
     all, which is when a scroll matters most. Higher specificity than the
     rule above, so source order here is not load-bearing.

     align-items:start IS HALF THE FIX, not a tidy-up. A centred item that
     overflows overflows BOTH ways, and a scroll container cannot scroll above
     zero — so with the track still centred, `overflow-y:auto` recovered the
     recovery links at the bottom and left the "← Home" link above the top edge
     just as unreachable as before (measured: back-link reachable=false on
     every phone that clips). Anchoring to the start throws the whole overflow
     downward, where scrolling can reach it. It applies ONLY when the card did
     not fit, so a card that fits keeps its centred position exactly. */
  body.mt-auth-centered .container:not([data-fit="ok"]) {
    overflow-x: hidden !important;
    overflow-y: auto !important;
    align-items: start !important;
  }body.mt-auth-login .container::before {
    content: "";
    grid-column: 1;
    justify-self: stretch;           /* box = full logo slot width … */
    align-self: stretch;             /* … and full row height */
    min-height: 0;
    background: url('../img/logo-transparent.png?v=2') center center / contain no-repeat;
    /* image is contain-scaled inside the slot → fits the height, centred,
       never taller than the available area */
  }body.mt-auth-login .mt-auth-card {
    grid-column: 2;
    justify-self: center;            /* frame centred in its slot width */
    align-self: center;              /* natural compact height, centred */
    margin: 0 !important;
    width: 480px !important;
    max-width: 100% !important;      /* never exceed its slot */
    min-width: 0 !important;
  }}/* ── Centered auth cards: portrait on tall phones — decorative inscription ──
   Big phones in portrait leave wide empty bands above/below the centred
   card. Fill them with the site's gold-rule + centre-diamond motif (same
   visual language as .mt-tagline-block on aboutus.php). Rendered ABSOLUTELY
   inside .container so it has zero layout impact — the card stays exactly
   centred and the no-scroll bar cannot be broken. Gated to portrait phones
   ≥740px tall (iPhone 12/13/14/Pro Max, Pixel, etc.): small phones (SE/8,
   where the card already fills the space) and iPad/desktop/landscape are
   untouched. Single opt-in: body.mt-auth-centered (login, 2FA verify,
   account step-up, admin / tester "Access Denied"). Page-specific classes
   (.mt-auth-login / .mt-auth-2fa) keep their OTHER rules elsewhere.       */
.mt-auth-deco { display: none; }@media (max-width: 767px) and (orientation: portrait) and (min-height: 740px) {/* Bars are absolutely positioned → zero layout impact, so they can't
     fight the no-scroll auto-fit or cause a scroll. js/auth-fit.js
     measures the card's actual rendered rect (after any scaling) and
     sets each bar's `top` to the exact midpoint of the real empty band
     above / below the card. CSS only supplies the look + horizontal
     placement; the vertical `top` is JS-driven. */
  body.mt-auth-centered .container { position: relative; }body.mt-auth-centered .mt-auth-deco {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 14px;
    position: absolute;
    left: 8%;
    right: 8%;
    color: #C9A84C;
    font-size: 14px;
    line-height: 1;
    pointer-events: none;
  }body.mt-auth-centered .mt-auth-deco::before,
  body.mt-auth-centered .mt-auth-deco::after {
    content: "";
    flex: 1;
    height: 1px;
  }body.mt-auth-centered .mt-auth-deco::before {
    background: linear-gradient(to right, transparent, #C9A84C 80%);
  }body.mt-auth-centered .mt-auth-deco::after {
    background: linear-gradient(to left, transparent, #C9A84C 80%);
  }}/* ── Centered auth cards: iPad portrait (768–1023px) — centurion above card ──
   Same proportional algorithm as the phone-landscape view, rotated to
   vertical: the common dimension is .container's height, split into a logo
   row-slot and a frame row-slot in proportion to their intrinsic heights,
   each element centred inside its slot. .container has a definite height
   here (explicit calc(100dvh − 50px) set by the mobile-fold-mirror block,
   minus its own 12px padding), so fr row tracks resolve exactly. The card
   fits; the footer (sibling after .container) flows just below the fold so
   the page is minimally scrollable → mobile URL-bar reclaim, then auth-fit
   re-fits on the resize when 100dvh grows. Single opt-in: body.mt-auth-centered
   (login, 2FA verify, account step-up, admin / tester "Access Denied");
   placed after the shared tablet-portrait rules so it wins on equal
   specificity + source order. Other auth pages (and the tall account /
   admin / tester panels) keep the lone-card layout.

   grid-template-rows 2fr 3fr  →  logo ≈40%, frame ≈60% of the height.
   This is the single tuning knob (raise the first value to enlarge the
   logo). NB iPad Mini 6 is 744px wide → it falls in the phone-portrait MQ
   (≤767) and shows the gold-rule decoration instead; classic iPad Mini /
   Air / Pro portrait (≥768) get this centurion treatment.                  */
@media (min-width: 768px) and (max-width: 1023px) and (orientation: portrait) {body.mt-auth-centered .container {
    display: grid !important;
    grid-template-columns: 100% !important;
    grid-template-rows: 2fr 3fr !important;
    justify-items: center !important;
    /* Same as the landscape grid: bounded fit + auth-fit scales the
       visible card, so hide overflow to suppress the residual grid-track
       border-box overflow scrollbar. Overrides shared tablet-portrait
       `overflow-y:auto`. */
    overflow: hidden !important;
  }/* …unless auth-fit could not fit it — see the landscape grid block (#806),
     including why align-items:start is needed for the scroll to reach the TOP
     of an overflowing card. Tablets measured clean on every page in both
     orientations, so here this is the invariant being made true everywhere
     rather than a fix for an observed break. */
  body.mt-auth-centered .container:not([data-fit="ok"]) {
    overflow-x: hidden !important;
    overflow-y: auto !important;
    align-items: start !important;
  }body.mt-auth-centered .container::before {
    content: "";
    grid-row: 1;
    align-self: stretch;             /* fill the logo slot height … */
    justify-self: stretch;           /* … and full width */
    min-height: 0;
    background: url('../img/logo-transparent.png?v=2') center center / contain no-repeat;
    /* image contain-scaled inside the slot → fits height, centred */
  }/* A TALL CARD DOES NOT SHARE THE SCREEN WITH THE CREST. The 2fr/3fr split above
     is right for a short card — login gets a generous crest above it and the card
     sits in the lower slot. register's card is 833px here, more than its 3fr
     share, so the grid squeezes the logo row down to whatever is left: measured
     40px, a postage-stamp centurion that reads as a rendering fault rather than a
     mark. Spend the whole container on the card instead and let the crest go —
     it is decoration, and the form is the page. */
  body.mt-auth-tall.mt-auth-centered .container {
    grid-template-rows: 0 1fr !important;
  }body.mt-auth-tall.mt-auth-centered .container::before {
    display: none !important;
  }body.mt-auth-centered .mt-auth-card {
    grid-row: 2;
    align-self: center;              /* natural height, centred in slot */
    justify-self: center;            /* designed-width card, centred */
    margin: 0 !important;
  }}/* ── An auth card that could NOT be fitted anchors to the top (#806) ───────
   The companion to `.container:not([data-fit="ok"]) { overflow-y: auto }` in
   the two blocks above, and without it that scroll is half useless: a centred
   item which overflows overflows BOTH ways, and no scroll container scrolls
   above zero — so the recovery links at the bottom became reachable while the
   "← Home" link stayed stranded above the top edge (measured on every phone
   that clips: back-link reachable=false).

   It has to target the CARD, not the container. `align-items: start` on the
   container loses to the card's own `align-self: center`, and the card is
   additionally centred by `margin: auto`, so both have to be overridden here;
   `transform-origin: top center` then makes the drawn card start where its
   layout box starts, so the scroll range and what you can see agree.

   ONE top-level rule rather than a copy inside each block: the selector is
   specific enough (0,4,1) to win wherever it applies, and it can only apply
   when auth-fit has NOT set data-fit="ok" — i.e. exactly the case that is
   currently broken. A card that fits keeps its centred position untouched, on
   every viewport, which is the whole point. It also covers JS-off, where no
   attribute is ever set and the unscaled card must simply scroll. */
body.mt-auth-centered .container:not([data-fit="ok"]) .mt-auth-card {
  align-self: start !important;
  justify-self: center !important;
  margin-top: 0 !important;
  margin-bottom: 0 !important;
  transform-origin: top center !important;
}/* ── Landscape-small auth cards SCROLL THE PAGE instead of shrinking (#806) ──
   Phone landscape is the one shape where the card genuinely does not fit, and
   the fitter's answer was to scale it — on a Galaxy S23 to 0.66, which against
   this block's already-compact 18px heading renders at about 12px, half of
   portrait's 22px. Squinting at a sign-in form is a worse outcome than a swipe,
   and tuning per device is not a thing anyone can maintain.

   So here the strip stops being a fixed box: the layout is content-height and
   the DOCUMENT scrolls. auth-fit then measures zero overflow and scales
   nothing, so the card keeps this block's own compact type at full size — no
   transform, no data-fit machinery, nothing to clip.

   THE SCROLL MUST REACH THE DOCUMENT, or the URL bar never retracts. Two
   things are load-bearing for that, and both were learned the hard way:
     · a viewport-height floor on .mt-layout — NOT calc(100vh - 50px). Stacked on
       body's 50px navbar padding the document is taller than the visible
       viewport, so a scroll always exists for the first drag to act on. This is
       the same trick the non-puzzle pages use, and it is why the earlier
       `min-height: calc(100vh - 50px)` neutraliser is overridden here. It is
       STATIC `100vh` rather than `100dvh` — see the rule itself for why, and for
       why no local measurement can tell the two apart.
     · overflow VISIBLE on .container. A drag starting inside a nested
       overflow:auto element is given to that element, and on iOS a nested
       scroller with nothing to scroll does not reliably chain to the document
       — which is exactly how the bar stopped hiding on a real iPhone SE. The
       :not([data-fit="ok"]) hatch is overridden for the same reason: in this
       route nothing may become an inner scroller.

   The centurion keeps its place beside the card: the grid stays, its row just
   grows with the content instead of being clamped to one viewport-high track. */
@media (max-height: 599px) and (orientation: landscape) {
  /* The route, published for auth-fit.js to read rather than duplicated there
     as a matchMedia string. One breakpoint, one place; the JS asks CSS which
     route it is in and cannot drift from it. */
  body.mt-auth-centered { --mt-auth-route: scroll; }

  /* THE LAYOUT CARRIES THE SCROLL; THE PANEL CARRIES THE PAGE (#806 follow-up).
     Two floors, deliberately 50px apart, and the gap between them IS the scroll
     travel the URL bar needs:

       .mt-layout       min-height: 100vh           -> on body's 50px navbar
                                                       padding, the document is
                                                       100vh + 50 -> overflow
                                                       always, and 100vh is the
                                                       BAR-COLLAPSED height, so
                                                       the travel is widest while
                                                       the bar is still up.
       .mt-panel-center min-height: calc(100dvh - 50px)
                                                    -> starts at y=50 and ends
                                                       exactly at the viewport
                                                       bottom, so the contact
                                                       band and the legal bar are
                                                       ON SCREEN at rest.

     The 50px the layout has spare sits BELOW the panel, off screen, and is never
     looked at — it exists only so the first drag has somewhere to go. That is the
     whole trick: empty space that provides travel without ever being seen.

     Both were `min-height: 100dvh` before, which made the panel as tall as the
     layout; the card region then grew into the whole of it and pushed the band
     and the footer 50px below the fold. Measured at 900x500 and 1200x580 (card
     top-anchored, 139px / 219px of dead space, footer off screen) — the
     regression that pulled #807 from the 1.0.1 release.

     align-items:flex-start on the layout is load-bearing: the default `stretch`
     would grow the panel back to the layout's own 100dvh and undo the split. */
  body.mt-auth-centered .mt-layout {
    height: auto !important;
    /* 100vh, NOT 100dvh (#806). dvh tracks the URL bar CONTINUOUSLY, so a layout
       floored on it grows underneath the very gesture meant to retract the bar:
       the 50px of travel is restored as fast as it is consumed and Safari never
       commits the collapse. Reported on a real iPhone SE in landscape, where
       PORTRAIT works — and portrait is floored in static `100vh`, which is the
       asymmetry that named this. Static is what the rest of this file already
       uses for exactly this job ("sized once to the final state — no reflow on
       collapse"); this route was the one place reaching for dvh.

       WHAT IT BUYS: with the bar SHOWN the layout is 375 against a 331 window,
       so the document is 425 and the travel is 94 rather than 50 — a longer,
       unambiguous drag that does not shrink as it is made. With the bar HIDDEN
       both agree at 375 and the residual is the same 50 the route has always
       wanted. THE COST is that the unseen band below the panel (which keeps its
       calc(100dvh - 50px) floor, so it still ends exactly at the window bottom)
       is 94 rather than 50 while the bar is up. Nothing is drawn in it either
       way; it exists only so the first drag has somewhere to go.

       DEVICE-VERIFIED, NOT DESK-VERIFIED: an iframe has no URL bar, so 100vh and
       100dvh are identical in every headless measurement — the local suites
       cannot tell these two apart and will stay green whichever is written. */
    min-height: 100vh !important;
    overflow: visible !important;
    align-items: flex-start !important;
  }
  /* THE POST-PAINT GROWTH (#806) -- rationale in js/auth-fit.js under 'grow the
     document AFTER first paint'. The class lands two frames after load, so this
     growth arrives post-paint where the reverted 08f08bc reserve arrived at
     parse; that difference is the whole of why Chrome Android retracts its URL
     bar here and did not before. Confirmed on a Samsung S20 FE. Remove this and
     the class together, and tests/auth-fit.html will say so. */
  body.mt-auth-centered.mt-auth-settled .mt-layout {
    min-height: calc(100vh + 24px) !important;
  }
  body.mt-auth-centered .mt-panel-center {
    height: auto !important;
    min-height: calc(100dvh - 50px) !important;
    overflow: visible !important;
  }
  /* GROW, NEVER SHRINK (flex: 1 0 auto). Growing is what centres the card in the
     strip above the band instead of stacking it at the top; never shrinking is
     what lets a card taller than the strip push the document out and scroll,
     which is the [data-auth-scroll] arrangement below.

     NOT GATED ON data-fit. In a content-height route auth-fit always reports
     `ok` (need == avail), so every rule hung off :not([data-fit="ok"]) here is
     dead code — that is exactly how the centring compensation in #807 came to
     never run. This route has to be correct with the JS doing nothing, which is
     also the JS-off case. */
  body.mt-auth-centered .container {
    flex: 1 0 auto !important;
    height: auto !important;
    min-height: 0 !important;
    overflow: visible !important;
    /* SAFE, and that keyword is the whole point. A centred item that overflows
       overflows BOTH ways, and no scroll container scrolls above zero — the
       #806 report's "← Home" stranded above the top edge. `safe` says: centre
       while there is room, fall back to start when there is not, decided by the
       layout engine every frame. The alternative was a JS attribute, and one is
       what #807 shipped: a rule gated on data-auth-scroll that a startup race
       could leave unset, dropping the copyright bar 164px below the fold on
       forgot-password.php in roughly one load in four. One declaration replaces
       the flag for both container shapes — this is the block axis for the login
       grid and the (never-overflowing) inline axis for the flex column, so it is
       correct in one and inert in the other. */
    align-items: safe center !important;
    justify-content: safe center !important;
    /* Same density argument as the band above: the shared block's 8px was sized
       for a clipped box that had to keep the card off its own edges. The card
       carries its own 10px padding and is centred in a strip it no longer
       fills, so this is pure spacing — and it was the last 2px keeping the
       copyright bar off the fold on an SE at 331. */
    /* PLUS THE NAVBAR'S OVERHANG. body reserves padding-top: 50px, but the bar
       measures 53 — 50px of min-height plus a 3px gold border-bottom — so the
       container's first 3px sit BEHIND it. Centring in the container therefore
       looked 3px high: measured at 667x375, 26px of visible gap above against
       29 below, and 34/37 at 844x390. Adding the overhang to the top padding
       moves the content box clear of the bar, and the centring lands where the
       eye reads it (27.5/27.5 at 667x375).
       Taken from --mt-auth-nav-h, which js/auth-fit.js already measures, rather
       than from a literal 53: the bar is 53 because of a border it could lose,
       and the fallback keeps today's behaviour with no JS. */
    padding-top: calc(4px + var(--mt-auth-nav-h, 50px) - 50px) !important;
    padding-bottom: 4px !important;
  }
  /* Grown by content, not clamped to the viewport — this is what lets the card
     stand at full size and the illustration stay beside it. */
  body.mt-auth-login .container {
    grid-template-rows: auto !important;
    overflow: visible !important;
  }
  /* THE CARD WAS NOT CENTRED, AND THE CULPRIT IS BOOTSTRAP'S CLEARFIX (#806).
     .container carries `::after { content: " "; display: table }`, which is
     inert in a block box — but this container is a GRID, and grid blockifies
     a table box into a grid ITEM. So the clearfix took a second row track
     (~25px, one line box at the container's 18px font) directly under the
     card: the card centred correctly inside row 1, and row 1 was not the
     container. Measured at 667x375 the gaps were 14 above / 42 below, and at
     844x390 17 / 53 — the difference being the ghost row each time.

     Killing the pseudo-element rather than the grid, for two reasons: the grid
     is what places the centurion beside the card (::before is the crest, and
     must survive), and the clearfix guards against floats this card has none
     of. Same treatment .row::before/::after already gets where a Bootstrap
     pseudo-element would otherwise occupy a grid cell (the puzzles gallery).
     ::after ONLY — ::before is the illustration. */
  body.mt-auth-centered .container::after { content: none !important; }
  /* Nothing is scaled here, so the card is placed by the ordinary centring
     rules; undo the start-anchoring the clipped route needs. UNCONDITIONAL for
     the reason given above: the :not([data-fit="ok"]) this used to carry can
     never match in this route, so the card kept whatever the top-level
     start-anchor rule gave it. */
  /* Margins ZERO, not auto: an auto margin does the centring itself and would
     override the container's `safe` fallback, reintroducing the upward overflow
     it exists to prevent. The container owns the alignment now. */
  body.mt-auth-centered .container .mt-auth-card {
    align-self: safe center !important;
    margin-top: 0 !important;
    /* 2px, NOT 0 — THE SHADOW IS NOT CENTRED (#806). The card carries
       box-shadow: 0 2px 10px rgba(0,0,0,.12), so it paints 2-3px BELOW its
       border and nothing above it. Centring the border box therefore leaves
       the card looking low: measured off the rendered pixels at 667x375, the
       gaps were 32 above the border and 33 below it — symmetric — while the
       shadow reached to within 30 of the band. A 2px bottom margin is inside
       the centred outer box, so it lifts the border box by ONE pixel and the
       two optical gaps meet. Not `auto`, which would do the centring itself
       and override the container's `safe` fallback. */
    margin-bottom: 2px !important;
  }
  /* The band and the bar are ordinary flow at the end of the panel, and the
     panel ends at the viewport bottom — so they are simply visible. No
     margin-top:auto: the container grows now, so there is no leftover for an
     auto margin to eat, and leaving it in would only fight the flex. */
  /* FIXED, AND STICKY WAS TRIED FIRST — the same correction portrait already
     carries, for the same reason. A sticky element is clamped inside its
     containing block, and this bar's is the CENTRE PANEL, which ends exactly at
     the viewport bottom AT REST but 50px before the document does: that 50 is
     the URL-bar travel, and it lives in the layout below the panel. So the bar
     sat correctly until the reader scrolled and then rose with the page —
     measured on every phone landscape size, 50px off the bottom edge at maximum
     scroll (667x331, 749x390, 843x412, 732x360, 780x303, 873x430, all delta=50).
     The comment this replaces called sticky "a no-op while the panel ends at the
     viewport bottom", which is true only of the resting state; the travel is the
     whole point of the route and it is precisely when the bar let go. Fixed has
     no containing block to run out of.

     Unconditional still — it used to be gated on [data-auth-scroll] as well, so
     whenever that flag lost its race the bar simply left the screen. */
  body.mt-auth-centered .mt-content-footer {
    flex: 0 0 auto !important;
    position: fixed !important;
    left: 0 !important;
    right: 0 !important;
    bottom: 0 !important;
    z-index: 5;
  }
  /* A fixed bar reserves nothing, so the panel pads by the bar's MEASURED height
     — it wraps to two lines at 360-390px and the version chip's channel suffix
     moves it again (#374), so a constant is wrong on some phone in some channel.
     js/auth-fit.js publishes it; the fallback only has to be sane. Inside
     min-height under border-box, so the panel still ends at the viewport bottom
     and it is the CONTENT that stops 42px earlier — which is what keeps the
     contact band clear of the bar. */
  body.mt-auth-centered .mt-panel-center {
    padding-bottom: var(--mt-auth-footer-h, 42px) !important;
  }

  /* ── Card too tall: crest and footer hold still, only the card travels ──
     Gated on [data-auth-scroll], which auth-fit.js sets ONLY when the card at
     its natural size exceeds the strip between navbar and footer. A card that
     fits keeps the arrangement above exactly as it was.

     STICKY, NOT FIXED, and that is the load-bearing choice. `fixed` would take
     the crest out of the grid, collapsing the 200fr/480fr split and handing the
     card the full width — the card would change size the moment it got long
     enough to scroll. Sticky leaves both in flow, so the columns stand and only
     the paint position moves. It also keeps the DOCUMENT as the scroller, which
     is the whole reason this route exists: an inner scroll region here would
     swallow the drag that retracts the URL bar. */
  /* The container's own bottom reserve is GONE, and its absence is the point: the
     panel reserves the bar's height now, so the container already ends above it
     and a second copy of the same clearance would only push the contact band a
     bar's height further down. */
  /* The card's own anchoring and the sticky bar both moved above, out of this
     attribute's reach. What is left here is only what genuinely needs to know
     that the card is taller than the strip: the padding that clears the pinned
     bar, and the crest. Both are cosmetic — get them wrong and the page is
     still usable, which is the property the structural rules now have too. */
  /* The crest holds its place on screen. FIXED, not sticky — sticky was tried
     and measurably fails here: a sticky item is constrained to its containing
     block, which for a grid item is its GRID AREA, i.e. one row as tall as the
     card. The document scrolls further than that (the footer and the 100dvh
     floor sit below the grid), so the crest pinned for part of the travel and
     then released, ending up scrolled under the navbar — which is exactly what
     the page looked like.

     Fixed does NOT collapse the 200fr/480fr split, which was the reason sticky
     was reached for first. `fr` tracks are sized from the container's available
     space, not from their contents, so both columns keep their widths with the
     crest out of flow and the card stays put in column 2. (Verified: the card's
     box is unchanged between the two.)

     Its left/width are MEASURED from the grid's own used track sizes rather
     than recomputed from the 200/680 ratio, so the one place the proportion is
     written stays the stylesheet. Height is the visible strip rather than the
     row, or `contain` would centre the image down the whole scrollable height
     instead of in the window being looked at. */
  /* The band gets the same compaction the card already gets in this media
     query (10px padding, 18px heading, 8px field rows). At 47px it was 14% of a
     331px-tall viewport spent on one line of chrome, and it was the 13px that
     left the copyright bar hanging below the fold on an iPhone SE with the URL
     bar showing. Density, not a device tweak: every other box in this query is
     already tighter than its portrait self. */
  body.mt-auth-centered .mt-privacy-strip {
    padding-top: 5px !important;
    padding-bottom: 6px !important;
  }

  /* ── No room for the centurion: do not draw one ───────────────────────────
     THE THRESHOLD IS THE GRID'S OWN NUMBER, not a device. `200fr 480fr` says
     the crest is designed at 200px beside a 480px card, and the card column
     never yields (the card is width:480, so its track has a 480px min) — so as
     the window narrows it is the CREST that is squeezed, and only the crest.
     Measured down the width range at 331 tall: the card is 480 at every width
     from 620 up, while the crest falls 200 -> 180 -> 160 -> 147 (iPhone SE, 667)
     -> 100 (620). It reaches its designed 200px at a 680px content box, which is
     a 720px viewport once the container's 2x20px padding is counted.

     Below that the page is spending a fifth of a landscape phone's width on a
     figure too narrow to read, while the card beside it sits at its minimum
     with the fields cramped. So the crest goes and the card takes the width.
     iPhone SE landscape (667) is comfortably inside that: 147px of crest for a
     figure drawn `contain` in a 281px-tall slot. */
  body.mt-auth-login .mt-auth-card { width: 480px !important; }

  /* ── The login card, repackaged for a landscape phone (#806) ──────────────
     The card was a single column — back link, heading, fields, button, links,
     each on its own row — and on a landscape phone that column is taller than
     the strip, which is what the scroll route below exists to rescue. Laying it
     out for the shape it is actually in removes the need to be rescued: the
     rows the viewport has to spare are horizontal ones.

     Two moves, and both pay for themselves in height rather than in width:

       · "← Home" rides the heading's row instead of owning one. It is absolutely
         positioned, so it costs the flow nothing at all; the heading is centred
         and the link is short, so they do not meet.
       · Sign In and the recovery links stand BESIDE the fields rather than under
         them. The fields column is two rows tall and the actions column is two
         rows tall, so the pair costs what the taller of them costs — not the sum.

     Measured on an iPhone SE in landscape (667x375, 331 visible with the URL bar
     showing): the card goes from taller than the strip — Sign In half cut, both
     recovery links off screen — to fitting inside it with room over.

     Scoped to this one media query. Everywhere else the wrappers are plain
     blocks and the card is the single column it has always been. */
  /* THE RECIPE IS NOT LOGIN'S ALONE (#806). It was written for login and keyed
     on body.mt-auth-login; the three recovery pages have the same shape — a Back
     link, a heading, one or two fields, one button, a links row — and the same
     problem, so they now share it through an opt-in body class rather than three
     copies of these selectors. A page opts in by adding mt-auth-compact and by
     supplying the two wrappers (.mt-auth-fields / .mt-auth-actions) plus, if it
     has links to spare, $mtAuthBandLinks. Everything below is generic; what stays
     on body.mt-auth-login is only what is genuinely login's — its 480px card and
     the centurion beside it. */
  body.mt-auth-compact .mt-auth-card { position: relative; }
  body.mt-auth-compact .mt-auth-back {
    position: absolute;
    top: 10px;
    left: 16px;
    margin: 0 !important;
  }
  body.mt-auth-compact .mt-auth-card form {
    display: flex;
    align-items: flex-start;
    gap: 18px;
  }
  /* min-width:0 or the fields refuse to shrink below their content and push the
     actions column off the card on the narrowest landscape phones. */
  body.mt-auth-compact .mt-auth-fields { flex: 1 1 auto; min-width: 0; }
  /* NARROW AND FULL-HEIGHT (#806): the column holds one button now that the
     recovery links live in the band, so it is sized to the WORD rather than to
     a pair of links it no longer carries (172px -> 124px), and the button
     spans both credential rows instead of floating in the middle of a column
     of dead space. The width it gives up goes to the fields beside it, which
     is where it is worth more — those are the inputs a thumb has to hit.

     STRETCH, NOT A HEIGHT. align-self: stretch makes the column exactly as
     tall as the fields column, whatever that turns out to be, so the button
     tracks the two rows through every font/padding step the compression
     ladder takes. A declared height would have to be re-derived at each. */
  body.mt-auth-compact .mt-auth-actions {
    flex: 0 0 auto;
    width: 124px;
    align-self: stretch;
    display: flex;
  }
  /* The button's inline wrapper is a plain <div> from the stacked layout; here
     it is the flex item that has to carry the stretch down to the button, and
     its 14px inline margin-bottom (written to clear the links that used to sit
     under it) would otherwise leave the button short by exactly that much. */
  body.mt-auth-compact .mt-auth-actions > div:first-child {
    flex: 1;
    display: flex;
    margin: 0 !important;
  }
  body.mt-auth-compact .mt-auth-actions .mt-auth-btn {
    flex: 1;
    width: 100%;
    margin-top: 0;
  }
  /* THE LAST FIELD'S BOTTOM MARGIN IS PART OF THE COLUMN, so stretching to the
     fields column would run the button ~15px past the Password input it is
     supposed to line up with. Zeroed here only — the card's own padding still
     separates the form from what follows. */
  body.mt-auth-compact .mt-auth-fields .form-group:last-child { margin-bottom: 0; }
  /* THE SUBTITLE GOES, AND THIS IS NOT THE ARGUMENT THAT WAS JUST REVERSED FOR
     THE HINT. Hiding the hint bought nothing — it sits inside a slot reserved at
     36px whether it renders or not. .mt-auth-sub has no reserve: it is its own
     block, and on forgot-password it is the whole difference between a card that
     fits the SE with its URL bar showing and one that does not (237px against
     login's 181, and 331 - 53 - band - bar leaves 281).

     What it costs is redundancy rather than information. "Enter your user ID and
     the email registered to it — we'll send a reset link to that email" is said
     again by two labelled fields (User ID, Email Address) and a button reading
     Send Reset Link; the same holds on the other two, whose headings name the
     task. The reassurance that is NOT said elsewhere — "We'll never reveal
     whether an account exists" — is the hint, and the hint stays. */
  body.mt-auth-compact .mt-auth-sub { display: none; }
  /* 110px, NOT the shared 90 (#806). Measured at the landscape label font
     (700 13px Cinzel) the labels in use are User ID 57, Password 74, Email 42
     and Email Address 108 — so 90 wrapped "Email Address" onto two lines and
     left the second field row taller than the first on both recovery pages.
     The 20px comes out of the input, which has it to spare beside a 124px
     button; widening it site-wide would move every auth page in landscape,
     including ones this change has not measured. */
  body.mt-auth-compact .mt-auth-card .control-label { flex-basis: 110px; }

  body.mt-auth-login[data-auth-scroll] .container::before {
    position: fixed;
    left: var(--mt-auth-crest-l, 0px);
    width: var(--mt-auth-crest-w, 29%);
    top: var(--mt-auth-nav-h, 50px);
    height: calc(100dvh - var(--mt-auth-nav-h, 50px) - var(--mt-auth-tail-h, 80px));
  }
}
/* The narrow half of that decision. Its own media query rather than a
   :not() inside the block above, because the block is keyed on height and this
   is keyed on width — two independent questions, and stacking them into one
   selector would make each unreadable. The card drops to one full-width column;
   nothing else about the route changes, so the two floors, the sticky tail and
   the scroll travel all behave exactly as they do beside a crest.

   819, RAISED FROM 719. The first cut asked only "can the crest have its
   designed 200px?", and 720 is the honest answer to that question — but it is
   the wrong question, because the crest is not the only thing competing. At the
   two Samsung landscape widths (S23 780, S20 800) the crest gets 218 and 224,
   its bare minimum, and pays for it out of the card: measured, the card stayed
   pinned at 480 with 259-272px of empty track to its LEFT and 41-48 to its
   right, while the page scrolled with the URL bar showing. Those widths get the
   split layout below instead, which is worth more than a 218px illustration. */
@media (max-height: 599px) and (orientation: landscape) and (max-width: 819px) {
  body.mt-auth-login .container { grid-template-columns: 1fr !important; }
  body.mt-auth-login .container::before { display: none !important; }
  body.mt-auth-login .mt-auth-card {
    grid-column: 1 !important;
    width: 100% !important;
    max-width: 100% !important;
  }
}
/* ── Landscape-small, 820px and up: the card takes the room it has ────────
   The container is a proportional grid (200fr 480fr), so above the threshold
   the card's TRACK grows — 567 at 844, 618 at 915, 630 at 932 — while the card
   itself stayed pinned at `width: 480px` and floated in it, leaving 41-95px of
   dead track on its right at every one of those sizes. Filling the track hands
   that width to the fields, which is where it is worth something on a phone.
   No cap: at its widest this is 630, which is what the sub-720 route already
   gives an iPhone SE (627) and reads correctly there. */
@media (max-height: 599px) and (orientation: landscape) and (min-width: 820px) {
  body.mt-auth-compact .mt-auth-card {
    width: 100% !important;
    /* CAPPED, because only login has a track to be capped by. Its container is
       the 200fr/480fr crest grid, so `100%` there is the card's own column —
       568 at 844, 630 at 932. The recovery pages have no crest and therefore
       one full-width column, where the same rule measured 804 and 892: a
       single-field form stretched across an entire phone in landscape. 630 is
       login's own widest, so the cap is a number the layout already produces
       rather than one invented for these pages. */
    max-width: 630px !important;
  }
}
/* ── Landscape-small, 720-819px: card left, page chrome stacked right ─────
   THE HEIGHT IS THE PROBLEM AT THESE WIDTHS, AND THE WIDTH IS THE SPARE. The
   centre panel is a flex COLUMN — card, then the privacy/contact band, then the
   legal+version bar — so the two strips cost 78px of height that a 780x303 or
   800x303 viewport (either Samsung with its URL bar showing) does not have:
   measured, the panel needed 310 in a 253 box and the page scrolled. Meanwhile
   the same viewport had 259-272px of empty track beside the card.

   So the panel becomes a two-column grid for this band only: the card takes the
   whole left column at full width, and the two strips stack in a right column
   that is as wide as its content. The height they were costing goes back to the
   card, and the width nobody was using pays for them.

   The crest is already gone here (the block above now reaches 819), which is
   what makes the left column worth having.

   .mt-center-title is explicitly hidden rather than left to its zero height: it
   is a real element on this page and would otherwise auto-place into a grid
   cell. The two <script> children cannot — they are display:none — but the
   title is not, and a grid does not care that a box measures 0. */
@media (max-height: 599px) and (orientation: landscape) and (min-width: 720px) and (max-width: 819px) {
  /* ── ONLY WHEN THE CARD FITS (#806) ───────────────────────────────────────
     [data-auth-beside] is js/auth-fit.js's answer to one question: is the card
     shorter than the room between the navbar and the legal bar? Beside a card
     that FITS, this column is the whole point — it spends width the page has on
     chrome the page would otherwise have to spend height on. Beside a card that
     does NOT fit it is the opposite: register at 780x279 is 564px tall against
     184px of room, so the contact links were stretched down a 577px column
     whose lower two thirds are below the fold, keeping station beside a card
     that is itself running off the screen.

     Ten of the eighteen auth cards are over that line at 780x279 (register 564,
     reactivate 583, accept-invitation 542, login-verify 397, change-password
     388, reset-password 383, edit-profile 357, delete-account 327, change-email
     316, confirm-identity 279) against seven that fit (login and forgot-password
     181, twofactor-setup 181, forgot-user-id 166, logout 160, verify-email 153,
     resend-verification 144) — so this is not a register defect, it is the whole
     class, and register only made it visible by becoming a centred card.

     THE BAR COUNTS, THE BAND DOES NOT. The legal bar is row 2 of this grid and
     is drawn across the bottom either way, so it is room the card does not have;
     the band is the thing being placed, so counting it would make the answer
     depend on the layout the answer chooses — which is a loop, not a test.

     A POSITIVE ATTRIBUTE, and the polarity is the whole safety argument. No CSS
     can compare a card's height to the viewport, so this layout only exists when
     something has measured; if the measurement never happens — JS off, or the
     startup race #807 records — every card gets the ordinary full-width band
     below it, which is what every landscape width outside this 100px window
     already renders. Wrong in the harmless direction. The reverse (default on,
     switched off when too tall) fails to today's bug on the same race. */
  body.mt-auth-centered .mt-center-title { display: none !important; }
  body.mt-auth-centered[data-auth-beside] .mt-panel-center {
    display: grid !important;
    grid-template-columns: 1fr auto;
    grid-template-rows: 1fr auto;
    column-gap: 10px;
  }
  body.mt-auth-centered[data-auth-beside] .mt-panel-center > .container {
    grid-column: 1;
    grid-row: 1;
  }
  /* Both strips lose their full-width chrome — a border-top and a centred row
     read as "bottom of the page", and here they are a side column. */
  body.mt-auth-centered[data-auth-beside] .mt-privacy-strip {
    grid-column: 2;
    grid-row: 1;
    flex-direction: column;
    flex-wrap: nowrap;
    /* CENTRED ON BOTH AXES. Vertically it sits beside the card rather than
       pushed down onto the legal line; horizontally it is centred in its own
       column instead of ragged against the right edge — as a column of four
       short lines of differing length, a centred stack reads as one block
       where right-alignment read as an overflow pushed to the margin. */
    align-items: center;
    justify-content: center;
    text-align: center;
    /* Room to breathe: these were 6 and 4, inherited from a horizontal band
       where the gap only had to separate items on one line. Stacked, the gap
       is the line spacing, and at 0.84em type four lines at 6px read as one
       paragraph rather than four separate things to act on. */
    row-gap: 18px;
    border-top: 0;
    padding: 0 16px;
  }
  body.mt-auth-centered[data-auth-beside] .mt-band-contact { flex-basis: auto; }
  body.mt-auth-centered[data-auth-beside] .mt-band-auth {
    flex-direction: column;
    align-items: center;
    gap: 14px;
  }
  /* DECORATION — THE SITE'S OWN AUTH ORNAMENT, NOT A NEW ONE. Every auth page
     already ships <div class="mt-auth-deco" aria-hidden>&#9670;</div> above and
     below the card: a gold diamond flanked by rules that fade to transparent.
     Those elements cannot be borrowed here — they live inside .container, which
     IS the left column, and are absolutely positioned against it — so the same
     device is drawn on the band's own ::before / ::after.

     ONE GRADIENT DOES THE WHOLE RULE. It fades in from transparent, breaks in
     the middle, and fades out, and the diamond sits in the break; that is the
     rule-gap-diamond-gap-rule shape .mt-auth-deco builds from two pseudo
     elements, in one, because here the pseudo elements are needed for the two
     ORNAMENTS rather than for the two halves of one. Same glyph, same #C9A84C,
     same fade — the column reads as part of the page instead of as leftover
     text beside it.

     They are flex items in the column, so the row-gap above spaces them like
     any other line, and aria-hidden is unnecessary: CSS content on a pseudo
     element is decorative and is not exposed as text by assistive tech. */
  body.mt-auth-centered[data-auth-beside] .mt-privacy-strip::before,
  body.mt-auth-centered[data-auth-beside] .mt-privacy-strip::after {
    content: "◆";   /* the same BLACK DIAMOND .mt-auth-deco renders */
    align-self: stretch;
    text-align: center;
    color: #C9A84C;
    font-size: 9px;
    line-height: 1;
    background-image: linear-gradient(to right,
      transparent 0%, #C9A84C 34%, transparent 40%,
      transparent 60%, #C9A84C 66%, transparent 100%);
    background-size: 100% 1px;
    background-position: center;
    background-repeat: no-repeat;
  }
  /* The dot separates two links ON A LINE; stacked it is a line of its own
     saying nothing. Hidden rather than removed from the string, because the
     same string renders horizontally in the band at every other width — which
     is why it is wrapped in an element at all. */
  body.mt-auth-centered[data-auth-beside] .mt-band-sep { display: none; }
  /* THE BLACK BAR STAYS WHERE IT IS — full width, across the bottom, styled
     exactly as on every other route. It is the page's bottom chrome, and it
     costs 42px the panel can afford: the band alone was the difference. (At
     800x303 the flex column needed 310 in a 253 box; the container's 232 was
     mostly absorbed slack, so with the band beside the card the real content
     is the card's ~189 plus this bar.) Only its PLACEMENT is set here — no
     colours, no direction, no position: it keeps its own rules, including the
     sticky bottom, which in a bottom row is simply inert. */
  body.mt-auth-centered[data-auth-beside] .mt-content-footer {
    grid-column: 1 / -1;
    grid-row: 2;
  }
}
/* ── Touch devices: an auth page must be able to lose its URL bar ─────────
   THE SAME DEFECT #806 FIXED FOR PHONE LANDSCAPE, MEASURED EVERYWHERE ELSE A
   FINGER TOUCHES THE SCREEN. A document exactly 100dvh tall has no overflow,
   so the browser never retracts its address bar, so the visible height stays
   at its smallest — and on an auth page it is `calc(100dvh - 50px)` plus the
   body's 50px navbar reserve, i.e. exactly the viewport. Measured travel
   (scrollHeight minus viewport) was ZERO on phone portrait, iPad Mini/Pro
   portrait, and every iPad landscape size.

   The two floors are the whole fix: the LAYOUT gets 100dvh while the PANEL
   keeps calc(100dvh - 50px), so the panel still ends exactly at the viewport
   bottom and the 50px of travel sits below it, unseen and unscrolled-to until
   the reader swipes. align-items: flex-start is what stops the panel
   stretching into that 50px — without it the card would centre in a box 50px
   taller and sit visibly low.

   TOUCH-GATED, because a desktop has no bar to retract: adding travel there
   would only add a scrollbar onto empty space. The landscape half keeps
   min-height: 600px so phone LANDSCAPE stays out — it has its own block above
   and a different layout — while portrait is taken at any height, because a
   short phone portrait (an iPhone SE is 375x573 with its bar showing) measured
   travel 0 just as the tablets did.

   THE GATE IS body[data-coarse], NOT THE MEDIA FEATURE ITSELF, and that is a
   reviewability decision rather than a styling one. includes/header.php sets
   the attribute from `(hover: none) and (pointer: coarse)`, so the meaning is
   identical on a real device — but no desktop browser can be made to report
   that feature, which left debug-mobile.php (the tool every layout change here
   is reviewed in) silently showing the DESKTOP layout while claiming to show
   an iPad. As an attribute, the tool sets it from the SELECTED DEVICE and these
   exact rules apply, un-duplicated. No JS means no attribute means today's
   behaviour, which is the safe direction. */
@media (orientation: portrait), (orientation: landscape) and (min-height: 600px) {
  /* PORTRAIT AT ANY HEIGHT. The gate was min-height: 600px, chosen to keep phone
     LANDSCAPE out (it has its own block), but that also excluded phone PORTRAIT
     on a short screen — an iPhone SE is 375x573 with its URL bar showing, and
     measured travel there was 0. Orientation says what was meant. */
  /* .mt-auth-PAGE, not .mt-auth-centered (#806). Nothing below is about a card
     being centred — it is about a three-column page whose document scrolls, which
     is just as true of a LONG auth page that never takes the centred class.
     Measured on reactivate at 1024x1366, the one portrait viewport where the side
     panels are drawn: align-items resolved to `normal`, so both panels stretched
     to the full 1366 layout instead of the 1316 the compliant pages get, and
     stayed `position: static` — so they scrolled away with the document and their
     art was cut off at the bottom edge. Every centred page also carries
     .mt-auth-page, so widening the selector changes nothing for them and gives the
     long ones the treatment they were missing.

     REACTIVATE IS NO LONGER THE EXAMPLE, and saying so is the point rather than a
     footnote: 8dd081e widened this selector BECAUSE reactivate was not centred,
     and then centred it in the same commit, so the measurement above is a true
     record of a page that stopped being uncentred a few hunks later. What the
     widening still buys is account.php — since 8dd081e the ONLY page carrying
     .mt-auth-page without .mt-auth-centered — which is a long auth page and wants
     these rules for exactly the reason the reactivate measurement gives. So the
     selector stays as it is; only the page it is kept for has changed. */
  body[data-coarse].mt-auth-page .mt-layout {
    /* height: auto IS WHAT MAKES align-items MEAN ANYTHING HERE. The base rule
       sets an explicit `height: calc(100dvh - 50px)`; raising only min-height
       leaves that specified height in place, the children get laid out against
       the smaller box, and the whole row ends up offset by the difference.
       Measured on an iPad Pro portrait: the side panels rendered 50px low and
       then slid the entire 50px of travel (100 -> 87 -> 75 -> 62 -> 50) instead
       of holding, while the same slack in LANDSCAPE — which already cleared the
       height — pinned them at 50 for every sample. */
    height: auto !important;
    min-height: 100dvh !important;
    align-items: flex-start !important;
    /* AND overflow MUST BE VISIBLE, or the sticky side panels are pinned to the
       wrong thing. `overflow: hidden` makes .mt-layout a scroll CONTAINER, and a
       sticky element sticks to its nearest scrolling ancestor — so the panels
       held their 50px inset from a box that never scrolls, i.e. they travelled
       with the page and never pinned to the viewport at all. Measured on an iPad
       Pro portrait: 100 -> 75 -> 50 across the scroll range, and still 100 -> 50
       after shortening the panel to give it 150px of slack, which is what ruled
       out every height explanation. Landscape pinned correctly only because that
       block had already cleared this. */
    overflow: visible !important;
  }
  /* A CENTRED CARD IS FLOORED IN STATIC vh, SO THE URL BAR CAN RETRACT (#806).
     The shared floor above stays dvh, because a LONG auth page (account.php, the
     only one left uncentred) is taller than the viewport anyway and takes its
     travel from its own content — and the rule further down deliberately ends its centre panel
     exactly 50px past the fold so the legal bar's own margin-top:auto slot IS
     that travel. Raising the layout past that panel would open an empty band
     under the bar, which is the one thing that rule exists to prevent.

     A CENTRED card is the opposite case: auth-fit.js scales it until it fits, so
     there is no content overflow and this floor is the ONLY thing the document
     has to scroll. On dvh that came to exactly 50px on every device, for two
     compounding reasons — 50 is simply less than some devices need (every iPad
     asks for 80: a 105px bar collapsing to 25), and dvh tracks the bar
     CONTINUOUSLY, so the layout grows underneath the very gesture meant to
     retract it and restores the travel as fast as it is consumed. Reported on an
     iPad Mini in portrait; the same unit was the iPhone SE landscape report one
     block up, and that fix is confirmed on the device.

     Static vh is the collapsed height whatever the bar is doing, so the document
     is a fixed 100vh + 50 and the travel while the bar is up works out at
     (bar − collapsed) + 50 — i.e. always 50px MORE than that device needs, with
     no per-device number written down anywhere. Measured at 744x1004: 50 -> 130
     against a requirement of 80. At rest, once the bar is down, it is the same
     50px band this route has always had.

     DEVICE-VERIFIED, NOT DESK-VERIFIED: an iframe has no URL bar, so vh and dvh
     are identical in every headless measurement here and the suites stay green
     whichever is written. */
  body[data-coarse].mt-auth-centered .mt-layout {
    min-height: 100vh !important;
  }
  /* THE POST-PAINT GROWTH (#806) -- pair of the rule in the landscape-small
     block above; rationale in js/auth-fit.js. Post-paint growth, not a
     parse-time reserve: the reserve was tried at parse (08f08bc) and reverted. */
  body[data-coarse].mt-auth-centered.mt-auth-settled .mt-layout {
    min-height: calc(100vh + 24px) !important;
  }
  body[data-coarse].mt-auth-centered .mt-panel-center {
    min-height: calc(100dvh - 50px) !important;
  }
  /* THE SIDE PANELS STAY PUT — a three-column page pins them everywhere else on
     the site, and the moment this route gained a scrolling document it stopped
     doing so. Measured at 1133x615 the left panel was `position: static`, 340px
     tall (shrunk to its own content by the layout's align-items: flex-start),
     and it travelled the full 214px off the top with the page — so the crest,
     the ad rail and the legal strip simply left, while the centre column did
     the scrolling they were meant to frame.

     Declared exactly as the site declares it twice already (the portrait
     variant, and the tablet fall-through — the latter written
     `:not(.mt-auth-page)`, which is precisely why an auth page never got it).
     The explicit height is also what restores them to full size: sticky pins
     the box it is given, and the box it was being given was 340px of content. */
  body[data-coarse].mt-auth-page .mt-panel-left,
  body[data-coarse].mt-auth-page .mt-panel-right {
    position: sticky;
    top: 50px;
    height: calc(100dvh - 50px);
    align-self: flex-start;
  }
}
/* ── Touch portrait: the legal bar holds the bottom of the viewport ───────
   PORTRAIT ONLY, because that is the only touch route where the bar exists at
   all — tablet landscape carries legal in the side panels and hides it, so
   fixing it there would reserve height for something that is not drawn. */
@media (orientation: portrait) {
  body[data-coarse].mt-auth-centered .mt-content-footer {
    /* FIXED, AND STICKY WAS TRIED FIRST. Sticky is the better tool — it stays in
       flow and needs no reserve — but it cannot hold here, because a sticky
       element is clamped inside its containing block and this bar's is the
       CENTRE PANEL, which ends 50px before the document does: that 50 is the
       travel, and it lives in the layout below the panel. Measured, the bar sat
       correctly at the viewport bottom at rest and then rose with the page
       (1004 -> 979 -> 954 on an iPad Mini portrait). Fixed has no containing
       block to run out of. */
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 5;
  }
  /* A fixed bar reserves nothing, so the panel pads by the bar's MEASURED
     height — it wraps to two lines at 360-390px and the version chip's channel
     suffix moves it again (#374), so a constant is wrong on some phone in some
     channel. js/auth-fit.js publishes it; the fallback only has to be sane. */
  body[data-coarse].mt-auth-centered .mt-panel-center {
    padding-bottom: var(--mt-auth-footer-h, 42px) !important;
  }
  /* NO RESERVE UNDER THE STRIP FOR THE CLOSING RULE, and none is needed: the
     centred pair leaves the same free space below the strip as above the card,
     so the rule has somewhere to go exactly when its partner above does. Where
     the pair fills the strip there is no room for either, and js/auth-fit.js
     draws neither rather than laying one across the card. */
  /* AND THE RULES HANG OFF THE PANEL HERE, NOT THE CARD'S CONTAINER. They are
     absolutely positioned, and the lower one now sits past the container's
     bottom edge — which an overflow: visible box counts into its scrollHeight,
     the very number js/auth-fit.js reads as "the card does not fit". Measured: a
     557px card drawn at 484 with the page top-anchored, for a decoration. Moving
     the containing block up one level takes the rules out of that sum; auth-fit
     reads each bar's offsetParent, so it follows without being told. */
  body[data-coarse].mt-auth-centered .mt-panel-center { position: relative; }
  body[data-coarse].mt-auth-centered .mt-panel-center > .container { position: static !important; }

  /* ── THE LONG CARDS NEED THE SAME BREATH ─────────────────────────────────
     They were never getting it: every travel rule above is scoped
     .mt-auth-centered, and register / accept-invitation / reactivate were not
     centred — they were precisely the pages that SCROLL. Measured on an iPad
     Mini portrait (744x1133), all three had travel 0, so the address bar could
     never retract and the reader was held at the short viewport on the three
     longest pages on the site.

     PAST TENSE, AND THE SELECTOR NOW SERVES ONE PAGE. 8dd081e centred all three
     (accept-invitation and reactivate outright, register everywhere but its
     payment view), so `:not(.mt-auth-centered)` matches account.php alone —
     which still wants this, being the one long auth page left outside the centred
     route. Kept rather than rewritten around account.php by name: the condition
     states the property that makes a page need this (it scrolls its own content
     instead of being fitted), and the next page that has it should inherit the
     rule rather than have to be remembered. The base rule floors both the layout and the
     panel at calc(100dvh - 50px), which with the body's 50px navbar reserve
     makes the document exactly one viewport tall — nothing to scroll.

     RAISING THE PANEL'S FLOOR, not adding a band below it the way the centred
     route does, and the difference is the footer's mechanism. The centred bar is
     `fixed` because its travel lives in the layout BELOW the panel, out of a
     sticky element's reach. This bar is `sticky` — it can be, because this panel
     grows with its content — and putting the travel below the panel would clamp
     it and undo that. So the floor goes up instead: the panel ends 50px past the
     fold, which is exactly where the footer's own margin-top:auto slot already
     sits. Sticky holds the bar on the viewport until the reader swipes into that
     50px, then it settles flush. Nothing empty is ever revealed, because the
     travel IS the bar's natural slot.

     PORTRAIT ONLY, unlike the centred block above (which also takes tablet
     landscape): there the bar is not drawn at all — legal lives in the side
     panels — so the same 50px would be a genuinely empty band, reserved for
     something that is not there.

     The layout needs no rule of its own: its own floor is lower, so it simply
     grows to the panel it contains. */
  body[data-coarse].mt-auth-page:not(.mt-auth-centered) .mt-panel-center {
    min-height: 100dvh !important;
  }
  /* …AND THE LAYOUT HAS TO LET THE PANEL OUT, which above 1023 it did not. The
     unclip rules that make a long auth page scroll the DOCUMENT live in the
     <=1023 portrait and landscape-small blocks, so an iPad Pro 12.9 portrait
     (1024) fell through to the desktop base — .mt-layout height calc(100vh-50px)
     with overflow: hidden — and clipped the very 50px the rule above adds.
     Measured at 1024x1237: panel 1237 inside a layout of 1187 marked
     overflow:hidden, so the document never grew and travel stayed 0 while login,
     being centred, had its 50.

     THAT RULE NOW LIVES IN THE SHARED BLOCK ABOVE rather than here, because the
     same widening fixes a second thing this copy did not: without
     align-items: flex-start the side panels stretched to the whole document and
     stayed static, which is the cropped columns #806 reported on an iPad Pro.
     Keeping a second copy of three of those four declarations would have left
     two writers on one property, so the copy is gone and .mt-auth-page carries
     it for centred and long pages alike. */
}
/* ── Touch phone portrait: give the room back to the card here too ────────
   THE SAME COMPRESSION, THE SAME FIX, ONE ORIENTATION OVER. In phone portrait
   .mt-panel-center is a fixed `calc(100vh - 50px)` box and .container is
   `flex: 1 1 auto` inside it, so the container is clamped and a card taller
   than the strip overflows it — which is exactly what js/auth-fit.js scales
   for. Measured on login.php: scale(0.7038) at 375x573 (a 557px card drawn as
   392) and scale(0.8725) at 375x667. The flex branch shrinks by TRANSFORM plus
   a negative margin-bottom (-165px at 375x573), so the layout box collapses to
   the painted height and the following content moves up under the drawing —
   which is the reported overlay: at 360x640 the contact band's top landed at
   551 inside the "Join the Mathturion Club" CTA's 504..598.

   So the container is allowed to GROW, as it already does in tablet landscape.
   With nothing overflowing, auth-fit measures scrollHeight - clientHeight and
   finds zero, stops scaling, and the card stands full size while the DOCUMENT
   carries the difference. The contact band then simply falls below the fold —
   it is the page's footer, not a viewport fixture — and the reader scrolls to
   it. The legal bar keeps its `position: fixed` above: that one was asked to
   hold the bottom of the viewport, and a fixed element reserves nothing, so
   growth cannot push it anywhere.

   PHONES ONLY (max-width: 767px). At 768-1023 portrait the card sits in the
   centurion grid, whose `grid-template-rows: 2fr 3fr` resolves only because
   .container has a DEFINITE height — let it grow and the logo's empty ::before
   track collapses to nothing. Tablets measured clean there anyway (no
   transform on iPad Mini portrait at 744x1004), so there is nothing to fix. */
@media (orientation: portrait) and (max-width: 767px) {
  /* THE CARD AND THE CONTACT STRIP ARE ONE BLOCK, CENTRED TOGETHER between the
     navbar and the legal bar. The strip is the page's footer and belongs
     directly under the card; centring the CARD alone in the whole strip and
     letting the band start at the fold put 100px of nothing between them
     (measured on a 14 Pro Max portrait: card ending 683, band starting 783).

     So the container hugs its card (flex: 0 0 auto) and the PANEL centres what
     it holds. Where the pair fits, the strip is on screen with no gap — which is
     what was asked for, "if there is space keep it visible" — and where it does
     not, the strip falls past the fold and the page scrolls to it, which is the
     other half of the same request.

     `safe center` and not plain centring: a centred group that overflows
     overflows BOTH ways, and no scroll container scrolls above zero, so the top
     of a tall card would be stranded above the edge. Safe says centre while
     there is room and fall back to start when there is not, decided by the
     layout engine every frame — the same keyword the landscape route uses for
     the same reason.

     --mt-auth-footer-h is the bar's MEASURED height (js/auth-fit.js publishes it;
     it wraps to two lines at 360-390px and the version chip's channel suffix
     moves it again, #374), so the fallback only has to be sane. */
  body[data-coarse].mt-auth-centered .mt-panel-center {
    height: auto !important;
    overflow: visible !important;
    justify-content: safe center !important;
  }
  body[data-coarse].mt-auth-centered .mt-panel-center > .container {
    flex: 0 0 auto !important;
    height: auto !important;
    min-height: 0 !important;
    overflow: visible !important;
  }
}
/* ── Tablet landscape: give the room back to the card ─────────────────────
   THE CARD WAS BEING COMPRESSED, and that is the thing to stop. On the tablet
   landscape route the panel and the container are both overflow-y: auto, so
   the container is a 431px scroll box holding a 621px card — js/auth-fit.js
   duly scaled it, measured at scale(0.8196) on an iPad Mini (621 painted as
   509) and scale(0.8132) on an iPad Air (505). Nothing is wrong with the
   fitter: it scales because the container told it the card does not fit.

   So the container is allowed to GROW instead. With nothing overflowing there
   is nothing for auth-fit to scale — it measures scrollHeight - clientHeight
   and finds zero — and the card stands at full size while the page carries the
   difference as scroll. That is also what pushes the contact band past the
   fold on the constrained sizes, which is the travel: at 1133x639 the card
   (621) plus the band already exceed the 589px strip, so the page scrolls, the
   bar retracts, the viewport becomes 719, and then it all fits with room.

   Centring needs no rule here: the card keeps the auth CSS's `margin: auto`,
   which centres it in a container that grows to fill the panel and has no
   slack to distribute once the card is taller. Start-anchored exactly when it
   overflows, centred exactly when it does not. */
@media (orientation: landscape) and (min-height: 600px) {
  /* THE LAYOUT HAS TO STOP CLIPPING TOO, and leaving it out is what made the
     first version of this block worse than the bug it fixed. .mt-layout is
     `height: calc(100dvh - 50px); overflow: hidden` on this route — the desktop
     model, where the PANEL scrolls internally and the page never does. Letting
     the container grow past it therefore produced a card the document could not
     reach: measured at 1133x615 the card ran 161..782 in document coordinates
     while the page's maximum scroll was 50, so 117px of it sat past the end of
     the document with no way to scroll to it. Unreachable content is a worse
     failure than a scaled card, and it is the one thing this route must never
     do. The phone-landscape block above sets both; this set only half. */
  body[data-coarse].mt-auth-centered .mt-layout {
    height: auto !important;
    overflow: visible !important;
  }
  body[data-coarse].mt-auth-centered .mt-panel-center {
    height: auto !important;
    overflow: visible !important;
  }
  body[data-coarse].mt-auth-centered .mt-panel-center > .container {
    flex: 1 0 auto !important;
    height: auto !important;
    min-height: 0 !important;
    overflow: visible !important;
  }
}/* ── The Mathturion Chronicle (chronicle.php) ──────────────────────────────
   Reverse-chronological announcements feed. The breadcrumb "Home / The
   Chronicle" is the page title (no H2); the header is a two-column intro block
   (icon left, intro text right). Cream entry cards with a gold left rule, a
   Cinzel title + date, and a small tag chip per the puzzle-tag visual
   language. Public news page — shares the site's non-puzzle body-scroll
   layout, so no special layout rules are needed here, only the feed styling. */
.mt-chronicle-intro-block {
  display: flex;
  align-items: center;
  gap: 20px;
  margin: 10px 0 22px;
}.mt-chronicle-intro-icon {
  width: 96px;
  height: 96px;
  flex: 0 0 auto;
  object-fit: contain;
}.mt-chronicle-intro {
  font-family: 'Crimson Text', serif;
  font-size: 1.05em;
  color: #5a5040;
  margin: 0;            /* sits in the flex row beside the icon */
}/* "New entries — refresh" banner — chronicle-refresh.js reveals it (clears the
   hidden attr) when the 30-min content poll sees newer audience-visible posts.
   The [hidden] rule has higher specificity than the base display:block, so it
   stays hidden until then. */
.mt-chronicle-newbanner {
  display: block;
  width: 100%;
  text-align: center;
  font-family: 'Cinzel', serif;
  font-size: 0.9em;
  letter-spacing: 0.04em;
  color: #0D1B3E;
  background: #E8D48B;
  border: 1px solid #C9A84C;
  padding: 9px 14px;
  margin: 0 0 16px;
  cursor: pointer;
}.mt-chronicle-newbanner:hover { background: #f0e0a0; }.mt-chronicle-newbanner[hidden] { display: none; }.mt-chronicle-notice {
  font-family: 'Crimson Text', serif;
  font-size: 1.05em;
  color: #8B6914;
  background: #FFFDF5;
  border: 1px solid #C9A84C;
  padding: 18px 20px;
  text-align: center;
}/* Puzzle gallery "temporarily unavailable" notice (issue #189 follow-up) —
   shown in place of the card grid when the account DB is down (no hardcoded
   fallback cards any more). Mirrors .mt-chronicle-notice; grid-column: 1 / -1
   spans the full .puzzle-grid width so it isn't squeezed into one column. */
.puzzles-notice {
  grid-column: 1 / -1;
  font-family: 'Crimson Text', serif;
  font-size: 1.05em;
  color: #8B6914;
  background: #FFFDF5;
  border: 1px solid #C9A84C;
  padding: 18px 20px;
  text-align: center;
}.mt-chronicle-feed {
  display: flex;
  flex-direction: column;
  gap: 18px;
  max-width: 820px;
}.mt-chronicle-entry {
  background: #FFFDF5;
  border: 1px solid #E3D7B0;
  border-left: 4px solid #C9A84C;
  padding: 16px 20px 14px;
}.mt-chronicle-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-bottom: 6px;
}.mt-chronicle-date {
  font-family: 'Cinzel', serif;
  font-size: 0.78em;
  letter-spacing: 0.06em;
  color: #8B6914;
  text-transform: uppercase;
}/* Tag chip — base look matches .puzzle-card .puzzle-tag; per-type colours below. */
.mt-chronicle-tag {
  display: inline-block;
  background: #0D1B3E;
  color: #F1E0A0; /* brighter gold + bold strokes — the thin 0.62em Cinzel read faint on navy (#55 legibility) */
  font-family: 'Cinzel', serif;
  font-size: 0.62em;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 2px 8px;
}.mt-chronicle-tag-news { background: #0D1B3E; color: #F1E0A0; }.mt-chronicle-tag-new-puzzle { background: #1f5d3a; color: #d8f0e0; }.mt-chronicle-tag-feature { background: #5a3d8a; color: #ece0ff; }.mt-chronicle-tag-fix { background: #7a4a00; color: #ffe8c8; }.mt-chronicle-tag-milestone { background: #8a2d2d; color: #ffd8d8; }/* Audience badge — only rendered for non-public posts (members/staff). */
.mt-chronicle-aud {
  display: inline-block;
  font-family: 'Cinzel', serif;
  font-size: 0.6em;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 2px 8px;
  border: 1px solid #B5882E; /* darker text + stronger border — gold-on-white text + faint border barely read (#55 legibility) */
  color: #5C3D11;
  background: #fff;
}.mt-chronicle-aud-staff { border-color: #a00; color: #a00; }.mt-chronicle-title {
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  font-size: 1.25em;
  margin: 2px 0 8px;
}.mt-chronicle-body {
  font-family: 'Crimson Text', serif;
  font-size: 1.05em;
  color: #1C130A;
  line-height: 1.5;
}.mt-chronicle-body p { margin: 0 0 10px; }.mt-chronicle-body p:last-child { margin-bottom: 0; }/* ---- Home page: "Latest from the Chronicle" featured-post block (issue #55) ---
   Reuses the .mt-chronicle-tag / .mt-chronicle-aud chips from above; only the
   card frame + title/excerpt are home-specific. The frame mirrors the Today's
   Challenge frame (scroll icon left, card right). */
.mt-home-chronicle-frame {
  display: flex;
  align-items: center;
  gap: 24px;
  border: 1px solid #C9A84C;
  background: #FAF6EE;
  padding: 22px 26px;
  margin-top: 10px;
}.mt-home-chronicle-iconwrap {
  flex: 0 0 auto;
  width: 150px; /* match the Today's Challenge wreath column so the card's left edge aligns */
  display: flex;
  align-items: center;
  justify-content: center;
}.mt-home-chronicle-iconwrap img {
  display: block;
  width: 80%; /* ~20% smaller than full column; column stays 150px + centered, so placement is unchanged */
  height: auto;
}.mt-home-chronicle-cardwrap {
  flex: 1 1 auto;
  min-width: 0;
}/* Card frame matches the Today's Challenge .puzzle-card: gold border with a
   4px gold top accent, cream fill, same hover glow. */
.mt-home-chronicle-card {
  display: block;
  background: #FFFDF5;
  border: 1px solid #C9A84C;
  border-top: 4px solid #C9A84C;
  padding: 16px 18px;
  text-decoration: none;
  transition: box-shadow 0.2s, background 0.2s;
}.mt-home-chronicle-card:hover,
.mt-home-chronicle-card:focus {
  text-decoration: none;
  background: #FFF8E8;
  box-shadow: 0 4px 16px rgba(201, 168, 76, 0.28);
}.mt-home-chronicle-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-bottom: 6px;
}.mt-home-chronicle-date {
  font-family: 'Cinzel', serif;
  font-size: 0.78em;
  letter-spacing: 0.06em;
  color: #8B6914;
  text-transform: uppercase;
}.mt-home-chronicle-title {
  font-family: 'Cinzel', serif;
  color: #0D1B3E;
  font-size: 1.2em;
  margin: 2px 0 8px;
}.mt-home-chronicle-excerpt {
  font-family: 'Crimson Text', serif;
  font-size: 1.05em;
  color: #1C130A;
  line-height: 1.5;
  margin: 0;
  /* Restrict a long excerpt to a few lines (ellipsis on overflow). */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}/* Heading row: title on the left, "View all →" on the right (same baseline). */
.mt-home-chronicle-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
}/* Let the heading shrink (and wrap) within the row instead of overflowing. */
.mt-home-chronicle-head .mt-home-heading-icon { min-width: 0; }.mt-home-chronicle-viewall {
  font-family: 'Cinzel', serif;
  font-size: 0.85em;
  letter-spacing: 0.04em;
  color: #8B6914;
  white-space: nowrap;
}/* Stack the scroll above the card on narrow phones, like the Today's
   Challenge frame. */
@media (max-width: 575px) {.mt-home-chronicle-frame {
    flex-direction: column;
    align-items: center;
    gap: 16px;
    padding: 18px;
  }.mt-home-chronicle-iconwrap { width: 120px; }.mt-home-chronicle-cardwrap { width: 100%; }}/* Landscape-small phones: the center panel is only ~3/5 of a short, wide
   viewport, so the fixed 150px icon column crushes the card (clipped title,
   one-word-per-line text) and detaches the oversized wreath/scroll. Drop the
   decorative icon and the frame chrome and give the card the full center
   width — the card keeps its own border, so it still reads as a card. Applies
   to BOTH the Today's Challenge and Latest-from-the-Chronicle frames. */
@media (max-height: 599px) and (orientation: landscape) {.mt-challenge-frame,
  .mt-home-chronicle-frame {
    gap: 0;
    padding: 0;
    border: none;
    background: transparent;
  }.mt-challenge-wreath,
  .mt-home-chronicle-iconwrap {
    display: none;
  }.mt-challenge-card-wrap,
  .mt-home-chronicle-cardwrap {
    width: 100%;
  }}/* Busy-on-submit spinner (#408 follow-up) — js/submit-busy.js swaps an opted-in
   form's submit button to this while a slow POST (e.g. the 2FA email send) runs. */
.mt-busy-spinner {
  display: inline-block;
  width: 1em;
  height: 1em;
  margin-right: 0.5em;
  vertical-align: -0.15em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: mt-busy-spin 0.7s linear infinite;
}@keyframes mt-busy-spin {to { transform: rotate(360deg); }}button.mt-busy { opacity: 0.85; cursor: default; }@media (prefers-reduced-motion: reduce) {.mt-busy-spinner { animation: none; }   /* the label carries the meaning */
}/* ============================================================
   LAYOUT ENGINE (#624) — body.pz-layout2
   ------------------------------------------------------------
   The engine computes every box from cl and writes it inline, so this section's
   only job is to get out of its way: it makes the CONTAINERS neutral and
   content-driven, and neutralises the caps and aspect locks that would clamp an
   explicitly-written box.

   There are deliberately NO media queries here. Route differences are decided by
   the layout function and arrive as inline px — a variant-gated rule would be a
   second decision-maker, which is the whole thing this refactor removes.
   Scoped to body.pz-layout2, which no un-migrated page carries.
   ============================================================ */
/* THE NAVBAR IS EXACTLY THE RESERVE. apply() writes G.NAVBAR_H (60) as the body's top
   padding, but the bar itself renders from its content — 53px in portrait, 59 wide — so
   the page opened with a cream strip under it, and the in-flow dropdown (which starts
   below the 50px header row) opened above the content edge. Giving the bar AND its header
   the reserve as a min-height makes all three agree: bar bottom = content top = menu top.
   The 60 mirrors MtLayout's G.NAVBAR_H. */
body.pz-layout2 .navbar-fixed-top {
  /* MIN-height, never height: the in-flow dropdown lives INSIDE this box, and the bar
     growing to contain it is what puts the navbar's dark background behind the menu. A
     fixed `height: 60px` left the panel hanging outside the bar with no backdrop — the
     menu rendered half-transparent over the board and was unusable. border-box so the
     bar's 3px gold border counts inside the reserve, making the closed bar exactly the
     60px the arithmetic subtracts. The header row is NOT touched: the toggle is FLOATED
     by Bootstrap, and a flex header cancels the float — measured, it threw the portrait
     hamburger from the right edge (x286) to x0. */
  box-sizing: border-box;
  min-height: 60px;
}/* CUTS THE ONE OUTPUT→INPUT PATH THE ENGINE CANNOT SEE. A composition that makes the
   page taller adds a scrollbar, which SHRINKS documentElement.clientWidth — the engine's
   input — and the browser does not reliably fire `resize` for it, so the layout would be
   composed for a width the page no longer has. Reserving the gutter always means cl.w does
   not depend on the layout's own height. (js/mt-layout.js names this fix in its classifier
   note; it was never actually applied.)

   ...AND ON `body` IT DID NOT DO THAT (#487). `scrollbar-gutter` applies to a scroll CONTAINER,
   and only the ROOT element's value is propagated to the viewport — `body` is neither, so the
   declaration was inert and the document's clientWidth still dropped by the scrollbar's width
   the moment the page overflowed. Measured: 876 -> 861.

   MOVING IT TO `html` WAS TRIED AND IS NOT FREE, which is why it is still here. On the root the
   property still does nothing on its own — it applies only to a box whose `overflow` is not
   `visible` — so making it work needs `overflow-y: scroll` alongside it, and that reserves the
   gutter ALWAYS, which is the whole point of `stable` and also its price. Measured on a desktop
   puzzle page: clientWidth 900 -> 885 in BOTH states. Stable, and 15px narrower than today on
   every desktop puzzle page, for a case that only bites a classic-scrollbar TOUCH device.

   So the trade is real and belongs to whoever owns the desktop board size, not to a scroll-model
   change. Left inert and honestly labelled. What it would buy, beyond the board no longer
   twitching: `cl.w` is also what `classify()` tiers on, so on a classic-scrollbar touch device a
   portrait width in 768..783 loses its 15px and composes `small/portrait` — phone chrome — where
   it should compose `large/portrait`. Narrow, but reachable since the touch block below makes
   the document scroll on this tier. */
body.pz-layout2 { scrollbar-gutter: stable; }/* The 60px below mirrors MtLayout's G.NAVBAR_H, which apply() also writes as the body
   padding-top. It is 60 rather than the site's historic 50 because the navbar RENDERS
   53–59px tall, so a 50px reserve left the top of the layout under it. */
body.pz-layout2 .mt-layout {
  height: auto;
  min-height: calc(100dvh - 60px);
  overflow: visible;
  align-items: flex-start;
}/* Side panels stay pinned while the centre column scrolls (as pz-desc-flow did). */
body.pz-layout2 .mt-panel-left,
body.pz-layout2 .mt-panel-right {
  position: sticky;
  top: 60px;
  height: calc(100dvh - 60px);
  align-self: flex-start;
}body.pz-layout2 .mt-panel-center { overflow: visible; height: auto; }body.pz-layout2 .mt-panel-center > .container {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  min-height: 0;
}/* .pz-area is content-tall: the engine sized what is inside it. */
body.pz-layout2 .pz-area {
  flex: 0 0 auto;
  height: auto;
  min-height: 0;
  overflow: visible;
  display: flex;
  flex-direction: column;
  gap: 10px;
}/* THE DESCRIPTION'S HEIGHT IS THE LAYOUT'S BUSINESS, so the legacy cap must not survive here.
   `.pz-description { max-height: 40%; overflow: hidden }` is the OLD path's no-JS backstop — it
   paired with the description tab and the pop-over that the engine does not build — and under the
   engine it silently TRUNCATED THE PROSE on the `large/*` routes, with no way to reach the tail.
   Its predecessor already carried this exact override for the same reason
   (the retired body-scroll rule had it); the engine path was simply missing it.

   THE MECHANISM IS A PERCENTAGE AGAINST AN INDEFINITE HEIGHT. `body.pz-layout2 .pz-area` is
   `height: auto`, so the area is first sized from the UNCLAMPED description and the 40% then
   resolves against that final height. Which means the area kept the full height either way — the
   page RESERVED the space, hid the prose in it, and painted blank cream. Measured identical to
   0.01px in both states on every puzzle, so lifting the cap cannot push anything: nothing else was
   ever sized from it.

   FLEET-WIDE, not one puzzle: 13 of the 14 engine puzzles were losing prose. Worst case at
   768x1024 — twelve-bowls 415px, eight-chests 316, trial-of-numbers 187 — against the Motif's 81;
   cats-and-mice alone was clean. At 820x1180 the Motif's 68px took the last instruction line AND
   the per-user `.pz-desc-copyright` watermark (#328 Part 3a), i.e. the cap was clipping a copyright
   notice. A no-op on every `small/*` route, where the prose sits in the fold host whose height is
   indefinite, so the 40% already resolved to `none`.

   The engine states where the prose goes — an area block in flow (`areaOrder`) or below the fold
   (`belowFold`) — and in both cases it should be FULL HEIGHT and let its scroller scroll.
   CAVEAT for a future boxed description: an explicit `height` from `areaBoxes` still beats
   `max-height: none`, but `overflow: visible` also removes the containment such a reservation
   implies, so content taller than the box would SPILL rather than clip — that case would need its
   own `overflow` alongside the height. Latent today: `areaBoxes` only ever boxes `feedback`. */
body.pz-layout2 .pz-description { max-height: none; overflow: visible; }/* The engine writes display / flex-direction / gap on .pz-bottom and the boxes on
   the two cells, so they must not carry their own flex sizing. */
body.pz-layout2 .pz-bottom { flex: 0 0 auto; min-height: 0; }body.pz-layout2 .pz-controls,
body.pz-layout2 .pz-play { min-width: 0; min-height: 0; overflow: visible; }body.pz-layout2 .pz-controls-inner { transform: none !important; width: auto; }/* An explicitly-written board box must not be clamped by a percentage cap or an
   aspect lock — the engine already made it square and made it fit. */
body.pz-layout2 .pz-play > canvas {
  max-width: none;
  max-height: none;
  aspect-ratio: auto;
}/* The rotate overlay is the old engine's answer to phone portrait, and for twelve
   of the thirteen migrated puzzles this engine composes a real portrait layout, so
   the overlay never applies — these two rules are what suppress it.

   THE EXCEPTION OPTS OUT, AND IT DOES SO STATICALLY. Escape from the Garden omits
   its `small/portrait` route (its gate is the north gate, so the maze must stay
   north-up), and MtLayout.init publishes that once as `data-mt-no-portrait`. Where
   that is set, these two rules simply step aside and the ordinary phone-portrait
   media query further up owns the state, exactly as it does on the old path.

   Deliberately keyed on a STATIC fact rather than on the composition. A first pass
   had apply() latch a per-composition attribute, and it stuck: the latch only
   cleared when apply ran again, and the engine's wake-up is best-effort in an
   iframed document — so rotating landscape -> portrait -> landscape left the page
   on the rotate prompt IN LANDSCAPE. A media query re-evaluates itself; JS state
   has to be told. The self-correcting mechanism owns the decision. */
body.pz-layout2:not([data-mt-no-portrait]) .mt-rotate-overlay { display: none !important; }
body.pz-layout2:not([data-mt-no-portrait]) .mt-layout { display: flex !important; }
/* THE DECLINED ROUTE MUST STILL SAY WHAT THE PUZZLE IS (#886). Google indexes MOBILE-FIRST — it
   crawls with a smartphone viewport — so whatever a phone portrait renders is what gets indexed.
   This route rendered `display: none` over the entire layout, which put the puzzle's own prose out
   of reach of the one crawler that decides whether the page exists. Measured as Googlebot:
   escape-maze.php returns 379 words of description and every one of them was hidden, on a page
   that is IN the sitemap. Of the seven free puzzles it was the only one with nothing to index.

   So the prompt stops REPLACING the page and starts standing in FRONT of it: it flows at the top a
   screenful tall (it precedes .mt-layout in the markup, so no re-ordering is needed) and the
   description follows BELOW THE FOLD — which is where this puzzle's prose already sits on every
   route it does compose, so the reading order is the one a player already meets.

   THE PLAY ROW IS WHAT STAYS HIDDEN, and that is the point rather than a compromise: there is no
   honest way to draw a north-up maze in a narrow portrait, which is the whole reason the route is
   declined. The ad bar stays hidden with it — nothing composed a reserve for it, and a fixed bar
   with no reserve lands on the prose it was added to reveal.

   Scoped to the declined route, and it has to be: every other puzzle composes a real portrait
   layout and never reaches the prompt at all — the `:not(...)` pair above is what suppresses it
   there. Keyed on the same STATIC attribute for the same reason (a media query re-evaluates itself
   on rotation; JS state has to be told). */
@media (max-width: 767px) and (orientation: portrait) {
  body.pz-layout2[data-mt-no-portrait] .mt-layout { display: flex !important; }
  body.pz-layout2[data-mt-no-portrait] .pz-bottom { display: none !important; }
  /* ...AND THE HEADER GOES WITH IT, because the prompt above already carries a Back to Puzzles and
     two of them one screen apart is one link too many. Only on THIS route: everywhere else the
     header is the page's only way back, which is why it is hidden here rather than in a rule that
     could reach a composed layout. The whole `.pz-header` and not just the link inside it — the row
     holds nothing else, so leaving it would leave an empty row claiming its gap. */
  body.pz-layout2[data-mt-no-portrait] .pz-header { display: none !important; }
  body.pz-layout2[data-mt-no-portrait] .mt-rotate-overlay {
    position: static;
    min-height: calc(100vh - 50px);
    min-height: calc(100dvh - 50px);
  }
}
/* ...AND WHAT SUCH A PUZZLE DECLINES IS A WIDTH, NOT AN ORIENTATION. A phone in portrait has no
   honest arrangement of Escape from the Garden; a portrait TABLET does — the roomy composition
   standing in the middle column alone, which is what an iPad Mini gets. So the prompt this hands
   rendering to must stop where the composition starts, and the site's own phone-portrait query
   (max-width: 767px) reaches 24px too far: it covers the 744-wide Mini.

   700, NOT 744, and the puzzle's `ROOMY_PORTRAIT_MIN_W` carries the same number for the same reason:
   `pz-layout2` sets `scrollbar-gutter: stable`, so a browser with classic scrollbars reports 729 for
   that device, and a boundary must not turn on whether the platform draws its scrollbar inside the
   viewport. 700 clears the gutter and is far above the widest phone portrait (430).

   THE PAIRING IS THE COST of letting a media query own the rendering: a query cannot read the
   engine's constant, and keying this on a published attribute instead would put the decision back
   into JS state that goes stale on rotation — the exact failure recorded above. So the number lives
   in both places and `tests/shape.html` asserts they agree, reading the spec's own value. */
@media (min-width: 700px) and (orientation: portrait) {
  body.pz-layout2[data-mt-no-portrait] .mt-rotate-overlay { display: none !important; }
  body.pz-layout2[data-mt-no-portrait] .mt-layout { display: flex !important; }
  /* THE EXCEPTION HAS TO UNDO EVERYTHING THE NARROW BLOCK HID, not just the layout (#886). That
     block hides the play row and the header because the prompt is standing in for them; here the
     prompt is gone and the puzzle composes for real, so both come back. The header especially:
     it is this route's ONLY way back, and hiding it left an iPad Mini portrait with no Back to
     Puzzles anywhere on the page — measured at 744x1133, which sits inside the narrow block's own
     max-width: 767px.
     `.pz-bottom` currently survives without this line because the writer sets its display inline
     and an inline !important outranks a stylesheet one — a dependency on which properties the
     writer happens to set, which is not a thing to rely on. Stated instead. */
  body.pz-layout2[data-mt-no-portrait] .pz-header { display: flex !important; }
  body.pz-layout2[data-mt-no-portrait] .pz-bottom { display: flex !important; }
}
/* Rows the engine forms inside the controls strip get their flex from the writer;
   this only stops a stray inherited style from breaking them. */
body.pz-layout2 .mt-lrow { box-sizing: border-box; }/* An engine-formed row spaces its items by the computed GAP and centres them. An `auto` margin on
   an item absorbs the free space instead, which quietly re-decides the placement: the puzzle's own
   `.nt-mode { margin: 0 auto }` pushed the Moves/Best pair to the far end of the strip while the
   layout had them centred as a pair. */
body.pz-layout2 .mt-lrow > * { margin-left: 0 !important; margin-right: 0 !important; }/* The fold's "Rules ↓" link. Same understated gold outline the old fold button had (its
   styling was gated on pz-mobile-fold, which this engine does not use), but it is a plain
   anchor now: the engine decides whether it EXISTS, the browser does the jump. */
body.pz-layout2 .pz-desc-scroll {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  /* No width:100%. In the landscape COLUMN the host is a stretch flex, so it fills on its own;
     inside a packed ROW it must be content-sized or it would claim the whole row (and its
     scrollWidth would report the row's width, which is how it first measured 707px). */
  box-sizing: border-box;
  padding: 3px 7px;
  background: transparent;
  color: #C9A84C;
  border: 1px solid #C9A84C;
  border-radius: 3px;
  font-family: 'Crimson Text', serif;
  font-size: 0.66em;      /* deliberately the smallest control: it shares the action row */
  letter-spacing: 0.02em;
  text-decoration: none;
  cursor: pointer;
}body.pz-layout2 .pz-desc-scroll:hover,
body.pz-layout2 .pz-desc-scroll:focus { background: #C9A84C; color: #0D1B3E; text-decoration: none; }/* The jump lands on .pz-header; leave the navbar's height above it so the Back link is not
   covered by the fixed bar. */
body.pz-layout2 .pz-header { scroll-margin-top: 64px; }/* Small routes compose across the WHOLE viewport — the 1:3:1 frame is a large-route
   shape, and the old portrait rules would wrap the panels (measured: a 768-wide
   window left the centre panel 0px). Keyed on data-mt-route / data-mt-ads, which
   are the engine's own published outputs, so CSS renders the decision rather than
   making a second one from the viewport. */
body.pz-layout2[data-mt-route^="small/"] .mt-layout { flex-wrap: nowrap !important; }body.pz-layout2[data-mt-route^="small/"] .mt-panel-left { display: none !important; }body.pz-layout2[data-mt-route^="small/"] .mt-panel-center {
  flex: 1 1 auto !important;
  max-width: none !important;
  min-width: 0;
  width: auto !important;
}/* A small route has no 1:3:1 frame, so the side panels are not chrome there — the right
   panel exists only if the layout put the AD in it. Placement rules follow (equal
   specificity, so source order decides) and re-show it. */
body.pz-layout2[data-mt-route^="small/"] .mt-panel-right { display: none !important; }/* ...and the mirror: a LARGE route gets the 1:3:1 frame at whatever width it was composed
   at. The site's own rule for this (~line 2170) is gated `min-width: 768px`, which is the
   portrait cutoff the classifier uses — so a puzzle that composes a NARROWER portrait
   viewport as roomy (the twins take iPad Mini portrait, 744) would get the desktop
   composition wrapped in the portrait-wrap chrome: side panels stacked above the centre at
   50% each, the board in whatever was left. Keyed on the engine's published route, so this
   renders the decision `f` made rather than making a second one from the viewport — and it
   is a no-op at ≥768, where the site rule already says the same thing. */
body.pz-layout2[data-mt-route^="large/"] .mt-layout {
  flex-wrap: nowrap;
  /* 60, not the site's historic 50: this is the height the engine's navbar RENDERS at, and it is
     what every box on the route was computed against. At 50 the frame ran 10px past the viewport. */
  height: calc(100vh - 60px);
  height: calc(100dvh - 60px);
  min-height: auto;
  overflow: hidden;
  /* The three columns FILL the frame. Without this they are content-sized, and a side panel that
     shrink-wraps its logo has nothing left for the ad slot between the logo and the footer — the
     slot is `flex: 1` of a column that is already exactly as tall as its content, so it collapsed
     to 2px and the panel looked like a logo floating above a copyright line. */
  align-items: stretch;
}body.pz-layout2[data-mt-route^="large/"] .mt-panel-left,
body.pz-layout2[data-mt-route^="large/"] .mt-panel-right {
  display: flex;
  flex: 1;
  height: 100%;
  order: 0;
  border-bottom: none;
}body.pz-layout2[data-mt-route^="large/"] .mt-panel-center {
  flex: 3;
  order: 0;
  height: 100%;
  /* ...and the centre panel SCROLLS INTERNALLY, which is the desktop tier's own model and what the
     board-then-description order needs: the play area is sized to the frame, the prose below it is
     not, and the difference has to go somewhere. `hidden` sent it nowhere — measured at 1366x768,
     a 937px centre panel inside a 718px frame with `overflow: hidden` on the frame, so the last
     219px of the description was unreachable by any scroll. */
  overflow-y: auto;
}/* ...EXCEPT ON A TOUCH DEVICE, where an internal scroller cannot dismiss the URL bar (#487).
   A browser retracts that bar for the DOCUMENT scroller and for nothing else, so on the large
   tier a puzzle page had no gesture that could reclaim it — measured on an iPad Mini in
   landscape, 129 CSS px of a 744 px screen (status 24 + URL bar ~52 + tab strip ~53), held
   permanently against a page whose whole business is fitting a board into the height it has.
   The description was cut mid-sentence at the frame's edge with no scrollbar to say so.

   THIS IS NOT A NEW SCROLL MODEL — it is declining to override the one already here. Every
   rule below restores a `body.pz-layout2` base declaration that the `large/` block above
   replaced: the frame is content-tall with a viewport floor, the side panels are sticky (the
   base sets `position: sticky; top: 60px`, which the block never reset — only its `height`
   needs restoring, since `height: 100%` of a now-auto frame would shrink-wrap them), and the
   centre column stops clipping. `.mt-panel-center > .container` and `.pz-area` are already
   content-tall in the base and need nothing.

   Keyed on the ENGINE'S PUBLISHED ROUTE, not on a width. `large/` is only ever published on
   the roomy tier, so the attribute already carries the size decision and re-deriving it from
   a `min-width` would be a second answer that could disagree with the `availW` the boxes were
   computed from. That leaves the media query with exactly one job: excluding real desktops,
   which keep the 1:3:1 frame held against the viewport because it is right there and there is
   no URL bar to reclaim.

   THE GATE CANNOT TELL A MAGIC KEYBOARD FROM A DESKTOP, and that is a real if narrow cost:
   iPadOS reports `hover: hover` / `pointer: fine` once a trackpad is attached, so the same iPad
   on the same puzzle goes back to the internal frame with its 129px of chrome unreclaimable —
   and attaching or detaching mid-session swaps which element is the scroll container, silently
   dropping the reader's position, since the two have different scrollTops. Accepted: no media
   feature distinguishes them, and gating on anything other than pointer type would take the
   frame away from the desktops it is right for.

   Two precedents, and the second is the one that names the cost. #490 gave CONTENT pages this
   treatment on touch tablets for this exact reason — and excluded puzzle pages because "their
   own desc-flow / mobile-fold routes own tablet layout", which stopped being true when #624
   piece H retired those routes; the exclusion outlived its reason. And the `panels="none"`
   block below already switched one puzzle route, having measured what the internal scroller
   costs: at 744x1133 a drag moved 54px of prose, stopped, and chained into 227px of blank.

   It must cost the play area NOTHING: the engine writes every box in px and reads no height
   from the frame, so this changes which element scrolls, not what is composed. Measured on
   trains at 1133x615 with OVERLAY scrollbars, the board is bit-identical either way and the
   495px that were trapped in the panel become the document's.

   With CLASSIC scrollbars the board comes out 15px narrower, once. This is the first rule that
   makes the document scroll on this tier, so a classic scrollbar appears, `cl.w` drops by its
   width and the engine composes a narrower board — one shot, no loop, since the board is
   height-bound here. `scrollbar-gutter: stable` (on `body`, above) does NOT prevent it and is
   inert for this: the property applies to scroll containers, and only the ROOT element's value
   reaches the viewport. Nor does this gate exclude the case — a detached Windows tablet reports
   `hover: none` and `pointer: coarse` and has classic scrollbars.

   AND IT IS NOT ONLY THE BOARD: `cl.w` is also what `classify()` tiers on (`cl.w < TIER_W`,
   768). So a classic-scrollbar touch device in PORTRAIT whose gutterless width lands in
   768..783 drops to 753 and composes `small/portrait` — the phone chrome — where it used to
   compose `large/portrait`. It settles rather than oscillating, since a small route always has
   below-fold content and so keeps the scrollbar, and the two `roomyPortraitMinW` puzzles are
   immune because `portraitRoute` keys on `cl.mqW`. A narrow band with a stable outcome, but a
   tier flip rather than a slightly narrower board.

   Accepted rather than fixed here, and the fix is priced rather than merely named: moving
   `scrollbar-gutter` to `html` needs `overflow-y: scroll` beside it to have any effect at all,
   which reserves the gutter ALWAYS — measured, a desktop puzzle page goes 900 -> 885 in both
   states, so every desktop board loses 15px to close a case that only reaches a classic-scrollbar
   TOUCH device. See the note at the declaration (~3372). That trade belongs to whoever owns the
   desktop board size. */
@media (hover: none) and (pointer: coarse) {
  body.pz-layout2[data-mt-route^="large/"] .mt-layout {
    height: auto;
    /* A FULL VIEWPORT, deliberately NOT `- 60px`, which is what the base and the neighbouring
       routes use. apply() writes 60px of body padding-top, so a `100dvh - 60` floor makes the
       document exactly one viewport tall and the ONLY overflow is whatever the description
       happens to overhang the frame by. Where that is less than the URL bar's own height the
       bar cannot be reclaimed at all — the page stops overflowing the moment it retracts — and
       #204 records the worse form of it: the browser snaps scrollTop back to 0. This route's
       whole purpose is that the bar CAN be dismissed, so the overflow has to be guaranteed
       rather than left to the shortest description in the catalogue. This is exactly what #204
       (line ~1073) and #490 do, for the same stated reason.

       On WebKit `100dvh` + the padding exceeds the viewport unconditionally. On Chrome Android
       it is conditional and worth stating rather than claiming otherwise: that engine pins
       `documentElement.clientHeight` to the LARGE viewport and leaves it there (which is why
       `bottomInset()` exists in the engine), while CSS `dvh` tracks the dynamic one — so while
       the bar SHOWS the guaranteed margin is `60 - barHeight`, a few px at Chrome's ~56, and
       negative under taller chrome. It becomes unconditional once the bar is hidden, which is
       the state this is trying to reach.

       THAT LAST CLAUSE USED TO READ "harmless either way, because the description always overhangs
       the frame here (measured 219px at 1366x768) so the floor never actually binds; `100lvh` would
       make it literal if that ever stops being true." IT STOPPED BEING TRUE, and a bigger tablet is
       where — the taller the landscape viewport, the more of the description fits above the fold and
       the less it overhangs. Measured with this block forced on (headless matches neither half of
       the query, so a plain run silently tests the DESKTOP model instead): at iPad Pro 12.9"
       landscape, 1366x896, trial-of-numbers, motif-dissection and gift-from-india all fall back to
       exactly the 60px floor, with cheese at 117 and mystery-masterpiece at 106; on an iPad Air, 692
       tall, trial-of-numbers is already there. Against iPad Safari's chrome — 129 CSS px (status bar 24
       + URL bar ~52 + tab strip ~53), of which the ~105 below the status bar is what retracts — 60px
       is not enough travel to finish the job. That 129 is `TABLET_CHROME` in
       tests/golden-viewports.js (#674), which is where the figure lives; do not re-derive it here.
       NOTE debug-mobile.php models this same device at `urlBar: 50 / urlBarMin: 25`, i.e. 25px
       retractable, so the SIMULATOR understates the effect about fourfold and cannot demonstrate
       this rule. Its device table is the thing that is wrong; correcting it is its own change.

       So `100lvh`, as that clause said. `100lvh - 100svh` IS the retractable chrome by definition,
       so the runway sizes itself to whatever this browser hides — chrome + navbar while it shows,
       settling back to 60px once it is gone, which keeps the page scrollable so it stays gone. No
       constant to guess and nothing to re-measure per device.

       `100vh` BELOW IT IS AN ANCIENT FALLBACK, NOT A LADDER RUNG, and the distinction matters
       because the first draft here wrote `100vh; 100dvh; 100lvh` and called the sequence a feature
       detect. It is not one, and it was backwards. `vh` IS the large viewport — that is the whole of
       the classic iOS "100vh overshoots" complaint — so the `100vh` line already delivers this
       rule's intent, and the `100dvh` line between them was the ONLY declaration that could make
       things worse: in the hypothetical browser with `dvh` but not `lvh` it downgrades a sized
       runway back to a flat 60px. Measured in the Chrome in play: top-level vh = lvh = dvh = svh
       when there is no chrome, and inside an iframe all four equal the frame. Two lines, and the
       second is the one that means it. */
    min-height: 100vh;
    min-height: 100lvh;
    overflow: visible;
    align-items: flex-start;
  }
  body.pz-layout2[data-mt-route^="large/"] .mt-panel-left,
  body.pz-layout2[data-mt-route^="large/"] .mt-panel-right {
    height: calc(100vh - 60px);
    height: calc(100dvh - 60px);
  }
  body.pz-layout2[data-mt-route^="large/"] .mt-panel-center {
    height: auto;
    overflow-y: visible;
  }
}/* ...and a roomy route composed as the MIDDLE COLUMN ALONE (`layout.panels === false`) drops the
   two side panels and gives their width to the centre. This is what a portrait TABLET gets: the
   1:3:1 frame is worth its third of the width in landscape and is not in portrait, where it left an
   iPad Mini's play area 410px of 744. Keyed on the engine's published answer rather than on a width,
   so the chrome cannot disagree with the `availW` the boxes were computed from. */
body.pz-layout2[data-mt-panels="none"] .mt-panel-left,
body.pz-layout2[data-mt-panels="none"] .mt-panel-right { display: none !important; }body.pz-layout2[data-mt-panels="none"] .mt-panel-center {
  flex: 1 1 auto !important;
  max-width: none !important;
  min-width: 0;
  width: auto !important;
}
/* ...AND IT SCROLLS THE PAGE, NOT A PANEL INSIDE IT. The large tier scrolls `.mt-panel-center`
   internally (its own rule above), which is right for the 1:3:1 frame: the side panels are chrome
   held against the viewport while the centre column moves. Drop the panels and there is no frame
   left to hold — one column IS the page — so an internal scroller buys nothing and costs the thing
   a reader notices first.

   Measured at 744x1133 with the ad bar's reserve on the body: the panel scrolled 54px of prose and
   the body a further 227px of reserve, so a drag moved the prose, stopped, and chained into blank
   cream. TWO NESTED SCROLLERS. Every other free portrait page — Cheese, Nine Tokens, Twelve Bowls —
   composes `small/portrait` and body-scrolls, which is why the maze felt different from all of them
   on the same device.

   So this route joins them: the frame is content-tall with a viewport floor, nothing clips, and the
   body is the only scroller. The floor is what keeps the no-ads case intact — the panel still
   stretches to the viewport, so the legal bar's `margin-top: auto` still has slack to take and it
   still sits on the bottom edge when the content is short.
   `100vh` first as the fallback, then `100dvh`, exactly as the large rule this overrides does.

   AND THE FLOOR IS A FULL VIEWPORT, NOT A VIEWPORT LESS THE NAVBAR (#671 follow-up). The body
   already carries 60px of padding for the fixed navbar, so subtracting 60 here made the document
   exactly `100dvh` — pixel-flush, and therefore UNSCROLLABLE. A mobile browser retracts its URL bar
   in response to a scroll gesture, so a page with no runway can never reclaim that chrome: measured
   on iPad Mini portrait, motif-dissection, gift-from-india and mystery-masterpiece all sat at
   scrollHeight === clientHeight and the bar stayed down for good. escape-maze escaped only because
   its board is 472 tall rather than 300-390, so its description ran past the fold and gave Safari
   something to scroll — i.e. the fault was CONTENT-dependent on a route shared by four puzzles, and
   one description edit away from reaching the fourth.
   Dropping the subtraction leaves the document taller than the viewport, which is the mechanism
   every ordinary content page on this site already uses for the same reason — see the
   `min-height: 100dvh` on the portrait/landscape-small `.mt-layout`, whose comment says so.
   The slack is not dead: the copyright bar is `position: sticky; bottom: 0` (below), so it stays on
   the bottom edge rather than sliding under it, and the slack lands between the prose and the bar.

   AND THE FLOOR IS `lvh`, NOT `dvh`, BECAUSE THE RUNWAY MUST BE AS LONG AS THE CHROME IS TALL.
   `dvh` tracks the bar, so it yields a constant 60px of runway — one navbar — in every state. That
   is enough to make the page scrollable but NOT enough to retract with: measured off a device
   screenshot, iPad Safari's chrome is 129 CSS px (status bar 24 + URL bar ~52 + tab strip ~53) of
   which the ~105 below the status bar retracts, and a browser collapses that progressively as you
   scroll. Give it 60px of travel and it can never finish the job. The 129 is `TABLET_CHROME` in
   tests/golden-viewports.js (#674) — one figure, named once, read here.
   `lvh` is the viewport with the retractable UI GONE, so `100lvh - 100svh` IS the retractable chrome
   — the runway sizes itself to whatever this browser actually hides, with no constant to guess and
   nothing to re-measure per device. Expanded it comes to chrome + navbar (~163px on an iPad Mini);
   once retracted, `lvh` is the whole viewport and it settles back to the same 60px, so the page
   stays scrollable and the chrome stays down.
   `100vh` above it is an ANCIENT FALLBACK, not a ladder rung — `vh` already IS the large viewport
   (the classic iOS "100vh overshoots" complaint), so it delivers the same intent; see the fuller
   note on the touch-landscape rule above for why a `100dvh` line between the two would be the one
   declaration able to make this worse.

   WHAT THIS COSTS WHERE THERE IS NO CHROME TO RECLAIM, stated because the rule is not media-gated
   and so also reaches a desktop window and a trackpad iPad. There the runway buys nothing and is
   not free: a classic scrollbar appears, `documentElement.clientWidth` drops 15px and the engine
   composes a narrower board (measured at 744x1133: motif-dissection, gift-from-india and
   mystery-masterpiece all go 744 -> 729), and the dead strip between the prose and the pinned bar
   grows by the same 60px. Accepted rather than overlooked: the configuration is a desktop browser
   sized to a narrow portrait window, the loss is 2% of board width, and gating this on
   `(hover: none) and (pointer: coarse)` would not separate the cases cleanly anyway — a detached
   Windows tablet reports coarse AND has classic scrollbars. */
body.pz-layout2[data-mt-panels="none"] .mt-layout {
  height: auto !important;
  min-height: 100vh;
  min-height: 100lvh;
  overflow: visible !important;
  /* STATED, not inherited. With the side panels gone the centre column is the only flex item,
     and `stretch` is the whole mechanism giving it the frame's viewport floor — which is what
     leaves the legal bar's `margin-top: auto` any slack to take, and is the note two paragraphs
     up. It used to arrive from the `large/` block, so this route depended on a value it never
     named; the #487 touch rule restores the base's `flex-start` for that route and the bar
     detached from the bottom edge and floated mid-page with dead cream under it (measured at
     744x1133: footer bottom 1038 -> 618). Re-asserted here so the dependency is explicit and
     cannot be taken away again from a rule that is not about this route. */
  align-items: stretch;
}
body.pz-layout2[data-mt-panels="none"] .mt-panel-center {
  height: auto !important;
  overflow-y: visible !important;
}

body.pz-layout2[data-mt-route^="large/"] .mt-side-top { height: 300px; }
body.pz-layout2[data-mt-route^="large/"] .mt-left-logo { padding: 12px 8px; }
body.pz-layout2[data-mt-route^="large/"] .mt-left-logo img { height: auto; max-height: 300px; }
body.pz-layout2[data-mt-route^="large/"] .mt-right-constellation { padding: 10px 8px; }
body.pz-layout2[data-mt-route^="large/"] .mt-right-constellation svg {
  height: auto; max-height: none; width: 100%; max-width: 170px;
}body.pz-layout2[data-mt-route^="large/"] .mt-ads-placeholder { display: flex; }body.pz-layout2[data-mt-route^="large/"] .mt-panel-footer { display: block; }body.pz-layout2[data-mt-route^="large/"] .mt-content-footer { display: none; }/* ...UNLESS the route dropped the side panels, which is where that footer's content lives on this
   tier. With no panels there is no legal + copyright and no version chip anywhere on the page, so
   the small tier's single content-footer bar comes back and the layout appends it after the prose.
   `flex`, not `block`: the base rule centres the legal link and the version chip as flex children
   and wraps them to two centred lines when they will not fit (#374), and none of that survives a
   block display.
   STUCK TO THE BOTTOM. The layout appends it after the description, so in plain flow the copyright
   and the version sit at the far end of the prose, reached only by scrolling to the very bottom.
   Sticky keeps the bar against the bottom of the scrollport the way the navbar is held against the
   top, and it costs nothing to reserve: sticky stays IN FLOW, so at the end of the scroll the bar is
   simply the last thing on the page and the prose ends above it. It is already the navbar's own
   #1C130A with a gold rule, so pinned it reads as the page's bottom chrome rather than as a floating
   banner. THE SCROLLPORT IS THE VIEWPORT: this route body-scrolls (see the block above — the panels
   are gone, so there is no frame for an inner scroller to hold still), and nothing between the bar
   and the viewport clips, which is what makes `bottom` bite. It used to be `.mt-panel-center`, and
   the rule reads the same either way. */
body.pz-layout2[data-mt-panels="none"] .mt-content-footer { display: flex; }
/* ...ONLY WHERE THE BOTTOM EDGE IS FREE, which is the no-ads case and only it. Where the route
   carries the ad bar (`data-mt-ads="bottom"`, published by the composition) the copyright is not
   this page's bottom chrome any more — the ad is — so it goes back to being the last thing in the
   PROSE and scrolls away with it, exactly as it does on Cheese and on every other route whose
   portrait carries a bar. Pinned, it read as a second bar stacked on the first: two strips of
   chrome across the bottom of a tablet, one of them repeating on every scroll position.
   The reserve still applies, so the copyright can always be scrolled clear of the ad. */
body.pz-layout2[data-mt-panels="none"]:not([data-mt-ads="bottom"]) .mt-content-footer {
  /* TWO CASES, and the bar has to be at the bottom in both.
     SHORT CONTENT — the usual one here, and the one plain flow gets wrong. Measured on an iPad Mini
     portrait: the play area plus the prose come to 978px inside a 1073px panel, so the panel does
     not scroll at all and the footer sat 107px above the bottom edge with dead cream under it.
     `margin-top: auto` hands that slack to the bar instead, which needs the area to REACH the
     bottom first — hence the two rules below. Nothing above it moves: every box in the column is
     `flex: 0 0`, so the slack has nowhere else to go.
     TALL CONTENT — a longer description, or a shorter viewport. Then the PAGE scrolls and
     `auto` contributes nothing, so `sticky` holds the bar against the bottom of the viewport the
     way the navbar is held against the top. Sticky stays IN FLOW, so at the end of the scroll it is
     simply the last thing on the page and the prose ends above it — no reserve to keep in step. */
  margin-top: auto;
  position: sticky;
  bottom: 0;
  z-index: 2;
}/* ...and the area has to reach the bottom for `margin-top: auto` to have anything to take. Both are
   content-sized by default (`flex: 0 0 auto`) because on every other route something else owns the
   remaining height; here nothing does. */
body.pz-layout2[data-mt-panels="none"] .mt-panel-center > .container,
body.pz-layout2[data-mt-panels="none"] .pz-area { flex: 1 1 auto; }body.pz-layout2[data-mt-route^="large/"] .mt-portrait-ads-bar { display: none; }/* THE CONTENT FOOTER (copyright + version chip) closes the page below the fold on the SMALL
   routes — the same place every old phone layout put it. The site hides it on every puzzle page
   (body.mt-puzzle-page, ~line 2191) and the old engine re-showed it by hand, so this is that
   rule's replacement. The engine appends it AFTER the description (each small route lists it in
   `belowFold`), so it renders last rather than above the prose.
   SMALL ROUTES ONLY: the 1:3:1 tier carries its footers in the SIDE PANELS instead — legal +
   copyright bottom-left, version chip bottom-right — so showing this one there would duplicate
   them. Keyed on the engine's published route, never a media query. */
body.pz-layout2[data-mt-route^="small/"] .mt-content-footer { display: flex; flex-wrap: wrap; }/* ...AND PINNED INTO THE STRIP THE AD BAR WOULD HAVE HELD, on the routes that reserved it (#624).
   Portrait has always ended in a pinned band; for a viewer with no ads the ad bar is not rendered at
   all, and rather than end the page on nothing the LEGAL + VERSION bar takes that strip — the same
   bar, in the same place, that the ordinary pages pin.
   KEYED ON THE COMPOSITION, never on a media query: `data-mt-pin-footer` is published by apply()
   only where the route RESERVED the strip (`bodyPaddingBottom`), so a fixed bar can never end up
   sitting over below-fold content that was not moved out from under it.
   ONLY THE NEAR-SQUARE FAMILY reserves it, and the reason is arithmetic rather than taste: those
   boards are WIDTH-bound in portrait, so the strip is paid out of height the board could not use
   (nine-tokens and lamplighter compose 354x354 either way). A LONG scene binds on height, and there
   the same strip came straight off the board — trains measured 258x751 against 244x709 — so that
   family keeps its copyright below the fold, after the description, and publishes no attribute. */
body.pz-layout2[data-mt-pin-footer] .mt-content-footer {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 500;
}/* ── LAYOUT ENGINE (#624) — AD SURFACES for the LONG-SCENE family ─────────────────
   Shared by every puzzle on that family (Reversing the Train, the Eight Chests, and
   the Trial of Numbers next). It lived in the first puzzle's own $headExtra and was
   COPIED into the second, which is the duplication this file exists to prevent — the
   rules are about the SLOT and the route, not about any one puzzle.

   Every selector keys on the body attributes apply() publishes (data-mt-ads /
   data-mt-nav), so CSS renders the decision `f` already made and never makes a second
   one from the viewport. The one puzzle-specific part left in each page is the LOOK of
   its own board; the in-column slot carries the shared .mt-col-ads class. */

/* The in-column slot (portrait): controls above, ad beneath, label centred. */
body.pz-layout2 .mt-col-ads,
body.pz-layout2 .mt-portrait-ads-bar { display: none; }/* The TOP-of-right-column slot (phone landscape): the board takes the width, the ad takes the
   height the board cannot use — see MtLayout.longScene's sideAdTop. Same element and same look as
   the portrait in-column slot, so it needs only to be listed here. */
body.pz-layout2[data-mt-ads="band"] .mt-col-ads,
body.pz-layout2[data-mt-ads="top"] .mt-col-ads,
/* 'colside' is the LANDSCAPE in-column placement — the same element, a different route. It is a
   separate name from 'column' on purpose: the roll-up bar's rules key on 'column', and sharing the
   name showed that bar off-screen on a landscape page and had an ad requested into it. */
body.pz-layout2[data-mt-ads="colside"] .mt-col-ads,
body.pz-layout2[data-mt-ads="column"] .mt-col-ads {
  display: flex; align-items: center; justify-content: center;
  width: 100%; box-sizing: border-box;
  border: 1px dashed #C9A84C; border-radius: 3px;
  color: #9A7B3A; font-family: 'Crimson Text', serif;
  letter-spacing: 0.15em; text-transform: uppercase; font-size: 0.7em;
}/* The stack must FILL the cell for the ad's bottom pin to mean anything: auto margins
   can only distribute space that exists, and a shrink-wrapped inner has none. */
body.pz-layout2[data-mt-ads="column"] .pz-controls-inner { height: 100%; }body.pz-layout2[data-no-ads] .mt-col-ads {
  color: transparent; background-color: #FBF7EC;
  background-size: cover; background-position: top center; background-repeat: no-repeat;
  border-style: solid; border-color: rgba(201, 168, 76, 0.25);
}/* The sticky bottom bar — present on BOTH portrait placements, because it is the
   SECOND surface rather than an alternative to the in-column one. Its height is driven
   by scroll (MtLayout's sticky driver) and starts at zero, so nothing shows until the
   trigger is crossed. */
body.pz-layout2[data-mt-ads="column"] .mt-portrait-ads-bar,
body.pz-layout2[data-mt-ads="sticky"] .mt-portrait-ads-bar {
  display: flex; position: fixed; left: 0; right: 0;
  /* FIXED SIZE, SLID INTO VIEW — not a box that grows (#709). The engine's sticky driver writes the
     final height once and animates translateY, so nothing inside is ever cropped; the viewport edge
     does the occluding. `translateY(100%)` is the at-rest state for the frames before the driver's
     first write. overflow:hidden is belt-and-braces now rather than the reveal mechanism. */
  transform: translateY(100%);
  overflow: hidden;
  transition: transform 120ms linear;
  align-items: center; justify-content: center; z-index: 1020;
}/* NO NAV-AD NAVBAR (#709). A whole navbar variant used to live here — one row of
   [hamburger][ad][shield] over a bar the engine sized at max(60, 20% of cl.h) — for the one
   composition that carried its ad in the navbar. That placement was measured out of existence
   (31 of 181,557 compositions, in viewport bands no device produces), so every rule keyed on
   `[data-mt-ads="nav"]` was unreachable and went with it, along with the .mt-nav-ads slot
   itself. The route composes with the site's ordinary bar now. */
/* The side ad IS the right panel; the engine writes its box. The panel's other contents   (constellation, version chip) are desktop chrome and would SHARE the slot: measured on a
   390-tall phone the ad placeholder came out 33px wide inside a 243px panel — a sliver, not
   an ad — because the panel lays out as a row there and the three children split it. So the
   panel becomes a column holding only the ad, and the ad fills it. That box is also what
   js/ad-slots.js measures to size a real unit. */
body.pz-layout2[data-mt-ads="side"] .mt-panel-right {
  display: flex !important;
  flex-direction: column !important;
  flex: 0 0 auto !important;
  /* STAYS PUT while the description scrolls past — the same pinning the desktop panels get, and
     what the old phone-landscape variant did. It was `position: static` here, which let the ad
     scroll away the moment the reader went below the fold. A sticky box is still a flex item, so
     nothing about the row changes. `top` comes from the short-navbar rule below (0, since the page
     reserves no top strip there). */
  position: sticky !important;
  align-self: flex-start !important;
}body.pz-layout2[data-mt-ads="side"] .mt-panel-right > * { display: none !important; }body.pz-layout2[data-mt-ads="side"] .mt-panel-right > .mt-ads-placeholder {
  display: flex !important;
  flex: 1 1 auto !important;
  width: auto !important;
  min-width: 0;
  margin: 0 !important;                 /* same reason as the base rule: the inset comes off the ad */
}/* FLEX, not block: the bar's own rule centres its content, and a block bar left the "Ads"
   marker as a 31x17 inline run in the top-left corner — a slot a real ad unit would mount
   into the same wrong place. */
body.pz-layout2[data-mt-ads="bottom"] .mt-portrait-ads-bar { display: flex !important; }/* ── PLAIN NAVBAR (data-mt-nav="plain") ───────────────────────────────────────────
   The site's ordinary full-width bar. It was written for the phone-landscape route a viewer
   with NO ads gets, and since #709 it also carries the STACK composition for everyone — the
   one that used to ride in a tall ad-bearing navbar. No ad rides in it either way, so it
   keeps the site's own 60px and the site's own look, and nothing here restyles it. The one
   thing it must do is what the ad-carrying bars each do in their own chrome — KEEP
   BOOTSTRAP'S COLLAPSED MODE AT EVERY WIDTH.

   Above 768px Bootstrap lays the nav links out inline, and the phone landscapes this
   route serves run to 932px: measured at 844x390 it rendered Home / Puzzles / About /
   Chronicle / Login as a link bar where the hamburger belongs. The routes with their own
   navbar chrome force the toggle themselves; this bar had nothing to force it.

   The dropdown rules mirror the short bar's exactly (top:100% off the bar's real bottom
   edge, not a hard-coded height) so the two bars open the same menu in the same place. */
body.pz-layout2[data-mt-nav="plain"] .navbar-fixed-top .navbar-toggle { display: block; }/* ...and the toggle then has to land where it lands below 768px — the bar's RIGHT edge.
   Bootstrap floats `.navbar-header` LEFT above 768, so a shrink-wrapped header put the
   hamburger immediately beside the brand and the bar read differently either side of a
   width no player crosses deliberately. Restoring the header to full width lets the
   toggle's own `float: right` reach the edge again.
   Scoped to the width where Bootstrap's rule applies, and ONLY there: the negative
   margins it gives the header BELOW 768 are load-bearing for the inset the brand and
   toggle already have, so this must not reach them. */
@media (min-width: 768px) {body.pz-layout2[data-mt-nav="plain"] .navbar-fixed-top .navbar-header {
    float: none; width: 100%;
  }}body.pz-layout2[data-mt-nav="plain"] .navbar-fixed-top .navbar-collapse.collapse {
  display: none !important; height: 0 !important; overflow: hidden !important;
}/* EVERY DECLARATION HERE IS !important, and that is not defensive noise — the rule above
   is, so an open panel that merely sets `display` inherits `height: 0` and `overflow:
   hidden` from it and opens as a 3px sliver with its links painting over the board on no
   background. Measured at 667x375 and 844x390 before this was mirrored from the short-bar
   block below, which carries the same list for the same reasons:
     - `height: auto` / `overflow-*`, or the panel stays the collapsed 0-height box;
     - `margin-left/right: 0`, or Bootstrap's collapse-mode negative margins (viewport
       <= 767) hang it at x-15, 682px wide in a 652px viewport;
     - its own background + border, because `position` detaches it from the bar and it
       has no navbar behind it;
     - the stacking rules under it, because above 768 Bootstrap lays the links out
       HORIZONTALLY and the "menu" opens as a strip of inline links.
   `fixed` + `top: 60px`, exactly as the short bar does. `absolute` + `top: 100%` reads as
   the tidier expression — hang the panel off the bar's own bottom edge, no second copy of
   the reserve — and it is wrong by 3px: a percentage `top` resolves against the containing
   block's PADDING box, which excludes the bar's 3px gold bottom border, so the panel opened
   over that border and the rule under the navbar vanished while the menu was up. The bar is
   at the viewport's top edge on this route, so viewport coordinates are exact. */
body.pz-layout2[data-mt-nav="plain"] .navbar-fixed-top .navbar-collapse.collapse.in,
body.pz-layout2[data-mt-nav="plain"] .navbar-fixed-top .navbar-collapse.collapsing {
  display: block !important;
  height: auto !important;
  overflow-y: auto !important;
  overflow-x: hidden !important;
  position: fixed !important;
  top: 60px !important;
  left: 0 !important;
  right: 0 !important;
  width: auto !important;
  margin-left: 0 !important;
  margin-right: 0 !important;
  max-height: calc(100dvh - 60px) !important;
  background: #1C130A !important;
  border-bottom: 3px solid #C9A84C !important;
  padding: 0 15px !important;
  z-index: 1010 !important;
}body.pz-layout2[data-mt-nav="plain"] .navbar-fixed-top .navbar-collapse .navbar-nav {
  float: none !important;
  margin: 7.5px 0 !important;
  width: auto !important;
}body.pz-layout2[data-mt-nav="plain"] .navbar-fixed-top .navbar-collapse .navbar-nav > li {
  float: none !important;
  display: block !important;
}body.pz-layout2[data-mt-nav="plain"] .navbar-fixed-top .navbar-collapse .navbar-nav.navbar-right {
  float: none !important;
}/* ── SHORT NAVBAR (data-mt-nav="short") ───────────────────────────────────────────
   The navbar spans only the controls column, so the play area and the ad start at y=0
   and keep the full viewport height — 60px is 15% of the board on a 390-tall phone.
   The WIDTH is written inline by the engine (it is the controls column, a computed
   box); everything here is the chrome that has to follow: no top reserve, no brand,
   and a dropdown that still spans the viewport instead of the 160px bar.
   Modelled on the proven pz-mobile-fold-ads mini-navbar. */
body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top { right: auto; min-height: 0; }body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .mt-navbar-inner {
  display: flex;
  align-items: center;
  padding: 0;
  margin: 0;
  max-width: none;
}body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-brand { display: none; }/* NEGATIVE MARGINS OFF. Bootstrap 3 gives .navbar-header `margin: 0 -15px` in its
   collapse-mode rule (viewport <= 767px), which bleeds the header 15px past the bar's
   left edge — measured: the hamburger sat 5px OUTSIDE the bar on every phone whose
   landscape viewport is under 768 (iPhone SE1 568, SE2/8 667, Galaxy S20 FE 740), while
   wider phones were clean, which is why it showed up only on those. `width: auto` for the
   same reason: 100% + those margins made the header 30px wider than its own bar. */
body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-header {
  float: none;
  width: auto;
  margin-left: 0;
  margin-right: 0;
  display: flex;
  align-items: center;
}body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-toggle {
  display: block;
  float: none;
  padding: 9px;
  margin: 6px 10px;
  border: 2px solid #C9A84C;
  border-radius: 4px;
  background: transparent;
}body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-toggle .icon-bar {
  width: 22px; height: 3px; background-color: #C9A84C;
}body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-toggle .icon-bar + .icon-bar {
  margin-top: 4px;
}/* Shield at the right edge of the short bar — [hamburger][gap][shield], the same
   ::after flex item the mini-navbar uses. */
body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .mt-navbar-inner::after {
  content: '';
  flex: 0 0 44px;
  width: 44px; height: 44px;
  margin-left: auto; margin-right: 8px;
  background: url('../img/logo-small.png') center / contain no-repeat;
}/* HAMBURGER AT EVERY WIDTH. Bootstrap shows the nav links inline above 768px, and inside a
   160px bar they stacked into a 303px-tall navbar (measured) — three times the reserve the
   layout subtracts. A short bar is only ever a hamburger. */
body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-collapse.collapse {
  display: none !important;
  height: 0 !important;
  overflow: hidden !important;
}/* The dropdown must not be trapped inside a 160px bar. Measured breakages this rule
   had to fix, all of them in this variant:
     - `width: 100vw` made the menu 844px inside an 829px layout viewport, because vw
       counts the scrollbar. left/right: 0 spans the real width instead.
     - Bootstrap's collapse-mode negative margins (viewport <= 767) put the menu at
       x-15, hanging off the left edge — the same margins that had the hamburger
       outside the bar.
     - Above 768 Bootstrap lays the links out HORIZONTALLY, so the "menu" opened as a
       56px-tall strip of inline links across the board. A short bar is only ever a
       hamburger, so the panel is forced to stack at every width (the same treatment
       body.mt-nav-forced applies elsewhere in this file).
     - max-height without !important lost to the unscoped rule at the top of this file,
       so the cap stayed at the old 50px navbar (340 instead of 330). */
body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-collapse.collapse.in,
body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-collapse.collapsing {
  display: block !important;
  height: auto !important;
  overflow-y: auto !important;
  overflow-x: hidden !important;
  position: fixed !important;
  top: 60px !important;
  left: 0 !important;
  right: 0 !important;
  width: auto !important;
  margin-left: 0 !important;
  margin-right: 0 !important;
  max-height: calc(100dvh - 60px) !important;
  /* ITS OWN BACKDROP. Detached from the bar by `position: fixed`, this panel gets no
     navbar background behind it — without these it painted half-transparent over the
     board. Values copied from the proven pz-mobile-fold variant so the two look alike. */
  background: #1C130A !important;
  border-bottom: 3px solid #C9A84C !important;
  padding: 0 15px !important;
  z-index: 1010 !important;
}body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-collapse .navbar-nav {
  float: none !important;
  margin: 7.5px 0 !important;
  width: auto !important;
}body.pz-layout2[data-mt-nav="short"] .navbar-fixed-top .navbar-collapse .navbar-nav > li {
  float: none !important;
  display: block !important;
}/* No top reserve ⇒ the layout and the sticky ad panel start at the very top. */
body.pz-layout2[data-mt-nav="short"] .mt-layout { min-height: 100dvh; }body.pz-layout2[data-mt-nav="short"] .mt-panel-left,
body.pz-layout2[data-mt-nav="short"] .mt-panel-right { top: 0; height: 100dvh; }/* ── THE BAND'S CORNER (data-mt-ads="band") ───────────────────────────────────────
   The band is one bar of chrome — [corner][compact controls][ad] — over a full-width
   board, so its left block is a SHORT navbar (all the rules above apply) sized to the
   band's own height rather than to 60px. Only two things differ from the short bar
   beside a controls column, and both follow from the corner being tall and narrow
   instead of wide and thin:

     1. it STACKS — hamburger above shield — because a 64px-wide block has no room for
        them side by side, and leaving the space under the hamburger empty would read as
        a hole in the bar rather than as part of it;
     2. the hamburger is pinned to the TOP and the shield takes everything under it. The
        toggle's position is not free: opening the menu drops a panel from the bar's top
        edge, so the control that opens it belongs against that edge — centring the pair
        moved the hamburger down by half the leftover and left the menu appearing to come
        from nowhere. The shield then centres inside the remainder, which is `background-
        position: center` doing the work rather than a second flex rule;
     3. the shield SHRINKS rather than clipping. Its box is `flex: 1 1 0` — the leftover,
        whatever that is (the corner is a computed box, 88–138px across the phone
        landscapes) — and `contain` scales the crest inside it, capped at the box's 40px
        width. `min-height: 0` is what lets a flex item shrink below its content at all. */
/* THE BAND'S CONTROLS STAND CLOSER TOGETHER, because its block is the one place on the site where
   horizontal room is the scarce axis: every px the buttons take is a px off the ad beside them, and
   the block's width is what decides whether the four controls wrap 2x2 or one-per-row. So the shared
   buttons lose 6px of horizontal padding each side here (14 → 8, and the fold link 7 → 4) — the text
   is untouched, only the furniture. Each puzzle's own badge is tightened in its own sheet, since a
   badge's look belongs to the puzzle. These MUST stay in step with the `band:` overrides in each
   <slug>.layout.js: the engine reserves a width from those constants and a min-height from the wrap
   they imply, so a padding the spec does not know about is a reservation that silently lies. */
body.pz-layout2[data-mt-ads="band"] .pz-controls-inner .pz-btn {
  padding-left: 8px; padding-right: 8px;
}body.pz-layout2[data-mt-ads="band"] .pz-controls-inner .pz-desc-scroll {
  padding-left: 4px; padding-right: 4px;
}/* The Gift's rotate PAIR gives up the same 6px per half. It needs its OWN rule rather than riding
   the .pz-btn one above, because the pair's halves are widened past a plain button (16px a side, so
   two icons read as a control rather than as two glyphs) by a rule in the puzzle page — which loads
   AFTER this file, so an equal-specificity selector here would lose to it and the pair would render
   99px against the 75 the spec reserves. Matching the neighbours' specificity is what wins. */
body.pz-layout2[data-mt-ads="band"] .pz-controls-inner .gfi-rotate .gfi-rotate-btn {
  padding-left: 10px; padding-right: 10px;
}/* ── THE STRIP IS CHROME, SO IT WEARS THE CHROME'S COLOURS ────────────────────────
   The corner is a navbar and the rest of the band is a layout cell, but they are ONE bar to look at
   — so the cell takes the navbar's own `#1C130A` and its `3px solid #C9A84C` bottom border, and the
   gold rule that used to stop at the corner's 64px runs the whole width. Values copied from
   `.navbar-inverse` rather than re-chosen, so the two halves cannot drift apart. `box-sizing` is
   what keeps them aligned: apply writes the cell's height from bandH exactly as it writes the
   navbar's, so the border has to count INSIDE both or the two bottom edges land 3px apart. */
body.pz-layout2[data-mt-ads="band"] .pz-controls {
  background: #1C130A;
  border-bottom: 3px solid #C9A84C;
  box-sizing: border-box;
}/* ...and the controls have to be legible ON that, which the site's own button is not: `.pz-btn` is
   dark brown on transparent, i.e. invisible here. Everything but Reset takes the gold-on-dark
   treatment `.pz-desc-scroll` already has, so the band reads as one set. RESET KEEPS ITS ROSE FILL:
   a light fill reads perfectly well on dark, and standing apart from the other controls is the
   entire point of that variant (issue #8). */
body.pz-layout2[data-mt-ads="band"] .pz-controls-inner .pz-btn:not(.pz-btn--reset) {
  color: #C9A84C;
  border-color: #C9A84C;
}body.pz-layout2[data-mt-ads="band"] .pz-controls-inner .pz-btn:not(.pz-btn--reset):hover:not(:disabled),
body.pz-layout2[data-mt-ads="band"] .pz-controls-inner .pz-btn:not(.pz-btn--reset):focus:not(:disabled) {
  background: #C9A84C;
  color: #1C130A;
}/* The ad slot likewise takes the navbar slot's look instead of the cream in-column one. */
body.pz-layout2[data-mt-ads="band"] .mt-col-ads {
  background-color: rgba(245, 237, 220, 0.10);
  color: #C9A84C;
  border-color: #C9A84C;
}body.pz-layout2[data-no-ads][data-mt-ads="band"] .mt-col-ads {
  background-color: #1C130A;
  border-style: solid; border-color: rgba(201, 168, 76, 0.25);
  color: transparent;
}body.pz-layout2[data-mt-ads="band"] .navbar-fixed-top { overflow: hidden; }body.pz-layout2[data-mt-ads="band"] .navbar-fixed-top .mt-navbar-inner {
  height: 100%;
  flex-direction: column;
  justify-content: flex-start;
  align-items: center;
  gap: 0;
  overflow: hidden;
}/* THE TOGGLE KEEPS THE SITE'S OWN INSET (`margin: 6px 10px` from the short-navbar rule above), so it
   sits the same distance from the bar's edge here as in every other navbar variant. It was zeroed
   when the corner first stacked — "pin the hamburger to the top" taken literally — and on the wider
   phone landscapes, where the band is at its shortest (83–91px), that put it flush against the bar's
   gold border and it read as stuck to it. 44px of toggle inside 10px margins is exactly the 64px
   corner, so the same number that insets it from the top also centres it. */
body.pz-layout2[data-mt-ads="band"] .navbar-fixed-top .navbar-header { margin: 0; flex: 0 0 auto; }body.pz-layout2[data-mt-ads="band"] .navbar-fixed-top .mt-navbar-inner::after {
  flex: 1 1 0;
  width: 40px; height: auto;
  min-height: 0;
  margin: 0;
  align-self: center;
}/* ...and NOT AT ALL once the crest would be under 60% of the hamburger — the layout works that out
   from the band's height and publishes the answer (MtLayout's CORNER_SHIELD_MIN_F), because CSS has
   no way to ask how much room is left under the toggle. The corner keeps its FULL strip height
   either way: apply writes that height from bandH, so the bar does not shrink around the loss — the
   hamburger simply sits alone in it. */
body.pz-layout2[data-mt-corner-shield="0"] .navbar-fixed-top .mt-navbar-inner::after {
  display: none;
}

/* ── AD SLOT: below Google's fixed-size floor (#709) ──────────────────────────────
   Google will not serve a unit under 120x50. A measured audit found 41 boxes that miss it on small
   phones — the in-column slot on the two long-scene puzzles in portrait, and the side rail on
   568-667px landscape, down to 67px. Those are LAYOUT problems, being fixed one at a time; until
   then js/ad-slots.js declares no slot for such a box and marks it here.

   RED ON BETA ONLY. A visitor must never be shown our diagnostics, so on production the marker is
   inert and the slot simply keeps the ornament it already has — which is the correct end state
   anyway: fail to decoration, never to blank. On beta it goes red and states its box, so a
   violation reports itself on whatever device it appears on, INCLUDING devices nobody has tested.
   That is the case an audit cannot cover by construction, and it is why this is a live marker
   keyed on the floor rather than a list of known-bad viewports that would go stale. */
body[data-ads-sim] .mt-ad-under-min {
  position: relative;
  background-color: rgba(168, 52, 44, 0.16) !important;
  background-image: none !important;
  outline: 2px solid #A8342C;
  outline-offset: -2px;
}
body[data-ads-sim] .mt-ad-under-min-label {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2px;
  /* The box is by definition narrow — that is what it is complaining about — so the label wraps and
     shrinks rather than overflowing the very slot it is measuring. */
  font: 600 9px/1.15 ui-monospace, 'Cascadia Mono', 'SF Mono', Consolas, monospace;
  letter-spacing: .02em;
  color: #7C231D;
  text-align: center;
  overflow-wrap: anywhere;
  pointer-events: none;
}
/* Production: no red, no label — the ornament stands and the violation is silent to the visitor. */
.mt-ad-under-min-label { display: none; }
body[data-ads-sim] .mt-ad-under-min-label { display: flex; }

/* ── Draining notice (#798) ────────────────────────────────────────────────
   Shown under the fixed navbar while includes/.draining is present, to
   SIGNED-IN visitors only (includes/header.php). Amber rather than red: the
   site is winding down on purpose and nothing has gone wrong, so it must read
   as an announcement, not an error — the same distinction the login card's
   "Signing In Is Paused" copy draws.

   position:static and in normal flow on purpose. A fixed strip would overlay
   whatever is beneath it, and on a puzzle page the layout engine composes
   around a viewport it measures itself — a floating band would sit on the
   board. In flow it simply pushes content down, which every route already
   handles because that is what the navbar does. */
.mt-draining-notice {
  background: #FFF4D6;
  color: #5C4813;
  border-bottom: 1px solid #C9A84C;
  font-family: 'Crimson Text', serif;
  font-size: 15px;
  line-height: 1.45;
  text-align: center;
  padding: 8px 16px;
}
.mt-draining-dot { color: #C9A84C; margin-right: 6px; font-size: 11px; vertical-align: middle; }

/* THE PUZZLE-PAGE SHAPE (#798). Not in flow, because in flow it moves the board:
   the layout engine composes against the VIEWPORT and does not recompose for a
   band above it, so the board keeps its size and slides down — measured at 390x740
   as 70px below the fold for Reversing the Train, which is height-bound and takes
   every pixel by design. Hidden until js/drain-countdown.js sees the win
   celebration's action bar, at which point fixed costs nothing: the board is
   finished and the screen is already an overlay.

   z-index clears the whole celebration stack — backdrop 1035, canvas 1038, action
   bar 1040 — so it sits above the dimmed page and reads against it. */
.mt-draining-notice.mt-draining-onwin {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1041;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
/* Explicit, so the `hidden` attribute wins whatever else this element is given. */
.mt-draining-notice[hidden] { display: none; }

/* THE LAST MINUTE (#798). js/drain-countdown.js adds .mt-draining-soon when the
   announced moment is under a minute away and .mt-draining-now once it arrives.
   Red, because by then it IS urgent — but the amber band above has been on screen
   since the drain began, so this is an escalation the reader has been prepared
   for rather than an alarm out of nowhere.

   COLOUR IS NOT THE ONLY SIGNAL: the text itself changes to a ticking count, so
   a reader who cannot distinguish the two grounds still sees the difference. */
.mt-draining-notice.mt-draining-soon {
  background: #FBE3E1;
  color: #8A1F17;
  border-bottom-color: #A6291E;
  font-weight: 600;
}
.mt-draining-soon .mt-draining-dot { color: #A6291E; }

/* ONCE IT IS RED IT MUST NOT SCROLL AWAY. Until then the band is an announcement
   and letting it go by is fine; inside the last minute it is the only thing on
   screen telling somebody their time is nearly up, and a reader who has scrolled
   down to the puzzle list or into an article would never see it.

   STICKY, NOT FIXED, and that is the whole reason this works: sticky keeps the
   element in FLOW, so nothing moves at the moment it turns red — a switch to fixed
   would pull it out of flow and yank the page up by its own height, mid-read.
   `top: 50px` is the reserve body already holds for the fixed navbar, so pinned it
   lands exactly where it sits unpinned. Below the navbar's z-index (1030), because
   it belongs under the chrome, not over it.

   INERT ON DESKTOP by construction rather than by a media query: there
   .mt-panel-center scrolls internally and the document does not, so the band is
   never scrolled past and sticky has nothing to do.

   NOT ON A PUZZLE PAGE. That shape (.mt-draining-onwin) is already fixed and
   hidden until the win celebration, precisely so nothing is pinned over a board
   the layout engine composed around. The :not() is belt-and-braces — header.php
   renders one shape or the other, never both — but the rule that must never be
   broken deserves to be written where it would be broken. */
.mt-draining-notice.mt-draining-soon:not(.mt-draining-onwin) {
  position: sticky;
  top: 50px;
  z-index: 1029;
}

/* At zero the dot stops being decoration and starts pulsing, the one moment on
   the site where motion earns its place. Honours the reduced-motion preference —
   the words carry the meaning regardless. */
@keyframes mt-draining-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.mt-draining-now .mt-draining-dot { animation: mt-draining-pulse 1.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .mt-draining-now .mt-draining-dot { animation: none; }
}

/* The way out, revealed only at zero. An inline button rather than a bare link:
   it is the one thing being asked of the reader at that point. */
.mt-draining-out {
  display: inline-block;
  margin-left: 10px;
  padding: 2px 12px;
  border: 1px solid #A6291E;
  border-radius: 3px;
  background: #A6291E;
  color: #FFF4D6;
  font-family: 'Cinzel', serif;
  font-size: 13px;
  text-decoration: none;
  vertical-align: baseline;
}
.mt-draining-out:hover,
.mt-draining-out:focus { background: #8A1F17; border-color: #8A1F17; color: #fff; text-decoration: none; }
.mt-draining-out[hidden] { display: none; }

/* Phone landscape is the scarcest vertical space on the site (the puzzle routes
   give every spare pixel to the board), so tighten rather than hide: a notice
   the visitor cannot see is the one case this feature exists to prevent. */
@media (max-height: 599px) and (orientation: landscape) {
  .mt-draining-notice { font-size: 13px; padding: 5px 12px; }
}

/* ── The T−5min interrupt dialog (#802) ─────────────────────────────────────
   Built by js/drain-countdown.js, so it exists only in the last five minutes.

   position: fixed and self-contained — it must sit over a composed puzzle board
   without the layout engine ever hearing about it, which is the same reason the
   puzzle-page notice is fixed rather than in flow. z-index clears Bootstrap's
   modal layer (1050) so it is never raised BEHIND a dialog a page already had
   open (the Twelve Bowls' and Cats and Mice's dead-end modals are exactly that
   case, and a warning nobody can see is the failure this feature exists to
   prevent). */
.mt-drain-dialog {
  position: fixed; inset: 0; z-index: 1060;
  display: flex; align-items: center; justify-content: center;
  padding: 20px;
  background: rgba(13, 27, 62, 0.72);
}
/* IT READS AS A WARNING. The first version was the site's ordinary cream card with
   gold trim — correct for a notice, wrong for the last five minutes, where it sat
   over the page looking like any other panel. The red rule and the red heading are
   doing the work the amber band did an hour earlier; the parchment ground stays,
   because a wholly red box reads as an ERROR, and nothing has gone wrong. */
.mt-drain-dialog-card {
  max-width: 460px; width: 100%;
  background: #F9F5EC; border: 2px solid #A6291E; border-radius: 4px;
  border-top-width: 6px;
  padding: 22px 24px; text-align: center;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45);
  outline: none;   /* focused programmatically for screen readers, not to be drawn */
}
.mt-drain-dialog-card h2 {
  font-family: 'Cinzel', serif; color: #A6291E;
  font-size: 21px; margin: 0 0 4px;
}
/* The count, and it is the largest thing in the box on purpose: it is the single
   fact the visitor needs in order to decide anything. tabular-nums so the digits
   do not jiggle the layout as they tick. */
.mt-drain-dialog-count {
  font-family: 'Cinzel', serif; color: #A6291E;
  font-size: 34px; line-height: 1.1; font-variant-numeric: tabular-nums;
  margin: 0 0 12px;
}
.mt-drain-dialog-card p {
  font-family: 'Crimson Text', serif; color: #4a4a4a;
  font-size: 16px; line-height: 1.45; margin: 0 0 18px;
}
.mt-drain-dialog-actions {
  display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;
}
.mt-drain-dialog-btn {
  display: inline-block; padding: 8px 16px;
  font-family: 'Cinzel', serif; font-size: 14px;
  border: 1px solid #C9A84C; border-radius: 3px;
  background: transparent; color: #0D1B3E; text-decoration: none;
}
.mt-drain-dialog-btn:hover,
.mt-drain-dialog-btn:focus { background: #E8D48B; color: #0D1B3E; text-decoration: none; }
.mt-drain-dialog-btn-primary { background: #A6291E; border-color: #A6291E; color: #fff; }
.mt-drain-dialog-btn-primary:hover,
.mt-drain-dialog-btn-primary:focus { background: #8A1F17; border-color: #8A1F17; color: #fff; }

/* Deliberately quiet, and deliberately present. "Keep going" is the escape for a
   player five minutes from finishing a puzzle — the interrupt is about the FLOWS
   the server already refuses, not about seizing a board — but it is styled as the
   secondary choice, because leaving is the one we are recommending. */
.mt-drain-dialog-dismiss {
  margin-top: 14px; padding: 4px 8px;
  font-family: 'Crimson Text', serif; font-size: 14px;
  background: none; border: 0; color: #6b6b6b;
  text-decoration: underline; cursor: pointer;
}
.mt-drain-dialog-dismiss:hover,
.mt-drain-dialog-dismiss:focus { color: #0D1B3E; }

@media (max-height: 599px) and (orientation: landscape) {
  /* The board's viewport. Trim the dialog rather than let it overflow — an
     unreachable button is worse than a cramped one. */
  .mt-drain-dialog-card { padding: 14px 16px; }
  .mt-drain-dialog-card h2 { font-size: 17px; margin-bottom: 6px; }
  .mt-drain-dialog-card p  { font-size: 14px; margin-bottom: 12px; }
  .mt-drain-dialog-dismiss { margin-top: 8px; }
}

/* ─────────────────────────────────────────────────────────────────────────
   THE PORTRAIT AD BAR IS A FRACTION OF THE VIEWPORT (#831)
   ─────────────────────────────────────────────────────────────────────────
   110px WAS A CONSTANT, and that was the defect: ~15% of a 740px phone — a
   deliberate-looking budget — and ~10% of an 1133px iPad Mini, which is the one
   layout where the bar is the ONLY ad surface, because portrait hides the side
   rails entirely (they measure 0x0 there). So the largest screen got the
   smallest inventory.

   #828 ANSWERED THAT WITH A SECOND CONSTANT (220px behind a roomy-portrait
   media query) AND THAT IS THE SAME MISTAKE WITH ANOTHER NUMBER. Two hard-coded
   heights behind a breakpoint still guess, they just guess twice, and every
   viewport between or beyond the two is served by whichever guess it happens to
   fall under. One fraction covers every viewport and has no breakpoint to be
   on the wrong side of.

   25% IS A CEILING, NOT THE FINAL HEIGHT. It is the box js/ad-slots.js builds
   the ladder against; the panel then SNAPS to whatever Ad Manager actually
   serves, plus breathing space. So this rule decides how much screen an ad may
   TAKE, and the auction decides how much it does take.

   WHY THE FRACTION HAD TO RISE TO 25 AND NOT 20: the ladder filters STANDARD on
   BOTH axes, so the reserve decides the INVENTORY. 300x250 — the most widely
   filled display size there is — needs 250px, which is 22% of an iPad Mini and
   30% of a phone, so a 20% ceiling offers it on NO device at all. At 25% the
   tablets admit 336x280 and 300x250; phones gain 200x200 and still cannot reach
   300x250, which is a property of phone viewports rather than of this rule.

   vh, NOT dvh. A mobile URL-bar collapse changes dvh continuously while the
   reader is merely scrolling, which would re-size the reserve — and re-ask for
   an ad — on every scroll. That is the same artifact js/ad-slots.js already
   refuses to treat as a size change ("RE-REQUEST ON RECOMPOSE, NOT ON RESIZE").
   vh is fixed to the largest viewport and does not move.

   AND vh RATHER THAN svh — ASKED, MEASURED, AND DECIDED TO KEEP. Both are fixed,
   so the dvh argument does not separate them; what separates them is WHICH fixed
   number. A phone gives the page less room on arrival (address bar showing) than
   after a scroll (address bar retracted), and vh is a quarter of the LARGER one,
   so the bar is slightly over a quarter at the moment a reader lands:

       iPhone 14   701 on arrival -> 749 scrolled   bar 187   27% then 25%
       Pixel 7     834 on arrival -> 891 scrolled   bar 223   27% then 25%

   Consistently ~27% on every phone measured (SE, 14, Pro Max, Pixel 7, S20).
   svh would make it exactly 25% on arrival and ~23% after, at the cost of ~7% of
   ceiling — which changes no available ad size on any phone, since the rungs that
   fit are the same either way. Two percentage points at the one moment a reader
   is deciding whether to stay, against a slightly roomier box: judged acceptable
   and left as it is. Recorded here because it was raised in review, and a
   question that has been answered should not have to be answered twice.

   (A figure of 30% was quoted during that review and is WRONG: it divided by the
   full screen height, which includes the OS status bar the page never gets.)

   NO CLAMP IS NEEDED at the small end: the site's documented minimum is
   320x480, where 25vh is 120px — above Google's 120x50 floor — so the fraction
   cannot produce a box too small to hold an ad on any supported device.

   ORDINARY PAGES ONLY. A pz-layout2 route composes its own bar height (the
   engine writes it once and animates translateY, measured at 150px on Cheese),
   so this must not reach them — two writers on one property fight on every
   resize, which is the same reason js/no-ads-footer.js stands aside there.

   THE RESERVE MOVES WITH THE BAR, because the reserve belongs to the bar
   (#828), and it is scoped to [data-ads-live] so it applies ONLY where a bar is
   actually rendered — a taller bar must not become a taller hole when there is
   no ad to put in it. Keyed POSITIVELY rather than :not([data-no-adbar]): that
   form MATCHES a body carrying no ad attributes at all, which is every page of
   the staff console, and it picked up 220px of dead padding there (#829). */
@media (max-width: 1023px) and (orientation: portrait) {
  body[data-ads-live]:not(.mt-puzzle-page) .mt-portrait-ads-bar { height: 25vh; }
  body[data-ads-live]:not(.mt-puzzle-page) { padding-bottom: 25vh; }

  /* AN UNFILLED ANSWER COLLAPSES THE BAR, AND height:0 IS NOT ENOUGH TO DO IT (#831 review).
     Bootstrap sets `* { box-sizing: border-box }`, so a border-box height of 0 still renders the
     1px border-top — measured at exactly 1.00px — and the element is `position: fixed; bottom: 0;
     left: 0; right: 0`. The result is a gold hairline (plus its soft box-shadow) drawn across the
     whole width of every ordinary portrait page, with the reserve at 0, so the page scrolls
     UNDERNEATH it. js/ad-slots.js's comment claimed it "collapses it entirely"; it collapses the
     content box, which is not the same thing.

     ZEROED RATHER THAN HIDDEN, and that is the whole reason this is CSS and not one more line in
     applySnap(): `visible()` in js/ad-slots.js tests exactly `display !== 'none' && visibility !==
     'hidden'`, so either of those would make sweepInner skip the element for the rest of the page's
     life — an unfilled slot could then never re-request when the viewport changed and a different
     ladder became available. Zeroing the border leaves a genuine 0x0 box that still measures, still
     mounts, and paints nothing.

     KEYED ON THE COLLAPSE, NOT ON THE AUCTION'S ANSWER. There are two ways to reach a zero height —
     the auction answered empty, and nothing ever answered at all (the give-up timer) — and only the
     first sets data-ad-status. This keyed on that status first and left the hairline on the timeout
     path; tests/adbar.html's E-block caught it. applySnap() owns the collapse, so applySnap() sets
     the attribute, and the two paths cannot diverge again.

     Still scoped :not(.mt-puzzle-page) to match snappable(): the puzzle routes never take the
     collapse at all, and an unscoped rule would be free to restyle a slot the engine owns. */
  body[data-ads-live]:not(.mt-puzzle-page) .mt-portrait-ads-bar[data-ad-collapsed] {
    border-top-width: 0;
    box-shadow: none;
  }
}
/* #886 — the footer slots swapped contents: the legal slot carries the words "Privacy Policy"
   (it is the only footer element above the fold on desktop, measured at top 868 of 900, and #325
   was spending that position on a copyright notice), and the copyright moved into the version
   chip's link to release-notes.php.

   THE VERSION CHIP IS GONE, so this anchor holds the copyright ALONE. An earlier pass of #886
   kept the chip beside it and needed wrap rules here — flex-wrap on the anchor, nowrap on each
   span — so the bar could break between the two. There is one span again, nothing to break
   between, and the inherited `white-space: nowrap` is once more exactly right; those rules are
   deleted rather than left inert.

   Removing the chip also hands back the row it cost: carrying it beside the copyright took
   .mt-content-footer from 337px of content to 438px at 390 and wrapped the bar to two lines. A
   build id is for an operator verifying a deploy, and that audience has version.php (gate-exempt
   by design) and release-notes.php's own build line; the number itself is now stated in full
   there as "Current version" instead of abbreviated into 0.7em type nobody reads.

   The copyright keeps its Crimson camel-case (#374) — it is the brand, not a build token.

   ITS OWN font-size DECLARATION LIVES FURTHER DOWN, with the rule that matches the two footer
   items to one size — there was a `1.05em` here as well, dead on arrival: same selector, same
   specificity, later wins. Sizing it in the block that also sizes the anchor is the only way the
   two stay in step, so it is stated once, there. */
/* The "Current version" line release-notes.php gained in exchange, sitting above the entries. */
.mt-release-current { color: #6B5A3E; margin: 0 0 14px; }
.mt-release-current strong { color: #8B6914; font-weight: 600; }
/* The legal slot no longer contains .mt-copyright, so its hover rule has nothing to colour; the
   label is a bare <span> and inherits the anchor's gold. Kept explicit so the whole link brightens
   as one, which is what #325's rule achieved through .mt-copyright. */
.mt-footer-legal:hover span,
.mt-footer-legal:focus span { color: #E8D48B; }
/* BOTH FOOTER ITEMS AT ONE SIZE (#886 follow-up). They were visibly mismatched: .mt-footer-legal
   inherits the container's `a { font-size: 1.1em }` while .mt-build-stamp forced 0.78em monospace,
   so "Privacy Policy" rendered about a third larger than the copyright beside it.

   The chip's small monospace was never a footer style — it was there to render a BUILD TOKEN
   ("v1.0.0 · beta"), where a fixed-width face and a size that keeps out of the way are both
   correct. With the token moved to release-notes.php there is no token left to set, and what
   remains is the brand, which already has a face: the same Crimson the legal link uses. So the
   two anchors share one rule rather than being nudged toward each other, which is what stops them
   drifting apart again the next time either is touched.

   0.95em, not the inherited 1.1em: matching UP would have widened the bar at the moment the
   version chip had just given a row back. Measured at 320 the pair sits at 279px of a 320px
   container on one row, and this keeps it there. */
.mt-panel-footer a.mt-footer-legal,
.mt-content-footer a.mt-footer-legal,
.mt-panel-footer a.mt-build-stamp,
.mt-panel-footer a.mt-build-stamp:visited,
.mt-content-footer a.mt-build-stamp,
.mt-content-footer a.mt-build-stamp:visited {
  font-family: 'Crimson Text', serif;
  font-size: 0.95em;
  letter-spacing: 0;
}
/* ...and the copyright span stops re-scaling inside it, or "same size" would be a size and a half. */
.mt-build-stamp .mt-copyright { font-size: 1em; }
/* HOVER MUST REACH THE LABEL, NOT JUST THE ANCHOR (#886 follow-up). The copyright did not
   highlight while "Privacy Policy" beside it did, and the cause is one both links share: the
   anchor's :hover sets a colour, but .mt-copyright carries its OWN `color: #B89A6A` on the span,
   which wins over the inherited one — so the box changed colour and the visible text did not.
   The legal link only appeared to work because #325 had already written it a specific
   `.mt-footer-legal:hover .mt-copyright` rule; the chip never got the equivalent, and when the
   copyright moved into it the omission became visible.

   Both are stated here together, to the SAME colour, so "the two footer items behave alike" is one
   rule rather than a coincidence maintained in two places. Also :focus — a keyboard user gets the
   same feedback a pointer does, which the chip's rule never offered at all. */
.mt-footer-legal:hover, .mt-footer-legal:focus,
.mt-footer-legal:hover span, .mt-footer-legal:focus span,
.mt-build-stamp:hover, .mt-build-stamp:focus,
.mt-build-stamp:hover .mt-copyright, .mt-build-stamp:focus .mt-copyright {
  color: #FFD700;
  text-decoration: none;
}
/* THE NARROWEST PHONE STILL NEEDS THE SHRINK (#886 follow-up). Making the two footer items equal
   overrode the ≤360 rule that had been shrinking the copyright to 0.72em, and 320x568 — the
   iPhone SE 1st gen, the narrowest width this site supports — went straight back to a two-line
   bar: content 306px in a 320px container, against 293/360 one row at the next size up.

   So the shrink comes back, applied to BOTH items rather than one. That is the whole lesson of
   the mismatch this pair of rules just fixed: the old media query moved the copyright and left
   the legal link where it was, which is how they drifted apart in the first place. Sized so the
   pair clears the 320 container with room for its padding. */
@media (max-width: 360px) {
  .mt-content-footer a.mt-footer-legal,
  .mt-content-footer a.mt-build-stamp,
  .mt-content-footer a.mt-build-stamp:visited { font-size: 0.82em; }
}

/* Op.Logs controls: one row of small controls that stays a row until it genuinely
   cannot, and wraps rather than overflowing when it cannot. The labels are the
   sentence "Show [50] of [all events] from [date]", so they sit inline with the
   inputs instead of above them. */
.mt-ops-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 14px; }
.mt-ops-controls .mt-admin-note { margin: 0; }
/* A LABEL WRAPS WITH ITS FIELD, NEVER AWAY FROM IT. The row's children used to be the
   labels and controls themselves, so a wrap could put "on" at the end of one line and the
   channel select at the start of the next — the sentence still reads, but the word no
   longer points at anything. Each pair is one flex item now, so the row breaks BETWEEN
   groups and never inside one. inline-flex rather than a block, so the group is still an
   item of the row above it and the two gaps stay the same 6px. */
.mt-ops-field { display: inline-flex; align-items: center; gap: 6px; }
.mt-ops-controls select, .mt-ops-controls input[type="date"] { font-family: 'Crimson Text', serif; width: auto; }
.mt-ops-clear { margin-left: 4px; }
/* With JS the controls apply themselves, so the button stops being the instruction
   and becomes the no-JS fallback — quieter, but still there and still pressable. */
.mt-ops-controls .mt-ops-apply-quiet { opacity: 0.55; }
.mt-ops-controls .mt-ops-apply-quiet:hover,
.mt-ops-controls .mt-ops-apply-quiet:focus { opacity: 1; }
.mt-ops-trunc { color: #a12020; }
.mt-ops-chan  { font-family: 'Crimson Text', serif; }

/* The pager. Numbers are monospace so they do not jitter as the digit count
   changes between pages — the row is read by position, not by reading it. */
.mt-ops-pager { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 10px 0 4px; }
.mt-ops-pager > a {
  font-family: 'Courier New', monospace; font-size: 12.5px;
  color: #0D1B3E; background: rgba(13, 27, 62, 0.045);
  border: 1px solid rgba(201, 168, 76, 0.55); border-radius: 3px;
  padding: 3px 9px; min-width: 30px; text-align: center;
}
.mt-ops-pager > a:hover, .mt-ops-pager > a:focus {
  background: rgba(201, 168, 76, 0.20); border-color: #C9A84C; text-decoration: none; color: #0D1B3E;
}
.mt-ops-pager > a.active,
.mt-ops-pager > a.active:hover { background: #0D1B3E; border-color: #0D1B3E; color: #E8D48B; }
/* A dead end is shown, not hidden: the ends of the range are information, and a
   Newer button that vanishes on page 1 makes the row jump every time you reach it. */
.mt-ops-pager > a.disabled { opacity: 0.4; pointer-events: none; }
.mt-ops-gap { color: #aaa; padding: 0 2px; }
