/* Attendee component layer.
 *
 * Every class here is prefixed .gp- and matches nothing that exists today, so this
 * file is inert until markup opts in. That is deliberate: new screens can build on
 * it immediately, while the pages still styled by Setting[custom_css] are untouched
 * until after the run.
 *
 * Values come from tokens.css. Nothing in this file hardcodes a colour or a radius.
 * Recipes are extracted from design/custom-css-baseline.css — the duplication count
 * for each is noted, because that is what justified extracting it.
 *
 * .gp-btn and .gp-field were reconciled against what production actually renders on
 * 2026-09-14. The differences, and which way each was settled, are in
 * docs/attendee-component-reconciliation.md. Read that before "correcting" anything
 * here back towards the baseline text: several baseline declarations have never
 * painted a pixel, and copying them in would ship a new design as if it were an
 * extraction.
 */

/* ---------------------------------------------------------------------------
 * Glass surface — the most duplicated recipe in the baseline. 16 literal copies,
 * but the honest count is lower: five attach to elements that cannot render a
 * pseudo-element at all (baseline:704 and :893 target <input>, and the three
 * .btn-primary::after copies at :1349, :1464 and :1592 target submit inputs), and
 * two of the survivors use different values — drop-shadow(10px 3px 6px #888)
 * brightness(122%), and a misspelt --backdrop-filter custom property, at
 * baseline:945-949 and :1279-1284. So: 16 written, 11 that can paint.
 *
 * Careful with that last number. All 11 carry an IDENTICAL box-shadow — the two
 * variants differ only in the filter chain and the misspelt property — so shadow/rim
 * has 11 live copies, not 9. It is the wider recipe that has 9. This rule implements
 * the shadow and deliberately not the filter: the baseline's
 * `filter: blur(1px) drop-shadow(10px 4px 6px black) brightness(115%)` and
 * `backdrop-filter: blur(1px)` are NOT here, which is an open difference on every
 * glass surface and is listed as unreconciled in the reconciliation doc.
 *
 * The rim highlight is a ::after overlay rather than a border so it can sit inside
 * the rounded corner. It is inert to pointers, and `z-index: -1` is what actually
 * puts it behind content: a positioned ::after is generated last, so at the default
 * `z-index: auto` it paints OVER every ordinary child and washes them out. All 16
 * copies of this recipe in the baseline carry that -1; the first extraction dropped
 * it and claimed the behaviour anyway.
 *
 * The -1 only stays *inside* this element because .gp-glass has a backdrop-filter,
 * which makes it a stacking context. Drop that and the rim paints behind the
 * element's own background instead of behind its content.
 * ------------------------------------------------------------------------ */
.gp-glass {
  position: relative;
  background: var(--gp-surface-glass);
  border: 1px solid var(--gp-border-glass);
  border-radius: var(--gp-radius-card);
  box-shadow: var(--gp-shadow-card);
  backdrop-filter: blur(var(--gp-dimension-blur-glass)) saturate(180%);
  -webkit-backdrop-filter: blur(var(--gp-dimension-blur-glass)) saturate(180%);
}

.gp-glass::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: inherit;
  background: var(--gp-color-glass-sheen);
  box-shadow: var(--gp-shadow-rim);
  opacity: 0.6;
  z-index: -1;
}

.gp-glass--pill,
.gp-glass--pill::after {
  border-radius: var(--gp-radius-pill);
}

.gp-glass--strong {
  background: var(--gp-surface-glass-strong);
}

/* ---------------------------------------------------------------------------
 * Glass v2 (round 11): the checkpoint's glass. The send card, the drawer
 * sheet, the −/+ and Buy More Chuds, from public/glass-test.html's
 * Recommended preset with Corey's amendments (plan, R11-A). .gp-glass above
 * is the older component-layer recipe; nothing in app/views uses it as of
 * round 11, and it is left for the pages the operator's custom_css styles.
 *
 * A surface is the element plus layer elements (ApplicationHelper#glass_layers,
 * <i aria-hidden>), in paint order:
 *   __blur    the tint and the one backdrop pass: blur + vibrancy
 *   __frost   TRUE edge frost: a second, heavier backdrop pass, masked to
 *             the edges — card and sheet only (two extra passes at most)
 *   __shine   sheen, inner top highlight / bottom shade, colour fringe, and
 *             the CHEAP frost (an inset glow, no backdrop pass)
 *   __rim     the specular rim, a gradient ring cut out with a mask
 *
 * The layers sit at z-index -1 inside the surface, which isolation makes a
 * stacking context: above the surface's own background, below its content.
 * A stacking context is not a backdrop root (filter, opacity, mask and
 * backdrop-filter are), so the layers still blur the clouds behind.
 *
 * position is on the modifiers, not here: the sheet is Bootstrap's fixed
 * .offcanvas, and a position here would unfix it (same specificity, later).
 * ------------------------------------------------------------------------ */
.gp-glass-v2 {
  isolation: isolate;
  box-shadow: var(--glass-depth);
}

.gp-glass-v2 > :is(.gp-glass-v2__blur, .gp-glass-v2__frost, .gp-glass-v2__shine, .gp-glass-v2__rim) {
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  pointer-events: none;
}

.gp-glass-v2__blur {
  background: var(--glass-tint);
  -webkit-backdrop-filter: blur(var(--gp-dimension-glass-blur)) saturate(var(--gp-glass-saturate)) brightness(var(--gp-glass-brightness));
  backdrop-filter: blur(var(--gp-dimension-glass-blur)) saturate(var(--gp-glass-saturate)) brightness(var(--gp-glass-brightness));
}

/* TRUE frost follows the rounded corners (amendment 6). The preset masked it
 * with two linear gradients, whose inner edge is square at every corner; a
 * 9-slice of a blurred rounded rect (images/glass/frost-edge.svg) keeps the
 * corners the same at any size. Off by default and on only where the browser
 * has both a 9-slice mask and backdrop-filter; anywhere else the surface is
 * simply the tint and blur, with the plain rounded corner. */
.gp-glass-v2__frost {
  display: none;
}

@supports ((-webkit-mask-box-image: none) or (mask-border: none)) and ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  :is(.gp-glass-v2--card, .gp-glass-v2--sheet) > .gp-glass-v2__frost {
    display: block;
    background: var(--gp-color-glass-frost-tint);
    -webkit-backdrop-filter: blur(var(--gp-dimension-glass-frost-blur)) saturate(var(--gp-glass-frost-saturate)) brightness(var(--gp-glass-frost-brightness));
    backdrop-filter: blur(var(--gp-dimension-glass-frost-blur)) saturate(var(--gp-glass-frost-saturate)) brightness(var(--gp-glass-frost-brightness));
    -webkit-mask-box-image: url("/assets/glass/frost-edge-b6dea6fa.svg") var(--gp-glass-frost-slice) / var(--gp-dimension-glass-frost-width) stretch;
    mask-border: url("/assets/glass/frost-edge-b6dea6fa.svg") var(--gp-glass-frost-slice) / var(--gp-dimension-glass-frost-width) stretch;
  }
}

.gp-glass-v2__shine {
  background: var(--glass-sheen, var(--gp-gradient-glass-sheen));
  box-shadow: var(--gp-shadow-glass-edge), var(--gp-shadow-glass-fringe), var(--glass-cheap, 0 0 transparent);
}

/* xor is the old WebKit spelling of exclude (amendment 7). */
.gp-glass-v2__rim {
  padding: var(--gp-dimension-glass-rim-width);
  background: var(--gp-gradient-glass-rim);
  -webkit-mask: linear-gradient(var(--gp-color-black) 0 0) content-box, linear-gradient(var(--gp-color-black) 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(var(--gp-color-black) 0 0) content-box exclude, linear-gradient(var(--gp-color-black) 0 0);
}

.gp-glass-v2--card {
  position: relative;
  --glass-tint: var(--gp-color-glass-tint-card);
  --glass-depth: var(--gp-shadow-glass-depth);
}

.gp-glass-v2--sheet {
  --glass-tint: var(--gp-color-glass-tint-sheet);
  --glass-depth: var(--gp-shadow-glass-depth);
}

/* −/+: round and dimensional. They keep their own blur 10 on the element
 * (no __blur layer), so no new backdrop pass: shine and rim only. */
.gp-glass-v2--control {
  position: relative;
  --glass-depth: var(--gp-shadow-glass-depth-control);
  --glass-cheap: var(--gp-shadow-glass-frost-cheap-control);
}

/* Buy More Chuds: opaque blue, no blur; a subtle rim and a soft sheen, the
 * least frost, the flattest depth (amendments 2, 3). */
.gp-glass-v2--button {
  position: relative;
  --glass-depth: var(--gp-shadow-glass-depth-button);
  --glass-cheap: var(--gp-shadow-glass-frost-cheap-button);
  --glass-sheen: var(--gp-gradient-glass-sheen-button);
}

/* Performer avatars (round 12, item 3: glass-test's preview row, shipped).
 * The photo circle takes the rim, sheen and edge OVER the picture and its
 * ring, and the controls' depth under it — no tint, no blur of its own.
 * Rules of their own, so the photo's ring, colour and greyscale rules
 * (.gp-checkpoint__photo, round 12's --pick) are untouched: the photo's
 * clip moves from overflow to the picture's own radius, so the layers can
 * reach out over the ring, and they paint above the picture (z-index 1,
 * not the family's -1, which would put them under it). The ring's width is
 * the photo's own expression. Two classes: .gp-checkpoint__photo's
 * overflow comes later in the file. */
.gp-glass-v2.gp-glass-v2--avatar {
  --glass-depth: var(--gp-shadow-glass-depth-control);
  --avatar-ring: calc(4px + var(--grow, 0) * 0.2px);
  position: relative;
  overflow: visible;
}

.gp-glass-v2--avatar > img {
  border-radius: 50%;
}

/* Round 14, item 4 (Corey, phone: the chromatic edge reads good and subtle
 * on the sheet and −/+, too dark and prominent on the card and the bottom
 * of the avatars). glass-edge and glass-rim are the same rgba on every
 * glass-v2 surface, but nothing dilutes them evenly: the sheet's tint (.55)
 * and the −/+'s (.3) are opaque enough to soften the dark end, the card's
 * (.22, thinned deliberately in round 12) is not, and the avatar has no
 * tint at all — its rim and shine paint above the photo (z-index 1, no
 * __blur layer), so the same rgba lands unmuted. Card and avatar get the
 * -muted tokens (dark end halved); the shared glass-edge/glass-rim that
 * the sheet, −/+ and thumb use stay exactly as they were. */
.gp-glass-v2--card > .gp-glass-v2__shine,
.gp-glass-v2--avatar > .gp-glass-v2__shine {
  box-shadow: var(--gp-shadow-glass-edge-muted), var(--gp-shadow-glass-fringe), var(--glass-cheap, 0 0 transparent);
}

.gp-glass-v2--card > .gp-glass-v2__rim,
.gp-glass-v2--avatar > .gp-glass-v2__rim {
  background: var(--gp-gradient-glass-rim-muted);
}

.gp-glass-v2.gp-glass-v2--avatar > :is(.gp-glass-v2__shine, .gp-glass-v2__rim) {
  inset: calc(-1 * var(--avatar-ring));
  z-index: 1;
}

/* The drawer sheet has no bottom edge (round 12, item 7; Corey: "No bottom
 * border on panel"): on a phone it runs off the bottom of the screen, under
 * Safari's toolbar, so a rim or a shade along its foot is a line across the
 * page, not an edge. The rim's ring keeps no bottom side (no bottom padding
 * for the mask to cut), the inner shade is the top highlight alone, and the
 * frost's bottom band is pushed below the sheet. Card and controls stay
 * four-sided. Not on the centred modal (≥768px), which has four edges. */
@media (max-width: 767.98px) {
  .gp-glass-v2--sheet > .gp-glass-v2__rim {
    padding-bottom: 0;
  }

  .gp-glass-v2--sheet > .gp-glass-v2__shine {
    box-shadow: var(--gp-shadow-glass-edge-top), var(--gp-shadow-glass-fringe), var(--glass-cheap, 0 0 transparent);
  }

  .gp-glass-v2--sheet > .gp-glass-v2__frost {
    bottom: calc(-1 * var(--gp-dimension-glass-frost-width));
  }
}

/* Graceful degradation (amendment 7). No backdrop-filter at all, or the
 * reader asked for less transparency: an opaque-enough tint, no frost, no
 * blur. The −/+ and the track blur on the element itself; they are in the
 * checkpoint section's own fallback. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .gp-glass-v2:is(.gp-glass-v2--card, .gp-glass-v2--sheet) {
    --glass-tint: var(--gp-color-glass-tint-solid);
  }
}

@media (prefers-reduced-transparency: reduce) {
  .gp-glass-v2:is(.gp-glass-v2--card, .gp-glass-v2--sheet) {
    --glass-tint: var(--gp-color-glass-tint-solid);
  }

  .gp-glass-v2 > .gp-glass-v2__blur {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  :is(.gp-glass-v2--card, .gp-glass-v2--sheet) > .gp-glass-v2__frost {
    display: none;
  }
}

/* Round 12 (R12-J, Corey on an iPhone: "when in low power mode everything
 * feels a little jerky"). No API says Low Power Mode is on, so
 * src/frame_budget.js judges the takeover's own opening frames instead and
 * checkpoint_controller.js adds this class to <html> when delivery is
 * degraded, removed again on disconnect. Drop the two costliest
 * backdrop-filter passes — the TRUE edge frost, and the blur under the −/+
 * and the slide track — and keep every tint, so it still reads as the same
 * glass, just without the two most expensive layers. Nothing here shortens
 * an animation. */
.gp-reduced-effects :is(.gp-glass-v2--card, .gp-glass-v2--sheet) > .gp-glass-v2__frost {
  display: none;
}

.gp-reduced-effects .gp-checkpoint__step,
.gp-reduced-effects .gp-checkpoint__slide {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/* ---------------------------------------------------------------------------
 * Primary action — the green pill. 3 byte-identical copies in the baseline
 * (#vouchers:1331, [id$="-edit"]:1446, #referrals-new:1574).
 *
 * Reconciled 2026-09-14. Four things that reconciliation settled, each of which
 * is easy to "fix" back the wrong way:
 *
 *   - Weight is 400, not 600. All three copies ship 400; the first extraction
 *     typed 600 and would have shipped a bolder button as if nothing changed.
 *   - The glass box-shadow is restored. The first extraction dropped it, which
 *     is the difference between this button and a flat one.
 *   - The baseline's `backdrop-filter: blur(2px) saturate(180%)` is deliberately
 *     NOT here. The fill is opaque #1aaf13, so there is nothing behind it to show
 *     through. Measured: 114 of 12,000 pixels differ, by 1 of 255, and all of it
 *     is antialiasing on the rounded border. A compositing layer for no pixels.
 *   - The baseline's `.btn-primary::after` rim is deliberately NOT here either.
 *     Every call site is an <input type="submit"> (submit_tag at
 *     vouchers/index.html.erb:14, f.submit at referrals/new.html.erb:15 and
 *     attendees/edit.html.erb:20), and a replaced element generates no
 *     pseudo-elements. That rule has never painted. Adding it would be a new
 *     visual dressed up as an extraction.
 *
 * Width is NOT fixed here. The baseline pinned 150px because those form pages
 * wanted it — and #referrals-new immediately overrides it with .w-100, so only
 * two of the three ever render 150px. A width belongs to the layout.
 *
 * Known and carried forward, not introduced here: white on #1aaf13 is 2.92:1, so
 * the button label fails WCAG AA at this size. It fails in production today too.
 * Fixing it is a visual change and belongs after 4 October.
 * ------------------------------------------------------------------------ */
.gp-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: var(--gp-control-height);
  padding: 0.25rem 1rem;
  border: 1px solid var(--gp-border-glass-bright);
  border-radius: var(--gp-radius-pill);
  background: var(--gp-action-primary);
  color: var(--gp-action-on-primary);
  box-shadow: var(--gp-shadow-glass);
  font-size: var(--gp-font-size-body);
  font-weight: var(--gp-font-weight-regular);
  line-height: 1.5;
  text-align: center;
  text-decoration: none;
  cursor: pointer;
}

/* Bootstrap repaints .btn-primary blue in six places, and .gp-btn at (0,1,0) loses
 * to every one of them on specificity, which source order cannot fix. While the
 * Setting row is live its ID-scoped block hides all of it — so this only surfaces
 * at cutover, which is the worst moment to discover it.
 *
 * The full list, because "every state" was claimed once already and was wrong:
 *   .btn-primary:hover                       (0,2,0)  bootstrap.css:2949
 *   .btn:focus / .btn-primary:focus          (0,2,0)  bootstrap.css:2935, :2954
 *   .btn-primary:active, .btn-primary.active (0,2,0)  bootstrap.css:2960
 *   .btn-primary:disabled, .disabled         (0,2,0)  bootstrap.css:2968
 *   .btn-primary:active:focus                (0,3,0)  bootstrap.css:2965
 *
 * The .active and .disabled CLASS forms matter separately from their pseudo-class
 * twins — Bootstrap applies them to anchors, which have neither :active in that
 * sense nor :disabled at all. And :disabled is the one that bites hardest: it
 * repaints the background to #0d6efd, so a disabled migrated button was going
 * Bootstrap blue while .gp-btn[disabled] dimmed it to 55%.
 *
 * :hover left this list 2026-09-20 — it has its own rule further down that clears
 * .btn-primary:hover on specificity alone (see the comment there), rather than
 * repainting to the same value as this block does for the states that are NOT
 * getting a new look in this pass. */
.gp-btn:focus,
.gp-btn:active,
.gp-btn.active,
.gp-btn:disabled,
.gp-btn.disabled,
.gp-btn[disabled],
.gp-btn[aria-disabled="true"] {
  background: var(--gp-action-primary);
  border-color: var(--gp-border-glass-bright);
  color: var(--gp-action-on-primary);
  box-shadow: var(--gp-shadow-glass);
}

/* The only Bootstrap button selector that needs three, so it gets its own rule
 * rather than dragging the whole list up to (0,3,0). */
.gp-btn:active:focus {
  box-shadow: var(--gp-shadow-glass);
}

/* Hover and press, with motion (Corey, 2026-09-20). This reverses the "no hover
 * brightness" rule that lived here through the /vouchers migration: that version
 * deliberately repainted :hover to the exact same value as the base rule, because
 * a visible hover change on a shipped page was a redesign however small, and the
 * whole earlier pass existed to avoid that. The migration is done; Corey has now
 * asked for real hover and press states with colour and motion, ported from the
 * checkpoint-ui-polish branch's .gp-buy-chuds recipe (that button's own -/+ and
 * Buy More Chuds treatment). .is-pressed is set by press_controller.js rather than
 * driven off :active — CSS :active is not dependable for touch on iOS Safari, see
 * that controller's own comment — and the hover rule only applies where there is
 * a pointer to hover with.
 *
 * Both new rules carry :not(.gp-btn--ghost): .gp-btn--ghost keeps its own
 * state-override block further down at ordinary (0,2,0) specificity, same as
 * before, but these rules sit at (0,7,0)/(0,8,0) once .is-pressed/:hover and the disabled
 * guards are counted, which would otherwise always beat ghost's block regardless
 * of source order and leak a filled button's background-color onto it. They also
 * repeat the four disabled selectors from the list above, so a disabled or
 * aria-disabled .gp-btn keeps today's dimmed, non-animated look rather than
 * animating on a soft-disabled anchor that still receives pointer events.
 *
 * The focus ring below is the deliberate exception that was already here: an
 * accessibility fix is not polish, and this reversal does not touch it. */
.gp-btn {
  transition:
    background-color var(--gp-motion-press) ease-out,
    transform var(--gp-motion-press-release) cubic-bezier(0.34, 1.56, 0.64, 1);
}

.gp-btn.is-pressed:not(.gp-btn--ghost):not(:disabled):not(.disabled):not([disabled]):not([aria-disabled="true"]) {
  transform: scale(0.94);
  background-color: color-mix(in srgb, var(--gp-action-primary), var(--gp-color-black) 18%);
  transition:
    background-color var(--gp-motion-press) ease-out,
    transform var(--gp-motion-press) ease-out;
}

@media (hover: hover) {
  .gp-btn:hover:not(.is-pressed):not(.gp-btn--ghost):not(:disabled):not(.disabled):not([disabled]):not([aria-disabled="true"]) {
    transform: translateY(-1px) scale(1.03);
    background-color: color-mix(in srgb, var(--gp-action-primary), var(--gp-color-white) 12%);
    /* border-color, color and box-shadow are NOT decoration here. Taking :hover out
     * of the no-op list above hands those three properties back to
     * .btn-primary:hover (bootstrap.css:2949), which paints border-color #0a58ca --
     * a blue rim on a green button. Two shipped pages carry both classes and would
     * have shown it: referrals/new.html.erb:15 and vouchers/index.html.erb:14. A
     * parity screenshot cannot catch a hover state, so nothing downstream would
     * have caught it either. */
    border-color: var(--gp-border-glass-bright);
    color: var(--gp-action-on-primary);
    box-shadow: var(--gp-shadow-glass);
  }
}

/* Reduced motion: the colour, not the movement -- same trade the source recipe
 * makes for .gp-buy-chuds. */
@media (prefers-reduced-motion: reduce) {
  .gp-btn,
  .gp-btn.is-pressed,
  .gp-btn:hover {
    transform: none !important;
  }
}

/* Keyboard and switch users need a visible focus indicator, and the control this
 * replaces has none.
 *
 * Attributed carefully, because it was wrong here for a while and the wrong version
 * spread into two accepted.json files and a plan: it is NOT the operator's row that
 * removes the ring. The row never sets `outline` at all -- its only `outline` matches
 * are btn-outline-* class names. `application.css` removes it, on
 * `#performers-pay .qty-pill .qty-btn:focus` and `.qty-input:focus`, which is the OLD
 * checkpoint stepper that .gp-stepper__btn replaces. So this is a repo stylesheet's
 * omission being corrected by a repo stylesheet, and it does not depend on the row
 * being emptied to take effect -- which is why it is visible in compat.
 *
 * Live in production since v196 on six elements: the /vouchers field and Redeem
 * button, and the checkpoint frame's two stepper buttons, Send and Buy More Chuds.
 * Corey confirmed 2026-09-15 that /referrals/new matches them. */
.gp-btn:focus-visible,
.gp-field__control:focus-visible,
.gp-stepper__btn:focus-visible {
  outline: 3px solid var(--gp-border-focus);
  outline-offset: 2px;
}

.gp-btn:disabled,
.gp-btn.disabled,
.gp-btn[disabled],
.gp-btn[aria-disabled="true"] {
  opacity: 0.55;
  cursor: not-allowed;
}

/* Busy state. ::before rather than the segmented control's ::after
 * (application.css:352, same @keyframes gp-spin) because most .gp-btn labels
 * read left to right with no trailing icon slot, so a leading spinner sits where
 * a reader's eye already lands first. Sized in em so it scales with the button's
 * own font-size instead of a fixed px -- "sized to the label". The gap already on
 * .gp-btn spaces it from the label text for free. Markup that adds .is-busy is
 * another agent's; this only draws the state. */
.gp-btn.is-busy::before {
  content: "";
  display: inline-block;
  width: 0.75em;
  height: 0.75em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: gp-spin 0.6s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .gp-btn.is-busy::before {
    animation: none;
  }
}

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

/* No border, no fill, no shadow -- the avatar step's Random button, which
 * must not compete with Done for attention. Overrides the base fill AND the
 * whole state-override list above it, for the same reason that list exists in
 * the first place: .gp-btn--ghost:focus etc. matches (0,1,1,0) same as
 * .gp-btn:focus, so it only wins on source order, and skipping any one state
 * here would let that state's rule repaint the button green again. Hover and
 * press are different: those two rules (above) carry :not(.gp-btn--ghost)
 * themselves, so a ghost button is excluded outright rather than relying on a
 * specificity tie it would now lose. The
 * focus-visible ring is deliberately NOT touched -- .gp-btn:focus-visible
 * above already matches (it keys on .gp-btn, not on which fill modifier rides
 * along with it), and an accessibility ring is exactly what a ghost button
 * still needs. */
.gp-btn--ghost {
  background: none;
  border-color: transparent;
  color: var(--btn-ghost-color, var(--gp-text-body));
  box-shadow: none;
}

.gp-btn--ghost:hover,
.gp-btn--ghost:focus,
.gp-btn--ghost:active,
.gp-btn--ghost.active,
.gp-btn--ghost:disabled,
.gp-btn--ghost.disabled,
.gp-btn--ghost[disabled],
.gp-btn--ghost[aria-disabled="true"] {
  background: none;
  border-color: transparent;
  color: var(--btn-ghost-color, var(--gp-text-body));
  box-shadow: none;
}

.gp-btn--ghost:active:focus {
  box-shadow: none;
}

/* ---------------------------------------------------------------------------
 * Form field — the same recipe on #login:725, #vouchers:1374, [id$="-edit"]:1483
 * and #referrals-new:1611. Those three form pages are the same page written three
 * times (251 lines total).
 *
 * These classes are MODIFIERS on Bootstrap's .form-control / .form-label / .mb-3,
 * not replacements for them, which is what the migration plan calls for: named
 * classes go on alongside the utility classes, and a utility comes off only once
 * its matched-rule inventory is empty. So this block carries only what the page
 * blocks added on top of Bootstrap — fill, border, padding and display all still
 * come from .form-control, exactly as they do in production.
 *
 * Reconciled 2026-09-14. What that changed:
 *
 *   - The glass fill and white border are gone. Exactly ONE .form-control in the
 *     row was ever glass — `#login .form-control { background-color: rgba(255,
 *     255, 255, 0.4) }` — and that block left the row in the 2026-09-20 cut, so
 *     none of the 1,612 baseline lines declares a glass .form-control today. It
 *     did not belong on the component either way: on the #fefefe page background
 *     the fill and the white border both composite to white — 1.01:1, an
 *     invisible control that fails WCAG 1.4.11.
 *   - #vouchers is NOT the odd one out. Its `.form-control { color: var(--green) }`
 *     at baseline:1325 never wins: text_field_tag emits id="code", so
 *     `#vouchers #code` at :1316 outranks it and paints the same dk-green/800/
 *     centred recipe as the other three pages. That colour declaration is dead,
 *     like the .footer non-breaking space.
 *   - The label is 0.75rem. `.mb-3 label` (0,1,1,1) beats `.form-label` (0,1,1,0)
 *     on all three pages that have labels, so the 0.6rem uppercase recipe has
 *     never rendered — see the note on font/size-label in design/tokens.json.
 *   - .gp-field sets no margin. The extraction's 0.5rem was dead anyway — .mb-3 is
 *     `margin-bottom: 1rem !important` (bootstrap.css:6992) and a non-important
 *     declaration never beats it. Removed as tidy-up, not as a fix.
 * ------------------------------------------------------------------------ */
.gp-field {
  /* #referrals-new:1609 centres the label and help text; #login and the edit page
   * leave them left. Centred is what the one page migrating before the run does. */
  text-align: center;
}

.gp-field__label {
  display: block;
  margin-bottom: 0;
  font-size: var(--gp-font-size-small);
  text-transform: uppercase;
}

/* No height. 42px was here, and it is ONE PAGE'S value wearing the component's name:
 * of the four blocks that share this recipe, only #vouchers (baseline:1316) sets a
 * height on .form-control. #login (:725), [id$="-edit"] (:1425) and #referrals-new
 * (:1553) all leave Bootstrap's own 38px standing, and that is what they render --
 * measured, 38px on /referrals/new against 42px on /vouchers.
 *
 * It mattered more than it looks. The operator's row never declares a height on
 * #referrals-new .form-control, so there was nothing there to outrank this: adopting
 * .gp-field__control on that page would have made the field 42px the moment the markup
 * deployed -- in compat, before any row edit, inside the freeze. The height now lives
 * in pages.css under #vouchers .form-control, mirroring the row's own selector.
 *
 * Same call, same reason, as #vouchers #code { font-size }. */
.gp-field__control {
  border-radius: var(--gp-radius-pill);
  color: var(--gp-text-field);
  font-weight: var(--gp-font-weight-heavy);
  text-align: center;
}

/* .form-control:focus is (0,2,0) and sets `color: #212529`, which beats
 * .gp-field__control at (0,1,0) — the green text would turn near-black the moment
 * the field is focused. Today an ID-scoped rule in the Setting row wins that
 * fight; after cutover nothing does. */
.gp-field__control:focus {
  color: var(--gp-text-field);
}

/* Invalid. These deliberately hook the classes bootstrap_form ALREADY emits —
 * .is-invalid on the control, .invalid-feedback on the message — rather than
 * inventing a --error modifier it will never render. Both selectors still require
 * one of ours, so nothing outside a .gp-field is touched. */
.gp-field__control.is-invalid {
  border-color: var(--gp-border-error);
}

.gp-field .invalid-feedback {
  color: var(--gp-text-error);
  font-size: var(--gp-font-size-small);
}

/* There is no .gp-field__hint. bootstrap_form's `help:` renders Bootstrap's
 * .form-text — 0.875em, #6c757d, upright — on every page that shows help text.
 * The italic version the extraction carried across exists only under #login
 * (baseline:721), where the very same selector is display:none (baseline:707).
 * It has never been seen. Use .form-text. */

/* The form column those three pages each re-declared. */
.gp-form {
  width: min(var(--gp-dimension-form-width), 100% - 2rem);
  margin-inline: auto;
}

/* ---------------------------------------------------------------------------
 * Card
 * ------------------------------------------------------------------------ */
.gp-card {
  border-radius: var(--gp-radius-card);
  background: var(--gp-surface-glass);
  border: 1px solid var(--gp-border-glass);
  box-shadow: var(--gp-shadow-card);
}

/* ---------------------------------------------------------------------------
 * Quantity stepper — replaces #performers-pay .qty-pill and its eight
 * !important declarations, which existed only to undo Bootstrap's .btn styling.
 * Building it from nothing avoids the fight entirely.
 *
 * Note: .qty-pill is ALSO a different admin component (admin/orders/_line_item).
 * That is why this is named .gp-stepper rather than reusing the class.
 * ------------------------------------------------------------------------ */
.gp-stepper {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  height: var(--gp-control-height);
  padding-inline: 0.5rem;
}

.gp-stepper__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.75rem;
  min-height: 2.75rem;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  line-height: 1;
  cursor: pointer;
}

.gp-stepper__btn svg {
  width: 22px;
  height: 22px;
}

.gp-stepper__input {
  width: 3rem;
  border: 0;
  background: transparent;
  font-size: 1.25rem;
  font-weight: var(--gp-font-weight-heavy);
  text-align: center;
  /* Number inputs get spinners that fight a 3rem width. */
  appearance: textfield;
  -moz-appearance: textfield;
}

.gp-stepper__input::-webkit-outer-spin-button,
.gp-stepper__input::-webkit-inner-spin-button {
  appearance: none;
  margin: 0;
}

/* ---------------------------------------------------------------------------
 * Progress meter — the attendee level bar.
 * ------------------------------------------------------------------------ */
.gp-meter {
  height: 0.4rem;
  border-radius: var(--gp-dimension-radius-round);
  background: var(--gp-color-grey-400);
  overflow: hidden;
}

.gp-meter__fill {
  height: 100%;
  background: var(--gp-action-primary);
}

/* ---------------------------------------------------------------------------
 * The broadcast board — the projected stage screen, which QLab screen-captures
 * from a browser on the venue machine and shows whole.
 *
 * Everything here is written in DESIGN PIXELS off the 1920x1080 mockup
 * (jVFl3xOobvKkxt3QtAdl66, node 102:253), multiplied by --stage-u. On a
 * machine running the capture at 1920x1080 that unit is exactly 1px and the
 * numbers below are the mockup's own; on any other screen the board scales
 * whole and letterboxes, so the same page is reviewable on a laptop or a phone
 * without a second layout. min() of the two ratios is what keeps it from ever
 * overflowing into a scrollbar, which a capture would record.
 *
 * The board is OPAQUE and full-bleed on purpose. Corey, 2026-09-14: QLab
 * screen-captures this page and shows it whole, so there is nothing behind it
 * to frost and nothing to key — only the purchase toasts on
 * /order_notifications are keyed.
 *
 * THAT IS A CHANGE, NOT A DESCRIPTION OF WHAT WAS. `#performers-status {
 * background-color: red !important }` in the custom_css Setting row is a
 * WORKING CHROMA KEY, not a debug leftover — the old board was composited into
 * video and needed a key colour, and flagging it as a bug was already wrong
 * once (docs/attendee-design-system-plan.md, section 1.1).
 *
 * So covering it had to be checked rather than assumed, because an opaque
 * plate removes exactly the transparency a keying cue depends on, and that
 * fails in front of an audience with no error anywhere. ASKED AND ANSWERED —
 * Corey, 2026-09-14: nothing in QLab is keying /performers/status any more. The
 * rule is now dead weight in the row, and it cannot be beaten in the cascade
 * anyway (the row is injected raw after every stylesheet), so the plate covers
 * it and it can be deleted from the row after the run.
 * ------------------------------------------------------------------------ */
.gp-stage {
  --stage-u: min(calc(100vw / 1920), calc(100vh / 1080));

  /* The board's one duration and one curve, declared HERE because every moving
   * thing on this screen is inside .gp-stage and nothing else on the site is.
   *
   * They used to live on .gp-performer-bar, which is a sibling of the feed and
   * not an ancestor of it — so .gp-feed-row inherited neither, silently took
   * the `260ms, ease-out` fallbacks written beside its own transition, and
   * animated on a different curve from the rail beside it. The rail only got
   * the right one because stage_board_controller.js reads the value off a BAR
   * element by hand. One movement, two easings, and nothing said so.
   *
   * easeOutCubic, so a payment lands rather than arrives: quick off the mark,
   * settling at the end. Longer than the 200ms poll on purpose — a transition
   * that finishes before the next sample arrives makes the bar pulse five
   * times a second, where overrunning means each new width interrupts the last
   * mid-flight and the browser re-targets from wherever it is.
   *
   * The client reads both of these rather than carrying its own copy, so this
   * is the one place either is tuned. */
  --stage-tween: 340ms;
  --stage-ease: cubic-bezier(0.33, 1, 0.68, 1);

  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  /* The letterbox, on a screen that is not 16:9. Black, like any projector. */
  background: var(--gp-color-black);
  overflow: hidden;
}

.gp-stage__board {
  position: relative;
  width: calc(1920 * var(--stage-u));
  height: calc(1080 * var(--stage-u));
  /* Sky and the CHUD(TM) CHECKPOINT title, both baked into the artwork.
   *
   * The title is art rather than text because the art is pixel-exact and needs
   * nothing installed. Grange and Richmond Display ARE installed on Corey's Mac
   * (Adobe CoreSync, which is why they are not in ~/Library/Fonts and why a
   * first look said they existed nowhere), so live text is now possible — but
   * it is only correct on a machine that has them, and this page is projected.
   * Art cannot fall back to Helvetica in front of a room. To switch: a sky-only
   * plate from node 102:257 and an element in --gp-font-family-grange. */
  /* The `none` fallback matters: an undefined var() poisons the whole
   * declaration, and a board with no background at all is where the custom_css
   * row's red would finally show through. */
  background: var(--stage-plate, none) center / cover no-repeat, var(--gp-color-black);
  color: var(--gp-text-on-image);
  /* The token carries both names — the web project's lowercase slug and the
   * CoreSync install's capitalised one — so this covers a venue machine whether
   * or not its Creative Cloud is signed in. Deliberately NOT --gp-font-family:
   * the one thing Corey ruled out for the stage is IBM Plex Sans, so the
   * excluded face must not be in the board's fallback chain either. */
  font-family: var(--gp-font-family-grange);
}

/* Left empty, deliberately. QLab composites its countdown over this region as
 * a video overlay, so anything drawn here is covered during a checkpoint and
 * only visible between them — which is worse than nothing. Mockup node 113:450:
 * 217x137 at (1575, 88). */
.gp-stage__timer-reserve {
  position: absolute;
  top: calc(88 * var(--stage-u));
  right: calc(128 * var(--stage-u));
  width: calc(217 * var(--stage-u));
  height: calc(137 * var(--stage-u));
}

/* Invisible while the board is doing its job. It appears only when the client
 * has given up or has been failing for two seconds, because the failure it
 * describes is a board frozen at numbers that look plausible — nobody is
 * standing at a projected screen to notice it stopped, and the QLab countdown
 * carries on regardless. Bottom left, away from the timer overlay and away from
 * the bars. */
/* A broken wifi symbol, bottom left. No words: this is projected across a
 * venue, so a sentence at any size that does not intrude is a sentence nobody
 * can read, and the one fact the room needs is that the board has stopped being
 * true.
 *
 * On a chip rather than bare, because the bottom of the plate is pale cloud and
 * a light glyph would vanish into it — the same dark ground the note used. */
.gp-stage__offline {
  position: absolute;
  left: calc(64 * var(--stage-u));
  bottom: calc(24 * var(--stage-u));
  display: none;
  padding: calc(12 * var(--stage-u));
  border-radius: var(--gp-dimension-radius-card);
  background: var(--gp-surface-ink);
  color: var(--gp-text-on-dark);
  opacity: 0.8;
}

.gp-stage__offline-icon {
  display: block;
  width: calc(48 * var(--stage-u));
  height: calc(48 * var(--stage-u));
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
}

.gp-stage__offline-dot {
  fill: currentColor;
  stroke: none;
}

/* The gap the slash sits in. Its colour is the chip's, not the page's, so it
 * erases the arcs underneath rather than painting a stripe over them. */
.gp-stage__offline-cut {
  stroke: var(--gp-surface-ink);
  stroke-width: 5;
}

.gp-stage[data-stage-board-state="stopped"] .gp-stage__offline,
.gp-stage[data-stage-board-state="failing"] .gp-stage__offline {
  display: block;
}

/* ---------------------------------------------------------------------------
 * The feed column — node 102:383, 500x804 at (64, 246), nine 500x71 rows 89
 * apart. Every number below is measured off that frame and expressed as a ratio
 * of --feed-row-height, so the whole column scales from one value.
 *
 * Newest at the BOTTOM. Entries rise and ghost out of the top, which is why the
 * rail is bottom-anchored and why the fade is a mask on the column rather than
 * an opacity on each row: a row never has to know where in the list it is.
 *
 * The mask fades the TOP ONLY. A send arrives by sliding in from the left at
 * full strength — it is the newest thing on the board and the room is watching
 * for it — so fading the bottom would dim the one row that has just earned
 * attention. Departure is the opposite: a row leaving the top should thin out
 * rather than be cut off.
 * ------------------------------------------------------------------------ */
.gp-feed {
  --feed-row-height: calc(71 * var(--stage-u));
  /* 89 / 71 — the mockup's row pitch over its row height, as a PLAIN NUMBER so
   * that the client can read it with parseFloat. A custom property holding a
   * calc() computes to the literal string "calc(...)", which is how railPitch
   * used to silently return zero; a bare ratio has nothing to resolve. The
   * pitch below is derived from it, so the two cannot drift apart. */
  --feed-pitch-ratio: 1.2535;
  --feed-row-pitch: calc(var(--feed-row-height) * var(--feed-pitch-ratio));

  /* How long one row takes to cross the whole column, bottom to gone.
   *
   * Written in milliseconds as a bare number for the same reason as the ratio
   * above: the client reads it, and "11s" would parse to 11.
   *
   * This IS the speed of the board. Travel is the column plus a row's height at
   * each end, about 946 design px, so 11s is roughly 86 px a second — which
   * puts consecutive sends about one mockup pitch apart when the room is
   * sending once a second. Slower sends leave a longer gap, because a gap is
   * now a real measurement of how quiet the room is rather than something the
   * column hides by closing up. */
  --feed-drift: 11000ms;

  /* The pop. A row does not fade in at the bottom any more — it arrives at its
   * place and springs open there, which is a beat the room can actually catch
   * at the back of a hall where a fade is just a smudge.
   *
   * Short and fixed, deliberately NOT a fraction of the drift: the pop is an
   * event and should feel the same whatever speed the column is running at. It
   * is its own animation rather than extra keyframes inside the drift for the
   * same reason — percentages inside an 11s timeline would stretch the pop
   * every time somebody retuned the drift. */
  --feed-pop: 420ms;

  /* How far a row's drift rate may sit either side of the column's own.
   *
   * Sends do not arrive at a metronome and rows should not travel like one. At
   * a fifth, two rows released a pitch apart reach the top having drifted
   * noticeably closer or further, and may slide over one another on the way.
   * That overlap is wanted: it is what a column of real traffic looks like
   * rather than a printout. The client picks one rate per row. */
  --feed-drift-jitter: 0.2;

  /* The closest two rows may be RELEASED, as a fraction of a pitch.
   *
   * Below one because overlap is allowed now. Not zero, because two sends
   * landing in the same poll would otherwise pop in on top of each other, which
   * is a collision rather than a drift — the rates need a little distance to
   * pull rows together or apart over. */
  --feed-min-gap-ratio: 0.72;

  /* A little lift under every row.
   *
   * Rows are allowed to cross now, and two white pills sliding over one another
   * with no separation read as one tangled shape rather than as one in front of
   * the other. The shadow is what makes the stacking order legible — which is
   * the whole reason newest-on-top is worth having.
   *
   * No --gp- prefix and not a token: it scales with --stage-u, and a design
   * token is a fixed value. Same reasoning as --stage-u and the
   * --performer-bar-* inputs. */
  --feed-row-shadow: 0 calc(2 * var(--stage-u)) calc(9 * var(--stage-u))
                     rgba(0, 0, 0, 0.3);

  /* Named, because the drift keyframe needs it and cannot ask for it. A
   * percentage inside translateY resolves against the ELEMENT's own height, not
   * its container's — so `translateY(-100%)` on a row is one row, not one
   * column. Measured: a row crossed at 19px a second where it should have done
   * 86, which would have piled the whole night up in the bottom fifth of the
   * column and never let a single row leave the top. */
  --feed-height: calc(804 * var(--stage-u));

  /* Room for a row's shadow to fall outside the row without being sliced by the
   * clip.
   *
   * A row is exactly as wide as this column and sits flush against both edges,
   * so `overflow: hidden` cut its shadow off dead straight at the right-hand
   * end — a hard vertical line with the shadow's own gradient beside it, which
   * reads as the pill itself being chopped rather than as a missing shadow.
   *
   * The fix is bleed on the CLIPPING box, not on the row: the container grows
   * and shifts by this much on each side while the rail is inset by the same
   * amount, so every row still lands at the mockup's 500 wide at x 64. The
   * geometry is unchanged; only the scissors moved. */
  --feed-bleed: calc(16 * var(--stage-u));

  position: absolute;
  top: calc(246 * var(--stage-u));
  left: calc((64 * var(--stage-u)) - var(--feed-bleed));
  width: calc((500 * var(--stage-u)) + var(--feed-bleed) * 2);
  height: var(--feed-height);
  overflow: hidden;
  /* Top only — back to Corey's original ruling of 2026-09-14, and for the reason
   * he gave then: the newest row is the one the room is watching for and must
   * not be dimmed. The bottom fade existed only because rows used to rise
   * THROUGH the bottom edge and were sliced by it. They pop into place now
   * instead of travelling through it, so there is nothing left to hide.
   *
   * A row still ghosts out at the top, where thinning is the right way to
   * leave. */
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 13%);
  mask-image: linear-gradient(to bottom, transparent, #000 13%);
}

/* Just a box now. It used to be the thing that moved — the whole column was
 * translated up by a pitch on every arrival and eased back — which is why rows
 * were stationary between sends and why a quiet room looked exactly like a busy
 * one. Every row carries its own movement now, so this holds them and nothing
 * else. */
.gp-feed__rail {
  position: absolute;
  /* Inset horizontally by the bleed, so rows sit exactly where the mockup puts
   * them while the clip sits further out. */
  inset: 0 var(--feed-bleed);
}

/* The drift. One row, from below the bottom edge to above the top one, at a
 * constant rate.
 *
 * Linear because this is a conveyor and a conveyor has one speed. Anything
 * eased would have rows bunching and spreading as they crossed, which would
 * read as the spacing meaning something when it only meant easing — and the
 * spacing does mean something now: it is the time between two sends. */
@keyframes gp-feed-drift {
  from {
    translate: 0 0;
  }

  to {
    translate: 0 calc(-1 * (var(--feed-height) + var(--feed-row-height)));
  }
}

/* Squash and stretch. Opens from nothing at zero height, overshoots a little
 * past full, settles.
 *
 * `scale` and `translate` are separate properties, not two halves of one
 * `transform` — which is the only reason a row can run two animations at once
 * without the second clobbering the first. Written as `transform`, the pop and
 * the drift would each be animating the same property and the later one would
 * simply win. */
@keyframes gp-feed-pop {
  from {
    scale: 0.7 0;
  }

  55% {
    /* Overshoot in HEIGHT ONLY, and never past 1 in width.
     *
     * A row is exactly as wide as the column it sits in, so 1.03 pushed it past
     * the clip on both sides; and with transform-origin now pinned to the row's
     * bottom edge, height can only grow upward into the column. Between them
     * that is the "you can see them get cut off at the edge" during the
     * stretch. Width grows into place and stops; height is where the spring
     * lives. */
    scale: 1 1.12;
  }

  to {
    scale: 1 1;
  }
}

/* Structurally the PerformerBar again: an accent capsule with the unfilled part
 * as a white capsule inset by the rim. Here the accent that shows is the
 * leading cap rather than a fill, and it is a fixed width instead of a value. */
.gp-feed-row {
  --feed-row-accent: var(--gp-accent-fallback);
  --feed-row-rim: calc(var(--feed-row-height) * 0.141);

  /* Anchored to the bottom of the column and moved only by the drift, so its
   * position is a function of how long ago it arrived and nothing else. That is
   * the whole idea: the column no longer has slots, so nothing has to shuffle
   * when something joins or leaves.
   *
   * The client sets a negative animation-delay to place a row that is already
   * part-way across — every row the server rendered, and any row held back to
   * keep two sends from overlapping. */
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  /* The pop grows from the row's own bottom edge, not its middle. With the
   * middle as the origin a stretch past full height hangs BELOW the row's
   * resting position — and that position is flush with the column's bottom
   * edge, so the overshoot was clipped every single time. */
  transform-origin: 50% 100%;

  /* NEWEST ON TOP. Rows are appended in arrival order and are positioned
   * siblings with no z-index, so paint order follows DOM order and the last one
   * added draws over the others. That was free and invisible until rows were
   * allowed to overlap; it is load bearing now, so anything that reorders this
   * list, or gives a row a stacking context of its own, changes which send is
   * legible when two cross. */
  /* BOTH, not forwards.
   *
   * A row held back to keep it off the one before it gets a positive
   * animation-delay, and during that delay `forwards` alone applies no
   * animation styles at all — the row sits at its natural position, which is
   * its resting slot at the bottom edge, in full view. Then the delay expires,
   * it snaps down to the start of the keyframes, and only then begins moving.
   * Every queued row appeared at the bottom, jumped, and restarted, and two of
   * them waiting at once sat on top of each other. `backwards` is what holds a
   * delayed row at its FROM state, and `both` keeps the `forwards` half so a
   * finished row stays off the top until it is removed.
   *
   * It matters twice as much now: a delayed row's POP is also held at its from
   * state, which is zero height, so a row waiting its turn is invisible rather
   * than sitting squashed at the bottom of the column. One animation-delay
   * covers both animations, which is what keeps them in step. */
  animation:
    gp-feed-drift var(--feed-drift) linear both,
    gp-feed-pop var(--feed-pop) ease-out both;


  display: flex;
  align-items: stretch;
  flex: none;
  height: var(--feed-row-height);
  padding: var(--feed-row-rim);
  border-radius: var(--gp-dimension-radius-round);
  background: var(--feed-row-accent);
  box-shadow: var(--feed-row-shadow);
  font-family: var(--gp-font-family-grange-condensed);
  font-stretch: condensed;
  font-weight: var(--gp-font-weight-bold);
  font-size: calc(var(--feed-row-height) * 0.4225);
  line-height: 1;
}

/* 120 design px of the 500, measured from the pill's left edge — so the cap is
 * the accent showing past the white capsule, not a separate shape. */
.gp-feed-row__cap {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: calc(var(--feed-row-height) * 0.056);
  width: calc(var(--feed-row-height) * 1.69 - var(--feed-row-rim));
  padding-left: calc(var(--feed-row-height) * 0.085);
  color: var(--gp-text-on-dark);
}

.gp-feed-row__plane {
  flex: none;
  width: calc(var(--feed-row-height) * 0.4366);
  height: calc(var(--feed-row-height) * 0.4366);
  fill: currentColor;
}

.gp-feed-row__amount {
  white-space: nowrap;
}

.gp-feed-row__name {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  margin: 0;
  padding: 0 calc(var(--feed-row-height) * 0.2817);
  border-radius: var(--gp-dimension-radius-round);
  background: var(--gp-surface-track);
  color: var(--gp-text-body);
  /* A long name shortens rather than wrapping or widening the pill. The row is
   * a fixed height and the column is a fixed width; there is nowhere for a
   * second line to go. */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Node 102:258: 1246x784 at (601, 266), three 250-tall rows 267 apart. Centred
 * rather than top-aligned so a bill of one or two sits where the eye already
 * is; with the three the show runs it is the mockup's layout exactly, since
 * 3 x 250 + 2 x 17 = 784. A fourth would clip, and clipping beats squashing
 * the cast down to unreadable on a screen read from the back of a room. */
.gp-stage__performers {
  position: absolute;
  top: calc(266 * var(--stage-u));
  left: calc(601 * var(--stage-u));
  width: calc(1246 * var(--stage-u));
  height: calc(784 * var(--stage-u));
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: calc(17 * var(--stage-u));
  overflow: hidden;
}

/* ---------------------------------------------------------------------------
 * PerformerBar — the Figma library component (BACEWPuGJ7nSBTmRcKmbfa 20:307):
 * ringed avatar, a track filled in the character's own colour with the running
 * total right-aligned AT THE FILL EDGE, and white remainder out to the max.
 * The total sitting inside the fill rather than on the track is the whole
 * point of the shape — the number travels with the bar.
 *
 * Sized entirely from --performer-bar-size, the avatar diameter: every other
 * number is a ratio of it, so the board sets one value and the row scales
 * whole. The ratios are the MOCKUP's (node 102:259, a 250 avatar), not the
 * library frame's — 20:307 draws a slightly taller track and bigger number on
 * its 120 avatar, and where the two disagree the board is what ships. The
 * accent comes in as --performer-bar-accent, which the caller sets per
 * performer from performers.icon_color through
 * PerformersHelper#checkpoint_accent.
 *
 * Those two and --stage-u carry no --gp- prefix on purpose. That prefix means
 * "defined in design/tokens.json", and script/build_tokens.rb --check fails CI
 * on any var(--gp-*) it cannot find there. These are a component's own inputs,
 * not tokens, so they are deliberately outside the namespace the check owns.
 * ------------------------------------------------------------------------ */
.gp-performer-bar {
  --performer-bar-size: 120px;
  --performer-bar-accent: var(--gp-accent-fallback);
  /* Every number below is a ratio of the avatar diameter, measured off the
   * mockup's 250. Radii included — an absolute px radius does not scale with
   * the board, so a bar that reads as a soft rectangle at 1920 turns into a
   * pill on a half-size screen. */
  --performer-bar-rim: calc(var(--performer-bar-size) * 0.04);
  --performer-bar-radius: calc(var(--performer-bar-size) * 0.056);
  --performer-bar-pad: calc(var(--performer-bar-size) * 0.072);

  display: flex;
  align-items: center;
  height: var(--performer-bar-size);
}

.gp-performer-bar__avatar {
  flex: none;
  width: var(--performer-bar-size);
  height: var(--performer-bar-size);
  border-radius: var(--gp-dimension-radius-round);
  /* The ring is the character's colour and the reason the row reads at
   * distance before any of the text does. */
  border: var(--performer-bar-rim) solid var(--performer-bar-accent);
  background: var(--gp-color-grey-200);
  object-fit: cover;
}

/* A WHITE bar with the character's colour as its border, and their fill
 * growing inside it. Worth being explicit, because an earlier build had it
 * inside out — an accent-filled track with the unfilled part as a white block
 * inset by the rim. The two produce identical pixels, and only one of them
 * survives someone reading it: this is a meter, and a meter's track is the
 * empty thing. */
.gp-performer-bar__track {
  position: relative;
  flex: 1 1 auto;
  /* Mockup: the track starts 286 into a row whose avatar is 250. */
  margin-left: calc(var(--performer-bar-size) * 0.144);
  height: calc(var(--performer-bar-size) * 0.52);
  box-sizing: border-box;
  border: var(--performer-bar-rim) solid var(--performer-bar-accent);
  border-radius: var(--performer-bar-radius);
  background: var(--gp-surface-track);
}

.gp-performer-bar__fill {
  height: 100%;
  border-radius: calc(var(--performer-bar-radius) - var(--performer-bar-rim)) 0 0
    calc(var(--performer-bar-radius) - var(--performer-bar-rim));
  background: var(--performer-bar-accent);
  /* The tween. The client polls at 5 Hz and writes a new width; letting the
   * compositor walk between the two is smoother than any number JavaScript
   * could interpolate, and is no code at all. */
  transition: width var(--stage-tween) var(--stage-ease);
}

/* The total rides the fill's leading edge rather than sitting at either end of
 * the track, so the number and the bar are one object. Positioned against the
 * padding box, which is the same coordinate space the fill's width is a
 * percentage of. */
.gp-performer-bar__total {
  position: absolute;
  top: 50%;
  transform: translate(-100%, -50%);
  padding-right: var(--performer-bar-pad);
  /* Family AND font-stretch, which looks redundant and is not. Condensed is a
   * separate family over the web link, where font-stretch does nothing; on the
   * CoreSync install it is a width axis, and font-stretch is what reaches the
   * condensed cut. The token's two names and this declaration's two properties
   * are the same fact from both sides.
   *
   * Condensed is not decoration. The mockup's "1703" (node 102:263) is Grange
   * Bold Condensed at 64px, read off the node rather than inferred from the
   * rendered width of the string — the inferred figure was 60, and the four
   * design px between them are four real projected pixels. Regular-width Grange
   * at that size is nearer 140 wide, enough to reach the left end of a short
   * fill and collide with it.
   *
   * 700, PINNED. Not "the heaviest available" — the web project stops at 700,
   * but a CoreSync machine has a real Grange Heavy, so asking for 800 would
   * render a genuine heavy in the venue and a synthesised bold everywhere it
   * was tested. A weight is the last place you want a page to diverge by
   * whether somebody is signed into Creative Cloud. */
  font-family: var(--gp-font-family-grange-condensed);
  font-size: calc(var(--performer-bar-size) * 0.256);
  font-weight: var(--gp-font-weight-bold);
  font-stretch: condensed;
  line-height: 1;
  white-space: nowrap;
  color: var(--gp-text-on-dark);
  transition: left var(--stage-tween) var(--stage-ease);
}

/* Nobody has any chuds yet, or few enough that the number is wider than the
 * fill. Inside, it would overhang the accent onto the white and be half-white
 * text on white. So it steps out and turns black until the fill has room —
 * which is the one moment of a checkpoint when every bar is at zero and the
 * room is watching for the first one to move. */
.gp-performer-bar__total--outside {
  transform: translateY(-50%);
  padding-right: 0;
  padding-left: var(--performer-bar-pad);
  color: var(--gp-text-body);
}

/* Board scale: the avatar is the mockup's 250 design px, and everything in the
 * component follows from it.
 *
 * Declared ON THE ROW, not on the column. It was on the column, with a comment
 * claiming that a value set on the row could never be overridden by a rule on
 * the parent. That is backwards, and it is how a custom property works rather
 * than a matter of specificity: a declaration on the element itself always
 * beats the value inherited from its parent, whatever the parent's selector.
 * So .gp-performer-bar's own 120px default won, the board drew every row at
 * 48% of its designed size for the whole of its life, and the two fallbacks
 * below were dead code that had never once applied. Measured in the browser,
 * not reasoned about: the avatar came back 120x120 where the mockup says 250,
 * and the track 1109 wide where the mockup says 960.
 *
 * Reaching the row means these selectors have to out-specify .gp-performer-bar
 * (0,1,0), which a descendant selector does at (0,2,0); the two :has() rules
 * carry their argument's specificity on top of that and land at (0,3,0), so
 * they still beat the base rule and each other by source order, exactly as the
 * column version intended.
 *
 * A FOURTH performer does not clip. The cast is three, but activating one is a
 * single click in admin and this screen is projected: a row silently cut in
 * half is not something anybody would notice until the room did. These sizes
 * are the same (784 - 17(N-1)) / N the three-row layout already satisfies. Past
 * five it clips, which by then is the honest outcome — six rows on this column
 * are unreadable from the back of a room at any size. */
.gp-stage__performers .gp-performer-bar {
  --performer-bar-size: calc(250 * var(--stage-u));
}

/* (784 - 17 x 3) / 4 */
.gp-stage__performers:has(> :nth-child(4)) .gp-performer-bar {
  --performer-bar-size: calc(183 * var(--stage-u));
}

/* (784 - 17 x 4) / 5 */
.gp-stage__performers:has(> :nth-child(5)) .gp-performer-bar {
  --performer-bar-size: calc(143 * var(--stage-u));
}

/* First paint, and only first paint. The board is server-rendered complete, so
 * a refresh has nothing to animate FROM — tweening the bars up from whatever
 * the markup said, or sliding in rows that were already in the HTML, is motion
 * that reports no news. The client adds this for its first response and takes
 * it off again a reflow later.
 *
 * Only the bars need it. The feed places a row the server already rendered by
 * giving it a negative animation-delay — it is dropped into the middle of a
 * drift already in progress rather than played in from the start — so there is
 * nothing there to suppress. Zeroing this duration turns off the fill and total
 * transitions, and countTo() reads the same property, sees a duration it cannot
 * count over, and writes the number straight in. */
.gp-stage--instant {
  --stage-tween: 0ms;
}

/* One switch, not two. Zeroing the duration turns off the CSS transitions AND
 * the counting total, because the client reads that same custom property to
 * decide how long to count for — so a reader who has asked for less motion does
 * not get a bar that holds still beside a number that spins. */
@media (prefers-reduced-motion: reduce) {
  .gp-stage {
    --stage-tween: 0ms;
  }

  .gp-performer-bar__fill,
  .gp-performer-bar__total {
    transition: none;
  }

  /* The feed's drift is not decoration — it is how a row gets from the bottom
   * of the column to the top, and switching it off would pile every send on top
   * of the last one at the bottom edge. So the column falls back to what it was
   * before: rows held still in slots, stacked from the bottom, replaced rather
   * than animated. The client positions them (see stackRows) because with no
   * animation running there is nothing else to place them. */
  .gp-feed-row {
    animation: none;
  }
}

/* ---------------------------------------------------------------------------
 * Respect the reader's motion and transparency settings. The baseline used
 * backdrop-filter heavily with no fallback; on a device with reduced
 * transparency the glass becomes unreadable rather than merely flat.
 *
 * .gp-field__control is not listed: it no longer sets a fill of its own, so
 * there is nothing here to opt out of.
 * ------------------------------------------------------------------------ */
@media (prefers-reduced-transparency: reduce) {
  .gp-glass,
  .gp-card {
    background: var(--gp-color-white);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .gp-glass::after {
    display: none;
  }
}

@media (prefers-contrast: more) {
  .gp-glass,
  .gp-card {
    background: var(--gp-color-white);
    border-color: var(--gp-text-body);
  }
}

/* ==== gp-status: attendee status bar (cluster A) ==== */

/* Checkpoint UI polish plan, 2026-09-17; rebuilt in round 16 (cluster A) to
 * public/glass-test-status.html revision 10, the prototype Corey approved.
 * Styles shared/_status_bar.html.erb exclusively — the homepage,
 * attendees/edit, the checkpoint page and the takeover.
 *
 * ONE CARD, TWO SKINS, AND THE ACCENT IS A VARIABLE. The checkpoint passes
 * `glass: true` and gets glass v2 plus the blue accent; the homepage and
 * attendees/edit keep the opaque fill and the site green. Round 14 did that
 * with a pair of `.gp-status.gp-glass-v2 .gp-status__x` overrides per
 * coloured part; there are more coloured parts now (the monogram, the bar,
 * the ink), so the two skins set --status-accent and --status-ink once
 * and every rule below reads them.
 *
 * ACCENT INK IS NOT THE ACCENT. --gp-color-blue measures 1.4-2.8:1 as text on
 * this card and --gp-color-green is no better, so anything that is TYPE takes
 * the darkened ink tokens; the monogram and the progress fill, which are not
 * type, keep the brand colours. Measured on the prototype over the sky's
 * darkest band, round 15.
 *
 * FILL LAYERS ARE PLAIN BACKGROUNDS HERE, NOT PLUS-LIGHTER. This card first
 * reproduced Figma's own recipe — an inner layer blended with
 * mix-blend-mode: plus-lighter rather than a flat fill — but rendered live on
 * the checkpoint's clouds background, 2026-09-18, plus-lighter can only ever
 * lighten, and it washed the card to pure white. Figma blends this layer
 * inside its own frame, against nothing, which is a normal fill. See the same
 * finding, in more detail, on the gp-checkpoint card/stepper/button below.
 */
.gp-status {
  position: relative;
  /* The glass layers sit at z-index -1, so the card has to be its own
   * stacking context or they paint through whatever is behind it. */
  isolation: isolate;
  width: 362px;
  max-width: 100%;
  margin-inline: auto;
  display: grid;
  gap: 14px;
  padding: 15px 17px 13px;
  border-radius: var(--gp-radius-card);
  border: 1px solid var(--gp-color-white-35);
  /* rgba(255, 255, 255, 0.8) — same primitive as border/glass-bright, reused
   * here as a fill rather than a border because the value is identical. */
  background: var(--gp-color-glass-border-bright);
  box-shadow: var(--gp-shadow-checkpoint-card), var(--gp-shadow-checkpoint-card-inset);
  font-family: var(--gp-font-family);
  color: var(--gp-color-status-ink);
  --status-accent: var(--gp-color-green);
  --status-ink: var(--gp-color-status-accent-ink-green);
}

/* Round 12 (item 2, Corey: "Did the status card also get the same glass
 * treatment"): on the checkpoint it is glass v2 --card, like the send card
 * under it. The fill, border and shadows above give way to the layers; the
 * 1px border becomes padding, so the content does not move. */
.gp-status.gp-glass-v2 {
  padding: 16px 18px 14px;
  border: 0;
  background: none;
  box-shadow: var(--glass-depth);
  /* Round 14, item 8 (Corey: "not white enough / a little too much blue
   * showing through"): the status card's own tint, whiter than the send
   * card's default (.gp-glass-v2--card, glass-tint-card) — that one stays
   * unchanged, this one is status-only. */
  --glass-tint: var(--gp-color-glass-tint-status);
  /* Round 14, item 5 (Corey: on this page, the secondary accent — the Buy
   * More Chuds blue — instead of the site green). */
  --status-accent: var(--gp-color-blue);
  --status-ink: var(--gp-color-status-accent-ink-blue);
}

/* The head, prototype layout A4: the name block is centred on the CARD, with
 * the monogram out on the left where it balances the share button on the
 * right. Both are positioned against the card's own corners rather than
 * flowed beside the text, so the padding either side of this block is the
 * share button's 44px and nothing has to be optically re-centred. */
.gp-status__head {
  min-height: var(--gp-dimension-status-avatar);
  padding-inline: var(--gp-dimension-status-share-target);
  text-align: center;
}

/* Squared against the card's corner — the same inset from the left edge as
 * from the top — and, since round 17, sized and placed against the TEXT
 * rather than the box: the disc's top edge sits on the name's cap top and
 * its foot on the level title's baseline, so it spans the text block
 * optically (Corey, twice). Both numbers are measured type metrics, not
 * layout boxes — see the two tokens' descriptions. The inset moved 18px →
 * 19px with the top edge, because Corey asked for the left to stay even with
 * it.
 *
 * Positioned against the CARD, which has a 1px border in the opaque skin and
 * none in the glass one, so the disc lands 0.46px above the cap on glass and
 * 0.54px below it opaque. Left as is: moving the containing block to the head
 * to chase that half pixel costs more CSS than the half pixel is worth. */
.gp-status__avatar {
  position: absolute;
  left: var(--gp-dimension-status-avatar-inset);
  top: var(--gp-dimension-status-avatar-inset);
  width: var(--gp-dimension-status-avatar);
  height: var(--gp-dimension-status-avatar);
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-status-avatar);
  letter-spacing: 0.02em;
  color: var(--gp-color-white);
  background: linear-gradient(145deg, var(--status-accent), var(--status-ink) 70%);
  box-shadow: var(--gp-shadow-status-avatar), var(--gp-shadow-glass-depth-control);
}

.gp-status__name {
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-status-name);
  line-height: 1.2;
}

/* Size-stable: the level name's line box never changes height and never
 * wraps, so nothing below it moves while round 16-B rolls one title into
 * another. .gp-status__title is the box the roll is measured in, and it has
 * to be EXACTLY the level name's line box, because the rolling clone is
 * positioned `inset: 0` against it.
 *
 * inline-FLEX, not inline-block, and that is the round-17 title-drop fix.
 * An inline-block title lays its child out on a line box of its own, so the
 * 17px inline-block level name sat on that line's baseline with the
 * inherited strut's half-leading above it: the box measured 24px and the
 * name's top was 5px below the box's (measured live 2026-09-20, 390px
 * viewport — box top 312.58, level name top 317.58). The clone at `inset: 0`
 * therefore settled 5px HIGH, and the frame that removed it dropped the
 * title 5px in one step. Flex has no line box and no strut, so the child's
 * top IS the box's top and the two copies land in the same place.
 * justify-content keeps the shorter of the two names centred while the box
 * is widened for the longer one — inline-block inherited that centring from
 * .gp-status__who's text-align, and a flex container does not. */
.gp-status__title {
  position: relative;
  display: inline-flex;
  justify-content: center;
  min-height: 17px;
  vertical-align: top;
}

.gp-status__level-name {
  display: inline-block;
  font-size: var(--gp-font-size-status-level-name);
  font-weight: var(--gp-font-weight-semibold);
  line-height: 17px;
  min-height: 17px;
  white-space: nowrap;
  color: var(--status-ink);
}

.gp-status__stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* stretch, not start (Corey, 2026-09-21: "the dividers on either side of
   * performance points should be the same height"). Each divider is drawn on
   * its own stat's box, so with `start` the boxes were as tall as their own
   * content and the dividers inherited that: "PERFORMANCE POINTS" wraps to two
   * lines at 390 and its neighbours do not, so the line to its left was a
   * label taller than the line to its right. Stretching makes every stat the
   * row's height, which is the tallest one, and the dividers match without
   * anybody having to pick a number. */
  align-items: stretch;
}

.gp-status__stat {
  position: relative;
  display: grid;
  /* The boxes stretch; their CONTENT still sits at the top, so a wrapped label
   * grows downwards rather than pushing its own value off centre. */
  align-content: start;
  justify-items: center;
  gap: 1px;
  text-align: center;
}

/* Hairline dividers between the stats: a soft vertical shade with a white
 * highlight on its right, the same two-tone edge the glass uses elsewhere. */
.gp-status__stat + .gp-status__stat::before {
  content: "";
  position: absolute;
  left: 0;
  top: 1px;
  bottom: 3px;
  width: 1px;
  background: var(--gp-gradient-status-divider);
  box-shadow: 1px 0 0 var(--gp-color-white-50);
  /* Quieter still (Corey, 2026-09-22: "slightly more quiet/muted divider").
   * On the whole element rather than by picking new colours for the shade and
   * its highlight separately -- they are a matched pair and fading them
   * together keeps the two-tone edge the glass uses everywhere else. */
  opacity: 0.55;
}

/* The level number grows past this box during a level-up, so it does not clip
 * by default; only the odometer roll needs a clipping box, and round 16-B
 * turns that on for the duration. The row keeps its height either way. */
.gp-status__clip {
  display: inline-block;
  overflow: visible;
  vertical-align: top;
}

.gp-status__value {
  display: block;
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-status-value);
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

/* Label type F2 (Corey's pick, round 15): 10px tracked caps at regular
 * weight. The values carry the hierarchy; dropping the labels from bold to
 * regular and to this grey is what gives it back, and this tone still
 * measures 4.64:1 through the card's tint on the darkest sky. */
.gp-status__label {
  font-size: var(--gp-font-size-caption);
  font-weight: var(--gp-font-weight-regular);
  line-height: 1.12;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--gp-color-status-label);
}

/* The bar does not run the full content width: the inset is one more step of
 * the card's own padding scale, so the track sits 36px in from each edge. */
.gp-status__progress {
  display: grid;
  gap: 6px;
  padding-inline: var(--gp-dimension-status-inset);
}

/* The progressbar element is the outer box, unclipped, because round 16-B
 * hangs a flare off the end of the fill; the groove inside it is what clips.
 * src/chud_status.js writes aria-valuenow here and the width on the fill. */
.gp-status__track {
  position: relative;
  height: var(--gp-dimension-status-bar-height);
}

.gp-status__track-clip {
  position: absolute;
  inset: 0;
  border-radius: var(--gp-dimension-radius-round);
  overflow: hidden;
  background: var(--gp-color-status-track);
  box-shadow: var(--gp-shadow-status-track);
}

.gp-status__track-fill {
  position: absolute;
  inset: 0 auto 0 0;
  /* A fill narrower than the bar is tall reads as a defect, not a small
   * number. */
  min-width: var(--gp-dimension-status-bar-height);
  border-radius: inherit;
  background: var(--gp-gradient-status-fill-sheen), var(--status-accent);
  /* Width changes on a level-up or a poll/morph refresh; the compositor
   * tweens it the same way stage board leaves its bar fill to CSS rather than
   * a rAF loop — see stage_board_controller.js's comment on the same choice. */
  transition: width var(--gp-motion-progress-fill);
}

/* The "Next Level" numbers are LABEL-class text — same colour, weight, size
 * and tracking as LEVEL and CHUDS — not values. Each child span is its own
 * clipping box so round 16-B can roll the numbers up into place. */
.gp-status__caption {
  margin: 0;
  display: flex;
  justify-content: center;
  gap: 4px;
  font-size: var(--gp-font-size-caption);
  font-weight: var(--gp-font-weight-regular);
  line-height: 1.12;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--gp-color-status-label);
  font-variant-numeric: tabular-nums;
}

.gp-status__caption > span {
  display: inline-block;
  overflow: hidden;
}

/* ---- round 16-B: the points bar and the level-up ----
 *
 * Built to public/glass-test-status.html revision 10 — option B4 for a gain
 * inside a level, option C6 for the level-up. The moves are
 * src/status_effects.js and their timings src/level_up.js; everything below is
 * the furniture those need and nothing else draws. Every one of these
 * elements is created by JS and removed again, because the two cards that are
 * mounted for one attendee during an open window would otherwise each carry a
 * shimmer and a flare the markup can never use.
 */

/* While a sequence owns the bar, its width is written frame by frame and the
 * transition on .gp-status__track-fill above would lag every one of those
 * frames by its own duration — the shimmer would arrive and the fill would
 * still be catching up. */
.gp-status--busy .gp-status__track-fill {
  transition: none;
}

/* The shimmer: a soft, wide band that runs the length of the bar and then
 * rides the growing end. It lives in the groove rather than inside the fill,
 * so it can travel past where the fill currently ends, and it overhangs the
 * groove top and bottom so its own soft edges are cut off by the groove's
 * clip instead of fading inside it. */
.gp-status__wave {
  position: absolute;
  top: calc(-1 * var(--gp-dimension-status-sweep-bleed));
  bottom: calc(-1 * var(--gp-dimension-status-sweep-bleed));
  left: 0;
  width: var(--gp-dimension-status-sweep-width);
  margin-left: calc(-0.5 * var(--gp-dimension-status-sweep-width));
  opacity: 0;
  pointer-events: none;
  background: var(--gp-gradient-status-sweep);
}

/* The ring flare where the shimmer arrives. It hangs off the END of the fill
 * and has to be allowed to, so it is a child of the unclipped
 * .gp-status__track rather than of the groove. */
.gp-status__glint {
  position: absolute;
  top: 50%;
  width: var(--gp-dimension-status-glint-size);
  height: var(--gp-dimension-status-glint-size);
  margin: calc(-0.5 * var(--gp-dimension-status-glint-size)) 0 0 calc(-0.5 * var(--gp-dimension-status-glint-size));
  border-radius: var(--gp-dimension-radius-round);
  opacity: 0;
  pointer-events: none;
  background: var(--gp-gradient-status-glint);
}

/* The level name rolling over: the box clips for the duration of the roll and
 * is widened to the wider of the two names, so a longer title is not cut off
 * and the line's height never changes. Both states are set by JS and taken
 * off again — .gp-status__title itself is size-stable above. */
.gp-status__title--roll {
  overflow: hidden;
}

.gp-status__level-name--next {
  position: absolute;
  inset: 0;
}

/* The caption's two numbers roll up into place the same way. They are inline
 * counters, and an inline box takes no transform; their parent span is the
 * clipping box (.gp-status__caption > span, above). */
.gp-status__caption [data-count-key-value] {
  display: inline-block;
}

/* The level-up's confetti, on a canvas of its own: aimed at a canvas rather
 * than at the page, the particles cannot escape the box it covers. It is
 * deliberately BIGGER than the card and hangs over its edges, so the glass
 * does not clip them, and its foot fades out so they dissolve at the bottom
 * instead of stopping dead on an invisible line. Position and size are
 * measured per burst and set inline. No z-index: it is appended last and
 * paints over the card's content, which is all it has to beat. */
.gp-status__burst {
  position: absolute;
  pointer-events: none;
  -webkit-mask-image: var(--gp-gradient-status-burst-mask);
  mask-image: var(--gp-gradient-status-burst-mask);
}

/* Share: a PLACEHOLDER affordance for the take-away graphic, top right, in
 * the card's own glass language. 44px tap target around a 30px disc. The
 * checkpoint never renders it (shared/_status_bar.html.erb) — sharing is not
 * offered mid-checkpoint — which is also why it is not scoped to the opaque
 * skin here: the markup, not the stylesheet, decides. */
.gp-status__share {
  position: absolute;
  top: 6px;
  right: 6px;
  width: var(--gp-dimension-status-share-target);
  height: var(--gp-dimension-status-share-target);
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  background: none;
  -webkit-tap-highlight-color: transparent;
}

.gp-status__share-disc {
  width: var(--gp-dimension-status-share-disc);
  height: var(--gp-dimension-status-share-disc);
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: var(--gp-color-white-42);
  border: 1px solid var(--gp-color-white-70);
  box-shadow: var(--gp-shadow-status-share), var(--gp-shadow-glass-depth-control);
}

.gp-status__share svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: var(--status-ink);
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* PRESS (round 20): .is-pressed from press_controller.js, not :active. Round 8
 * moved every other button in this system off :active — it is not dependable
 * for touch on iOS Safari, and press_controller.js's own comment says why —
 * and this one was left behind, so the one press treatment on the card the
 * homepage and the profile page render was the one that does not fire. It also
 * snapped: there was no transition in either direction.
 *
 * The scale and the two curves are the house recipe (.gp-buy-chuds, and the
 * −/+ further down): sink quickly, come back on an overshoot. The disc moves,
 * not the 44px target around it — the same split the corner ✕ makes between
 * the shape that acts and the shape that is drawn.
 *
 * Reduced motion keeps the press and drops the movement, as the rest of the
 * recipe does. A local rule rather than another name on the shared list in the
 * Buy More Chuds block: this component ships on three pages that block knows
 * nothing about. */
.gp-status__share-disc {
  transition: transform var(--gp-motion-press-release) cubic-bezier(0.34, 1.56, 0.64, 1);
}

.gp-status__share.is-pressed .gp-status__share-disc {
  transform: scale(0.94);
  transition: transform var(--gp-motion-press) ease-out;
}

@media (hover: hover) {
  .gp-status__share:hover:not(.is-pressed) .gp-status__share-disc {
    transform: scale(1.04);
  }
}

@media (prefers-reduced-motion: reduce) {
  .gp-status__share-disc,
  .gp-status__share.is-pressed .gp-status__share-disc,
  .gp-status__share:hover .gp-status__share-disc {
    transform: none !important;
  }
}

/* ==== gp-checkpoint: checkpoint screen and takeover (cluster B) ==== */

/* Checkpoint UI polish plan, 2026-09-17. The screen in
 * performers/_checkpoint_screen.html.erb, in three of Figma's states:
 * 193:1182 inactive (the /checkpoint page), 193:1913 idle, 193:1535
 * selected. (193:2013, sent, went in round 3: the send is optimistic and
 * the card stays selected.) The takeover around it is application.css's
 * .takeover--checkpoint.
 *
 * Every state is drawn from [data-checkpoint-state-value] on the card and
 * [data-pressed], --slot, --grow and --pick on the avatars — the controller
 * changes those, and nothing else.
 *
 * FILL LAYERS ARE PLAIN BACKGROUNDS HERE, NOT PLUS-LIGHTER. Figma's
 * get_design_context reports the card, stepper and Buy button fills as inner
 * layers with mix-blend-mode: plus-lighter, and this section first reproduced
 * them that way (the .gp-status recipe). Rendered live on the clouds,
 * 2026-09-18, they came out wrong, and Figma's own render of the same frames
 * says why: plus-lighter can only ever lighten, yet
 *   - the inactive card's rgba(0,0,0,.07) visibly DARKENS the clouds in
 *     193:1182 (plus-lighter black adds nothing: there would be no card);
 *   - the Buy More Chuds button is solid #1c96e5 in every frame (plus-lighter
 *     on the pale clouds washed it to near-white, white label and all);
 *   - the status card's pills are darker than the card around them.
 * Figma blends each of those layers inside its own frame, against nothing,
 * which is a normal fill. So these are normal fills, and the screenshots in
 * the cluster B report show both versions.
 *
 * THE ROW NEVER REORDERS; IT RE-SLOTS. Every avatar is a 70px button in
 * Performer.active order, and where it is drawn is --slot, a translateX
 * from its own flex position (--index) to the slot's. Recentring is a change
 * of --slot, which checkpoint_controller.js springs with Motion (round 3 —
 * it was a CSS transition), so nothing moves in the DOM. That last part is
 * the point. The plan had auto-animate re-append the buttons and FLIP them;
 * measured live, 2026-09-18, a node moved in the DOM throws away its
 * computed style, so every CSS transition on it — ring colour, greyscale —
 * snapped instead of animating, and only the FLIP itself moved.
 *
 * Size is the photo's, by transform: 70 → 100 is scale(100/70), driven by
 * --grow (0 or 1, sprung by the controller, so it overshoots a little and
 * settles). Figma's selected row is 70 / 100 / 70 with space-between, which
 * puts every centre exactly where the idle row's are, so a scale about the
 * centre of a 70px box is not an approximation of the mockup: it is the
 * mockup. The ring is 4.2px before the scale so that it is Figma's 6px after
 * it. Two elements, two transforms: the button's is position, the photo's is
 * size, and neither ever animates the other's.
 *
 * Movement is Motion's, and since round 12 so is the avatars' colour: ring,
 * greyscale and strength follow --pick, sprung with the row (and scrubbed by
 * a drag), so they are not transitions. The title and the controls' opacity
 * are still CSS transitions keyed off the state.
 *
 * Layout geometry (widths, gaps, paddings) is the Figma value in px, as
 * .gp-status does it; colours, type, radii, shadows and durations are tokens.
 */

/* The /checkpoint page: the clouds behind everything under the navbar, the
 * way #performers-pay and the operator's #login already do it. On body so it
 * reaches the canvas — see FILL LAYERS above. Compressed JPEG, same reasoning
 * as .takeover--checkpoint: this loads on every visit to the page, not just
 * inside an open window. */
body#checkpoint {
  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%);
}

/* flex: 1 (from application.css's body#checkpoint rule, which reaches the
 * layout's own yield wrapper) makes this box exactly as tall as the room
 * under the navbar; `display: flex` here stretches .gp-checkpoint, its one
 * child, to that full height for free, and .gp-checkpoint centres its own
 * contents inside it. overflow-y: auto is the no-clip rule: a short screen
 * scrolls this box instead of the block overlapping the navbar above it. */
.gp-checkpoint-page {
  display: flex;
  flex: 1;
  min-height: 0;
  justify-content: center;
  overflow-y: auto;
  padding-bottom: 24px;
}

/* The content column: padding 10 20, and 20 between the main block and the
 * Buy More Chuds button — round 5, Corey: the same as between the stats and
 * the send card (it was Figma's 30). justify-content centres the two vertically now that
 * the row direction above stretches this to the full height on offer;
 * `safe center` (round 2, Corey 2026-09-18) is what stops a short screen
 * clipping the top of the card instead of scrolling to it — it falls back
 * to flex-start, inside .gp-checkpoint-page's own scrollbar, only when
 * centring would overflow. Unsupported browsers keep the plain `center`
 * above it and clip; there is no third option that both centres and never
 * clips without it. */
.gp-checkpoint {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  justify-content: safe center;
  gap: 20px;
  width: 100%;
  padding: 10px 20px;
}

.gp-checkpoint__main {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Round 2: .gp-checkpoint is now a flex column stretched to the full
   * height on offer (application.css, .takeover__body / .gp-checkpoint-page)
   * so it can centre this and .gp-buy-chuds inside it. On a screen too short
   * for both, a flex item's default flex-shrink: 1 would rather squeeze this
   * below its own children's fixed sizes than let it overflow — measured
   * live, 2026-09-18, the Buy button came out 16px tall. flex-shrink: 0
   * keeps it at its natural height, so a short phone scrolls instead. */
  flex-shrink: 0;
  gap: 20px;
  width: 362px;
  max-width: 100%;
}

/* Round 7: a little closer to the status card below (Figma's 10 under the
 * heading taken to 2). */
.gp-checkpoint__header {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  padding: 10px 0 2px;
}

.gp-checkpoint__mascot {
  width: 80px;
  height: 80px;
}

.gp-checkpoint__heading {
  width: 300px;
  max-width: 100%;
  margin: 0;
  text-align: center;
  font-family: var(--gp-font-family);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-h2);
  line-height: normal;
  color: var(--gp-color-white);
}

/* ONE SCREEN (round 5 item 8). With the controls open, the whole column —
 * stats, card, Buy More Chuds — fits the visible screen without scrolling.
 * When it would not, the Company wordmark goes, then the Chuddy mascot:
 * hidden, not shrunk. Buy More Chuds never goes. fit_controller.js measures
 * which, against the real visible height (the container is sized to the
 * small viewport, after Safari's toolbars), and sets data-fit on the
 * container; each hideable carries the level it goes at. */
[data-fit="1"] [data-fit-level="1"],
[data-fit="2"] [data-fit-level] {
  display: none !important;
}

/* --- the person-selector card --- */

.gp-checkpoint__card {
  display: flex;
  flex-direction: column;
  gap: 20px;
  width: 362px;
  max-width: 100%;
  /* Round 11: glass v2 (.gp-glass-v2--card) paints fill, rim and shadow. The
   * 1px border it replaces is now padding, so the content box — and the
   * avatar row's centre — does not move. */
  padding: 11px 11px 16px;
  border-radius: var(--gp-radius-card);
}

/* Nothing on the card is text to select (round 9, Corey's iPhone): holding
 * − or + started iOS's long-press selection, with its callout and
 * magnifier, over the whole card — and that gesture cancels the press.
 * The amount stays an editable number field, so it keeps selection; the
 * status bar outside the card keeps it too. VoiceOver is unaffected. */
.gp-checkpoint__card,
.gp-buy-chuds {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

.gp-checkpoint__card .gp-checkpoint__input {
  -webkit-user-select: text;
  user-select: text;
}

/* A send playing out (round 5 item 5): taps are ignored, not queued, and
 * nothing looks disabled — the card simply does not answer until the
 * sequence ends. checkpoint_controller.js guards the same in code. */
.gp-checkpoint__card[data-busy] {
  pointer-events: none;
}

.gp-checkpoint__card[data-checkpoint-state-value="inactive"] {
  --glass-tint: var(--gp-color-black-7);
}

.gp-checkpoint__upper {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 15px;
}

/* CLOSED, THE ROW CENTRED (round 12, Corey: "I meant vertically centered
 * avatars"). With nobody chosen the controls are closed and the card ends
 * under the row, which left the photos 41px below the prompt and 31px above
 * the card's bottom (measured, 402 wide). Here the row moves up by
 * --gp-dimension-checkpoint-row-lift (less gap above it) and the card's
 * bottom padding grows by the same, so the photos are centred and the card
 * is exactly as tall as before — nothing around it moves. The open state is
 * the rules above, untouched; choosing somebody eases between the two over
 * the state's own time, while the controls open. */
.gp-checkpoint__card,
.gp-checkpoint__upper {
  transition:
    padding-bottom var(--gp-motion-checkpoint-state) ease-in-out,
    gap var(--gp-motion-checkpoint-state) ease-in-out;
}

.gp-checkpoint__card:not([data-checkpoint-state-value="selected"]) {
  padding-bottom: calc(16px + var(--gp-dimension-checkpoint-row-lift));
}

.gp-checkpoint__card:not([data-checkpoint-state-value="selected"]) .gp-checkpoint__upper {
  gap: calc(15px - var(--gp-dimension-checkpoint-row-lift));
}

/* The title block: 300 wide, centred. One prompt and one name, restyled per
 * state rather than swapped, so a change of state is a transition. 42 tall
 * rather than Figma's 38 (round 4): the name's line is 24, so a descender is
 * not cut off at its foot. */
.gp-checkpoint__title {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 300px;
  max-width: 100%;
  height: 42px;
  margin: 0;
  text-align: center;
  font-family: var(--gp-font-family);
}

/* idle: "Send Chuds to", Bold 20 black, line-height 1. */
.gp-checkpoint__prompt {
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-title);
  line-height: 1;
  color: var(--gp-color-black);
  transition:
    font-size var(--gp-motion-checkpoint-state) ease-in-out,
    line-height var(--gp-motion-checkpoint-state) ease-in-out,
    color var(--gp-motion-checkpoint-state) ease-in-out,
    opacity var(--gp-motion-checkpoint-state) ease-in-out;
}

/* The name's box opens to one line when somebody is chosen. The name itself
 * comes and goes by Motion (checkpoint_controller.js#nameTo): up from
 * nobody, down to nobody, sideways with the row when it changes hands, the
 * outgoing one a copy drawn over this box (.gp-checkpoint__name--leaving). */
.gp-checkpoint__names {
  position: relative;
  display: block;
  width: 100%;
  height: 0;
  transition: height var(--gp-motion-checkpoint-state) ease-in-out;
}

.gp-checkpoint__name {
  display: block;
  max-width: 100%;
  height: 24px;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-title);
  line-height: 24px;
  color: var(--gp-color-black);
}

.gp-checkpoint__name--leaving {
  position: absolute;
  inset: 0 0 auto;
  pointer-events: none;
}

/* inactive: Medium 14 white. Figma set this one line in Helvetica Neue; every
 * other line in the file is IBM Plex Sans, so this is too. */
.gp-checkpoint__card[data-checkpoint-state-value="inactive"] .gp-checkpoint__prompt {
  font-weight: var(--gp-font-weight-medium);
  font-size: var(--gp-font-size-body);
  line-height: normal;
  color: var(--gp-color-white);
}

/* selected: Medium 14 grey, 18 tall, over the name. (Figma's fourth state,
 * sent — 193:2013 — is gone: round 3's send is optimistic, and after the chip
 * lands the card goes back to nobody chosen, the row where it was.) */
.gp-checkpoint__card[data-checkpoint-state-value="selected"] .gp-checkpoint__prompt {
  font-weight: var(--gp-font-weight-medium);
  font-size: var(--gp-font-size-body);
  line-height: 18px;
  color: var(--gp-color-grey-700);
}

.gp-checkpoint__card[data-checkpoint-state-value="selected"] .gp-checkpoint__names {
  height: 24px;
}

/* --- the corner ✕ (Corey's addition, this round) --- */

/* The real tap target, top LEFT and on the title's own line (Corey,
 * 2026-09-21). Pinned to the card's top padding and given the title block's
 * height, so the glyph centres against "Send Chuds to" rather than floating
 * in the corner above it — which is what used to reserve a band of empty
 * card over the header with nobody chosen. The card is already
 * position: relative (.gp-glass-v2--card).
 *
 * Invisible itself: what is drawn is the smaller glyph inside it
 * (.gp-checkpoint__clear-glyph). No explicit z-index — a plain box in
 * normal flow already paints above the card's own glass layers, which sit
 * at z-index -1 inside the card's own isolated stacking context (.gp-glass-
 * v2 > :is(__blur, __frost, __shine, __rim)). */
.gp-tap.gp-checkpoint__clear-tap {
  /* Compound, and deliberately: .gp-tap sets position: relative at the same
   * specificity further down this file, so a bare .gp-checkpoint__clear-tap
   * lost on source order and this box stayed IN FLOW -- centred by the card's
   * align-items, above the title, reserving a band of empty card over the
   * header. Both of Corey's notes about this control ("don't reserve all that
   * space", "fix chevron placement") were that one lost declaration. */
  position: absolute;
  /* The card's own padding-top, so the box starts where the content does. */
  top: 11px;
  left: 4px;
  display: grid;
  place-items: center;
  /* .gp-checkpoint__title's height: the two share a centre line. */
  height: 42px;
  z-index: 1;
  opacity: 0;
  pointer-events: none;
  /* Fades with the controls, not on a timer of its own: [data-checkpoint-
   * state-value="selected"] is set and cleared in the same calls that open
   * and close them (choose(), clearSelection()), and [data-busy] — set for
   * the WHOLE of a send, panel close included, not only the stretch where
   * somebody is still chosen (checkpoint_controller.js#send keeps the
   * state "selected" until its own clearSelection() call partway through)
   * — keeps it off the rest of the way. The aria-hidden/tabindex side of
   * "hidden" is the controller's job (#updateClear): a CSS transition is
   * not itself an attribute change. */
  transition: opacity var(--gp-motion-checkpoint-controls) ease-in-out;
}

.gp-checkpoint__card[data-checkpoint-state-value="selected"]:not([data-busy]) .gp-checkpoint__clear-tap {
  opacity: 1;
  pointer-events: auto;
}

/* 44 wide and the title's 42 tall: still a full-size target, and Apple's
 * 44pt minimum is about the SMALLER dimension of a touch area, which this
 * keeps. No border-radius — round 17 measured taps in the corners of a
 * circular target getting the haptic but not the action, because
 * border-radius clips hit testing. */
.gp-checkpoint__clear {
  display: grid;
  place-items: center;
  width: 44px;
  height: 42px;
  padding: 0;
  border: 0;
  background: none;
}

/* The drawn glyph: the chevron alone, no container (Corey, 2026-09-21: "no
 * outer container. Icon should be very muted/quiet"). It used to wear
 * .gp-glass-v2--control, a 26px glass disc with its own sheen and rim, which
 * read as a second button beside the performer's name rather than a way
 * back. Quieter than the ✕ was, too: 0.38 against the old 0.55, on the same
 * grey the card already uses for its secondary text (color/grey-700, "Send
 * Chuds to") rather than the −/+'s black. Round 8's press/hover below brings
 * it up on contact, which is where the affordance lives now. */
.gp-checkpoint__clear-glyph {
  display: grid;
  place-items: center;
  color: var(--gp-color-grey-700);
  opacity: 0.38;
  transition: opacity var(--gp-motion-checkpoint-state) ease-in-out;
}

/* PRESS AND HOVER (round 8's pattern): .is-pressed from press_controller.js
 * on .gp-checkpoint__clear-tap itself (the same element the haptic overlay
 * covers), sinking and brightening the glyph rather than the invisible tap
 * target. */
.gp-checkpoint__clear-tap.is-pressed .gp-checkpoint__clear-glyph {
  transform: scale(0.9);
  opacity: 0.8;
  transition: transform var(--gp-motion-press) ease-out, opacity var(--gp-motion-press) ease-out;
}

@media (hover: hover) {
  .gp-checkpoint__clear-tap:hover:not(.is-pressed) .gp-checkpoint__clear-glyph {
    opacity: 0.62;
  }
}

.gp-checkpoint__clear-glyph svg {
  stroke: currentColor;
}

/* --- the avatar row --- */

/* A size container so a slot can be measured in the row's own width: the
 * distance between two slots is (content width - 70) / (count - 1), which
 * is exactly where space-between puts them. */
/* touch-action was left off entirely here through round 6: ios-vibrator-pro-
 * max treated ANY pan-* or none as a drag surface and laid its own input
 * over the row, breaking every tap. That library is gone (src/haptics.js) —
 * round 11 C's continuous drag needs pan-y back, or iOS can decide mid-drag
 * that the gesture is its own vertical scroll and stop delivering pointermove
 * (the row itself has nothing to scroll sideways, so horizontal stays ours). */
.gp-checkpoint__avatars {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  height: 100px;
  padding: 0 var(--gp-dimension-checkpoint-row-inset);
  touch-action: pan-y;
  container-type: inline-size;
  /* The avatars' own stacking context: the controller writes each one's
   * z-index here every frame (round 13, checkpoint_controller.js#restack —
   * the one crossing the row at the back, the one nearest the middle in
   * front), and none of it can ever put an avatar behind the card. */
  isolation: isolate;
}

/* --slot is sprung by the controller; no transition here, or the two would
 * fight. The landing bounce is NOT on this element: any scale here composes
 * with the slot's translate and scales the translation too, so only the
 * avatar whose slot is its home (translate 0) bounced in place — the others
 * lurched towards the middle. It is on .gp-checkpoint__bump inside. */
.gp-checkpoint__avatar {
  --slot-step: calc((100cqi - 70px) / (var(--count, 2) - 1));
  position: relative;
  display: block;
  flex: 0 0 auto;
  width: 70px;
  height: 70px;
  /* Clamped to the row's two ends (round 12, Corey: "The avatars should
   * never leave the edge of the card"): a drag only ever scrubs between real
   * slots (src/carousel_drag.js), and the clamp stops a spring's overshoot
   * carrying an end avatar past its end. */
  transform: translateX(calc((clamp(0, var(--slot), var(--count, 1) - 1) - var(--index)) * var(--slot-step)));
}

/* The real button, filling the avatar: what the keyboard and VoiceOver use.
 * A finger's tap lands on the switch over it (.gp-haptic-tap). */
.gp-checkpoint__avatar-button {
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
}

/* The bounce box: the avatar's own 70px square, scaled about its centre by
 * Motion when chuds land. The "+N" is positioned in it. */
.gp-checkpoint__bump {
  position: relative;
  display: block;
  width: 70px;
  height: 70px;
  transition: transform var(--gp-motion-press-release) cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* PRESS (round 14, item 1): the same sink-and-spring as the −/+ and Buy More
 * Chuds (round 8), .is-pressed from press_controller.js on .gp-checkpoint__
 * avatar itself. The scale is on .gp-checkpoint__bump, not on the avatar —
 * the avatar carries the slot's translate (see above), and a scale there
 * would compose with it and move the avatar off its slot, the same reason
 * the landing bounce lives on .gp-checkpoint__bump. A finger's press
 * overrides the landing bounce's own inline transform for its duration;
 * both settle back to none. */
.gp-checkpoint__avatar.is-pressed .gp-checkpoint__bump {
  transform: scale(0.94);
  transition: transform var(--gp-motion-press) ease-out;
}

/* A cast of one: space-between would put it at the left edge. */
.gp-checkpoint__avatar:only-child {
  margin-inline: auto;
}

.gp-checkpoint__avatar-button:disabled {
  cursor: default;
}

/* THE SWITCH UNDER THE FINGER (round 6; src/haptics.js says why). A
 * <label> holding a hidden <input type="checkbox" switch>, laid exactly over
 * a control: a finger's tap lands on the label, the label toggles its switch
 * — the one thing iOS 26 answers with a buzz — and the switch's change is the
 * control's action. The control underneath never sees that tap, so it acts
 * once. aria-hidden and out of the tab order: the keyboard and VoiceOver use
 * the real control. Invisible, but not display: none, or it could not be
 * tapped. */
.gp-tap {
  position: relative;
  display: inline-grid;
}

.gp-haptic-tap {
  position: absolute;
  inset: 0;
  /* No double-tap zoom on a stepper tapped quickly. */
  touch-action: manipulation;
  z-index: 2;
  margin: 0;
  /* NO border-radius (round 17, Corey on his iPhone: "I'm able to tap and get
   * haptic but it's off the edge of the button, and it doesn't change the
   * count"). A border-radius clips HIT TESTING as well as paint, so a 50% here
   * made this label a circle inscribed in its box while the element that takes
   * the press — .gp-tap for the steppers, .gp-checkpoint__avatar for the
   * avatars, press_controller.js — stayed the full box. A tap in a corner
   * therefore sank the button (.is-pressed) and did nothing else: no switch
   * toggle, so no buzz, and no step. Measured in the iOS 26.3 Simulator: at
   * the button's centre elementFromPoint is this label, 17px out on the
   * diagonal it is .gp-tap, and a real tap there left the amount unchanged.
   * Square, the haptic area, the acting area and the pressed area are one
   * shape — the control's own box. The button underneath keeps its own 50%:
   * that is what it LOOKS like, not what it answers to. */
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.gp-haptic-tap > input {
  display: none;
}

/* The ring and the picture. 4px ring, the performer's own colour; 6px (4.2
 * before the scale) at full --grow.
 *
 * COLOUR IS A NUMBER NOW (round 12). --pick, 0..1 on each avatar, is how
 * much of its own colour it shows: 1 is full colour, the performer's ring
 * and full strength; 0 is greyscale and a grey ring (round 6, Corey: the
 * ones not chosen step back; round 13 took away the two-thirds strength).
 * It was a CSS transition keyed off [data-pressed] and the card's state —
 * which a finger cannot scrub. Now checkpoint_controller.js springs it with --slot and --grow on a
 * tap (1 for the chosen one, 0 for the rest; 1 for everybody when nobody is
 * chosen) and writes it frame by frame under a drag, so the colour moves
 * exactly with the row. No transition on the properties it drives, or each
 * frame's write would restart one and lag the finger. Unset is 1: the
 * server draws everybody in colour. Clamped, because the spring overshoots. */
.gp-checkpoint__photo {
  --pick-clamped: clamp(0, var(--pick, 1), 1);
  display: block;
  width: 70px;
  height: 70px;
  overflow: hidden;
  border-radius: 50%;
  border: calc(4px + var(--grow, 0) * 0.2px) solid
    color-mix(in srgb, var(--performer-accent, var(--gp-accent-fallback)) calc(var(--pick-clamped) * 100%), var(--gp-color-grey-700));
  transform: scale(calc(1 + var(--grow, 0) * (100 / 70 - 1)));
  filter: grayscale(calc(1 - var(--pick-clamped)));
  /* Round 13 (Corey: "avatars should never be translucent/reveal what's
   * behind them ... Component itself should have solid bkg"). The muting
   * was greyscale AND two-thirds opacity, and at two-thirds the avatar
   * crossing behind showed through. Greyscale alone now; and a solid disc
   * under the picture, the muted ring's own grey, so a transparent photo
   * edge never lets the row through either. */
  background: var(--gp-color-grey-700);
}

.gp-checkpoint__photo img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* inactive (the /checkpoint page, no controller): grey ring and greyscale
 * at full strength — nobody is chosen there, the grey is the whole state. */
.gp-checkpoint__card[data-checkpoint-state-value="inactive"] .gp-checkpoint__photo {
  border-color: var(--gp-color-grey-700);
  filter: grayscale(1);
}

/* "+N" over the avatar a chip just landed in, on a translucent white disc so
 * it reads over a photo. The controller fades it in, lifts it and removes it.
 * Round 7: it starts clear above the ring — the chosen avatar's ring, grown
 * to 100px, reaches 15px above this 70px box, and the disc is 44 tall. */
.gp-checkpoint__plus {
  position: absolute;
  left: 50%;
  top: -64px;
  translate: -50% 0;
  z-index: 2;
  display: grid;
  place-items: center;
  min-width: 44px;
  height: 44px;
  padding: 0 6px;
  border-radius: 22px;
  background: var(--gp-color-white-76);
  box-shadow: var(--gp-shadow-checkpoint-control);
  pointer-events: none;
  font-family: var(--gp-font-family-mono);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-title);
  line-height: 1;
  color: var(--gp-color-black);
  white-space: nowrap;
  opacity: 0;
}

/* The same disc rising off a status pill, a beat before its number moves:
 * "+N" off Performance Points (round 7), "−N" off Available Chuds (round 9).
 * Fixed at the pill's top edge (the controller places it), inside the
 * takeover. */
.gp-checkpoint__plus--stat {
  position: fixed;
  translate: -50% -100%;
  z-index: 3;
}

/* The amount in flight: fixed to the viewport at the slider's end, flown by
 * the controller (x, y, scale — a transform, so `translate` centres it on its
 * start point without being overwritten). Inside the takeover's stacking
 * context, above the card. */
.gp-checkpoint__chip {
  position: fixed;
  z-index: 2;
  translate: -50% -50%;
  display: grid;
  place-items: center;
  min-width: 40px;
  height: 40px;
  padding: 0 10px;
  border-radius: var(--gp-dimension-radius-checkpoint-control);
  background: var(--checkpoint-accent, var(--gp-accent-fallback));
  box-shadow: var(--gp-shadow-checkpoint-control);
  pointer-events: none;
  font-family: var(--gp-font-family-mono);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-body);
  color: var(--gp-color-white);
  white-space: nowrap;
}

/* --- the controls --- */

/* Pulled up over the card's 20px gap: with the controls closed the form is
 * empty, and the gap would be a blank band under the avatars. The controls
 * carry that 20px themselves, inside the height they open and close. */
.gp-checkpoint__form {
  margin: -20px 0 0;
}

.gp-checkpoint__fieldset {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}

/* Everything that acts on a selection. Hidden until somebody is chosen; the
 * controller opens and closes it with Motion (height and opacity), and only
 * clips it while it moves — round 4 item 7 was the slide's outline being cut
 * off, so nothing here clips at rest. */
.gp-checkpoint__controls {
  width: 100%;
}

.gp-checkpoint__controls-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  padding: 20px 0 2px;
}

.gp-checkpoint__controls[hidden] {
  display: none;
}

/* The stepper, centred over the slide. */
.gp-checkpoint__amount {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 190px;
  padding: 0;
}

/* −/+ in the cards' glass: a translucent fill, a bright rim and top edge, a
 * soft inner glow, and the clouds blurred through. 48px — over the 44px a
 * thumb needs. Round 4, Corey: bigger and glassier than Figma's 40. */
.gp-checkpoint__step {
  display: grid;
  place-items: center;
  flex: none;
  width: 48px;
  height: 48px;
  padding: 0;
  border-radius: 50%;
  border: 0;
  /* Round 12: the card's faint cool tint, not white at .35 — over white
   * clouds a white fill read as a solid disc. */
  background: var(--gp-color-glass-tint-control);
  backdrop-filter: blur(var(--gp-dimension-blur-product-card));
  -webkit-backdrop-filter: blur(var(--gp-dimension-blur-product-card));
  /* Round 11: rim, sheen, edge, fringe, cheap frost and depth are glass v2
   * (.gp-glass-v2--control). The blur stays here, on the element: the
   * round, dimensional treatment keeps its blur 10 (Codex amendment). */
}

/* At a limit (round 7): looks disabled — aria-disabled, not disabled, so a
 * tap still reaches the controller and the number shakes (src/step_limits.js). */
.gp-checkpoint__step[aria-disabled="true"] {
  opacity: 0.4;
  cursor: default;
}

/* PRESS AND HOVER (round 8): colour and motion both. Pressed, the button
 * sinks to 0.94 and its glass deepens, quickly; let go, it comes back on an
 * overshooting curve — a light spring. Hover, on devices that have it,
 * lifts it a little and brightens it. The press is .is-pressed on the .gp-tap
 * around it (checkpoint_controller.js#pressStep), because a finger is on the
 * hidden switch laid over the button, never the button, and :active cannot
 * fire there. At a limit it stays as it is: the number shakes instead. */
.gp-checkpoint__step {
  transition:
    opacity var(--gp-motion-checkpoint-state) ease-in-out,
    background-color var(--gp-motion-press) ease-out,
    transform var(--gp-motion-press-release) cubic-bezier(0.34, 1.56, 0.64, 1);
}

.gp-tap.is-pressed .gp-checkpoint__step:not([aria-disabled="true"]) {
  transform: scale(0.94);
  background-color: color-mix(in srgb, var(--gp-color-glass-tint-control), var(--gp-color-grey-700) 18%);
  transition:
    opacity var(--gp-motion-checkpoint-state) ease-in-out,
    background-color var(--gp-motion-press) ease-out,
    transform var(--gp-motion-press) ease-out;
}

@media (hover: hover) {
  .gp-tap:hover:not(.is-pressed) .gp-checkpoint__step:not([aria-disabled="true"]) {
    transform: translateY(-1px) scale(1.04);
    background-color: var(--gp-color-white-50);
  }
}

.gp-checkpoint__step img {
  width: 14px;
  height: auto;
}

/* Plex Mono Bold 32, black: an editable number styled as the number. Shows 0
 * at zero, not blank (round 13). */
.gp-checkpoint__input {
  flex: 1 1 0;
  width: 0;
  min-width: 0;
  padding: 0;
  border: 0;
  background: none;
  text-align: center;
  font-family: var(--gp-font-family-mono);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-h1);
  line-height: 1;
  color: var(--gp-color-black);
  appearance: textfield;
  -moz-appearance: textfield;
}

/* Round 15: red while the typed amount is over the limit
 * (checkpoint_controller.js#render sets [data-over]; the shake that goes
 * with it is Motion, not CSS — see #shake). Reuses the field-error token
 * rather than a new one; nothing about this red is checkpoint-specific. */
.gp-checkpoint__input[data-over] {
  color: var(--gp-text-error);
}

.gp-checkpoint__input::-webkit-inner-spin-button,
.gp-checkpoint__input::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

/* Slide to send: full card width x 56 (round 11 item 3 widened it from a
 * fixed 290 to the card's own content box; round 4: taller than
 * the 40 it was), in the same glass as the −/+. The native range sits on top,
 * transparent but for its thumb; under it, the fill and the label.
 *
 * THE OUTLINE (round 4 item 7). Measured on the round 3 track: nothing
 * clipped it — the card has no overflow, and the takeover's edge is 30px
 * away. The thumb did: it was as tall as the track and sat flush with both
 * ends, so it covered the rim's curves. The input sits --inset inside the
 * rim; round 5 (Corey, on an iPhone: "less padding") took that from 4px to
 * 2px, so the thumb is all but the track's full height and still never on
 * the rim. The focus ring is the track's own outline, outside it.
 *
 * --slide (0–1, slide_to_confirm_controller.js) is the thumb's travel. A
 * native range with no padding centres its thumb at thumb/2 + --slide ×
 * (width − thumb) along its own box; that box starts --inset in, which gives
 * --thumb-centre. */
.gp-checkpoint__slide {
  --thumb: 50px;
  --inset: 2px;
  --thumb-centre: calc(var(--inset) + var(--thumb) / 2 + var(--slide, 0) * (100% - 2 * var(--inset) - var(--thumb)));
  position: relative;
  /* Round 11 item 3, Corey's phone: the track stopped short of the card's
   * edges. The fixed 290px predates the card's own width ever mattering here;
   * 100% of the content box (.gp-checkpoint__card's flex column, padding
   * already excluded) reaches it exactly, and --thumb-centre above is
   * already a % of this element's own width, so nothing downstream assumed
   * 290 — slide_to_confirm_controller.js measures
   * sliderTarget.getBoundingClientRect().width live, not a constant. */
  /* Round 12 (item 8, Corey): the track spans the three avatars as a unit,
   * the first one's left edge to the last one's right — the row's own inset
   * (dimension checkpoint-row-inset) in from each side of the card's
   * content box, not the full width round 11 gave it. */
  width: calc(100% - 2 * var(--gp-dimension-checkpoint-row-inset));
  max-width: 100%;
  height: 56px;
  border-radius: 28px;
  /* Round 11 (amendment 4): the track is FLAT — tint and its blur 10, no rim,
   * sheen, depth or frost; the thumb carries the glass. The border stays as
   * 1px of transparent, the tint showing through it, so --inset and the
   * thumb's travel measure from the same edge as before. The fill is a
   * faint grey, not white: flat white glass on the white card vanished
   * (Simulator, round 11). */
  border: 1px solid transparent;
  background: var(--gp-color-black-7);
  backdrop-filter: blur(var(--gp-dimension-blur-product-card)) saturate(1.4);
  -webkit-backdrop-filter: blur(var(--gp-dimension-blur-product-card)) saturate(1.4);
}

.gp-checkpoint__slide:has(.gp-checkpoint__slider:focus-visible) {
  outline: 2px solid var(--gp-color-black);
  outline-offset: 2px;
}

/* Behind the thumb, the performer's colour darkened a sixth (round 4 item
 * 16), so the lighter thumb stays the thing to look at. Grey from the moment
 * a send is confirmed until the thumb is home (item 12): the only saturated
 * thing moving then is the chip.
 *
 * ONE PILL AT EVERY WIDTH (round 5 items 1, 6, 9). It runs from the track's
 * left end to the thumb's RIGHT edge — round 4 stopped at the thumb's
 * centre, leaving white under its right half — and is never narrower than
 * the thumb, so it is always the same full-radius pill with its right end
 * under the thumb; round 4's could be narrower than its own radius, which is
 * the crescent that showed at rest on the iPhone. And it fades in with the
 * travel, 0 at rest to full at the end, so at rest there is nothing to see. */
.gp-checkpoint__slide-fill {
  position: absolute;
  inset: var(--inset) auto var(--inset) var(--inset);
  width: max(var(--thumb), calc(var(--thumb-centre) + var(--thumb) / 2 - var(--inset)));
  border-radius: calc(var(--thumb) / 2);
  /* Round 14, item 2 (Corey: full-strength colour, not the 82%-with-black
   * darkening): the performer's own bright accent, no color-mix. */
  background: var(--checkpoint-accent, var(--gp-color-grey-700));
  /* Round 12 (item 6, Corey): full-strength colour, but nothing until the
   * thumb is past 40% of its travel, then up to full by 60% (slide
   * fill-start, fill-full). Round 11's straight --slide put the fill at half
   * opacity over white mid-slide: the washed tan. */
  opacity: clamp(0, (var(--slide, 0) - var(--gp-slide-fill-start)) / (var(--gp-slide-fill-full) - var(--gp-slide-fill-start)), 1);
  pointer-events: none;
  transition: background-color var(--gp-motion-checkpoint-state) ease-in-out;
}

/* Round 7: centred on the track itself (not on the room right of the thumb)
 * and more muted. */
.gp-checkpoint__slide-label {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
  font-family: var(--gp-font-family);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-body);
  color: var(--gp-color-grey-500);
  white-space: nowrap;
  /* Gives way as the thumb and the fill cross it. */
  opacity: calc(1 - var(--slide, 0) * 1.5);
}

.gp-checkpoint__slider {
  position: absolute;
  inset: var(--inset);
  display: block;
  width: auto;
  height: auto;
  margin: 0;
  padding: 0;
  background: transparent;
  appearance: none;
  -webkit-appearance: none;
  cursor: grab;
}

.gp-checkpoint__slider:focus-visible {
  outline: none;
}

.gp-checkpoint__slider:disabled {
  cursor: default;
}

.gp-checkpoint__slider::-webkit-slider-runnable-track {
  height: 100%;
  background: transparent;
}

.gp-checkpoint__slider::-moz-range-track {
  height: 100%;
  background: transparent;
}

/* The thumb: the performer's colour with the paper plane (round 5: the icon
 * again, not the amount), grey once confirmed. Disabled — nothing chosen to
 * send — it is faint. Round 11: it carries the glass the track gave up.
 *
 * Round 12 (item 4, Corey: its highlights looked different from the −/+,
 * a hard transition from highlight to shadow): round 11's rim was four
 * border colours, light top-left and dark bottom-right, which meet at a hard
 * diagonal. Now it is the −/+'s language: the same gradient rim (glass-rim,
 * painted into the transparent border, border-box, over the colour), the
 * same soft top highlight and bottom shade (glass-edge), fringe and depth.
 * A native thumb cannot hold the layer elements, so they are its
 * background layers. --thumb-colour is the one thing states change. */
.gp-checkpoint__slider {
  --thumb-colour: var(--checkpoint-accent, var(--gp-color-grey-700));
}

.gp-checkpoint__slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: var(--thumb);
  height: var(--thumb);
  border: var(--gp-dimension-glass-rim-width) solid transparent;
  border-radius: 50%;
  background: url("/assets/checkpoint/icon-send-b21857c8.svg") center / 18px 18px no-repeat padding-box,
    var(--gp-gradient-glass-sheen-thumb) padding-box,
    var(--gp-gradient-glass-rim) border-box,
    var(--thumb-colour) border-box;
  box-shadow: var(--gp-shadow-glass-edge), var(--gp-shadow-glass-fringe), var(--gp-shadow-glass-depth-control);
  transition:
    background-color var(--gp-motion-checkpoint-state) ease-in-out,
    opacity var(--gp-motion-checkpoint-state) ease-in-out;
}

.gp-checkpoint__slider::-moz-range-thumb {
  width: var(--thumb);
  height: var(--thumb);
  border: var(--gp-dimension-glass-rim-width) solid transparent;
  border-radius: 50%;
  background: url("/assets/checkpoint/icon-send-b21857c8.svg") center / 18px 18px no-repeat padding-box,
    var(--gp-gradient-glass-sheen-thumb) padding-box,
    var(--gp-gradient-glass-rim) border-box,
    var(--thumb-colour) border-box;
  box-shadow: var(--gp-shadow-glass-edge), var(--gp-shadow-glass-fringe), var(--gp-shadow-glass-depth-control);
}

/* MUTED (round 14, item 3; Corey: the confirmed grey should look like the
 * amount-0 disabled slider, not a separate grey). Disabled (nothing to
 * send) and confirmed (data-confirmed, until the thumb springs home —
 * confirmation does not set :disabled) get the identical treatment: the
 * accent stays, at half opacity, on the thumb. One selector list, so the two
 * states can never disagree about what "muted" looks like, and a slider that
 * is somehow both stays this. */
.gp-checkpoint__slider:disabled::-webkit-slider-thumb,
.gp-checkpoint__slide[data-confirmed] .gp-checkpoint__slider::-webkit-slider-thumb {
  opacity: 0.5;
}

.gp-checkpoint__slider:disabled::-moz-range-thumb,
.gp-checkpoint__slide[data-confirmed] .gp-checkpoint__slider::-moz-range-thumb {
  opacity: 0.5;
}

/* The thumb spins from the moment a send is held for a captcha
 * (captcha_controller.js#run, toggleLoading) until the overlay shows or the
 * flow ends any other way. data-captcha-loading sits on the same element as
 * data-confirmed, so this rides on top of the muted state above rather than
 * replacing it. Reuses gp-spin (application.css), the same keyframes
 * .gp-btn.is-busy already spins with, rather than a second one -- rotating
 * the whole thumb (icon, rim and sheen together) rather than drawing a
 * separate ring, since a native thumb has no room to add one. */
.gp-checkpoint__slide[data-captcha-loading] .gp-checkpoint__slider::-webkit-slider-thumb {
  animation: gp-spin 0.8s linear infinite;
}

.gp-checkpoint__slide[data-captcha-loading] .gp-checkpoint__slider::-moz-range-thumb {
  animation: gp-spin 0.8s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  /* Two rules, not one list: an engine drops a whole list over one vendor
   * pseudo-element it does not know. */
  .gp-checkpoint__slide[data-captcha-loading] .gp-checkpoint__slider::-webkit-slider-thumb {
    animation: none;
  }

  .gp-checkpoint__slide[data-captcha-loading] .gp-checkpoint__slider::-moz-range-thumb {
    animation: none;
  }
}

/* Round 15 (Corey: the fill behind the muted thumb/track reads darker than
 * the amount-0 disabled slider it's meant to match). At rest the fill is
 * already invisible — the base rule's clamp() is 0 at --slide 0 — so
 * "match the disabled slider" means the fill goes to 0, not 0.5, once
 * confirmed. The transition lives here, not on the base rule: draw() sets
 * --slide every animation frame during a drag or the spring-back, and the
 * base rule's opacity already rides that in real time (no transition) so the
 * 40%→100% ramp stays 1:1 with the finger. Scoping the transition to this
 * override means it only fires for the one discrete jump — full opacity at
 * confirmation to 0 — and reusing --gp-motion-checkpoint-state matches the
 * thumb's own confirmed-state transition above. */
.gp-checkpoint__slide[data-confirmed] .gp-checkpoint__slide-fill {
  opacity: 0;
  transition: opacity var(--gp-motion-checkpoint-state) ease-in-out;
}

/* "Balance after send: N" — small and muted, live with the stepper.
 *
 * Round 14 O.6, Corey: "Don't show balance after send line if send amount is
 * 0. It should fade in/out (but reserve space on the card)." Opacity and
 * visibility, not display, so the line's own height stays reserved even
 * hidden — a display:none here would leave the stepper to jump when it
 * disappears. checkpoint_controller.js#render sets [data-zero] off the
 * amount (and off a send's own roll-down state, O.7 amendment); visibility
 * only flips once the fade-out has finished, so nothing under the line is
 * reachable while it is still visibly there.
 *
 * Round 15: an amount over the limit takes over THIS line instead of
 * opening a second box below it (not in Figma, which draws no error state)
 * — same reserved space, no layout shift, and [data-zero] never applies
 * while [data-over] does (checkpoint_controller.js#render). */
.gp-checkpoint__after {
  margin: -4px 0 0;
  text-align: center;
  font-family: var(--gp-font-family);
  font-weight: var(--gp-font-weight-medium);
  font-size: var(--gp-font-size-small);
  color: var(--gp-color-grey-700);
  opacity: 1;
  visibility: visible;
  transition: opacity var(--gp-motion-checkpoint-state) ease-in-out, visibility 0s linear 0s;
}

.gp-checkpoint__after[data-zero] {
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--gp-motion-checkpoint-state) ease-in-out,
    visibility 0s linear var(--gp-motion-checkpoint-state);
}

.gp-checkpoint__after[data-over] {
  color: var(--gp-text-error);
}

/* "Max ¢N per send": the operator's cap (Setting.max_chuds_per_send), shown
 * whenever it applies to what is being chosen. */
.gp-checkpoint__hint {
  margin: 0;
  text-align: center;
  font-family: var(--gp-font-family);
  font-weight: var(--gp-font-weight-medium);
  font-size: var(--gp-font-size-small);
  color: var(--gp-color-grey-700);
}

.gp-checkpoint__hint[hidden] {
  display: none;
}

/* ==== The toast system (cluster C) ==== */

/* One lane, at the TOP of the viewport, one toast at a time with the rest
 * queued behind it (src/toast_queue.js). Round 16 replaced the round-5 lane
 * that sat at the bottom in Bootstrap alert markup: that one stacked, and two
 * glass cards overlapping the sky at phone width is unreadable — and a
 * persistent toast somebody has to dismiss must never be buried under a later
 * one.
 *
 * z-index 1100: #notifications is 1050, under the takeover's 1065, and a
 * refusal nobody can see until the window closes is no refusal at all. Above
 * the Buy More Chuds drawer (1075) too, which opens over the takeover. */
.gp-toasts {
  position: fixed;
  left: calc(16px + env(safe-area-inset-left));
  right: calc(16px + env(safe-area-inset-right));
  top: calc(10px + env(safe-area-inset-top));
  z-index: var(--gp-zIndex-checkpoint-toast);
  max-width: 370px;
  margin-inline: auto;
  display: grid;
  gap: 8px;
  pointer-events: none;
}

/* One shape for every attendee toast (Figma 296:1013) -- the /login flash,
 * every branch of shared/_notification.html.erb, and (redrawn by hand,
 * since it is JS) src/toasts.js's own lane. It replaces three things that
 * used to look different: the /login flash's Bootstrap `.alert`, the
 * chuds-alert notifications' black "+N CHUDS" block, and this file's own
 * `.gp-checkpoint-toast`.
 *
 * Round 2 (Corey: "the same frost as the other"): back onto glass-v2, the
 * `.gp-checkpoint-toast` recipe this superseded (origin/main
 * components.css:2965-2995) rather than the flat fill the first unification
 * landed with. `.gp-glass-v2 .gp-glass-v2--card` plus glass_layers(:blur,
 * :shine, :rim) (ApplicationHelper) in Ruby, the same three <i> elements
 * hand-drawn in src/toasts.js -- NOT :frost. The true frost is a second
 * backdrop pass, and it is the thing "cheap" below exists to avoid: this
 * surface MOVES (spring in, spring out), so a second compositing pass on top
 * of the first is the expensive one to pay every time it does. --glass-cheap
 * substitutes an inset glow instead (glass-frost-cheap-control, into
 * .gp-glass-v2__shine's box-shadow).
 *
 * The base fill is opaque (glass-border-bright, .8) rather than the card's
 * usual translucent tint: it is what stops the toast being see-through on
 * its first frame, before __blur's backdrop-filter has composited (the
 * round-15 flash, same cause as ever). --glass-tint is white-70, not
 * glass-tint-card -- a toast sits over whatever is on screen, not over the
 * checkpoint sky in particular.
 *
 * box-shadow stays the Figma toast shadow, not --glass-depth: declared here,
 * after .gp-glass-v2 (which sets box-shadow: var(--glass-depth)), same
 * specificity, later wins.
 *
 * position: relative comes from .gp-glass-v2--card, and is required now, not
 * merely harmless: the three layer elements are position: absolute; inset: 0,
 * and without it they would place themselves against the next positioned
 * ancestor up instead of this card.
 *
 * pointer-events: auto here, not on a lane: `.gp-toasts`, `.sale-toasts` and
 * `.gp-toast-stack` are all pointer-events: none so a lane's own footprint
 * cannot swallow a tap meant for whatever is under it (see each container's
 * own comment). This is what turns tapping back on for the card itself. */
.gp-toast {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 4px 12px 12px;
  border: 1px solid var(--gp-color-glass-border);
  border-radius: var(--gp-radius-card);
  background: var(--gp-color-glass-border-bright);
  box-shadow: var(--gp-shadow-toast);
  font-family: var(--gp-font-family);
  color: var(--gp-color-status-ink);
  pointer-events: auto;
  --glass-tint: var(--gp-color-white-70);
  --glass-cheap: var(--gp-shadow-glass-frost-cheap-control);
}

/* Figma's 10px vertical padding is for a toast with no second line -- a body
 * makes the card taller on purpose, so the shorter padding only applies
 * without one. */
.gp-toast:not(:has(.gp-toast__body)) {
  padding-top: 10px;
  padding-bottom: 10px;
}

/* A 24px icon lead sits closer to the text than a 40px avatar does (Figma:
 * 8px after an avatar, 4px after an icon) -- gap is the row's only spacing
 * rule, so the icon case overrides it rather than the avatar case setting
 * one. */
.gp-toast:has(.gp-toast__lead--icon) {
  gap: 4px;
}

/* Semantic tints, the same technique .gp-quiz__choice[data-tone] already
 * uses (below): color-mix over the same opaque base .gp-toast paints, rather
 * than a flat colour, so a toned toast still reads as glass. --glass-tint
 * gets the same mix over white-70 -- the __blur layer paints --glass-tint,
 * not the element's own background, so without this the layer would wash a
 * toned toast back to plain white. */
.gp-toast[data-tone="success"] {
  background: color-mix(in srgb, var(--gp-color-green) 14%, var(--gp-color-glass-border-bright));
  border-color: var(--gp-color-green-border);
  --glass-tint: color-mix(in srgb, var(--gp-color-green) 14%, var(--gp-color-white-70));
}

.gp-toast[data-tone="danger"] {
  background: color-mix(in srgb, var(--gp-color-red-brand) 14%, var(--gp-color-glass-border-bright));
  border-color: var(--gp-color-red-brand-border);
  --glass-tint: color-mix(in srgb, var(--gp-color-red-brand) 14%, var(--gp-color-white-70));
}

.gp-toast__lead {
  flex: none;
}

/* The 24px icon box: a coin disc, the alert glyph, or the checkpoint mascot,
 * all centred in the same footprint so a toast reads the same shape
 * whichever one it carries. Unmodified .gp-toast__lead is the 40px avatar
 * case instead, sized by .gp-avatar's own --avatar-size. */
.gp-toast__lead--icon {
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
}

/* The alert SVG (shared/_toast_alert_icon.html.erb) carries its own 24x24
 * width/height, so it fills the box above with no sizing rule of its own --
 * this only clears the inline-SVG baseline gap that would otherwise nudge it
 * a couple of px off-centre inside a grid cell that does not care about
 * text baselines. */
.gp-toast__lead--icon svg {
  display: block;
}

/* 20px, inside the 24px box above -- the coin's existing gradient/shadow
 * tokens, reused rather than re-measured (round 16 cluster C, via
 * .gp-checkpoint-toast__coin, which this supersedes at a smaller size). */
.gp-toast__coin {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: var(--gp-gradient-coin-face);
  box-shadow: var(--gp-shadow-coin);
  font-weight: var(--gp-font-weight-bold);
  color: var(--gp-color-coin-ink);
}

/* The checkpoint mascot (round 19, issue #211), now sized to the same 24px
 * icon box every other lead icon uses instead of its own 26px square.
 * object-fit: ChuddyPoint.png is square but nothing here should depend on
 * that. */
.gp-toast__mascot {
  width: 24px;
  height: 24px;
  object-fit: contain;
}

.gp-toast__text {
  flex: 1;
  min-width: 0;
}

.gp-toast__title {
  display: block;
  font-weight: var(--gp-font-weight-semibold);
  font-size: var(--gp-font-size-body);
}

.gp-toast__body {
  display: block;
  margin-top: 2px;
  font-size: var(--gp-font-size-small);
  font-style: italic;
  line-height: 1.35;
  color: var(--gp-text-muted);
}

/* The product name inside a purchase toast's body -- semibold on top of the
 * body's own italic, not a second colour or size. */
.gp-toast__body strong {
  font-weight: var(--gp-font-weight-semibold);
}

/* 44px, the app's own control sizing, pulled back out of the padding with
 * negative margins so the toast is not made taller by its own dismiss --
 * the same trick the superseded .gp-checkpoint-toast__close used. */
.gp-toast__close {
  flex: none;
  width: 44px;
  height: 44px;
  margin: -8px -6px -8px 0;
  border: 0;
  border-radius: var(--gp-dimension-radius-round);
  background: none;
  display: grid;
  place-items: center;
  color: var(--gp-text-muted);
}

/* Drawn, not typed: IBM Plex Sans has no multiplication-sign glyph that reads
 * as a close, and the Simulator rendered the one the prototype tried as
 * tofu. shared/_toast_close_icon.html.erb carries the same markup for the
 * two Ruby-rendered callers. */
.gp-toast__close svg {
  width: 11px;
  height: 11px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

.gp-toast__close:focus-visible {
  outline: 2px solid var(--gp-border-focus);
  outline-offset: 2px;
}

/* /order_notifications is a transparent OBS browser source (see the stage
 * board's own comment above for the same constraint on a different element)
 * -- the glass fill above would let whatever is behind the browser source
 * straight through a purchase toast there, which .chuds-alert's opaque
 * Bootstrap background never risked. Fill only: the border and shadow stay
 * translucent, same as every other opaque-plate surface in this file. This
 * body id is WelcomeController#set_layout's "minimum" branch, order_notifications
 * alone. */
body#order_notifications .gp-toast {
  background: var(--gp-surface-plate);
}

/* __blur is the layer with a backdrop-filter (and its own translucent
 * --glass-tint fill) -- on an opaque plate on ordinary pages that reads as
 * frost, but on the transparent OBS source above it is a second hole
 * straight through the card, on top of whatever the opaque background line
 * above already closed. __shine and __rim carry no backdrop-filter and stay:
 * they are gradients and inset box-shadows painted over the plate, the same
 * "border and shadow stay translucent" the comment above already accepts. */
body#order_notifications .gp-toast > .gp-glass-v2__blur {
  display: none;
}

/* ==== Chuds received: the bubble and the coins (cluster C) ==== */

/* The "+¢N" pill, positioned in the CARD's coordinates by JS
 * (src/chuds_received.js). No transform here on purpose: Motion writes an
 * element's whole transform when it animates x/y, so a translate(-50%, -100%)
 * base would be thrown away on the first frame and the pill would land in a
 * corner. */
.gp-status__bubble {
  position: absolute;
  z-index: 5;
  padding: 2px 8px;
  border-radius: var(--gp-radius-pill);
  background: var(--gp-color-status-ink);
  box-shadow: var(--gp-shadow-status-bubble);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-body);
  color: var(--gp-color-white);
  white-space: nowrap;
  pointer-events: none;
}

/* The coins' own layer, and the only place the perspective lives. On the card
 * itself it would be an ancestor transform over the glass layers' own
 * backdrop pass, which is the round-15 trap; on a layer of its own it reaches
 * nothing but the discs. z-index 0 keeps them under the bubble. */
.gp-status__fx {
  position: absolute;
  inset: 0;
  z-index: 0;
  perspective: 520px;
  pointer-events: none;
}

/* A disc, not a confetti flake: the bright rim, the off-centre highlight and
 * the shaded inside edge are what make it read as heavy while rotateY turns
 * it through face, edge and back. Positioned from its centre by the margins,
 * so JS can write a plain left/top without measuring it. */
.gp-coin {
  position: absolute;
  width: 12.5px;
  height: 12.5px;
  margin: -6.25px 0 0 -6.25px;
  border-radius: 50%;
  transform-style: preserve-3d;
  display: grid;
  place-items: center;
  background: var(--gp-gradient-coin-face);
  box-shadow: var(--gp-shadow-coin), var(--gp-shadow-coin-relief);
  font-size: 10px;
  font-weight: var(--gp-font-weight-bold);
  color: var(--gp-color-coin-ink);
}

/* --- Buy More Chuds --- */

/* #1c96e5 (a normal fill — see FILL LAYERS at the top). Opens cluster C's
 * drawer. Round 4, Corey: bigger. Figma's 121 x 25 pill scaled to 44 tall —
 * the smallest a thumb can be asked to hit — with its padding, gap, icon and
 * label scaled with it (x1.6, the label to 16). */
.gp-buy-chuds {
  display: inline-flex;
  align-items: center;
  /* Same reason as .gp-checkpoint__main's flex-shrink: 0 above — this is
   * .gp-checkpoint's other flex child, and without it a too-short screen
   * squashed the button rather than scrolling to it. */
  flex-shrink: 0;
  gap: 10px;
  height: 44px;
  padding: 0 18px 0 20px;
  border: 0;
  border-radius: var(--gp-dimension-radius-buy-chuds-button);
  background: var(--gp-color-blue);
  /* The rim, soft sheen and shadow are glass v2 (.gp-glass-v2--button).
   * Opaque, no blur. Round 11 flattened it to 12px corners; round 12
   * (Corey): back to the pill (radius-buy-chuds-button, 33px). */
  font-family: var(--gp-font-family);
  font-weight: var(--gp-font-weight-medium);
  font-size: var(--gp-font-size-buy-chuds);
  white-space: nowrap;
  color: var(--gp-color-white);
}

/* Press and hover, as on the −/+ (round 8): .is-pressed from
 * press_controller.js, hover only where there is a pointer to hover. */
.gp-buy-chuds {
  transition:
    background-color var(--gp-motion-press) ease-out,
    transform var(--gp-motion-press-release) cubic-bezier(0.34, 1.56, 0.64, 1);
}

.gp-buy-chuds.is-pressed {
  transform: scale(0.94);
  background-color: color-mix(in srgb, var(--gp-color-blue), var(--gp-color-black) 18%);
  transition:
    background-color var(--gp-motion-press) ease-out,
    transform var(--gp-motion-press) ease-out;
}

@media (hover: hover) {
  .gp-buy-chuds:hover:not(.is-pressed) {
    transform: translateY(-1px) scale(1.03);
    background-color: color-mix(in srgb, var(--gp-color-blue), var(--gp-color-white) 12%);
  }
}

/* Reduced motion: the colour, not the movement. */
@media (prefers-reduced-motion: reduce) {
  .gp-buy-chuds,
  .gp-buy-chuds.is-pressed,
  .gp-buy-chuds:hover,
  .gp-tap .gp-checkpoint__step,
  .gp-checkpoint__avatar.is-pressed .gp-checkpoint__bump,
  .gp-checkpoint__clear-tap.is-pressed .gp-checkpoint__clear-glyph {
    transform: none !important;
  }
}

/* Round 11, amendment 7: with reduced transparency the −/+ and the track
 * stop blurring and fill in (glass v2's own fallback covers card and sheet). */
@media (prefers-reduced-transparency: reduce) {
  .gp-checkpoint__step,
  .gp-checkpoint__slide {
    background: var(--gp-color-glass-tint-solid);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

.gp-buy-chuds__icon {
  flex: none;
  width: 18px;
  height: 19.6px;
}

@media (prefers-reduced-motion: reduce) {
  .gp-checkpoint__card * {
    transition: none !important;
  }
}

/* ==== gp-drawer: buy chuds drawer and product card (cluster C) ==== */

/* The Buy More Chuds drawer (shared/_buy_chuds_drawer) and the product card
 * it lists (shared/_product_card, Figma 143:320). No Figma frame exists for
 * the drawer: it is designed here and drawn afterwards from what ships.
 *
 * Bootstrap 5.0.2's offcanvas, restyled rather than replaced. Its sheet's
 * z-index is hard-coded (1050), under the checkpoint takeover at 1065 that
 * the drawer opens from, so it is lifted below. .gp-drawer-root itself sets
 * nothing: a stacking context there would trap the z-indexes inside it.
 *
 * THE SCRIM IS OURS, NOT BOOTSTRAP'S (round 10). Corey, iPhone, iOS 26: the
 * page behind the open drawer was neither darkened nor blurred. Reproduced in
 * the iOS 26.3 Simulator's Safari: Bootstrap 5.0.2's Backdrop keeps its one
 * element across hide and show (dispose() removes it from the page but keeps
 * the reference), and round 9's sheet drag wrote an inline opacity and
 * "transition: none" on it as the sheet was dragged. A drag-dismiss hid the
 * drawer, Bootstrap took the element out, and the clean-up on hidden looked
 * for it in the page, found nothing, and cleared nothing. Every later open
 * put the same element back at the opacity the drag had let go at, near 0
 * after a long drag, for the life of the page; and below opacity 1 iOS drew
 * no blur either. So the drawer turns Bootstrap's backdrop off
 * (data-bs-backdrop="false") and owns its scrim: one element that is always
 * in the page, so the drag's inline styles are always found and cleared, and
 * always opacity 1, fading by --scrim-level, a registered number that scales
 * the blur's radius. The same level is what the sheet drag moves
 * (sheet_drag_controller.js).
 *
 * BLUR ONLY (round 13). Corey: the drawer's glass read grey over the
 * darkening scrim. The 55% black (color/scrim) is gone; the scrim only
 * blurs. It also ends the flash Corey saw at the status bar on close —
 * see the note after the scrim.
 *
 * AND IT IS PATTERN B'S VEIL NOW (round 19). The drawer is the fourth of the
 * four surfaces that arrive by blur and lift — the other three are the
 * participation panels in application.css — so this element and their
 * ::before are the same layer with the same tokens. Two things changed here.
 * The RADIUS IS FIXED and the layer's OPACITY is what animates: it used to
 * scale the radius by --scrim-level, which re-filters a full-screen backdrop
 * every frame, and that is the expensive way to the same picture — in the
 * prototype it was what made everything else stutter. And a colour comes
 * back, but a tenth of the sky's own blue (color/panel-scrim) rather than
 * round 13's 55% black; that one was removed for reading GREY through the
 * drawer's glass, which this does not do. --scrim-level is still the one
 * number the drag moves, so sheet_drag_controller.js needs no new API — it
 * drives opacity instead of the radius.
 *
 * HIDDEN WHILE SHUT (round 11). A fixed, full-bleed element at the top edge
 * stopped iOS 26 Safari tinting its status bar from the takeover's sky
 * strip (application.css, .takeover__tint), transparent or not: measured in
 * the iOS 26.3 Simulator, the bar went to the body's pale colour. So it is
 * visibility: hidden at rest, and goes hidden only once the close fade has
 * run — a delayed visibility transition, not display: none, which would cut
 * the fade — and it stays in the page for the drag's clean-up. */
@property --scrim-level {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

.gp-drawer-scrim {
  --scrim-level: 0;
  position: fixed;
  inset: 0;
  z-index: var(--gp-zIndex-buy-chuds-drawer-backdrop);
  background: var(--gp-color-panel-scrim);
  backdrop-filter: blur(var(--gp-dimension-blur-panel));
  -webkit-backdrop-filter: blur(var(--gp-dimension-blur-panel));
  opacity: var(--scrim-level);
  pointer-events: none;
  visibility: hidden;
  transition: --scrim-level var(--gp-motion-panel) ease-in-out,
    visibility 0s var(--gp-motion-panel);
}

.gp-drawer-scrim.is-open {
  --scrim-level: 1;
  pointer-events: auto;
  visibility: visible;
  transition: --scrim-level var(--gp-motion-panel) ease-in-out;
}

/* THE STATUS BAR UNDER THE SCRIM (rounds 11, 13). With a dark scrim the bar
 * had to dim too, and iOS 26 Safari retints it only when a fixed edge strip
 * is inserted or removed, so round 11 inserted a dimmed strip on show and
 * removed it on hide. That removal was the flash: it ran at the start of the
 * close fade, with the scrim still visible over the takeover's strip, so
 * Safari fell back to the body's pale sky-bottom for ~0.25s before the
 * scrim's delayed visibility: hidden let it find sky-top again (measured in
 * the iOS 26.3 Simulator, a screen recording sampled per frame, on close,
 * scrim tap and drag-dismiss alike). A blur-only scrim has nothing to dim
 * the bar to, so the strip is gone, and with it the flash: the bar holds
 * sky-top through never opened, open, close, scrim tap, drag-dismiss and a
 * reopen after it. */
.gp-drawer.offcanvas {
  z-index: var(--gp-zIndex-buy-chuds-drawer);
  /* offcanvas-bottom is a fixed 30vh. The sheet sizes to its cards instead,
   * up to most of the screen, and the body scrolls past that. dvh where the
   * browser has it, so a phone's toolbar does not push the bottom off. */
  height: auto;
  max-height: 85vh;
  max-height: 85dvh;
  /* left and right are both 0, so this centres it on anything wider than a
   * phone. */
  max-width: 30rem;
  margin-inline: auto;
  border: 0;
  border-radius: var(--gp-dimension-radius-product-card) var(--gp-dimension-radius-product-card) 0 0;
  /* Round 11: glass v2 (.gp-glass-v2--sheet). Tint, blur, edge frost, rim
   * and depth are its layers; the sheet itself paints nothing, and must
   * not blur — a backdrop-filter here would make it a backdrop root and
   * leave the frost layer inside it nothing to frost. Bootstrap's white
   * .offcanvas fill is cleared. */
  background: transparent;
  font-family: var(--gp-font-family);
  /* ROUND 19, PATTERN B: it lifts, it does not slide. Bootstrap's
   * .offcanvas-bottom starts the sheet a whole screen below the fold; the
   * approved prototype brings it up dimension/panel-lift and fades it, so the
   * sheet reads as settling onto the page rather than being flung up it. The
   * curve overshoots (y2 = 1.3) because that surface was a spring — written
   * as a bezier rather than run through Motion, since the same pattern has to
   * work on the participation panels, which can be in a page's first paint.
   * These two rules are also the OUT: the base one is what the sheet
   * transitions back to when .show goes, so leaving is the plain ease-in over
   * motion/panel that Corey tuned, and only the entrance is springy.
   * The drag is untouched — it writes an inline transform, which outranks
   * both (sheet_drag_controller.js), and its dismiss still travels the
   * sheet's whole height because that one is following a finger. */
  transform: translateY(var(--gp-dimension-panel-lift));
  opacity: 0;
  transition: transform var(--gp-motion-panel) ease-in,
    opacity var(--gp-motion-panel) ease-in;
}

.gp-drawer.offcanvas.show {
  transform: translateY(0);
  opacity: 1;
  transition: transform var(--gp-motion-panel-lift) cubic-bezier(0.22, 1.3, 0.36, 1),
    opacity var(--gp-motion-panel) ease-out;
}

/* Round 2: ≥768px, the same offcanvas becomes a centred modal. Bootstrap
 * 5.0.2's Offcanvas class only ever toggles .show, visibility, the backdrop
 * and focus (bootstrap.bundle.js ~4925-4987) — it never touches position or
 * transform beyond that, so a media query here is the whole mechanism. The
 * bottom-sheet rules above (fixed to the bottom edge, slide-up transform)
 * stay for mobile; this overrides position and swaps the slide for a
 * fade + slight scale, still driven by the same .show class. */
@media (min-width: 768px) {
  .gp-drawer.offcanvas {
    top: 50%;
    bottom: auto;
    left: 50%;
    right: auto;
    /* Wide enough for three tiled 325px cards plus the body's own padding
     * (3 * 325 + 2 * 16 gap + 2 * 16 body padding + 2 * 1 border = 1041,
     * since border-box (bootstrap.css's global reset) pulls the border into
     * the same box this width sets), capped so it never crowds a narrower
     * desktop window. An explicit width, not just max-width: with left: 50%
     * and right: auto the box has no far edge to stretch to, so a
     * fixed-position element sizes to shrink-to-fit its content (here,
     * whatever the flex-wrapped cards happen to need) and max-width only
     * ever caps that from above. Setting width to the same expression is
     * what actually makes it grow, while left/translate keep doing the
     * horizontal centring untouched. */
    width: min(92vw, 1041px);
    max-width: min(92vw, 1041px);
    border-radius: var(--gp-dimension-radius-product-card);
    transform: translate(-50%, -50%) scale(0.96);
    opacity: 0;
  }

  .gp-drawer.offcanvas.show {
    transform: translate(-50%, -50%) scale(1);
    opacity: 1;
  }

  /* The grab handle is a bottom-sheet affordance; a centred modal has a
   * close button instead. */
  .gp-drawer__handle {
    display: none;
  }
}

/* Wired since round 9: dragging it (or the header, or the body from the top)
 * pulls the sheet down, and far enough or fast enough dismisses it
 * (sheet_drag_controller.js). The pill is 40 x 5, drawn by ::before; the
 * padding beside and above it widens what a thumb can catch, and the header
 * just under it drags too. Neither takes browser panning, so a drag
 * there is all ours. */
.gp-drawer__handle {
  flex: none;
  align-self: center;
  padding: 8px 40px 0;
  touch-action: none;
  cursor: grab;
}

.gp-drawer__handle::before {
  content: "";
  display: block;
  width: 40px;
  height: 5px;
  border-radius: var(--gp-dimension-radius-round);
  background: var(--gp-color-black-15);
}

.gp-drawer__header {
  touch-action: none;
}

.gp-drawer__header {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 8px 20px 12px;
}

.gp-drawer__title {
  margin: 0;
  font-family: var(--gp-font-family);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-title);
  line-height: normal;
  color: var(--gp-text-body);
}

.gp-drawer__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* The home indicator: the layout's viewport-fit=cover lets the sheet run
   * under it, and its last card must not. */
  padding: 0 16px calc(24px + env(safe-area-inset-bottom));
}

.gp-drawer__body > turbo-frame {
  display: block;
}

/* Round 2: mobile is a horizontal carousel, desktop a tiled grid. --drawer-card-w
 * is read by both the cards (flex-basis) and this row (inline padding), so the padding
 * always matches whatever width the min() actually resolved to. The subtracted 100px is
 * not a guess: this row's own padding eats it symmetrically, so the neighbour's peek
 * comes out to a constant ~17px at ANY viewport under the 325px cap — verified live at
 * 375 and 402 wide, where a flat 85vw clamp left literally 0px of peek once the
 * drawer's own 16px body padding and this row's centring padding were subtracted.
 * scroll-snap-align lives on the card itself (below), not here. */
/* Round 11 item 2, Corey's phone: the product cards' drop shadow was cut off
 * along the top and bottom edges. `overflow-y: visible` above reads as an
 * escape hatch but is not one: the spec computes a `visible` on one axis to
 * `auto` the moment the other axis is anything but `visible` (CSS
 * Overflow §3), and `overflow-x: auto` is exactly why this scroller exists —
 * so this box clips vertically no matter what overflow-y says. --gp-shadow-
 * product-card is `0px 8px 32px rgba(0,0,0,.1)` (tokens.css): a shadow with
 * no spread extends blur-minus-offset above the box and blur-plus-offset
 * below it, 24px and 40px here. Block padding of exactly that, cancelled by
 * an equal negative margin so the row's own height in the drawer body does
 * not change, gives the shadow room to paint without moving anything. */
/* Round 12, Corey's phone: the carousel was clipped ~16px shy of the sheet's
 * left/right edges (the drawer body's own inline padding) instead of scrolling
 * full-bleed to them. margin-inline: -16px pulls the row out to the sheet edge;
 * percentage padding always resolves against the (unmoved) drawer body content
 * box, so it doesn't grow to match on its own — the +16px added to padding-inline
 * and to the matching scroll-padding-inline is what keeps the first/last card's
 * rest position at scroll 0 and at the end exactly where it was before. */
.gp-drawer__cards {
  --drawer-card-w: min(325px, 100vw - 100px);
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: 16px;
  overflow-x: auto;
  overflow-y: visible;
  scroll-snap-type: x mandatory;
  margin-inline: -16px;
  padding-inline: calc(50% - (var(--drawer-card-w) / 2) + 16px);
  scroll-padding-inline: calc(50% - (var(--drawer-card-w) / 2) + 16px);
  padding-block: 24px 40px;
  margin-block: -24px -40px;
  /* Scrollbar hidden; the snap and the peeking neighbour are the only affordance. */
  scrollbar-width: none;
}

.gp-drawer__cards::-webkit-scrollbar {
  display: none;
}

.gp-drawer__cards > .gp-product-card {
  flex: 0 0 var(--drawer-card-w);
  scroll-snap-align: center;
}

@media (min-width: 768px) {
  .gp-drawer__cards {
    flex-wrap: wrap;
    flex-direction: row;
    justify-content: center;
    overflow-x: visible;
    scroll-snap-type: none;
    margin-inline: 0;
    padding-inline: 0;
    scroll-padding-inline: 0;
  }

  .gp-drawer__cards > .gp-product-card {
    flex: 0 0 325px;
    scroll-snap-align: none;
  }
}

/* One card's height, so the sheet opens at about the size it is about to be
 * rather than jumping when the frame lands. */
.gp-drawer__loading {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 350px;
  margin: 0;
  font-size: var(--gp-font-size-body);
  color: var(--gp-text-muted);
}

.gp-drawer__empty {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: var(--gp-font-size-body);
  color: var(--gp-text-muted);
}

/* The product card, Figma 143:320: 325x350, image over info.
 *
 * Glass by backdrop-filter on the card itself, with an ordinary fill — Figma
 * draws this one as a normal-blend layer, unlike the status card's
 * plus-lighter. Inside the drawer the blur has only the drawer's own glass to
 * sample (the drawer's backdrop-filter makes it the backdrop root), so there
 * it reads as a flat fill; over a page it blurs the page. */
.gp-product-card {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 325px;
  max-width: 100%;
  /* min-, not height: a name that wraps grows the card instead of spilling. */
  min-height: 350px;
  overflow: clip;
  border: 1px solid var(--gp-color-white-30);
  border-radius: var(--gp-dimension-radius-product-card);
  background: var(--gp-color-glass-border-bright);
  backdrop-filter: blur(var(--gp-dimension-blur-product-card));
  -webkit-backdrop-filter: blur(var(--gp-dimension-blur-product-card));
  box-shadow: var(--gp-shadow-product-card), var(--gp-shadow-product-card-inset);
  font-family: var(--gp-font-family);
}

.gp-product-card__image {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 200px;
  padding: 0 12px;
}

.gp-product-card__image img {
  width: 200px;
  max-width: 100%;
  height: 200px;
  object-fit: contain;
}

.gp-product-card__info {
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 9px;
  padding: 0 20px 15px;
}

.gp-product-card__titles {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.gp-product-card__label {
  margin: 0;
  font-weight: var(--gp-font-weight-medium);
  font-size: var(--gp-font-size-small);
  line-height: 14px;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--gp-color-grey-700);
}

.gp-product-card__name {
  margin: 0;
  font-family: var(--gp-font-family);
  font-weight: var(--gp-font-weight-medium);
  font-size: var(--gp-font-size-title);
  line-height: 18px;
  color: var(--gp-color-bootstrap-body);
}

.gp-product-card__price {
  margin: 0;
  font-weight: var(--gp-font-weight-regular);
  font-size: var(--gp-font-size-price);
  line-height: 16px;
  color: var(--gp-color-bootstrap-success);
}

.gp-product-card__error {
  margin: 0;
  font-size: var(--gp-font-size-small);
  line-height: 1.3;
  color: var(--gp-text-error);
}

.gp-product-card__actions {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 8px;
}

/* Figma positions this box absolutely, over the quantity stepper it hides
 * (143:387 at left 4.5, top 67 of the info block), so it does not sit on the
 * info's 20px padding: it hangs 15.5px into it on the left and 12.2px below
 * the Buy button's baseline. The negative margins put it where Figma draws
 * it without taking it out of the flow. */
.gp-product-card__commission {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
  margin: 0 0 -12.2px -15.5px;
  padding: 4px 15px;
  border: 1px solid var(--gp-color-bootstrap-input-border);
  border-radius: var(--gp-dimension-radius-card);
  background: var(--gp-color-white-88);
}

.gp-product-card__commission-label {
  margin: 0;
  font-weight: var(--gp-font-weight-regular);
  font-size: var(--gp-font-size-commission-label);
  line-height: 19.2px;
  color: var(--gp-color-grey-500);
}

/* Wraps past three: Figma draws three, the cast may be more. */
.gp-product-card__performers {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

/* Fixed 45px border-box, and the border-WIDTH never changes: R2 found the
 * photo shrinking 43px -> 39px when chosen, because the ring used to widen
 * the border from 1px to 3px inside that fixed box. The ring is a box-shadow
 * instead — drawn outside the box, so it costs the photo nothing, nothing
 * else in the row can reflow off it, and it leaves the `outline` property
 * free for the focus-visible ring below rather than fighting it for the
 * same box. */
.gp-product-card__performer {
  flex: none;
  width: 45px;
  height: 45px;
  padding: 0;
  overflow: hidden;
  border: 1px solid var(--gp-color-grey-400);
  border-radius: var(--gp-dimension-radius-round);
  background: none;
  box-shadow: 0 0 0 3px transparent;
  transition: box-shadow var(--gp-motion-checkpoint-state) ease-in-out, border-color var(--gp-motion-checkpoint-state) ease-in-out;
}

.gp-product-card__performer[aria-pressed="true"] {
  border-color: var(--gp-color-green);
  box-shadow: 0 0 0 3px var(--gp-color-green);
}

.gp-product-card__performer img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: filter var(--gp-motion-checkpoint-state) ease-in-out;
}

/* Same B&W rule as the checkpoint card: nobody chosen, everyone's in colour;
 * once someone is, everyone else greys out. */
.gp-product-card__performers:has(.gp-product-card__performer[aria-pressed="true"])
  .gp-product-card__performer:not([aria-pressed="true"]) img {
  filter: grayscale(1);
}

.gp-product-card__buy {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  min-width: 80px;
  height: 35px;
  padding: 9px 15px;
  border: 1px solid var(--gp-color-green);
  border-radius: var(--gp-dimension-radius-product-buy-button);
  background: var(--gp-color-green);
  font-weight: var(--gp-font-weight-regular);
  font-size: var(--gp-font-size-body);
  line-height: 14px;
  color: var(--gp-color-white);
  text-decoration: none;
  white-space: nowrap;
}

/* Figma has no disabled Buy. This one is disabled until a commission is
 * chosen, and while a purchase is in flight. */
.gp-product-card__buy:disabled {
  opacity: 0.5;
}

.gp-product-card__buy-icon {
  width: 15.46px;
  height: 20px;
}

.gp-product-card__performer:focus-visible,
.gp-product-card__buy:focus-visible {
  outline: 3px solid var(--gp-border-focus);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  /* Round 19: a cross-fade, and nothing travels — the same reduced path the
   * participation panels take. Not `transition: none`, which is what this
   * said before pattern B gave the sheet an opacity to fade: with no
   * transition at all the drawer would now appear and vanish in one frame. */
  .gp-drawer.offcanvas,
  .gp-drawer.offcanvas.show {
    transform: none;
    transition: opacity var(--gp-motion-panel-reduced) linear;
  }

  .gp-drawer-scrim {
    transition: --scrim-level var(--gp-motion-panel-reduced) linear,
      visibility 0s var(--gp-motion-panel-reduced);
  }

  .gp-drawer-scrim.is-open {
    transition: --scrim-level var(--gp-motion-panel-reduced) linear;
  }

  .gp-product-card__performer,
  .gp-product-card__performer img {
    transition: none !important;
  }
}

/* The avatar chip.
 *
 * The ring is load-bearing, not decoration. An avatar renders inside a container
 * painted with a performer's icon_color, which is operator-editable data — and
 * every colour in the palette measures between 1.0:1 and 3.4:1 against today's
 * three character colours. Nothing here separates itself by contrast; the ring
 * is what makes the chip a distinct object, whatever it is sitting on.
 */
.gp-avatar {
  --avatar-size: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--avatar-size);
  height: var(--avatar-size);
  border-radius: 50%;
  overflow: hidden;
  font-size: calc(var(--avatar-size) * 0.55);
  line-height: 1;
  color: var(--gp-color-white);
  box-shadow: 0 0 0 2px var(--gp-color-white);
  user-select: none;
}

.gp-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Diagonal rather than flat, per each colour's own -light stop (design/tokens.json)
 * mixed 18% toward white -- one gradient definition here, consumed by the
 * preview chip, the picker swatches (.gp-avatar-swatch composes these classes
 * for its background) and every rendered attendee avatar alike, so the three
 * cannot disagree about what a colour looks like. */
.gp-avatar--teal   { background: linear-gradient(135deg, var(--gp-color-avatar-teal-light) 0%, var(--gp-color-avatar-teal) 100%); }
.gp-avatar--ocean  { background: linear-gradient(135deg, var(--gp-color-avatar-ocean-light) 0%, var(--gp-color-avatar-ocean) 100%); }
.gp-avatar--violet { background: linear-gradient(135deg, var(--gp-color-avatar-violet-light) 0%, var(--gp-color-avatar-violet) 100%); }
.gp-avatar--rose   { background: linear-gradient(135deg, var(--gp-color-avatar-rose-light) 0%, var(--gp-color-avatar-rose) 100%); }
.gp-avatar--slate  { background: linear-gradient(135deg, var(--gp-color-avatar-slate-light) 0%, var(--gp-color-avatar-slate) 100%); }

/* The pale chip is the exception on both counts. A white ring on it measures
 * 1.26:1 and is invisible, and white text on it fails outright. */
.gp-avatar--bone {
  background: linear-gradient(135deg, var(--gp-color-avatar-bone-light) 0%, var(--gp-color-avatar-bone) 100%);
  color: var(--gp-color-black);
  box-shadow: 0 0 0 2px var(--gp-color-avatar-slate);
}

/* The avatar picker's own controls (avatars/_picker.html.erb) -- a squircle
 * for colour, a bare glyph for symbol, neither of which is the round .gp-avatar
 * chip above. Centered rather than left-packed like the old flex-wrap they
 * replace. Two modifiers, not one shared column count: 6 colours read best as
 * one row, but 12 symbols at that same 6-wide count would wrap into two rows
 * of 6 rather than the requested 4x3, so each row states its own grid. */
.gp-avatar-picker-row {
  display: grid;
  justify-content: center;
}

/* minmax(0, ...) so the six tracks shrink below 52px when the form is
 * narrower than 352px (360/375px phones, and the col-sm-8 step near 576px)
 * instead of overflowing it. */
.gp-avatar-picker-row--colors {
  grid-template-columns: repeat(6, minmax(0, 52px));
}

.gp-avatar-picker-row--symbols {
  grid-template-columns: repeat(4, 64px);
}

/* The only visible states .form-check used to draw for free -- gone along
 * with it once the picker stopped using f.radio_button (see the comment in
 * avatars/_picker.html.erb). :has() is already assumed elsewhere in this app
 * (application_controller.rb's commented allow_browser call names it). Sized
 * by its row modifier above (width/height: 100% of the grid cell) rather than
 * fixed here, so the two rows can carry different tap-target sizes. */
.gp-avatar-option {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  aspect-ratio: 1;
  border-radius: 30%;
  cursor: pointer;
}

/* A white gap inside the ring, so it reads against the swatch's own colour as
 * well as against the page. Green rather than black: measured live off
 * #attendee_name on /attendees/edit while focused (script/measure_live.js) --
 * the field's own ID-scoped rule in the Setting row (custom_css, `[id$="-edit"]
 * .form-control { color: var(--dk-green) }`) wins over Bootstrap's focus
 * repaint even while focused, so --gp-color-green-dark IS what an attendee's
 * focused input actually shows, not merely its resting label colour. */
.gp-avatar-option:has(input:checked) {
  box-shadow: 0 0 0 2px var(--gp-color-white), 0 0 0 4px var(--gp-color-green-dark);
}

.gp-avatar-option:has(input:focus-visible) {
  outline: 2px solid var(--gp-color-green-dark);
  outline-offset: 2px;
}

/* Dims the options in a row that has a pick, so the selection reads at a
 * glance rather than by hunting for the ring. Scoped to its group -- the six
 * colours are one row div, the twelve symbols one grid wrapping to 4x3 --
 * so picking a symbol dims the other eleven and never the colours. */
.gp-avatar-picker-row:has(input:checked) .gp-avatar-option:not(:has(input:checked)) {
  opacity: 0.35;
}

.gp-avatar-swatch {
  width: 100%;
  height: 100%;
  border-radius: 30%;
}

/* The bone chip's 2px slate ring (above) is what makes it visible against
 * white, but on a swatch it reads as the selection ring. A 1px inset hairline
 * keeps the edge without looking picked. */
.gp-avatar-swatch.gp-avatar--bone {
  box-shadow: inset 0 0 0 1px var(--gp-color-avatar-slate);
}

/* Deliberately no chip behind the glyph -- that container is for a SAVED
 * preset (shared/_avatar, the preview circle above this picker), not for an
 * option in the list choosing one. */
.gp-avatar-glyph {
  font-size: 2.25rem;
  line-height: 1;
}

/* ---------------------------------------------------------------------------
 * Login
 *
 * Everything below serves app/views/attendees/_form.html.erb and the layout's
 * flash slot above it. Appended as a block rather than folded into .gp-field
 * above so the reconciliation notes there keep describing what was measured.
 * ------------------------------------------------------------------------ */

/* The error line. Deliberately NOT .form-text, and deliberately a class the
 * operator's row has never heard of: `#login .form-text { display: none }`
 * (design/custom-css-baseline.css:707) hides helper text on this page outright,
 * and the redeclaration at :721 sets font-style only -- it never restores
 * display. An error in a .form-text would render nowhere.
 *
 * min-height: 1lh keeps one line of space reserved whether or not there is a
 * message, so marking or clearing a field never moves the submit button out
 * from under a finger already travelling towards it. */
.gp-field__msg {
  display: block;
  /* Reserve the line so an error appearing does not shove the field below it
   * down by 14.4px. Two declarations, in this order, because `lh` is Safari
   * 16.4+ (March 2023) and this audience is on phones: older Safari drops the
   * `1lh` line and keeps the em, newer Safari takes the last one it understands.
   * They agree here -- font-size is 0.75rem = 12px and line-height 1.2, so both
   * compute to 14.4px, against the 14.39px measured on the rendered page. */
  min-height: 1.2em;
  min-height: 1lh;
  margin: 0.25rem 0 0;
  font-size: var(--gp-font-size-small);
  font-weight: var(--gp-font-weight-semibold);
  line-height: 1.2;
  color: var(--gp-text-error);
}

/* The ring. .gp-field__control.is-invalid already sets border-color further up
 * this file; this adds the second ring pixel and kills Bootstrap's own
 * exclamation-mark background, which sits hard right on a field whose text is
 * centred. Both selectors are (0,2,0), the same as Bootstrap's
 * .form-control.is-invalid -- components.css loads after bootstrap.css, so
 * source order settles the tie our way. */
.gp-field__control.is-invalid,
.gp-field__control[aria-invalid="true"] {
  border-color: var(--gp-border-error);
  box-shadow: 0 0 0 2px var(--gp-border-error);
  background-image: none;
  padding-right: 0.75rem;
}

/* Show code reads back as the printed code. Display only: the value posted is
 * whatever was typed, and AttendeesController#create downcases both sides. */
.gp-field__control--code {
  text-transform: uppercase;
}

/* Rejection. The class goes on the form, is removed on animationend, and is
 * re-added after a reflow so a second rejection shakes again. */
@keyframes gp-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-6px); }
  40% { transform: translateX(6px); }
  60% { transform: translateX(-4px); }
  80% { transform: translateX(4px); }
}

.gp-shake {
  animation: gp-shake 0.4s ease-in-out;
}

@media (prefers-reduced-motion: reduce) {
  .gp-shake {
    animation: none;
  }
}

/* The flash toast. Rendered by shared/_flash_toasts, which every layout that
 * has adopted it includes -- layouts/application and layouts/store, issue
 * #215 -- in place of each one's own in-flow Bootstrap banner.
 *
 * It replaces the in-flow banner and the .gp-flash-slot mitigation that used to
 * hold space for one: min-height 4.5rem on body#login stopped the shift Corey
 * objected to by paying a permanent 72px gap on a page that usually has no
 * message. position: fixed takes part in no layout at all, so there is nothing
 * to shift and nothing to reserve.
 *
 * Pinned to the TOP because that is where the banner was -- an arrival message
 * is the first thing to read on the page, and the other fixed box on this
 * surface (#notifications) owns the bottom-right corner.
 *
 * The padding carries env(safe-area-inset-*) on top of the token: the layout
 * sets viewport-fit=cover, so anything pinned to an edge paints under the
 * status bar unless it keeps itself out. Body padding cannot help a fixed box.
 * See the "Safe areas" block at the end of application.css.
 *
 * pointer-events: none on the stack, auto on the toasts: the stack spans the
 * full width, and without this it would swallow taps on whatever it covers --
 * on /login that is the logo and the top of the form. */
.gp-toast-stack {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--gp-zIndex-toast);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--gp-dimension-toast-gap);
  padding:
    calc(var(--gp-dimension-toast-inset) + env(safe-area-inset-top))
    calc(var(--gp-dimension-toast-inset) + env(safe-area-inset-right))
    0
    calc(var(--gp-dimension-toast-inset) + env(safe-area-inset-left));
  pointer-events: none;
}

/* Width matched to the login form rather than to the viewport: a toast the full
 * width of a laptop reads as a banner again. margin-bottom: 0 because the stack
 * spaces its children with `gap` and Bootstrap's .alert carried 1rem of its own,
 * which would have added to it -- .gp-toast itself is no longer a Bootstrap
 * .alert, but nothing here relies on that having changed. pointer-events: auto
 * moved onto .gp-toast itself (every caller needs it, not just this stack), so
 * it is not repeated here. */
.gp-toast-stack .gp-toast {
  width: 100%;
  max-width: var(--gp-dimension-form-width);
  margin-bottom: 0;
}

/* The sky. Live: this paints the /login background.
 *
 * It was inert while Setting[custom_css] still carried
 * `#login { background-image: ... }`, whose id selector beat this class
 * everywhere. That block is cut (docs/login-row-cutover.md), so restoring the
 * row without also rolling the code back brings the overpaint back -- and with
 * it the portrait-only PNG, which stretches in landscape. See section 4 of that
 * runbook before restoring anything.
 *
 * scroll, not fixed: background-attachment: fixed janks badly on iOS Safari,
 * which is the whole audience. */
.gp-sky {
  /* Under the photograph, and it has two jobs. It is what the page holds while
   * the JPEG is still arriving -- this audience is on venue wifi -- and it is
   * what iOS 26 Safari tints the status bar with now that the layout sets
   * viewport-fit=cover. Safari never reads background IMAGES, gradients or
   * pseudo-elements: it takes the background-color of a fixed, full-width
   * element at the top edge, and failing that the body's, then the root's. This
   * page has no fixed element up there, so this is the one it finds. Without it
   * the sky runs under a white status bar, which is the band this change exists
   * to remove. Measured off the image itself -- see color/sky-top. */
  background-color: var(--gp-color-sky-top);
  background-image: url("/assets/gp-sky-portrait-62ab4fa2.jpg");
  background-size: cover;
  /* Moot against `cover`, and carried anyway: the row declares it (baseline:651) and
   * the parity harness reads background-repeat as a computed property, so leaving it
   * out reports `repeat` against the row's `no-repeat` as a difference in a page that
   * paints identically. */
  background-repeat: no-repeat;
  background-position: center top;
  background-attachment: scroll;
}

@media (orientation: landscape) {
  .gp-sky {
    background-image: url("/assets/gp-sky-landscape-b2eabd07.jpg");
  }
}

/* ==== gp-prompt: participation/_live takeover (cluster A) ==== */

/* Black full-screen, light-grey card — deliberately NOT `.takeover`. That
 * class (and `.takeover--blocking`) is what application.css's :has()
 * suppression looks for to know a checkpoint should stand down while
 * somebody is on stage; it finds this element by the ABSENCE of the class,
 * so gp-prompt paints the same family without ever joining it (plan R1/R2).
 * env(safe-area-inset-*) keeps the card off the notch/home-indicator on an
 * iPhone the way the checkpoint takeover already does. */
.gp-prompt {
  background: var(--gp-color-black);
  color: var(--gp-color-white);
  display: flex;
  align-items: center;
  justify-content: center;
  padding-top: max(1rem, env(safe-area-inset-top));
  padding-right: max(1rem, env(safe-area-inset-right));
  padding-bottom: max(1rem, env(safe-area-inset-bottom));
  padding-left: max(1rem, env(safe-area-inset-left));
}

/* The card every branch renders into (holding / no-panel / hold beat /
 * normal panel) — none of the four is left on the bare black (plan point 2). */
.gp-prompt__card {
  background: var(--gp-color-grey-100);
  color: var(--gp-color-black);
  border-radius: var(--gp-dimension-radius-card);
  padding: 1.75rem 1.5rem;
  width: 100%;
  max-width: 32rem;
}

/* "Medium thick", read as 500 — Bootstrap's h2 default (700) is heavier than
 * the design calls for here. #000 on --gp-color-grey-100 (#ededed) is
 * 18.6:1, well past AA for text this size. */
.gp-prompt__text {
  font-size: clamp(1.5rem, 7vw, 2.5rem);
  font-weight: 500;
  line-height: 1.2;
}

.gp-prompt__body {
  font-size: clamp(1.125rem, 4.5vw, 1.5rem);
  font-weight: 400;
}

/* ==== gp-quiz: quiz takeover (cluster B) ==== */

/* Sky takeover, like the checkpoint — the quiz's own column inside
 * .takeover__body.
 *
 * Round 2 (Corey, revising the preview three times in one sitting — this is
 * the last version): the choices moved INSIDE .gp-quiz__panel, below the
 * question rather than stacked above it. A phone reading the answers before
 * it reads the question was the thing round 1 got wrong. See
 * .gp-quiz__choices below for why that move needs its own width rule.
 *
 * .takeover--checkpoint (application.css, shared with .takeover--quiz now)
 * sets padding: 0 and align-items: stretch on the takeover itself, so this
 * column supplies its own centring, its own max-width, and its own
 * safe-area side gutters (plan R8) rather than inheriting the base
 * .takeover's — .takeover__body already contributes the bottom gutter and
 * the bare env() insets, with no 1rem floor under them. */
.gp-quiz {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 20px;
  width: 100%;
  max-width: 28rem;
  margin: 0 auto;
  padding-left: max(1rem, env(safe-area-inset-left));
  padding-right: max(1rem, env(safe-area-inset-right));
}

/* width: 100% is load-bearing, not tidiness. .gp-quiz__panel (below) sets
 * align-items: center, and this element declares no width of its own — so
 * once it lives inside the panel (round 2) it shrink-wraps to its longest
 * line instead of spanning, and every row's right-aligned .gp-quiz__mark
 * ends up hugging the text instead of sitting at the row's own edge. */
.gp-quiz__choices {
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: 100%;
}

/* .gp-glass (the layer above) paints the fill, border, radius and shadow —
 * this only adds the layout and the tap target. min-height: 44px is the
 * real target; the label's own padding is what makes it that tall, not a
 * fixed height, so a two-line answer still grows. */
.gp-quiz__choice {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  min-height: 44px;
  padding: 0.75rem 1rem;
  cursor: pointer;
  color: var(--gp-color-black);
  font-family: var(--gp-font-family);
  font-size: var(--gp-font-size-body);
  font-weight: var(--gp-font-weight-medium);
  text-align: left;
  border-width: 1px;
  transition: background var(--gp-motion-checkpoint-state, 0.2s) ease-in-out,
              border-color var(--gp-motion-checkpoint-state, 0.2s) ease-in-out,
              opacity var(--gp-motion-checkpoint-state, 0.2s) ease-in-out;
}

/* Visually hidden, never display: none / visibility: hidden — a clip-rect
 * keeps the real radio/checkbox focusable, in the tab order, and reachable
 * by a screen reader, and keeps `required`'s native validation UI live on
 * the radios (plan point 4). The label wraps its own input, so native
 * :active on .gp-quiz__choice is the press feedback; no controller. */
.gp-quiz__choice-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.gp-quiz__choice-body {
  flex: 1;
  min-width: 0;
}

/* Corey, 2026-09-22, marking up a phone screenshot: "let's have the Chuds
 * overlay on corner of card with no label." Moved off the text line
 * entirely, so .gp-quiz__choice-main (the nested flex row that used to pin
 * the pill to the body's first line) has nothing left to wrap — deleted,
 * along with the flex row it existed only to give the pill.
 *
 * .gp-quiz__choice (the row/label) is the positioning context — it already
 * carries position: relative (its own comment above, for the tap target),
 * so this needed no new wrapper. Absolute + a small negative inset straddles
 * the row's own top-right corner, matching the circled "+5" Corey drew
 * overlapping the card boundary rather than sitting inside its padding.
 *
 * Checked for clipping before trusting it: neither .gp-quiz__choice,
 * .gp-quiz__choices, .gp-quiz__panel, nor .gp-glass sets overflow — the
 * only clipping ancestor in the takeover chain is .takeover__body's
 * overflow-y: auto (application.css), and the panel's own side padding and
 * gutters keep the overhang well clear of that edge. z-index: auto
 * (the default) already paints above .gp-glass::after's rim, which sits at
 * z-index: -1. Verified in a live render at 390x844, top row and bottom row,
 * single and multi: painted in full, hit-testable, nothing cut.
 *
 * The -0.5rem is 8px of OFFSET but 6px of visible overhang, and that is not
 * a rounding error: an absolute offset anchors to the padding box, and every
 * row that can carry a badge is [data-tone="correct"], which has a 2px
 * border. 8 - 2 = 6, on every card that ships. Change the offset, not this
 * comment, if 8px of visible overhang is ever what is wanted.
 *
 * The unit is dropped from the VISIBLE text (Corey's "no label"), not from
 * the accessible name — "+5" alone tells a screen reader nothing a sighted
 * person doesn't get for free from the pill's ink-green, on-a-card styling.
 * The full "+N Chuds" moves into a .visually-hidden span instead (Bootstrap's
 * own convention, already used in app/views/avatars/_picker.html.erb) rather
 * than inventing a new hide-text class. */
.gp-quiz__row-chuds {
  position: absolute;
  top: -0.5rem;
  right: -0.5rem;
  padding: 0.0625rem 0.5rem;
  border-radius: var(--gp-radius-pill);
  background: var(--gp-color-status-accent-ink-green);
  color: var(--gp-color-white);
  font-size: var(--gp-font-size-caption);
  white-space: nowrap;
}

/* The round control at the row's own LEADING edge — a deliberate departure
 * from the PicPay reference image (text left, control trailing at the right
 * edge), Corey's round-3 call: "let's have the checkmark go at the leading
 * edge of the card instead of trailing". It is the first child of the label
 * in the DOM (_screen.html.erb, both the answering and graded markup), with
 * .gp-quiz__choice-body after it and flex: 1 on the body doing the rest —
 * see .gp-quiz__choice's own comment for the row's flex/gap/align-items.
 * Sized on the wrapper; every SVG inside is stacked on top of the next with
 * inset: 0, and which one shows is a CSS opacity switch, not a DOM swap —
 * see .gp-quiz__mark-check below for the live-answering pair, and
 * _screen.html.erb for the single static glyph a graded row renders once
 * its data-tone is already known server-side. */
.gp-quiz__mark {
  position: relative;
  flex: none;
  width: 20px;
  height: 20px;
  color: var(--gp-color-grey-400);
}

.gp-quiz__mark svg {
  position: absolute;
  inset: 0;
}

/* Selected: pure CSS off the input's own checked state — no controller, no
 * [data-pressed] attribute for the server or a script to set.
 *
 * :not([data-tone]) is load-bearing, not tidiness. A revealed question still
 * renders the attendee's own pick CHECKED, so without it this rule also
 * matched the row carrying data-tone="wrong" — and it wins there, because
 * the :has() argument contributes its own class and pseudo-class, making
 * this (0,3,0) against the tone rule's (0,2,0). The tone's `color: white`
 * still applied (this rule sets no colour), so your own wrong answer came
 * out white-on-near-white. Measured, on a live reveal, before this was
 * added. Same guard on the check glyph's opacity switch just below, and on
 * the muting rule further down — every graded appearance is drawn by the
 * [data-tone] rules alone; this structurally cannot touch one.
 *
 * Chunk B (2026-09-22): border-width used to jump to 2px here, which made a
 * picked row 2px taller than an unpicked one — the single measurable cause
 * of the panel changing height between answering-empty and answering-picked.
 * The border stays the base 1px (set on .gp-quiz__choice) and an inset
 * box-shadow draws the second px inward instead, which is the same visual
 * ring at zero layout cost — the box-shadow chain must still carry
 * --gp-shadow-card (.gp-glass's own drop shadow + inset highlight) or this
 * rule would erase it instead of adding to it. */
.gp-quiz__choice:not([data-tone]):has(.gp-quiz__choice-input:checked) {
  background: var(--gp-color-glass-fill-strong);
  border-color: var(--gp-color-blue);
  box-shadow: var(--gp-shadow-card), inset 0 0 0 1px var(--gp-color-blue);
}

/* The two-SVG toggle for a row still being answered: an empty ring by
 * default, a filled blue circle + check once its own input is checked.
 * Guarded the same way as the fill above, for the same reason — a revealed
 * row's own pick is still `checked`, and its mark is drawn by data-tone
 * instead (the graded branch renders one static glyph, no toggle). */
.gp-quiz__mark-check {
  opacity: 0;
  transition: opacity var(--gp-motion-checkpoint-state, 0.2s) ease-in-out;
}

.gp-quiz__choice:not([data-tone]):has(.gp-quiz__choice-input:checked) .gp-quiz__mark-ring {
  opacity: 0;
}

.gp-quiz__choice:not([data-tone]):has(.gp-quiz__choice-input:checked) .gp-quiz__mark-check {
  opacity: 1;
}

.gp-quiz__choice:has(.gp-quiz__choice-input:focus-visible) {
  outline: 3px solid var(--gp-border-focus);
  outline-offset: 2px;
}

/* At max_selections, quiz_choices_controller.js disables every unchecked
 * input directly (no class swap) rather than the view re-rendering — so
 * .gp-quiz__choice's own unconditional cursor: pointer above is now wrong
 * for these rows: the label still looks tappable but a click does nothing.
 * The row's opacity now answers "why can't I add more" too (the muting rule
 * below keys off this same :disabled state); this only fixes the cursor, on
 * its own property so it cannot enter the specificity races above it. */
.gp-quiz__choice:has(.gp-quiz__choice-input:disabled) {
  cursor: default;
}

/* Corey, 2026-09-21: "dead tap. maybe it should shake to reject" — a tap on
 * one of the disabled rows above stays dead (a disabled control forwards no
 * event, and lockUnchecked never disables a CHECKED input, so this can never
 * un-pick anyone), but it must now say so. quiz_choices_controller.js adds
 * .gp-quiz__choice--shake on the tap and removes it again on `animationend`
 * (Stimulus action, not manual listener bookkeeping) — so a class that is
 * never cleared cannot dead-end a second tap's re-trigger.
 *
 * Confirmed before building on it, headless Chrome, a disabled checkbox
 * inside its own <label>: the click on the label still fires and bubbles
 * (target the tapped child, checkbox stays unchecked) — the browser only
 * suppresses forwarding the click TO the disabled control, not the original
 * event. So plain `disabled` is enough; no need for the aria-disabled
 * fallback that would otherwise be the escape hatch here.
 *
 * No existing shake to reuse: checkpoint_controller.js#shake animates via
 * the Motion library (src/motion.js) on a numeric input, and press_controller
 * .js only toggles a static .is-pressed class for touch's missing :active —
 * neither fits a "reject" animation that needs to replay on every tap. Kept
 * as a CSS keyframe instead of Motion here because there is no numeric value
 * to interpolate and no controller-held animation handle to stop/replace —
 * a class + `animationend` is the smaller diff.
 *
 * @media guard is non-negotiable: prefers-reduced-motion: reduce must see no
 * animation at all, not a smaller one. */
@media (prefers-reduced-motion: no-preference) {
  @keyframes gp-quiz-choice-shake {
    from, to { transform: translateX(0); }
    25% { transform: translateX(-4px); }
    50% { transform: translateX(3px); }
    75% { transform: translateX(-2px); }
  }

  .gp-quiz__choice--shake {
    animation: gp-quiz-choice-shake 0.3s ease-in-out;
  }
}

/* Answered / closed / revealed: the same shape, disabled, toned by
 * data-tone instead of Bootstrap's list-group-item-success/danger/primary. */
.gp-quiz__choice[data-tone] {
  cursor: default;
}

/* Round 3 (Corey: "let's use our brand green for this (and pick a red that
 * matches)" / "just lightly tint the fill with red/green"). A light tint
 * over the glass rather than a dark solid fill also fixes the gap Corey
 * reported between the ring and the fill on green and red (not blue) — that
 * gap is .gp-glass::after (above), a white inset rim painted on every glass
 * surface. On a light background it is invisible, which is why blue
 * (data-tone="mine", glass-fill-strong) already read clean; on the previous
 * dark fills (green-dark #1a8754, red-dark #842029) that same white rim
 * showed as a pale line between the coloured border and the fill. Do not
 * touch .gp-glass::after itself — every other glass surface in the app
 * shares it. color-mix over --gp-surface-glass rather than a new fill
 * token, matching how --gp-gradient-status-glint derives from a colour
 * already in the cascade instead of duplicating it (tokens.css). Text
 * stays --gp-color-black: white would be unreadable on a 14% tint.
 *
 * Chunk B (2026-09-22): border-width was 2px here, which made a graded
 * correct/wrong row 2px taller than an untoned one — the card must be one
 * height across answering-empty, answering-picked, submitted and graded, and
 * this was the other place (besides the live-checked rule above) that broke
 * it. border stays the base 1px and an inset box-shadow draws the second px
 * inward — same visual ring, no layout cost. This shifts the padding box
 * (the containing block for .gp-quiz__row-chuds, position: absolute) 1px
 * outward on a correct row that also carries a Chuds pill, so its overhang
 * is now 8 - 1 = 7px, not the 6px the badge's own comment measured against
 * the old 2px border — read that comment's math as superseded here, not the
 * badge rule itself, which was not touched. */
.gp-quiz__choice[data-tone="correct"] {
  background: color-mix(in srgb, var(--gp-color-green) 14%, var(--gp-surface-glass));
  border-color: var(--gp-color-green-border);
  box-shadow: var(--gp-shadow-card), inset 0 0 0 1px var(--gp-color-green-border);
  color: var(--gp-color-black);
}

.gp-quiz__choice[data-tone="wrong"] {
  background: color-mix(in srgb, var(--gp-color-red-brand) 14%, var(--gp-surface-glass));
  border-color: var(--gp-color-red-brand-border);
  box-shadow: var(--gp-shadow-card), inset 0 0 0 1px var(--gp-color-red-brand-border);
  color: var(--gp-color-black);
}

/* Round 5: the ring came off. Before this, the submitted-but-not-revealed
 * row was indistinguishable from mid-choosing — same blue ring, same filled
 * blue disc, the Submit button's disappearance the only (subtractive) cue
 * that anything had changed. Corey's call from a preview: drop the ring,
 * keep the disc — a ring reads as "still tappable", the disc as "this was
 * mine". So this row falls back to the plain glass hairline .gp-glass
 * already sets (border-width: 1px, --gp-border-glass), and the blue lives
 * only on .gp-quiz__mark-check's own fill (_screen.html.erb renders it
 * directly, not toggled by CSS here). It must stay --gp-color-blue, not
 * grayed: --gp-color-grey-600 already means "the correct answer you missed"
 * on data-tone="missed", and blue appears nowhere else in grading. */
.gp-quiz__choice[data-tone="mine"] {
  background: var(--gp-color-glass-fill-strong);
  border-width: 1px;
  border-color: var(--gp-border-glass);
}

/* Muted: dims a row that was not the attendee's own pick, once there is a
 * pick to compare it to.
 *
 * data-tone="missed": the right answer, on a question this phone got wrong.
 * Gray, not green — green is reserved for something the attendee got right,
 * so it never appears on a question they missed (Corey, from a preview,
 * choosing gray over the first green draft). */
.gp-quiz__choice[data-tone="missed"] {
  opacity: 0.55;
}

/* Single choice only: unchanged from before this ruling. A radio question
 * cannot take a second pick once one is checked, so dimming every OTHER row
 * the moment anything is checked is honest here — and radios are never
 * disabled while live (lockUnchecked always returns false for a
 * single-choice question's null max, quiz_choices.js#lockUnchecked), so
 * :disabled is not available as a signal for this branch the way it is for
 * multi-select below. Scoped to input[type="radio"] specifically so it
 * cannot also fire for a checkbox multi-select form still under its max —
 * see the next rule for why that distinction is the whole point of Corey's
 * ruling 2026-09-21 ("if a multi select and you are able to select more
 * options, they should not dim").
 *
 * :not([data-tone]) is the same specificity guard as the selected-fill rule
 * above, for the identical reason: a revealed row's own pick is still
 * `checked`, so without the guard this would win against data-tone="missed"
 * itself and mute the one row that must stay legible. */
.gp-quiz__choices:not(.gp-quiz__choices--locked):has(input[type="radio"]:checked) .gp-quiz__choice:not([data-tone]):not(:has(input:checked)) {
  opacity: 0.55;
}

/* Multi-select: Corey's ruling 2026-09-21. Before this, EVERY unchecked row
 * dimmed the instant anything was checked — wrong on a multi-select with
 * room left, since those rows are still tappable and must read as
 * available. The dim is now the LOCKED signal instead: an unchecked row
 * dims only once IT is the one that can no longer be picked.
 *
 * quiz_choices_controller.js disables an unchecked input once checked
 * reaches max_selections (lockUnchecked) — so :has(.gp-quiz__choice-input
 * :disabled) on the row itself is that signal, structural rather than a
 * fourth specificity race: below max the input stays enabled and this rule
 * cannot match it, whatever wins the cascade. Do not "simplify" this back to
 * a bare :has(input:checked) on the container — that is the exact rule this
 * replaces, and it is what a mutation test on this file must fail against.
 *
 * The :has(input:checked) container gate still earns its place: without it,
 * this would also dim every row on a closed-but-never-answered screen
 * (every choice renders disabled there too, but none are checked) — a
 * screen this ruling was never asked to touch. With it, this also happens
 * to keep dimming the correct thing on a REVEALED question's untoned rows
 * (data-tone is blank there too, but every input, including that row's own,
 * is disabled server-side) — the same case the old rule's comment
 * described, preserved without being asked to touch it either. */
.gp-quiz__choices:not(.gp-quiz__choices--locked):has(input:checked) .gp-quiz__choice:not([data-tone]):has(.gp-quiz__choice-input:disabled) {
  opacity: 0.55;
}

/* Round 5: a second, deeper mute for the rows the attendee did NOT pick once
 * their answer is locked in and waiting on the reveal — distinct from the
 * 0.55 the still-choosing state and data-tone="missed" share above (do not
 * touch that value; data-tone="missed" is on the approved graded screen).
 * 0.4, checked live against the choosing state and the glass fill behind
 * it: dim enough that the picked row (data-tone="mine", full opacity) reads
 * as clearly locked in, not dim enough that the unselected choices stop
 * being legible text — a person waiting on the reveal may still want to
 * reread what they didn't pick. Gated on .gp-quiz__choices--locked (set by
 * the view only when pending, see _screen.html.erb) rather than
 * :has(input:checked), which is also true on the graded screens — this
 * selector must never reach a row that carries a data-tone. */
.gp-quiz__choices--locked .gp-quiz__choice:not([data-tone]) {
  /* NOT a lower opacity. 0.4 was the first attempt and it never rendered:
   * the rule above is (0,5,2) — it carries :has(input:checked) twice, and
   * each :has() takes its argument's specificity — against this rule's
   * (0,3,0), so it won outright whatever the source order. Hence the
   * :not(.gp-quiz__choices--locked) up there: structural exclusion, the
   * same fix 970c1d1 used, because a specificity race this file keeps
   * losing is not worth entering a fourth time.
   *
   * And when it was forced on at runtime to see it, 0.4 measured 2.96:1 —
   * under AA, under even the 3:1 large-text floor. Dimming the whole row
   * dims the words with it, and these are words somebody waiting on the
   * reveal may want to reread.
   *
   * So the deepening is in the CHROME, not the text. The card dissolves —
   * fill, hairline and shadow all go — and what is left is the answer as
   * plain text at the same legibility it had while choosing. Two steps:
   * a dimmed card while the question is live, no card at all once it is
   * locked. */
  background: transparent;
  border-color: transparent;
  box-shadow: none;
}

/* .gp-glass paints its rim on ::after, not on the element, so clearing the
 * element's own box-shadow above left a hairline of the card still drawn —
 * visible along the bottom of each locked row in the first capture. It is
 * the same ::after that showed as the "gap" between ring and fill before
 * the graded rows went to a light tint. Scoped to these rows only: every
 * other glass surface in the app keeps its rim. */
.gp-quiz__choices--locked .gp-quiz__choice:not([data-tone])::after {
  opacity: 0;
}

/* The empty circle goes further than the words do — it is the affordance,
 * and the affordance is what is no longer true. */
.gp-quiz__choices--locked .gp-quiz__choice:not([data-tone]) .gp-quiz__mark {
  opacity: 0.45;
}

/* Chunk C (2026-09-22): the card is gone (above), but the words were left at
 * full strength — 17.1:1, brighter than before, because the card's own fill
 * had been cutting into the contrast the whole time. Corey wants these words
 * dimmed too, now that it is affordable.
 *
 * Scoped to .gp-quiz__choice-body, NOT the row: dimming the row dims
 * .gp-quiz__mark with it, which already has its own approved 0.45 two rules
 * up, set for a different reason (the affordance, not legibility) and NOT to
 * be touched here. Same specificity guards as every other locked rule on
 * this file — .gp-quiz__choices--locked, :not([data-tone]) — so this cannot
 * enter the specificity races the comments above describe; nothing else in
 * the file touches .gp-quiz__choice-body's opacity for it to race against.
 *
 * 0.55 is the plan's ESTIMATE (black over the panel's own fill, ~4.7:1) but
 * an estimate is not a measurement — see the handoff's "measure on the
 * composited render" trap, which the brand-green mark already failed once by
 * checking CSS instead of pixels. Measured here the same way: screenshot the
 * locked state, sample the text pixel and the panel pixel beside it, run the
 * WCAG formula on the two RGB triples actually rendered (390x844 @1x,
 * demo_quiz_takeover, hold_and_reveal, submitted-and-waiting). 0.55 rendered
 * text (113,114,114) beside panel (239,239,237) — 4.19:1, under the 4.5:1
 * floor. 0.6 renders (102,103,103) beside the same panel pixel — 4.93:1,
 * clears it. */
.gp-quiz__choices--locked .gp-quiz__choice:not([data-tone]) .gp-quiz__choice-body {
  opacity: 0.6;
}

/* The panel: the question itself, dark-on-glass like the checkpoint card
 * (.gp-checkpoint__prompt/__name — --gp-color-black on the card's own
 * glass). .gp-glass-v2--card supplies only position: relative, --glass-tint
 * and --glass-depth (plan R7) — radius and width are the panel's own. */
.gp-quiz__panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  padding: 1.5rem 1.25rem;
  border-radius: var(--gp-radius-card);
  text-align: center;
  font-family: var(--gp-font-family);
}

.gp-quiz__set {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  color: var(--gp-color-grey-700);
  font-size: var(--gp-font-size-small);
}

.gp-quiz__set-name {
  text-transform: uppercase;
}

.gp-quiz__eyebrow {
  margin: 0;
  color: var(--gp-color-grey-700);
  font-size: var(--gp-font-size-small);
  text-transform: uppercase;
}

.gp-quiz__question {
  margin: 0;
  color: var(--gp-color-black);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-h2);
}

.gp-quiz__subtitle,
.gp-quiz__instruction,
.gp-quiz__status {
  margin: 0;
  color: var(--gp-color-grey-700);
  font-size: var(--gp-font-size-small);
}

/* Chunk B (2026-09-22, "one card size across every state"): this paragraph
 * carries "Hold for the correct answer" while submitted and nothing at all
 * while answering or graded — an empty <p> generates no line box, so it
 * collapsed to 0 height and the panel grew 18px the moment that text showed
 * up. min-height: 1lh reserves exactly the one line the text would occupy,
 * on both branches of _screen.html.erb (the live status target and the
 * static "Hold for..." paragraph share this class). Confirmed live at
 * 390x844: an empty status paragraph measures the same height as one holding
 * the real copy. */
.gp-quiz__status {
  min-height: 1lh;
}

/* Corey, 2026-09-22: "let's have the typeface of the scoring ... be smaller"
 * — this line only, not the subtitle/instruction/status it used to share
 * font-size-small with. font/size-caption (10px) already exists and is
 * already reused across unrelated components (the checkpoint status card's
 * "Next Level" caption, the Buy More Chuds button label) rather than tied to
 * one thing, so this reuses it instead of adding a token for the same size.
 * Below 12px: grey-700 on white measures 5.25:1, comfortably over the 4.5:1
 * AA floor for text at any size — but that is the reduced-motion/opaque
 * fallback tint (glass-tint-solid), not a live measurement of the blurred
 * sky behind the panel's default translucent tint, which this repo has no
 * tooling in this pass to capture. Same colour this row already used at
 * font-size-small; only the size and threshold class change. */
.gp-quiz__scoring-line {
  margin: 0;
  color: var(--gp-color-grey-700);
  font-size: var(--gp-font-size-caption);
}

.gp-quiz__scoring {
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
}

/* The wrong-count refusal (QuizController#answer's invalid_selection_count).
 * --gp-text-error, not --gp-color-red-brand: brand-red is the graded "wrong"
 * mark's fill, sized for a glyph disc, not letterforms -- gp-text-error
 * (var(--gp-color-red-dark), #842029) is this app's existing ink for error
 * TEXT (.gp-field__control.is-invalid's message, #login's .gp-field__msg),
 * chosen there over the raw --gp-color-red (#dc3545, 4.49:1) for a cleaner
 * margin. Against the panel's opaque-white fallback (glass-tint-solid; see
 * .gp-quiz__scoring-line above for why that is the only render this pass can
 * measure) #842029 is 9.46:1, comfortably over the 4.5:1 AA floor.
 * Structural, not a specificity climb: exists only in the answerable branch,
 * so it only ever adds height in the one state a wrong count can happen in --
 * one line at font-size-small (12px) plus the panel's own 0.5rem flex gap,
 * ~28px total. */
.gp-quiz__error {
  margin: 0;
  color: var(--gp-text-error);
  font-size: var(--gp-font-size-small);
}

.gp-quiz__submit {
  margin-top: 0.5rem;
}

/* button_to wraps its button in its own <form> — a block box .gp-quiz__panel
 * (align-items: center) sizes to ITS content, not to 100% of the panel, so
 * gp-btn--block's width: 100% on the button alone resolved against the
 * form's own shrink-wrapped width, not the panel's. Same fix as
 * .gp-quiz__choices above: give the wrapper its own width: 100%, matching
 * what the answering card's <input type="submit"> gets for free by being a
 * direct child of the panel instead of a grandchild through a form. */
.gp-quiz__submit-form {
  width: 100%;
}

/* Skip (docs/plans/2026-09-22-quiz-skip.md, chunk 2): the ghost shell —
 * no fill, no border, no shadow — is .gp-btn--ghost's job unchanged; this
 * only overrides ITS text colour, because a Skip button is not the avatar
 * step's Random button that .gp-btn--ghost was written for. "Grey text, no
 * fill" per the design doc, never --gp-text-body (black, .gp-btn--ghost's
 * own default) and never a brand value — this is a secondary action's label,
 * not a graded glyph.
 *
 * A prior version of this rule won the colour with a doubled-specificity
 * selector list (.gp-quiz__skip.gp-btn--ghost, repeated for every one of
 * .gp-btn--ghost's own states) — a losing-rule-gets-higher-specificity fix,
 * which Corey has ruled out for this file: it is the same shape of bug as
 * the three specificity races already lost here, just won instead of lost.
 * The structural fix is a custom property: .gp-btn--ghost's own base AND
 * state rules above read `color: var(--btn-ghost-color, var(--gp-text-body))`,
 * so any element that sets --btn-ghost-color controls every one of those
 * states through inheritance, not through outranking them. No selector here
 * to keep in sync with .gp-btn--ghost's state list ever again.
 *
 * --gp-color-grey-700, not grey-600: measured against THIS panel's own
 * already-established composited reference pixel (239,239,237 — the
 * opaque-fallback tint this file's other grey-on-glass measurements use, see
 * .gp-quiz__choices--locked and .gp-quiz__scoring-line above, because a live
 * blurred-sky composite has no tooling in this pass to capture). grey-700
 * (108,108,108) measures 4.56:1 there — clears the 4.5:1 AA floor for body
 * text. grey-600 (110,110,110) measures 4.43:1 and FAILS it — close enough
 * that picking the wrong one of the two greys already in this file would
 * have shipped a silent AA regression. */
.gp-quiz__skip {
  --btn-ghost-color: var(--gp-color-grey-700);
}

/* Chunk B's one-size rule: the reserved slot for a non-answering state.
 * `visibility: hidden`, not `display: none` — the box has to keep taking up
 * space, that is the entire point — and no dimension of its own: it shares
 * every class the real button above carries (.gp-quiz__skip .gp-btn
 * .gp-btn--ghost .gp-btn--block), so it gets the identical min-height,
 * padding and border from those rules with no height value duplicated here
 * to drift out of sync with them. */
.gp-quiz__skip--placeholder {
  visibility: hidden;
}

.gp-quiz__summary {
  width: 100%;
  margin-top: 0.5rem;
  padding: 0.75rem;
  border: 1px solid var(--gp-color-black);
  border-radius: var(--gp-radius-card);
}

.gp-quiz__summary-score {
  margin: 0 0 0.25rem;
  color: var(--gp-color-black);
  font-weight: var(--gp-font-weight-bold);
  font-size: var(--gp-font-size-lead);
}

/* The payout, and the only thing left under the answers now that the row's
 * own ring and mark carry the verdict (Corey, 2026-09-21).
 *
 * The ink green, not the brand green and not Bootstrap's. White on
 * --gp-color-green (#1aaf13) measures 2.99:1 and fails for text at this
 * size; --gp-color-status-accent-ink-green is the brand green's own
 * darkening, already used for accent text on the homepage and profile
 * cards, and measures 9.3:1. This is the rule the design system states as
 * "accent TEXT on glass uses the darker inks, not the brand values"
 * (docs/attendee-design-system.md, the accent section) — the brand value
 * belongs on the grading ring and the mark, where it is a shape and not
 * a letterform. */
.gp-quiz__chuds {
  display: inline-block;
  margin-top: 0.5rem;
  padding: 0.125rem 0.625rem;
  border-radius: var(--gp-radius-pill);
  background: var(--gp-color-status-accent-ink-green);
  color: var(--gp-color-white);
  font-size: var(--gp-font-size-lead);
}

.gp-quiz__finished {
  margin: 0.5rem 0 0;
  color: var(--gp-color-black);
  font-weight: var(--gp-font-weight-bold);
}

.gp-quiz__finished-waiting {
  color: var(--gp-color-grey-700);
  font-weight: var(--gp-font-weight-regular);
}

/* A sibling of .takeover__body (quiz/_frame.html.erb), not a child: the body
 * scrolls with the card, so a counter inside it would drift as cards change
 * height between questions. flex:none holds this at a fixed spot near the
 * bottom of the takeover instead, the same as .takeover__wordmark at the
 * top. */
.gp-quiz-progress {
  flex: none;
  align-self: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  margin-bottom: calc(16px + env(safe-area-inset-bottom));
  /* Dark, not white: the sky is pale cloud at the bottom, where white text
   * measured unreadable (live check, 2026-09-22). */
  color: var(--gp-color-grey-800);
}

.gp-quiz-progress__dots {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.gp-quiz-progress__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  border: 1.5px solid currentColor;
}

.gp-quiz-progress__dot--done {
  background: currentColor;
}

.gp-quiz-progress__count {
  margin: 0;
  font-size: var(--gp-font-size-small);
}

/* Without this, .takeover__body's own 24px-plus-inset bottom padding stacks
 * on top of the dots' margin-bottom, doubling the gutter under the card. */
.takeover__body:has(+ .gp-quiz-progress) {
  padding-bottom: 12px;
}

/* The joke captcha over a held checkpoint send (performers/_captcha_overlay,
   captcha_controller.js). reCAPTCHA's look on purpose — the white card and the
   blue block ARE the joke — so its three colours are captcha tokens rather than
   the brand's, and its dimensions are reCAPTCHA's rather than the design
   system's. A sibling of the takeover, ranked by --gp-zIndex-captcha. */
.gp-captcha {
  position: fixed;
  inset: 0;
  z-index: var(--gp-zIndex-captcha);
  display: grid;
  place-items: center;
  padding: 16px;
  background: rgba(0, 0, 0, 0.5);
}

.gp-captcha[hidden] {
  display: none;
}

.gp-captcha__card {
  width: min(100%, 400px);
  background: #fff;
  border: 1px solid #d3d3d3;
  box-shadow: 0 0 4px 1px rgba(0, 0, 0, 0.08);
  color: #000;
  font-family: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
}

.gp-captcha__instructions {
  margin: 8px;
  padding: 24px 16px;
  background: var(--gp-color-captcha-blue);
  color: #fff;
}

.gp-captcha__eyebrow,
.gp-captcha__subtitle {
  margin: 0;
  font-size: 14px;
  line-height: 1.3;
}

.gp-captcha__title {
  margin: 2px 0;
  font-size: 24px;
  font-weight: 700;
  line-height: 1.2;
}

.gp-captcha__body {
  position: relative;
  padding: 0 8px;
}

.gp-captcha__grid {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
}

.gp-captcha__grid[data-kind="vision"] {
  grid-template-columns: repeat(4, 1fr);
  gap: 0;
}

.gp-captcha__vision-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.gp-captcha__cell {
  position: relative;
  aspect-ratio: 1;
  padding: 0;
  border: 0;
  background: transparent;
  overflow: hidden;
}

/* The 4x4 lines drawn over the one image. */
.gp-captcha__grid[data-kind="vision"] .gp-captcha__cell {
  outline: 1px solid #fff;
}

.gp-captcha__cell img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.1s ease-out;
}

/* Selected, as reCAPTCHA draws it: the square insets and a blue check sits in
   its corner. */
.gp-captcha__cell[aria-pressed="true"] img {
  transform: scale(0.8);
}

.gp-captcha__grid[data-kind="vision"] .gp-captcha__cell[aria-pressed="true"] {
  box-shadow: inset 0 0 0 8px #fff;
}

.gp-captcha__cell[aria-pressed="true"]::after {
  content: "✓";
  position: absolute;
  z-index: 2;
  top: 2px;
  left: 2px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--gp-color-captcha-blue);
  color: #fff;
  font-size: 16px;
  line-height: 24px;
  text-align: center;
}

/* The reveal. A layer of its own, not the cell's box-shadow: an inset shadow
   paints under the cell's content, so on id_grid the tile image hid it, and on
   vision the selected square's own inset white shadow (a more specific rule)
   replaced it. Over the image and over the selected look, under the check. */
.gp-captcha__cell[data-correct]::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  box-shadow: inset 0 0 0 4px var(--gp-color-captcha-pass);
  pointer-events: none;
}

.gp-captcha__text-image {
  display: block;
  width: 100%;
}

/* 16px, or iOS zooms the page when the field takes focus. */
.gp-captcha__input {
  width: 100%;
  margin-top: 8px;
  padding: 8px;
  border: 1px solid #c1c1c1;
  font-size: 16px;
}

/* line-height equal to the reserved min-height, so a message appearing does
   not move the card. */
.gp-captcha__message {
  min-height: 1.4em;
  margin: 8px 0 0;
  line-height: 1.4;
  color: var(--gp-color-captcha-fail);
  font-size: 14px;
}

.gp-captcha__footer {
  display: flex;
  justify-content: flex-end;
  margin-top: 8px;
  padding: 8px;
  border-top: 1px solid #dfdfdf;
}

.gp-captcha__submit {
  padding: 12px 24px;
  border: 0;
  border-radius: 2px;
  background: var(--gp-color-captcha-blue);
  color: #fff;
  font-size: 14px;
  font-weight: 500;
  text-transform: uppercase;
}

.gp-captcha__submit:disabled {
  opacity: 0.6;
}

.gp-captcha__card[data-result="pass"] .gp-captcha__body::after {
  content: "✓";
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgba(255, 255, 255, 0.85);
  color: var(--gp-color-captcha-pass);
  font-size: 96px;
  font-weight: 700;
}

.gp-captcha__card:is([data-result="fail"], [data-result="retry"]) {
  border-color: var(--gp-color-captcha-fail);
  animation: gp-captcha-shake 0.4s ease-in-out;
}

@keyframes gp-captcha-shake {
  0%, 100% { transform: translateX(0); }
  20%, 60% { transform: translateX(-8px); }
  40%, 80% { transform: translateX(8px); }
}

@media (prefers-reduced-motion: reduce) {
  .gp-captcha__card {
    animation: none !important;
  }
}
