/* 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 :951 target <input>, and the three
 * .btn-primary::after copies at :1407, :1522 and :1650 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:1003-1007 and :1337-1342. 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:1389, [id$="-edit"]:1504, #referrals-new:1632).
 *
 * 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(--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(--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,670 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:1383 never wins: text_field_tag emits id="code", so
 *     `#vouchers #code` at :1374 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:1667 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:1374) sets a
 * height on .form-control. #login (:725), [id$="-edit"] (:1483) and #referrals-new
 * (:1611) 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;
}

/* 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;
}

/* Glass v2 with the CHEAP frost, not the true one: this surface MOVES, and a
 * second backdrop pass on a springing element is the expensive one. The
 * opaque fill under the layers is not decoration either — without it the
 * toast is see-through on its first frame, before the blur has composited
 * (the round-15 flash, same cause). */
.gp-checkpoint-toast {
  display: flex;
  gap: 10px;
  align-items: flex-start;
  padding: 10px 12px 10px 14px;
  border-radius: var(--gp-radius-card);
  /* rgba(255, 255, 255, 0.8) as a FILL, the same primitive .gp-status reuses
   * for the opaque card; the tint above it is white-70, the prototype's, not
   * the status card's cooler glass-tint-status — a toast sits over whatever
   * is on screen, not over the sky in particular. */
  background: var(--gp-color-glass-border-bright);
  font-family: var(--gp-font-family);
  font-size: var(--gp-font-size-body);
  color: var(--gp-color-status-ink);
  opacity: 0;
  pointer-events: auto;
  /* The layer exists before it is asked to move, so the first frame of the
   * spring is not also the frame that creates it. */
  will-change: transform, opacity;
  --glass-tint: var(--gp-color-white-70);
  --glass-cheap: var(--gp-shadow-glass-frost-cheap-control);
}

.gp-checkpoint-toast--persistent {
  padding-bottom: 12px;
}

.gp-checkpoint-toast__coin {
  flex: 0 0 26px;
  width: 26px;
  height: 26px;
  margin-top: 1px;
  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 mascot in a toast's icon slot (round 19, issue #211): the same 26px
   square the coin occupies, drawn as an image rather than a disc, so a toast
   with either one lays out the same way. object-fit, because ChuddyPoint.png
   is square but nothing here should depend on that. */
.gp-checkpoint-toast__mascot {
  flex: 0 0 26px;
  width: 26px;
  height: 26px;
  margin-top: 1px;
  object-fit: contain;
}

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

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

.gp-checkpoint-toast__body {
  display: block;
  margin-top: 3px;
  font-size: var(--gp-font-size-small);
  line-height: 1.35;
  color: var(--gp-color-toast-body);
}

/* 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. */
.gp-checkpoint-toast__close {
  flex: 0 0 44px;
  width: 44px;
  height: 44px;
  margin: -8px -6px -8px 0;
  border: 0;
  background: none;
  display: grid;
  place-items: center;
  color: var(--gp-color-status-label);
}

/* 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. */
.gp-checkpoint-toast__close svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

/* ==== 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 layouts/application for any page
 * ApplicationHelper#flash_as_toast? names -- /login only today, issue #215 for
 * the rest.
 *
 * 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 carries 1rem of its own,
 * which would add to it. */
.gp-toast-stack .gp-toast {
  pointer-events: auto;
  width: 100%;
  max-width: var(--gp-dimension-form-width);
  margin-bottom: 0;
}

/* The toast sits over the sky, where Bootstrap's own .btn-close:focus shadow is
 * easy to lose. Keyboard dismissal is the close button and nothing else, so its
 * focus ring has to be unmissable. */
.gp-toast-stack .gp-toast .btn-close:focus-visible {
  outline: 2px solid var(--gp-border-focus);
  outline-offset: 2px;
}

/* 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");
  }
}
