.pagination-container {
  display: flex;
  justify-content: center;
}


.chuds-amount {
  /* border: 0px; */
  padding: 0;
  margin: 0;
  font-size: 2em;
  text-align: center;
  max-width: 80px;
}

ul.timeline {
  list-style-type: none;
  position: relative;
}
ul.timeline:before {
  content: ' ';
  background: #d4d9df;
  display: inline-block;
  position: absolute;
  left: 29px;
  width: 2px;
  height: 100%;
  z-index: 400;
}
ul.timeline > li {
  margin: 20px 0;
  padding-left: 20px;
}
ul.timeline > li:before {
  content: ' ';
  background: white;
  display: inline-block;
  position: absolute;
  border-radius: 50%;
  border: 3px solid #22c0e8;
  left: 20px;
  width: 20px;
  height: 20px;
  z-index: 400;
}

.icon-text-white {
  /* 
    https://isotropic.co/tool/hex-color-to-css-filter/
  */
  
  filter: invert(97%) sepia(98%) saturate(64%) hue-rotate(271deg) brightness(115%) contrast(100%);
}

.fade-out {
  opacity: 0;
  transition: opacity 0.5s ease-in-out;
}

.fade-highlight {
  transition: background-color 2s ease-out;
  background-color: transparent !important;
}

.pagy {
  display: flex;
  font-family: sans-serif;
  font-size: 0.875rem;
  line-height: 1.25rem;
  font-weight: 600;
  color: #6b7280;
}
.pagy > :not([hidden]) ~ :not([hidden]) {
  --space-reverse: 0;
  margin-right: calc(0.25rem * var(--space-reverse));
  margin-left: calc(0.25rem * calc(1 - var(--space-reverse)));
}
.pagy a:not(.gap) {
  display: block;
  text-decoration: none;
  border-radius: 0.5rem;
  background-color: #e5e7eb;
  padding: 0.25rem 0.75rem;
  color: inherit;
}
.pagy a:not(.gap):hover {
  background-color: #d1d5db;
}
.pagy a:not(.gap):not([href]) { /* disabled links */
  cursor: default;
  background-color: #f3f4f6;
  color: #d1d5db;
}
.pagy a:not(.gap).current {
  background-color: #9ca3af;
  color: white;
}
.pagy label {
  white-space: nowrap;
  display: inline-block;
  border-radius: 0.5rem;
  background-color: #e5e7eb;
  padding: 0.125rem 0.75rem;
}
.pagy label input {
  line-height: 1.5rem;
  border-radius: 0.375rem;
  border-style: none;
  background-color: #f3f4f6;
}

/* Row striping stays as you have */


/* Qty pills for line items */
.qty-pill {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 38px; height: 28px; border-radius: 8px;
  font-weight: 700; color: #fff; padding: 0 .5rem;
}

/* ===== performers/pay page only ===== */
#performers-pay {
  background-image: url("/assets/GrossProphets-StoryBKG-Clouds-9x16-39928e9f.png");
  background-size: cover;
  background-position: center top;
  background-repeat: no-repeat;
}

/* Hero */
#performers-pay .pay-hero .hero-inner {
  max-width: 560px;
}
#performers-pay .pay-hero .hero-title {
  color: #fff;
  text-shadow: 0 1px 2px rgba(0,0,0,.35);
  font-weight: 800;
}
#performers-pay .pay-hero .hero-subtitle {
  color: rgba(255,255,255,.9);
  text-shadow: 0 1px 2px rgba(0,0,0,.25);
  margin-bottom: 0.75rem;
}
#performers-pay .pay-hero .hero-mascot {
  filter: drop-shadow(0 2px 6px rgba(0,0,0,.35));
}

/* Remaining chuds pill */
#performers-pay .balance-pill {
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.25);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(255, 255, 255, 0.55);
  color: #fff;
  min-width: 70px;  /* keep pill shape */
}

/* Performer cards: soft glass on white so bg peeks through */
#performers-pay .performer-card {
  background: rgba(255, 255, 255, 0.78);              /* <= transparency */
  border: 1px solid rgba(255, 255, 255, 0.6);
  border-radius: 1rem;
  backdrop-filter: saturate(140%) blur(4px);           /* subtle glass */
  -webkit-backdrop-filter: saturate(140%) blur(4px);   /* iOS Safari */
  box-shadow:
    0 6px 16px rgba(0, 0, 0, 0.12),
    0 1px 0 rgba(255, 255, 255, 0.6) inset !important;            /* faint top shine */
}

/* Two-line stacked name style (subtle stylization like the mock) */
#performers-pay .name-stack .name-top { letter-spacing: .04em; }
#performers-pay .name-stack .name-bottom { letter-spacing: .06em; font-size: 1.05rem; }

/* Quantity control */
/* The overall pill */
#performers-pay .qty-pill {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  padding: 1.25rem .5rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.65);
  border: 1px solid rgba(255, 255, 255, 0.75);
  box-shadow:
    inset 0 1px 2px rgba(0,0,0,.06),
    0 2px 6px rgba(0,0,0,.08);
}

/* Kill outlines/borders/backgrounds on +/– so it feels like one pill */
#performers-pay .qty-pill .qty-btn {
  border: 0 !important;
  background: transparent !important;
  box-shadow: none !important;
  padding: .25rem .5rem;          /* bigger tap target */
  line-height: 1;
}
#performers-pay .qty-pill .qty-btn:focus,
#performers-pay .qty-pill .qty-btn:active {
  outline: none !important;
  box-shadow: none !important;
  background: transparent !important;
}

/* Icon sizing inside the pill */
#performers-pay .qty-pill .qty-btn svg {
  width: 22px;
  height: 22px;
}

/* The number field melts into the pill */
#performers-pay .qty-pill .qty-input {
  width: 2.5rem;
  text-align: center;
  border: 0;
  background: transparent;
  box-shadow: none !important;
  font-weight: 800;
  font-size: 1.25rem;
  padding: 0;
}
#performers-pay .qty-pill .qty-input:focus {
  outline: none;
  box-shadow: none;
}

/* Optional: tighter look—remove any residual Bootstrap borders */
#performers-pay .qty-pill .btn-warning,
#performers-pay .qty-pill .btn-outline-secondary {
  border: 0 !important;
  background: transparent !important;
}


#performers-pay .qty-input {
  width: 2.5rem;
  background: transparent;
  font-weight: 700;
  font-size: 1.25rem;
  padding: 0;
  height: 2rem;
  line-height: 2rem;
}
#performers-pay .qty-input:focus {
  box-shadow: none;
}
#performers-pay .qty-btn {
  width: 2rem;
  height: 2rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}

/* Sticky CTA
#performers-pay .send-chuds-cta {
  background: linear-gradient(to top, rgba(255,255,255,.9), rgba(255,255,255,0));
} */

.transparent-glass {
  position: relative;
  backdrop-filter: blur(2px) saturate(180%);
  border: 1px solid rgba(255, 255, 255, 0.8);
  border-radius: 2rem;
  box-shadow: 0 8px 32px rgba(31, 38, 135, 0.2), 
              inset 0 4px 20px rgba(255, 255, 255, 0.3);

}
.transparent-glass::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 2rem;
  backdrop-filter: blur(1px);
  box-shadow: inset -10px -8px 0px -11px rgba(255, 255, 255, 1),
              inset 0px -9px 0px -8px rgba(255, 255, 255, 1);
  opacity: 0.6;
  z-index: -1;
  filter: blur(1px) drop-shadow(10px 4px 6px black) brightness(115%);
}


.glass {
  position: relative;
  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(2px) saturate(180%);
  border: 1px solid rgba(255, 255, 255, 0.8);
  border-radius: 2rem;
  box-shadow: 0 8px 32px rgba(31, 38, 135, 0.2), 
              inset 0 4px 20px rgba(255, 255, 255, 0.3);
}

.glass::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 2rem;
  backdrop-filter: blur(1px);
  box-shadow: inset -10px -8px 0px -11px rgba(255, 255, 255, 1),
              inset 0px -9px 0px -8px rgba(255, 255, 255, 1);
  opacity: 0.6;
  z-index: -1;
  filter: blur(1px) drop-shadow(10px 4px 6px black) brightness(115%);
}
/* The dashboard's tabs. Bootstrap 5.0.2 has no .nav-underline — it arrived in
   5.3 — and the tabs here are plain links rather than a JS component, because
   which screen the operator is on has to survive a redirect and a reload. */
.nav-underline .nav-link {
  border-bottom: 2px solid transparent;
  color: var(--bs-gray-700, #495057);
  padding-bottom: 0.5rem;
}

.nav-underline .nav-link:hover,
.nav-underline .nav-link:focus {
  border-bottom-color: var(--bs-gray-400, #ced4da);
}

.nav-underline .nav-link.active {
  border-bottom-color: var(--bs-primary, #0d6efd);
  color: var(--bs-primary, #0d6efd);
  font-weight: 600;
}

.dashboard-tabs .nav-link.active {
  border-bottom-width: 3px;
}

/* While the next screen is on its way. A tab press that looks like nothing
   happened gets pressed again. */
.dashboard-tabs.is-loading .nav-link.is-target::after {
  content: "";
  display: inline-block;
  width: 0.75em;
  height: 0.75em;
  margin-left: 0.4em;
  vertical-align: -0.1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: gp-spin 0.6s linear infinite;
}

@keyframes gp-spin {
  to { transform: rotate(360deg); }
}

/* The busy state of a segmented control. The spinner sits BESIDE the label
   rather than replacing it: the label vanishing for a moment reads as the
   button having lost its mind. */
.segmented__btn.is-busy::after {
  content: "";
  display: inline-block;
  width: 0.75em;
  height: 0.75em;
  margin-left: 0.4em;
  vertical-align: -0.1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: gp-spin 0.6s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .dashboard-tabs.is-loading .nav-link.is-target::after,
  .segmented__btn.is-busy::after { animation: none; }
}

/* ---------------------------------------------------------------------------
   The dashboard, on a phone, in a booth. Bootstrap 5.0.2 is what this app has;
   everything below is what 5.0.2 does not carry.
   --------------------------------------------------------------------------- */

/* Flash as an overlay rather than a block in the flow. Fixed, so nothing under
   it moves when a cue lands mid-show. */
.admin-toasts {
  position: fixed;
  bottom: 0.75rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1080;
  width: min(36rem, calc(100vw - 1.5rem));
}

.admin-toasts .alert {
  margin-bottom: 0.5rem;
}

/* The tabs scroll rather than wrap: a second row of tabs moves every control on
   the screen down, and the operator is reading this one-handed. Edge to edge on
   a phone, so the scroll is visibly a scroll. */
.dashboard-tabs {
  flex-wrap: nowrap;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  scroll-behavior: smooth;
  /* The muted rule the active underline sits on. The underline is thicker and
     sits over this, so the selected tab reads as a mark on the line rather than
     a break in it. */
  border-bottom: 1px solid var(--bs-gray-300, #dee2e6);
  /* Soft at both ends rather than a hard cut, so a tab half out of view reads
     as "there is more" instead of as a clipped word. */
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 1.25rem,
                                      #000 calc(100% - 1.25rem), transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, #000 1.25rem,
                              #000 calc(100% - 1.25rem), transparent 100%);
}

.dashboard-tabs::-webkit-scrollbar {
  display: none;
}

.dashboard-tabs .nav-link {
  white-space: nowrap;
  /* Over the nav's own rule, not above it. */
  margin-bottom: -1px;
}

/* The show header: what is true on every tab. */
.show-header .show-header__qr {
  line-height: 1;
  color: var(--bs-gray-600, #6c757d);
  text-decoration: none;
}

.show-header .show-header__qr:hover {
  color: var(--bs-primary, #0d6efd);
}

.show-header__qr svg {
  width: 1rem;
  height: 1rem;
}

/* Three tiles, equal width, each a link to the screen that changes it. */
.show-tiles {
  margin-left: -0.5rem;
  margin-right: -0.5rem;
}

.show-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.125rem;
  height: 100%;
  padding: 0.5rem 0.25rem;
  border-top: 1px solid var(--bs-gray-200, #e9ecef);
  color: inherit;
  text-align: center;
  text-decoration: none;
}

/* The header card's top rule separates the tiles from the card above it. A
   card that already opens with its own top border — the Participation tab's
   summary card — does not need a second one directly under it. */
.show-tile--flush {
  border-top: 0;
}

.show-tile:hover,
.show-tile:focus {
  background-color: var(--bs-gray-100, #f8f9fa);
  color: inherit;
}

.show-tile__label {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--bs-gray-600, #6c757d);
}

.show-tile__value {
  font-size: 1.25rem;
  font-weight: 700;
  line-height: 1.1;
}

.show-tile__value--success { color: var(--bs-success, #198754); }
.show-tile__value--info    { color: var(--bs-info, #0dcaf0); }
.show-tile__value--warning { color: var(--bs-warning, #ffc107); }
.show-tile__value--dark    { color: var(--bs-dark, #212529); }
.show-tile__value--secondary { color: var(--bs-gray-600, #6c757d); }

/* The segmented control. One pill, moved by a custom property the server sets,
   so the browser animates the move and there is no JS to keep in step with the
   state. Each segment stays its own form: the URL a segment posts to is the URL
   the QLab cue fires. */
.segmented {
  position: relative;
  display: flex;
  padding: 0.25rem;
  background-color: var(--bs-gray-200, #e9ecef);
  border-radius: 0.5rem;
}

.segmented__pill {
  position: absolute;
  top: 0.25rem;
  bottom: 0.25rem;
  left: 0.25rem;
  width: calc((100% - 0.5rem) / var(--segments));
  border-radius: 0.375rem;
  background-color: var(--bs-secondary, #6c757d);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
  transform: translateX(calc(100% * var(--segment-index)));
  transition: transform 0.2s ease-in-out, background-color 0.2s ease-in-out;
}

.segmented__pill--success { background-color: var(--bs-success, #198754); }
.segmented__pill--info    { background-color: var(--bs-info, #0dcaf0); }
.segmented__pill--warning { background-color: var(--bs-warning, #ffc107); }
.segmented__pill--dark    { background-color: var(--bs-dark, #212529); }
.segmented__pill--secondary { background-color: var(--bs-gray-600, #6c757d); }

@media (prefers-reduced-motion: reduce) {
  .segmented__pill { transition: none; }
}

.segmented__form {
  flex: 1 1 0;
  min-width: 0;
}

.segmented__btn {
  position: relative;
  z-index: 1;
  width: 100%;
  padding: 0.375rem 0.5rem;
  border: 0;
  border-radius: 0.375rem;
  background: transparent;
  color: var(--bs-gray-700, #495057);
  font-weight: 500;
  white-space: nowrap;
}

.segmented__btn.is-current {
  color: #fff;
  font-weight: 600;
}

/* Warning is the one state light enough that white text on it fails to read. */
.segmented__pill--warning ~ .segmented__form .segmented__btn.is-current {
  color: var(--bs-dark, #212529);
}

/* A segment the server will not accept. The quiz uses one: a question the room
   has already been shown the answer to cannot go back up, so "open" is offered
   greyed rather than as a button whose only outcome is a refusal. */
.segmented__btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  text-decoration: line-through;
}

.segmented.is-pending {
  opacity: 0.6;
  cursor: progress;
}

/* The destructive end of a screen, fenced off. */
.danger-zone {
  border-color: var(--bs-danger, #dc3545);
}

.danger-zone__title {
  font-size: 0.75rem;
  letter-spacing: 0.08em;
}

/* One performer, as a card. The table this replaces ran off the side of a
   phone, and the ID column was the widest thing on it. */
.performer-card {
  padding: 0.75rem;
  border: 1px solid var(--bs-gray-300, #dee2e6);
  border-radius: 0.5rem;
  margin-bottom: 0.5rem;
  background-color: #fff;
  transition: opacity 0.15s ease-in-out, border-color 0.15s ease-in-out;
  cursor: pointer;
}

/* Everyone who is not the one being paid. The number about to be typed belongs
   to one person, and that has to be unmistakable at a glance. */
.performer-card.is-muted {
  opacity: 0.4;
}

.performer-card.is-open {
  border-color: var(--bs-primary, #0d6efd);
}

.performer-card__head {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.performer-card__avatar {
  flex: 0 0 auto;
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  object-fit: cover;
}

.performer-card__avatar--initial {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.25rem;
  font-weight: 700;
  text-transform: uppercase;
  color: var(--bs-dark, #212529);
}

.performer-card__who {
  min-width: 0;
  flex: 1 1 auto;
}

.performer-card__name {
  font-size: 1.125rem;
  font-weight: 700;
  line-height: 1.2;
}

.performer-card__meta {
  color: var(--bs-gray-600, #6c757d);
  font-size: 0.875rem;
}

.performer-card__sep {
  margin: 0 0.25rem;
}

.performer-card__chuds {
  flex: 0 0 auto;
  text-align: right;
  line-height: 1.05;
}

.performer-card__chuds-value {
  display: block;
  font-size: 1.75rem;
  font-weight: 700;
}

.performer-card__chuds-label {
  display: block;
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--bs-gray-600, #6c757d);
}

.performer-card__controls {
  margin-top: 0.75rem;
}

/* The modal's scrim. Bootstrap 5.0.2 renders .modal-backdrop at 0.5 opacity;
   over a page that is mostly white cards it reads as barely there, and what is
   behind the QR keeps competing with it. */
.modal-backdrop.show {
  opacity: 0.7;
}

.modal-content {
  box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.4);
}

.attendee-optin svg {
  width: 0.85em;
  height: 0.85em;
}

/* The drag handle for reordering quiz questions, moments and answers. A
   dedicated cursor and focus ring, rather than an icon font pulled in for one
   glyph — the dots read as a handle on their own. */
.drag-handle {
  cursor: grab;
  display: inline-block;
  padding: 0 0.35em;
  font-size: 1.1rem;
  line-height: 1;
  user-select: none;
}

.drag-handle:active {
  cursor: grabbing;
}

.drag-handle:focus-visible {
  outline: 2px solid var(--bs-primary, #0d6efd);
  outline-offset: 2px;
  border-radius: 0.2rem;
}

/* Wide admin tables, on a phone.
   Below md a table that does not FIT stops being a grid and becomes one card
   per row: every cell on its own line with its column heading beside it. Both
   halves of that come from the stacked-table Stimulus controller — the heading
   text, because CSS cannot read another element's text, and the is-stacked
   class, because CSS cannot ask whether a table overflowed. A narrow table
   keeps its columns: stacking a three-column list of levels would turn short
   rows into three lines each for no gain.

   The alternative was the sideways scroll that .table-responsive gives, which
   hides half of every row behind a gesture nobody makes in a booth. */
@media (max-width: 767.98px) {
  .table-stacked.is-stacked thead {
    /* Not display:none — that takes the headings out of the accessibility tree
       as well, and they are still the names of the values below. */
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }

  .table-stacked.is-stacked,
  .table-stacked.is-stacked tbody,
  .table-stacked.is-stacked tr,
  .table-stacked.is-stacked td {
    display: block;
    width: auto;
  }

  .table-stacked.is-stacked tr {
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: 0.375rem;
    margin-bottom: 0.75rem;
    padding: 0.25rem 0.75rem;
    background: var(--bs-body-bg, #fff);
  }

  /* Striping is a row-level cue and rows are cards now, so it reads as a card
     colour rather than as a stripe. */
  .table-stacked.is-stacked.table-striped > tbody > tr:nth-of-type(odd) > td {
    background: transparent;
  }

  .table-stacked.is-stacked td {
    border: 0;
    border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.1));
    padding: 0.5rem 0;
    text-align: left;
  }

  .table-stacked.is-stacked tr > td:last-child {
    border-bottom: 0;
  }

  /* The label, from the controller. A cell with no data-label — an actions
     column, whose heading is deliberately blank — just takes the full width. */
  .table-stacked.is-stacked td[data-label]::before {
    content: attr(data-label);
    display: block;
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    color: var(--bs-secondary-color, #6c757d);
    margin-bottom: 0.125rem;
  }

  /* A cell holding nothing would otherwise be a label with a blank under it. */
  .table-stacked.is-stacked td:empty {
    display: none;
  }
}

/* A row's menu, inside a table that scrolls.
   .table-responsive scrolls sideways, and an element that scrolls on one axis
   clips on the other too — so a menu opened on the last row is cut off at the
   table's bottom edge. Only while one is open, because the scroll is what
   holds a wide table together on the rows where no menu is showing. Below md
   these tables stack instead, so this is the laptop's case. */
.table-responsive:has(.dropdown-menu.show) {
  overflow: visible;
}

/* A setting's value in the list. Long ones are truncated server-side; this is
   what keeps a value with no spaces in it — a key, a URL, a minified line —
   from pushing the column wider than the screen anyway. */
.setting-preview {
  display: inline-block;
  max-width: 100%;
  overflow-wrap: anywhere;
  font-family: var(--bs-font-monospace, monospace);
  font-size: 0.875rem;
}

/* The quiz card's heading is its own toggle, so the whole width of it is a
   target for a thumb in the dark rather than a chevron at one end. It is a
   button, so Bootstrap's link styling has to be undone to leave the heading
   looking like a heading. */
.quiz-card__toggle {
  color: inherit;
  border: 0;
  background: none;
}

.quiz-card__toggle:hover,
.quiz-card__toggle:focus {
  color: inherit;
}

/* Which way the card is facing. Rotates rather than swapping glyphs, so there
   is one thing to look at and it moves. */
.quiz-card__toggle::after {
  content: "\203A";
  float: right;
  margin-left: 0.5rem;
  transform: rotate(90deg);
  transition: transform 0.15s ease-in-out;
  display: inline-block;
}

.quiz-card__toggle[aria-expanded="false"]::after {
  transform: rotate(0deg);
}

@media (prefers-reduced-motion: reduce) {
  .quiz-card__toggle::after { transition: none; }
}

/* A takeover: the whole phone, with nothing to close it.
   Used by the two moments the show cannot proceed past — a quiz question the
   room is answering, and the "I'm ready" the operator is waiting on. Both stay
   until the operator moves on, which is why there is no close control here to
   style: there isn't one. */
.takeover {
  position: fixed;
  inset: 0;
  z-index: 1055;
  display: flex;
  align-items: center;
  justify-content: center;
  /* The safe areas, because the layout's viewport-fit=cover lets this paint
     under the status bar and the home indicator: the backdrop may go there,
     the card may not. */
  padding:
    max(1rem, env(safe-area-inset-top))
    max(1rem, env(safe-area-inset-right))
    max(1rem, env(safe-area-inset-bottom))
    max(1rem, env(safe-area-inset-left));
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* Participation: the page behind is gone. Waiting to be called on stage is not
   something to read past, so there is nothing behind the card to look at.

   ONE partial wears this now — participation/_standby, on both of its
   branches. Round 19 gave it to being chosen as well and took it off standby
   before "I'm ready"; Corey reversed both on 2026-09-21. Standby is full
   screen and dark whether or not the button has been pressed, and being chosen
   moved to .takeover--panel with the opt-in ask.

   THE DARK IS NOT A BACKGROUND ON THIS ELEMENT (round 19). This class takes
   the curtain, and a curtain needs its dark to be a LAYER it can translate —
   so it lives on .takeover__sky below, exactly as the checkpoint's clouds do.
   The class keeps the z-index and nothing else, which is why the partial
   renders the clip box and the sky div: without them a .takeover--blocking is
   transparent. */

/* The quiz: a big card sitting high, so a thumb reaches the answers without
   stretching.

   It used to dim the page and leave it there, on the reasoning that the room
   is answering a question rather than leaving the site. Corey overruled that
   2026-09-21: the quiz takes the SKY, like the checkpoint, so the question
   arrives on the house background instead of on a grey copy of whatever the
   phone happened to be reading. The page behind is therefore gone, which is
   what .takeover__sky's one declaration now says. */
.takeover--quiz {
  align-items: flex-start;
  padding-top: calc(3.5rem + env(safe-area-inset-top));
}

.takeover--quiz .takeover__inner {
  max-width: 36rem;
}

.takeover--quiz .takeover__card {
  padding: 1.5rem;
  /* The one thing the move to the sky cost, measured in the iOS 26.3 Simulator
     rather than guessed. The card is opaque, so the question and the answer
     buttons did not move at all: 15.4:1 and 4.7:1, the same numbers they had
     over the old 60% black. What fell is the card's own SILHOUETTE — against
     the scrim it was a white card on near-black, and against the sky's cloud
     bank it is 1.15:1, white on white, with nothing but a 40% box-shadow
     holding the edge. On a long question the card reaches down into the
     clouds and its bottom corners dissolve.

     A hairline, not a heavier shadow: a shadow is soft by definition and
     cannot be measured at the edge, and this one has to clear 3:1 on both
     sides of itself - 3.54:1 against the card, 3.43:1 against the cloud,
     sampled off the Simulator. See color/card-edge-on-sky. Quiz only — the other two
     .takeover__card surfaces sit on the solid dark sheet, where the card is
     already 15:1 against its own background and a grey line would be a
     change nobody asked for. */
  border: 1px solid var(--gp-color-card-edge-on-sky);
}

.takeover__inner {
  width: 100%;
  max-width: 32rem;
  /* A long question with many answers scrolls inside the card rather than
     pushing the buttons off a short phone. */
  max-height: 100%;
  overflow-y: auto;
}

.takeover__card {
  background: var(--bs-body-bg, #fff);
  color: var(--bs-body-color, #212529);
  border-radius: 0.75rem;
  padding: 1.25rem;
  box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.4);
}

/* Participation outranks the quiz when both are up. Being about to go on stage
   is more urgent than a question the whole room is answering, and the operator
   is holding a cue on the first one.

   Corey split this rung three ways on 2026-09-21, after reading the whole
   ladder back:

   - STANDBY goes up to being-on-stage's own rung (--gp-zIndex-participation-
     live, 1077). Waiting to be called is the same order of urgency as being
     called; it used to sit under the checkpoint, which meant an attendee could
     be on standby behind a screen telling them to spend money.
   - BEING CHOSEN stays here at 1060, above the quiz.
   - THE OPT-IN ASK drops below the quiz (.takeover--ask, below). It keeps
     Pattern B's look -- it is still .takeover--panel -- and loses Pattern B's
     rung, which is why the two needed separating at all. */
.takeover--panel {
  z-index: 1060;
}

.takeover--blocking {
  z-index: var(--gp-zIndex-participation-live);
}

/* The opt-in ask, under the quiz (Corey, 2026-09-21: "Opt in should be under
   quiz takeover"). The least urgent thing that can be on screen: it asks
   whether you would like to take part at some point tonight, while a quiz
   question is live and being answered by the room. Ranked by this class
   rather than by .takeover--panel because the panel is a LOOK -- the
   blur-and-lift -- worn by two screens the show ranks differently. */
.takeover--ask {
  z-index: var(--gp-zIndex-participation-ask);
}

/* PATTERN B, THE PANEL (round 19). Corey reversed the decision above for the
   opt-in ask and the opt-in confirmation: being asked whether you would like
   to take part is not the same size of moment as being told you are on in a
   minute, and painting out the whole phone for it said it was.

   CORRECTED 2026-09-21, and the membership moved both ways. Being chosen
   (participation/_instruction_card) is a panel too now — the urgency is in the
   z-index and in there being no way out, not in how much paint the screen
   takes. Standby went the other way, back to .takeover--blocking on both of
   its branches, so it no longer chooses a pattern per render: it is full
   screen and dark from the moment it arrives, because the room it is read in
   is dark.

   THE VEIL IS THIS ELEMENT'S OWN ::before, not a div, so the partials that
   wear the class gain no markup for it. Fixed and full-bleed: nothing in the chain is
   transformed, so its containing block is the viewport and .takeover's
   overflow-y: auto does not clip it (the same reason
   the checkpoint's sky sits in a fixed clip box, round 19). z-index -1 inside .takeover's own
   stacking context puts it behind the card without having to position the
   card.

   It is a backdrop-filter LAYER rather than filter: blur() on the page.
   A filtered element has no pixels beyond its own bounds to sample, so all
   four of its edges fade out and leave a seam - Corey's screenshot of that bug
   showed an unblurred strip above the blurred copy and the wordmark
   apparently doubled. A backdrop-filter's source is whatever is painted behind
   it, WebKit duplicates the edge pixels at the backdrop root, and nothing
   moves. The radius is FIXED and the layer's OPACITY is what animates:
   re-filtering a full-screen backdrop every frame is the expensive way to the
   same picture, and in the prototype it was what made everything else
   stutter. */
.takeover--panel::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background: var(--gp-color-panel-scrim);
  backdrop-filter: blur(var(--gp-dimension-blur-panel));
  -webkit-backdrop-filter: blur(var(--gp-dimension-blur-panel));
  /* Decorative, and it must never take a tap. Measured in the iOS 26.3
     Simulator: without this, a tap on the opt-in panel's own buttons reached
     nothing at all - no request left the phone - even though the card paints
     above a z-index: -1 pseudo-element. The drawer's scrim has carried the
     same line since round 10. */
  pointer-events: none;
}

/* The card is an ordinary flex child, so it paints under a z-index: -1
   pseudo-element unless it is positioned. This is the whole reason. */
.takeover--panel .takeover__inner {
  position: relative;
}

/* Arriving. Gated the same way the checkpoint's entrance is: a phone that has
   already been looking at this panel gets data-seen from src/transition_gate.js
   and no motion, and a gate that failed outright leaves the attribute off and
   plays it - the harmless direction.

   The card's curve overshoots (y2 = 1.3) because the approved prototype's
   sheet was a spring. It is written as a bezier, not run through Motion, for
   the same reason the checkpoint's entrance is CSS: the opt-in ask can be in
   the page's first paint, and a controller only connects once the module graph
   has loaded. */
.takeover--panel:not([data-seen])::before {
  animation: takeover-panel-veil-in var(--gp-motion-panel) ease-out;
}

.takeover--panel:not([data-seen]) .takeover__inner {
  animation: takeover-panel-lift-in var(--gp-motion-panel-lift) cubic-bezier(0.22, 1.3, 0.36, 1);
}

/* Leaving, written by participation_opt_in_controller.js - the only panel exit
   anything can hold. Being chosen is replaced inside #participation_frame, and
   nothing there may defer the DOM write: that takeover is the operator's cue.
   Last, so it beats the entrance rules at equal specificity. */
.takeover--panel.is-leaving::before {
  animation: takeover-panel-veil-out var(--gp-motion-panel) ease-in forwards;
}

.takeover--panel.is-leaving .takeover__inner {
  animation: takeover-panel-lift-out var(--gp-motion-panel) ease-in forwards;
}

@keyframes takeover-panel-veil-in {
  from { opacity: 0; }
}

@keyframes takeover-panel-veil-out {
  to { opacity: 0; }
}

@keyframes takeover-panel-lift-in {
  from {
    opacity: 0;
    transform: translateY(var(--gp-dimension-panel-lift));
  }
}

@keyframes takeover-panel-lift-out {
  to {
    opacity: 0;
    transform: translateY(var(--gp-dimension-panel-lift));
  }
}

/* Reduced motion: a cross-fade, and nothing travels. A fade rather than no
   transition at all, so the panel still reads as arriving - and the content
   must actually end up visible, which is the bug the prototype shipped in its
   revision 5. */
@media (prefers-reduced-motion: reduce) {
  .takeover--panel:not([data-seen])::before,
  .takeover--panel:not([data-seen]) .takeover__inner {
    animation: takeover-panel-veil-in var(--gp-motion-panel-reduced) linear;
  }

  .takeover--panel.is-leaving::before,
  .takeover--panel.is-leaving .takeover__inner {
    animation: takeover-panel-veil-out var(--gp-motion-panel-reduced) linear forwards;
  }
}

/* The Chud Checkpoint takeover.

   1065 clears the quiz (1055), being chosen (1060) and the opt-in ask (1052),
   which is the order Corey settled on 2026-09-14: a checkpoint is the most
   important thing anyone is doing for that minute, unless they personally have
   something to do in the show.

   It does NOT clear standby any more. Standby was 1060 and went to 1077,
   being-on-stage's own rung, on 2026-09-21 -- "standby should be the same
   level as live on stage" -- because waiting to be called is the same order of
   urgency as being called, and under the old order an attendee could be on
   standby behind a screen telling them to spend money. */
.takeover--checkpoint {
  z-index: 1065;
}

/* The whole phone is the checkpoint screen (Figma 193:1913): the clouds, the
   wordmark pinned at the top, and .takeover__body — the [status bar + send
   card] block plus the Buy More Chuds button, as one unit — centred in
   whatever height is left under it.

   Round 2 (Corey, 2026-09-18): top-aligning the whole takeover, as round 1
   did, scrolled the wordmark away with the card on a tall phone and clipped
   the card against it on a short one — neither is the mockup. The wordmark
   is a non-shrinking flex child; .takeover__body is the one that grows and
   scrolls, so the wordmark never moves and a short phone scrolls instead of
   clipping. overflow is hidden here (not `.takeover`'s own auto) because two
   scrollbars fighting over one drag is worse than the wordmark simply never
   needing one. */
.takeover--checkpoint {
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  padding: 0;
  overflow: hidden;
  /* The SMALL viewport (round 5 item 8): what is visible with Safari's
     toolbars out, so what fits here fits however the page is scrolled. The
     sky itself runs on under the toolbars (::before, 100lvh). */
  bottom: auto;
  height: 100vh;
  height: 100svh;
  /* NO background of its own since round 19: the sky is .takeover__sky, a
     layer below that the curtain can translate, and an empty takeover is what
     lets the page behind show through while the sky is still on its way
     down. */
}

/* WHAT iOS 26 SAFARI TINTS ITS BARS WITH (round 5 item 11). Round 4's
   full-bleed failed on Corey's iPhone: a pale band behind the status bar and
   white behind the bottom toolbar. Safari 26 (Liquid Glass) does not look at
   what is painted there. It ignores <meta name="theme-color">, and it never
   reads background images, gradients or pseudo-elements; it takes the
   background-COLOR of a position: fixed (or sticky) element at least 6px tall
   and the full width that touches the top edge (for the status bar) and the
   bottom edge (for the toolbar, which needs viewport-fit=cover), and falls
   back to the body's, then the root's, background-color. The takeover's own
   background is an image over a gradient — no colour at all — so Safari fell
   through to the page's.
   Sources, read 2026-09-18: https://1ar.io/updates/safari-26-liquid-glass-web/,
   https://jahir.dev/blog/safari-toolbar,
   https://benfrain.com/ios26-safari-theme-color-tab-tinting-with-fixed-position-elements/
   (a fixed element shown after load does retint, at once).
   So the takeover carries two fixed strips of plain colour, one at each edge,
   in the sky's own top and bottom colours (sampled from the image). They are
   ON TOP at the edge, not tucked under the sky. Our reading (an inference —
   none of the sources says how the element is chosen) is that Safari takes
   the fixed element it finds AT the edge, and what it found there in round
   4 was the takeover itself, colour transparent, which is how it failed. The top one is the image's own top colour,
   so it cannot be seen; the bottom one sits under the home indicator and
   the toolbar. Touch screens only, where Safari's bars are: on a desktop the
   bottom one would be a flat band across the clouds. While the takeover is
   open the root and body take the bottom colour too, for Safari's fallback.

   Round 11, Corey's iPhone: a pale band behind the status bar anyway —
   sky-bottom, i.e. the fallback, so Safari had not found the top strip.
   Reproduced in the iOS 26.3 Simulator (top-level page, not an iframe), and
   two causes, each measured by removing it alone. (1) The strips were
   children of the takeover, which fades in from opacity 0: Safari takes
   the strip's colour mid-fade and never looks again (a pale blend of the
   sky-top and the page, #7686ad). So they are the takeover's siblings now,
   at its z-index and later in the page. (2) The drawer's scrim is a
   fixed, full-bleed element above them even while closed; with it in the
   way Safari fell back to the body's colour, so it is visibility: hidden
   while shut (components.css, .gp-drawer-scrim). Safari picks again only
   when an edge strip is inserted or removed — not when one is restyled,
   shown or hidden. Round 11's dimmed strip for the open drawer went in
   round 13 with the scrim's colour (components.css says why). Still
   unproven on a device. */
.takeover__tint {
  display: none;
}

@media (hover: none) and (pointer: coarse) {
  .takeover__tint {
    position: fixed;
    left: 0;
    right: 0;
    /* The takeover's own 1065, and later in the page, so above it. */
    z-index: 1065;
    display: block;
    pointer-events: none;
  }
}

.takeover__tint--top {
  top: 0;
  height: max(6px, env(safe-area-inset-top));
  background-color: var(--gp-color-checkpoint-sky-top);
}

.takeover__tint--bottom {
  bottom: 0;
  height: max(6px, env(safe-area-inset-bottom));
  background-color: var(--gp-color-sky-bottom);
}

html:has(.takeover--checkpoint),
html:has(.takeover--checkpoint) body {
  background-color: var(--gp-color-sky-bottom);
}

/* THE SKY, as ONE layer that can move (round 19), inside a box that clips it.

   It was two layers that could not move together: a background on
   .takeover--checkpoint at 100svh, and its ::before at 100lvh, fixed so the
   takeover's overflow: hidden would not clip the strip under Safari's
   collapsed toolbar. A curtain has to translate the sky, and that shape has
   no way to. Measured in the iOS 26.3 Simulator, frame by frame: with the
   pair kept and the transform on the inner one, the fixed pseudo-element
   stayed exactly where it was and covered the screen with sky for the whole
   travel — the entrance appeared in ONE frame, while the wordmark behind it
   faded in correctly. The transform cannot go on .takeover--checkpoint
   either: a transformed element is the containing block for its own fixed
   descendants, so it would capture that ::before and clip it.

   So there is one sky now, and the thing that is fixed is the BOX AROUND it.
   The clip is 100lvh and fixed (the escape from the takeover's overflow that
   the ::before used to be); the sky fills it and travels inside it. That also
   removes a seam nobody could see: the two layers scaled `cover` against two
   different heights, so the clouds met at slightly different sizes under the
   toolbar. It is one image at one size now, the large viewport's.

   z-index: -1 inside the takeover's stacking context, where the ::before
   already sat: behind the wordmark and the column, in front of the page. The
   takeover itself has NO background any more, which is what lets the page
   show through while the sky is still on its way down.

   The gradient is what shows while the image is still on its way: an opaque
   takeover even on bad venue wifi. See color/sky-top. A compressed JPEG
   (~220KB), not the 8.3MB PNG #performers-pay uses below — every phone in the
   room fetches this the instant the window opens, and the PNG made that a
   real stall on venue wifi for a soft gradient with no fine detail to lose.
   The layout preloads it, so a cold phone does not get the flat gradient with
   the photo popping in a second later.

   ROUND 19, the quiz and the participation screens: the box is `.takeover >`
   rather than `.takeover--checkpoint >` because EVERY curtain needs one, and
   for the same two reasons — `.takeover` is overflow-y: auto, so a sky
   translated out of the surface itself is a full-screen sky sliding over the
   page, and a sky that stops at the small viewport leaves a strip of the old
   page under Safari's collapsed toolbar. Only the paint below differs per
   surface. */
.takeover > .gp-curtain-clip {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 100vh;
  height: 100lvh;
  z-index: -1;
  pointer-events: none;
}

.takeover__sky {
  position: absolute;
  inset: 0;
  /* ROUND 19, CORRECTED (Corey, 2026-09-21): "quiz shade for right now should
     also use the sky background (which isn't exclusive to checkpoint)". So the
     sky is not the checkpoint's signature - it is the house paint for an
     arriving takeover, and it is declared ONCE, here, on the layer every
     curtain already renders. The checkpoint and the quiz share this
     declaration rather than each carrying a copy; a surface that wants
     something else overrides it below, which .takeover--blocking does.

     Round 19 shipped this qualified by the checkpoint's own class, and gave
     the quiz a scrim of its own. Both are gone: one paint, one place, and the
     quiz spec asserts neither qualifier comes back. */
  background:
    url("/assets/GrossProphets-StoryBKG-Clouds-9x16-a965aa4f.jpg") center top / cover no-repeat,
    linear-gradient(var(--gp-color-checkpoint-sky-preload), var(--gp-color-sky-cloud) 45%);
}

/* LANDSCAPE ON A PHONE, third attempt, and the first two were both wrong in
   the same direction: they tried to make the takeover behave like a page.

   1. Fixed at 100svh with the scroll moved onto it. The footer of the page
      BEHIND showed through the gap between the small viewport and the large
      one — the white band across the middle of the sky.
   2. position: static, in the flow, with the body lending its background.
      That fixed the footer and exposed everything else: scrolling past the
      takeover landed on welcome/index underneath it, navbar, welcome heading
      and a second copy of the status card (Corey, 2026-09-22: "when
      horizontal, I don't want to see the nav bar, I don't want to see the
      duplicated stats bar").

   The takeover is not a page. It is the whole phone, sideways as well as
   upright — that is what "no way out of it" means — and the only thing
   landscape actually changes is that the content no longer fits, so it has to
   scroll INSIDE it.

   So: still fixed, still covering, but anchored top AND bottom rather than
   given a height. 100svh left the strip between the small and large viewports
   uncovered, which is exactly where the page behind was showing through;
   inset covers the layout viewport however Safari's toolbars happen to be
   sitting. The sky stays in its own fixed clip box, so the background does not
   move when the send panel opens (Corey: "make the background height stable so
   that when the send panel expands, it doesn't shift the background too") —
   the content scrolls over a background that is not in the scrolling box. */
/* (hover: none) and (pointer: coarse) as well as the height: a desktop window
   dragged short is landscape and under 576px tall, and none of this belongs
   there -- it hides the wordmark and the mascot and takes the avatar row's
   drag away from the browser. Codex, reviewing this range: the rules "apply to
   every landscape viewport <= 575.98px tall, including desktop windows". */
@media (orientation: landscape) and (max-height: 575.98px) and (hover: none) and (pointer: coarse) {
  .takeover--checkpoint {
    top: 0;
    bottom: 0;
    height: auto;
    /* The takeover is the scrolling box now, not .takeover__body: one box
       scrolls the wordmark and the cards together, so there is still only one
       scrollbar and the mark does not eat height that is needed for cards. */
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .takeover--checkpoint .takeover__body {
    overflow: visible;
    flex: 0 0 auto;
  }

  /* Start at the top rather than centring in a height the content exceeds:
     centred overflow is what puts half a card past the bottom edge. */
  .takeover--checkpoint .gp-checkpoint {
    justify-content: flex-start;
  }

  /* Neither survives the turn: sideways there is ~390px of height for two
     cards and a button, and the wordmark and the mascot are the two things
     that are not the interaction (Corey, 2026-09-22: "horizontal still show
     the company logo and chud graphic"). Hidden rather than shrunk -- a 20px
     wordmark is not a wordmark. */
  .takeover--checkpoint .takeover__wordmark,
  .takeover--checkpoint .gp-checkpoint__mascot {
    display: none;
  }

  /* The row's drag is ours outright here. pan-y let iOS decide mid-drag that
     the gesture was a vertical scroll of the takeover -- which in landscape
     IS scrollable, unlike portrait where the body is the scrolling box and the
     row has nothing under it to pan. The row moved under the finger and the
     selection jumped (Corey: "swiping thru characters on horiz, randomly
     jumps"). There is plenty of other surface to scroll by. */
  .gp-checkpoint__avatars {
    touch-action: none;
  }

  /* The sky is painted by the clip box, which is fixed at 100lvh and outside
     the scrolling box — so it is stable while the content moves over it. */
  .takeover--checkpoint > .gp-curtain-clip {
    display: block;
  }
}

/* The one surface that does NOT take the sky: standby, both branches, and any
   other participation prompt that comes down full screen. Corey, 2026-09-21,
   asked for exactly this split - "standby stays full screen, and the
   participation prompts keep the solid dark sheet - since you're in a dark
   room". A theatre is dark, the phone is the brightest thing in it, and a
   daylit sky dropped over somebody who is about to be called on stage lights
   their face up in a room where that is the operator's business, not the
   design's. So the sheet stays solid and stays --bs-dark: Bootstrap's own
   value, the one this surface carried as a `background` before round 19, moved
   into the layer rather than chosen. No --gp-* token - inventing one for a
   Bootstrap default would assert a relationship to the design system that
   does not exist. */
.takeover--blocking .takeover__sky {
  background: var(--bs-dark, #212529);
}

/* The scrolling region below the wordmark. `display: flex` with the default
   row direction stretches its one child (.gp-checkpoint) to this box's full
   height for free — no min-height: 100% needed — and .gp-checkpoint centres
   its own contents vertically inside that height (components.css). */
.takeover__body {
  flex: 1;
  min-height: 0;
  display: flex;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding:
    0
    env(safe-area-inset-right)
    calc(24px + env(safe-area-inset-bottom))
    env(safe-area-inset-left);
}

/* Round 19: the wrapper the curtain drifts the content on
   (performers/_checkpoint_frame). It stands between .takeover__body and the
   card, so it has to hand the stretch straight through — the body's row flex
   is what gives .gp-checkpoint its full height, and components.css
   (.gp-checkpoint__main) depends on having it. flex: 1 takes the body's
   width; display: flex repeats the row-direction stretch for the card. */
.takeover__drift {
  flex: 1;
  min-width: 0;
  display: flex;
}

.takeover__drift > .gp-checkpoint {
  position: relative;
}

/* 121.21 x 32 in Figma, 14px from the top, and white. The only wordmark in
   the repo is the navbar's black one, so this inverts it rather than adding
   a second file. */
.takeover__wordmark {
  flex: none;
  align-self: center;
  width: 121.21px;
  height: 32px;
  margin-top: calc(14px + env(safe-area-inset-top));
  filter: brightness(0) invert(1);
}

/* ---------------------------------------------------------------------------
   PATTERN A · THE CURTAIN (round 19). The sky comes down over the page, and
   the content fades in behind it, drifting into place. It replaces the plain
   fade-and-rise this file used to do, which is why takeover-checkpoint-enter
   and -rise (and their two motion tokens) are gone rather than gated.

   REUSABLE, and it knows nothing about the checkpoint. A surface plays it by
   declaring data-gp-transition="curtain" — the gate (src/transition_gate.js)
   writes back data-enter="curtain" when this device was not already showing
   this window — and by naming its parts with data-gp-curtain:

     sky           the layer that travels. One layer: see .takeover__sky.
     content       what fades and drifts under it
     content-lead  the same, a beat earlier and 0.7 of the distance (the
                   wordmark), so the screen does not arrive all at once
     tint          the iOS edge strips, which are neither

   The quiz and the participation screens are meant to be the next callers and
   need nothing from this file but those attributes — plus .gp-curtain-clip
   around the sky. Every curtain needs one: the base .takeover is
   overflow-y: auto, so a sky translated out of the surface itself is a
   full-screen sky sliding over the page, and even .takeover--checkpoint's own
   overflow: hidden is the wrong box (it stops at the small viewport, and the
   sky has to run on under Safari's collapsed toolbar). The wrapper says where
   the sky is allowed to be; the checkpoint's own rule above says where that
   box is.

   THE ENTRANCE IS CSS, deliberately, for the same reason the old fade was: a
   takeover can arrive in the page's FIRST PAINT, and a Stimulus controller
   only connects once the module graph has loaded — a JS entrance would paint
   the takeover whole and then snap it away to animate it, and on a phone
   whose esm.sh request never lands it would not animate at all. The EXIT is
   JS (src/curtain.js): it has to hold the turbo stream's render open until it
   has finished, and CSS cannot.

   Nothing starts at a true opacity: 0. WebKit does not paint a fully
   transparent subtree, so its first visible frame would also be its first
   PAINT — the flash. 0.001 is invisible and painted.

   `backwards`, so the content is still hidden through its delay rather than
   showing for 180ms and then fading in from nothing. Not `both`: the end
   state is the element's own, and a forwards fill would keep a composited
   layer alive for the length of the window. */
.gp-curtain-clip {
  overflow: hidden;
}

[data-enter="curtain"] [data-gp-curtain="sky"] {
  animation: gp-curtain-sky-in var(--gp-motion-curtain-in) cubic-bezier(0.2, 0.7, 0.2, 1);
}

/* Promoted for the length of the travel and no longer, which is what
   src/curtain.js's data-gp-curtain-landed is for on this side. */
[data-enter="curtain"]:not([data-gp-curtain-landed]) [data-gp-curtain="sky"] {
  will-change: transform, opacity;
}

[data-enter="curtain"] [data-gp-curtain="content"] {
  animation: gp-curtain-content-in
             var(--gp-motion-curtain-content-in)
             var(--gp-motion-curtain-content-in-delay)
             cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
}

[data-enter="curtain"] [data-gp-curtain="content-lead"] {
  animation: gp-curtain-lead-in
             calc(var(--gp-motion-curtain-content-in) * 0.85)
             calc(var(--gp-motion-curtain-content-in-delay) * 0.7)
             cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
}

/* The edge strips must not stand as sky-coloured bars over a page the sky has
   not reached yet — the bottom one is the whole 540ms of the travel, at the
   foot of the old page. Hidden until the curtain has landed, and then
   src/curtain.js REMOVES and RE-INSERTS them: showing a hidden strip is
   exactly what Safari does not re-sample on (round 11), and an insert is.

   .takeover--checkpoint, not any curtain, and that qualifier is load-bearing:
   the strips are rendered by the checkpoint frame alone, and landCurtain() —
   the only thing that ever writes data-gp-curtain-landed — is called for the
   checkpoint by its controller and for the quiz and participation screens by
   curtain_controller.js. Unqualified, a quiz curtain entering over an open
   checkpoint matched this and hid the CHECKPOINT's strips, and on a phone
   whose module graph never landed it hid them for the whole life of the quiz
   question rather than for 540ms. A surface that grows strips of its own joins
   this selector and takes on the duty to land. */
body:has(.takeover--checkpoint[data-enter="curtain"]:not([data-gp-curtain-landed])) [data-gp-curtain="tint"] {
  display: none;
}

/* The sky fades over the first 30% of its travel (Corey's 5, tuned on the
   phone), so it arrives as sky rather than as a hard edge coming down. */
@keyframes gp-curtain-sky-in {
  from { transform: translateY(-100%); opacity: 0.001; }
  30% { opacity: 1; }
  to { transform: translateY(0); }
}

/* A transform, and on the WRAPPER: .gp-checkpoint itself is moved with `top`
   elsewhere in this file precisely so it does not become a stacking context
   for the status bar's plus-lighter fills, and the wrapper exists to take the
   transform instead. `top` here would relayout and repaint the glass card
   every frame. */
@keyframes gp-curtain-content-in {
  from {
    opacity: 0.001;
    transform: translateY(calc(var(--gp-dimension-curtain-drift-in) * -1));
  }
}

@keyframes gp-curtain-lead-in {
  from {
    opacity: 0.001;
    transform: translateY(calc(var(--gp-dimension-curtain-drift-in) * -0.7));
  }
}

/* Reduced motion, written with the transition rather than after it (round
   19): the background arrives in 140ms and the two content layers fade in
   behind it, staggered so it is still a sequence, with nothing moving. The
   content must actually APPEAR — the prototype's revision 5 shipped a warm-up
   that left it invisible here, which is the bug this stagger replaces. */
@media (prefers-reduced-motion: reduce) {
  [data-enter="curtain"] [data-gp-curtain="sky"] {
    animation: gp-curtain-fade-in 140ms linear;
  }

  [data-enter="curtain"] [data-gp-curtain="content-lead"] {
    animation: gp-curtain-fade-in 180ms 100ms linear backwards;
  }

  [data-enter="curtain"] [data-gp-curtain="content"] {
    animation: gp-curtain-fade-in 220ms 120ms linear backwards;
  }
}

@keyframes gp-curtain-fade-in {
  from { opacity: 0.001; }
}

/* The two states that outrank it, made to outrank it.

   Being chosen and being on stage both beat the checkpoint; standby does not.
   But being chosen and standby are one class (.takeover--blocking) on two
   partials, so no z-index can put the checkpoint between them. This does the
   split by presence instead, which also means the checkpoint comes back by
   itself when the higher frame clears — no broadcast, no round trip.

   The first selector is the id the instruction card already sets. The second
   identifies participation/_live by what makes it structurally unique: a modal
   dialog inside the participation frame that is NOT a .takeover, because it is
   styled inline rather than with the class. Neither hook belongs to this
   change, so neither breaks when the other session refactors — and both fail
   visible rather than hidden.

   The floating reminder's reopened confirmation card (_reminder.html.erb)
   sits under `hidden` inside #participation_frame too, every accepted
   attendee, whether tapped open or not — but it renders with a distinct id,
   `participation_instruction_header_reopened`, so the exact-match attribute
   selector below does not see it. That is deliberate (_instruction_card.html.erb),
   not incidental: this rule stays correct without a `.takeover--reopened`
   exclusion because it never keys off the class the reopened copy shares with
   a real takeover, only the id the genuine one owns alone. Verified when the
   sale-toast suppression below got that exclusion (sale-toast-layer branch). */
body:has(#participation_frame [aria-labelledby="participation_instruction_header"]) .takeover--checkpoint,
body:has(#participation_frame [role="dialog"][aria-modal="true"]:not(.takeover)) .takeover--checkpoint,
body:has(#participation_frame [aria-labelledby="participation_instruction_header"]) .takeover__tint,
body:has(#participation_frame [role="dialog"][aria-modal="true"]:not(.takeover)) .takeover__tint {
  display: none;
}

/* /checkpoint, under the navbar: the same "centre in the room that's left,
   scroll rather than clip" rule as the takeover, without touching the shared
   layout template to get it. .gp-checkpoint-page is the layout's `<%= yield
   %>` wrapper's only child on this one route, so :has() reaches that wrapper
   by what it contains rather than by a class this file would have had to
   add to a template every other page also uses. That div becomes the flex
   item that grows to fill whatever the navbar left; .gp-checkpoint-page then
   does exactly what .takeover__body does above. */
body#checkpoint main > div:has(> .gp-checkpoint-page) {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

/* Sale toasts. --gp-zIndex-sale-toast (1095, tokens.css) clears EVERYTHING on
   the attendee surface, including being on stage (1077); only the checkpoint's
   own lane is above it, at 1100, because that one answers a tap the attendee
   has just made and a sale does not.

   It was 1070, then 1072, then this, over two days. The last move is the one
   that changes how to read the rest of this comment: the rank no longer
   decides whether a toast is SEEN. The suppression below does, and it is a
   list of screens rather than a number (Corey, 2026-09-22: "toasts should just
   always be the absolute top line, z layer over everything, there are just
   some views where we don't show them"). That means a missing entry below is
   now a toast OVER a takeover rather than merely a wrong rank -- which is why
   .gp-toast-stack joined every line of it in the same commit that raised it.

   Suppressed outright, not merely out-stacked, while the quiz or any
   participation takeover owns the screen: nobody can buy anything during
   either, so a toast there would only cover a question or a cue. Reuses the
   two hooks the checkpoint suppression above already established —
   .takeover--blocking covers the opt-in ask, being chosen and standby/ready
   in one class; #participation_frame's dialog covers being live on stage,
   which is not a .takeover because it is styled inline (see _live.html.erb)
   — plus .takeover--quiz, which the checkpoint never had to consider because
   the checkpoint already outranks it. These are broadcast, not queued: a
   suppressed toast is lost, not shown late.

   :not(.takeover--reopened) excludes the floating reminder's reopened
   confirmation card (_reminder.html.erb, _instruction_card.html.erb) — it
   sits under `hidden` on every accepted attendee's page whether or not it is
   ever tapped open, and :has() matches structural presence, not visibility.
   Without the exclusion this rule would suppress sale toasts for every
   accepted attendee permanently, including through a Chud Checkpoint. CSS has
   no "not a descendant of a hidden ancestor" combinator, so the exclusion is
   a class on the element itself rather than a check on its hidden wrapper —
   see _instruction_card.html.erb's `dismissible` comment.

   That class is not merely static markup, though: it is server-rendered ON
   (matching the card's server-rendered `hidden` state) and then
   participation_reminder_controller.js#expand/#collapse remove and restore
   it to track whether the overlay is actually visible. A card that has been
   tapped open is a genuine full-screen takeover, not a hidden exempt copy
   any more — the exemption has to end when the hiding does, and the
   controller that changes the card's visibility is the only thing that can
   keep the class in step with it (sale-toast-layer). */
/* Round 19 split the blocking rung in two: the opt-in ask and being chosen
   are .takeover--panel now (Pattern B, the blur-and-lift), while standby and
   ready kept .takeover--blocking. Both rungs suppress a sale toast for the
   same reason -- nobody can buy anything during either -- so the panel gets a
   line here too, carrying the same :not(.takeover--reopened) exclusion,
   because the floating reminder's hidden copy is a panel too now.

   AND EVERY LINE IS GUARDED ON THE CHECKPOINT NOT BEING UP, which is not
   fussiness -- it is the difference between this rule working and this rule
   hiding every sale toast in the building. `:has()` matches structural
   PRESENCE, not visibility, and these takeovers coexist in the DOM: an
   attendee who has not answered the participation opt-in carries the opt-in
   panel on every page, underneath whatever else is on screen. A Chud
   Checkpoint (1065) paints over the panel (1060) and deliberately does NOT
   suppress -- "a sale still shows while a checkpoint is up" -- so without
   this guard the panel nobody can see suppresses the toast anyway.

   Measured 2026-09-21, on Corey's phone: four notifications broadcast, four
   rendered into #notifications by the server, none visible, and none visible
   after a reload either. The page carried `takeover--checkpoint` and
   `takeover--panel` at the same time. The same hole exists for standby under
   a checkpoint, which is why the guard is on the whole group rather than on
   the line I added.

   The live line is deliberately NOT guarded: being on stage is 1075, above
   both the checkpoint and the toast, so it is genuinely the top of the screen
   whenever it is present. */
body:not(:has(.takeover--checkpoint)):has(.takeover--quiz) :is(.sale-toasts, .gp-toast-stack),
body:has(.takeover--blocking:not(.takeover--reopened)) :is(.sale-toasts, .gp-toast-stack),
body:not(:has(.takeover--checkpoint)):has(.takeover--panel:not(.takeover--reopened)) :is(.sale-toasts, .gp-toast-stack),
body:has(#participation_frame [role="dialog"][aria-modal="true"]:not(.takeover)) :is(.sale-toasts, .gp-toast-stack) {
  display: none;
}

/* A fourth suppression, not a :has() like the three above: this branch moved
   the toast from 1050 to 1070 so it would clear the checkpoint (1065), and
   the vendored .modal (bootstrap.css:5224) sits at 1060 — between the two —
   so the toast now paints over an open modal and can steal a tap meant for
   its button. It cannot simply drop back under 1060, because staying above
   the checkpoint is the point of this change.

   Bootstrap's modal.js adds/removes `modal-open` on <body> itself around
   Modal#show/#hide (bootstrap.bundle.js, CLASS_NAME_OPEN) — the same hook
   every Bootstrap modal on this app already relies on for its own body
   scroll-lock, so this needs no :has() at all. Two real ones exist on the
   attendee surface: the logged-in welcome modal (application.html.erb) and
   the shop's buy-button confirmation. */
body.modal-open :is(.sale-toasts, .gp-toast-stack) {
  display: none;
}

/* --gp-zIndex-reminder (1040, tokens.css) was already the value inline
   markup used here; wired to the token now that the class exists to hang it
   on. Below every takeover (1055+) deliberately: the reminder is dismissible
   and never the show's own moment. */
.participation-reminder {
  z-index: var(--gp-zIndex-reminder);
}

.sale-toasts {
  z-index: var(--gp-zIndex-sale-toast);
  /* The container spans corner-to-edge of the phone. Without this, an
     attendee tapping a checkpoint's performer button or a quiz answer
     underneath it would hit dead space instead — even while the toast above
     is invisible or suppressed, because the box itself still occupies that
     screen area. */
  pointer-events: none;
}

.sale-toasts > * {
  pointer-events: auto;
}

/* At the bottom-right corner on a phone, a toast lands directly on the
   controls an attendee is actively pressing: the checkpoint's performer
   buttons and confirm, a quiz answer. Moved to the top; left rather than
   right, because the cart icon and navbar toggler already occupy top-right
   on a phone (see application.html.erb's navbar). Desktop keeps the
   bottom-right corner — nothing sits under it there to cover.

   top: 0 used to mean top of the VIEWPORT, which is also where
   application.html.erb's navbar-brand wordmark sits — a persisted
   notification never times out, so one could sit on that link until
   dismissed. --gp-dimension-navbar-clearance (tokens.css) is the navbar's own
   measured bottom edge at this width (see tokens.json for the live
   measurement), so the toast now clears it instead of covering it. The
   navbar is position: relative here, not fixed, so this only matters while
   the page is scrolled to the top — scrolled down, the navbar and its
   wordmark leave the viewport with the rest of the page and the toast (still
   position: fixed) has nothing left to cover.

   env(safe-area-inset-top) is added on top of the token, not folded into it
   (PR #209, main): the token is a measurement of the navbar's own height,
   taken in headless Chrome where every env() reads 0, and .sale-toasts is
   position: fixed — positioned against the VIEWPORT, not the body, so it
   never receives the top padding PR #209 put on <body>. On a notched phone
   the navbar itself is pushed down by that same inset (it is in normal
   flow, under the padded body), so a toast still pinned to the bare token
   would sit back on top of the navbar-brand wordmark, exactly the bug this
   token exists to prevent. Both terms are load-bearing: dropping the env()
   term reproduces the notch regression; dropping the token reproduces the
   original wordmark overlap. See "has .sale-toasts actually consume the
   safe-area inset" in takeover_layering_spec.rb, which fails if either
   disappears. */
/* Phone-shaped, either way up. max-width alone missed LANDSCAPE: a phone on
   its side is ~844 wide, so it took the desktop branch and put the toast
   bottom-right, on top of the card (Corey, 2026-09-21, landscape
   screenshot). The height half catches it without catching a small desktop
   window, which is genuinely a desktop and has room in the corner. */
@media (max-width: 575.98px), (orientation: landscape) and (max-height: 575.98px) and (hover: none) and (pointer: coarse) {
  .sale-toasts {
    top: calc(env(safe-area-inset-top) + var(--gp-dimension-navbar-clearance)) !important;
    bottom: auto !important;
    left: 0 !important;
    right: auto !important;
    /* #217 (main, commit 69a07f6) narrowed the id rule below to
       #notifications.bottom-0.end-0.p-3 specifically so it lands only on
       the box actually anchored bottom-right, and stated the reason: padding
       an edge the box is NOT anchored to wastes the full inset "for nothing,
       in landscape on a notched phone." This box still carries the
       bottom-0/end-0 classes in markup (_notifications.html.erb), so it
       still matches that id rule at every width -- but at this width it is
       no longer anchored there. Anchored left now, not right; still anchored
       top, not bottom. So: drop the padding-right/padding-bottom insets back
       to the plain p-3 1rem (the sides are no longer touching those edges),
       and add the left inset for the edge that now applies. No top inset
       here -- the `top:` declaration two lines up already carries
       safe-area-inset-top via --gp-dimension-navbar-clearance; adding it
       again here would double-count it.

       The padding-right/-bottom/-left declarations that used to sit right
       here (commit 98042ae) were DEAD from the moment they were written: at
       .sale-toasts, specificity (0,1,0), they tied on !important with
       #notifications.p-3's (1,1,0) below and lost outright on specificity --
       #notifications.p-3 always won, so the box kept taking right/bottom
       insets on edges it no longer touches. Measured post-merge at 390px
       with stub insets (top 59, right 33, bottom 34, left 35):
       padding-right computed 49px (16+33, #notifications.p-3's value) and
       padding-bottom 50px (16+34), not the 16px this block intended.
       padding-left computed correctly (51px = 16+35) only because nothing
       else competed for that property. Moved below, past
       #notifications.p-3, as `#notifications.sale-toasts` -- see that
       selector for why. */
  }
}

/* The chosen character, ringed in their own colour and lifted.
   --performer-accent is set per button and already falls back when
   icon_color is blank or white; see PerformersHelper#checkpoint_accent. */
.takeover--checkpoint [data-checkpoint-target="performerButton"] img {
  border: 3px solid transparent;
  transition: transform 120ms ease, border-color 120ms ease;
}

.takeover--checkpoint [data-checkpoint-target="performerButton"].is-selected img {
  border-color: var(--performer-accent, var(--gp-accent-fallback));
  transform: scale(1.08);
}

/* ---------------------------------------------------------------------------
   Safe areas.

   layouts/application sets viewport-fit=cover, so every page on that layout
   paints under the iPhone's status bar, notch and home indicator. These keep
   what is pinned to an edge out from under them. Every env() is 0 on a desktop,
   on an older phone and on the four layouts that do NOT set the flag
   (store, minimum, admin, admin_login), so nothing moves there.

   The top inset is padding on the BODY rather than a margin on each thing that
   happens to be first -- the navbar when signed in, the Gross Prophets logo
   block when signed out, the page itself on a layout that renders neither.
   checkpoint-ui-polish pushes the navbar down instead, which is the same result
   for the one case it had; it has no logged-out page to keep out of the notch.
   MERGING THE TWO: take one or the other, not both, or a signed-in phone gets
   the inset twice.

   The body's background is untouched by its own padding -- a background on the
   body propagates to the canvas and paints the whole viewport including the
   insets -- which is what lets .gp-sky run under the status bar while the form
   inside it does not. See .gp-sky in components.css for the colour Safari reads.
   --------------------------------------------------------------------------- */
body {
  padding-top: env(safe-area-inset-top);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

/* The footer, not the body, takes the bottom inset: it is `bg-light` and runs
   edge to edge, so padding keeps its fill under the home indicator where body
   padding would leave a strip of page background below it. `py-3` is a
   Bootstrap utility and therefore !important; this has to be too. */
footer.footer {
  padding-bottom: calc(1rem + env(safe-area-inset-bottom)) !important;
}


/* The notification box is position: fixed at bottom-0 end-0, so it is the one
   thing the body's padding cannot move. p-3 is a utility and !important; the id
   outranks it on specificity, but importance is decided first, hence both.

   Deliberately (1,1,0), NOT narrowed to the anchor classes. Two elements carry
   id="notifications", and the other one -- welcome/order_notifications.html.erb:4,
   pinned top-0 start-0 -- is handled by its own rule below rather than by making
   this selector more specific. Narrowing this one to
   `#notifications.bottom-0.end-0.p-3` was tried and reverted: at (1,3,0) it
   outranks the operator row's `#shop .p-3` at (1,1,0), specificity is resolved
   before source order, and the row therefore stopped masking it -- so on /shop
   the box went to 8px top/left and 16px right/bottom, asymmetric, live, before
   any cutover. Measured in the real link order, not reasoned about. At (1,1,0)
   the row wins on /shop again until its block is emptied, which is when this
   change is meant to happen and is what design/parity/shop/accepted.json
   records. See docs/attendee-design-system-plan.md section 3.5 for why a
   body-scoped row rule reaches shared chrome at all. */
#notifications.p-3 {
  padding-right: calc(1rem + env(safe-area-inset-right)) !important;
  padding-bottom: calc(1rem + env(safe-area-inset-bottom)) !important;
}

/* At the mobile width where the box re-anchors top-left (the @media block
   above .sale-toasts), the right/bottom insets #notifications.p-3 sets
   above no longer belong on this box -- see that block's comment for the
   defect this replaces and the numbers that proved it dead.

   `#notifications.sale-toasts` is (1,1,0): the SAME specificity as
   #notifications.p-3 above, deliberately, not higher. Both carry
   !important, so importance ties and specificity ties too, which means
   source order decides -- and this rule is declared after
   #notifications.p-3, so it wins the padding-right/-bottom tie on this box
   at this width. It is also (1,1,0) against the operator row's
   `#shop .p-3` -- see #notifications.p-3's comment above for why that tie
   is deliberate and must not be broken by going higher. That tie is not
   decided here, though: the row is Setting[code: "custom_css"], rendered in
   its own <style> tag after every linked stylesheet including this one
   (application.html.erb) -- so on /shop it is always the last-loaded
   #shop .p-3 declaration in the document, regardless of where within THIS
   file our rule sits, and it keeps winning there exactly as it does on
   main today. */
/* Phone-shaped, either way up. max-width alone missed LANDSCAPE: a phone on
   its side is ~844 wide, so it took the desktop branch and put the toast
   bottom-right, on top of the card (Corey, 2026-09-21, landscape
   screenshot). The height half catches it without catching a small desktop
   window, which is genuinely a desktop and has room in the corner. */
@media (max-width: 575.98px), (orientation: landscape) and (max-height: 575.98px) and (hover: none) and (pointer: coarse) {
  #notifications.sale-toasts {
    padding-right: 1rem !important;
    padding-bottom: 1rem !important;
    padding-left: calc(1rem + env(safe-area-inset-left)) !important;
  }
}

/* The OTHER #notifications box: welcome/order_notifications.html.erb:4 renders one
   pinned top-0 start-0. The rule above matches it too, and padded the two sides it
   is not anchored to -- up to the full inset for nothing, in landscape on a notched
   phone. This takes those two back to the p-3 value rather than making the rule
   above more specific, because raising that one's specificity is what took it past
   the operator row. (1,3,0) here is fine: nothing else sets padding on this box. */
#notifications.top-0.start-0.p-3 {
  padding-right: 1rem !important;
  padding-bottom: 1rem !important;
}
