/* 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 :1096 target <input>, and the three
 * .btn-primary::after copies at :1552, :1667 and :1795 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:1148-1152 and :1482-1487. 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);
}

/* ---------------------------------------------------------------------------
 * Primary action — the green pill. 3 byte-identical copies in the baseline
 * (#vouchers:1534, [id$="-edit"]:1649, #referrals-new:1777).
 *
 * 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%. */
.gp-btn:hover,
.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);
}

/* No hover brightness. An earlier draft lit the button 6% on hover, which is a real
 * improvement over a button that today gives no hover feedback at all — the fill is
 * pinned by !important and Bootstrap's hover cannot move it. But /vouchers migrates
 * BEFORE the run, and a visible hover change on a shipped page is a redesign however
 * small, which is the one thing this whole pass exists to avoid. It also fails a
 * strict pixel diff in the harness. After 4 October, not now.
 *
 * The focus ring below is the deliberate exception: an accessibility fix is not
 * polish, and removing focus outlines is a defect rather than a look. */

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

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

/* ---------------------------------------------------------------------------
 * Form field — the same recipe on #login:725, #vouchers:1519, [id$="-edit"]:1628
 * and #referrals-new:1756. 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. No .form-control anywhere in the
 *     1,815 baseline lines is glass; the extraction invented it. On the #fefefe
 *     page background 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:1528 never wins: text_field_tag emits id="code", so
 *     `#vouchers #code` at :1519 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:1812 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:1519) sets a
 * height on .form-control. #login (:725), [id$="-edit"] (:1628) and #referrals-new
 * (:1756) 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);
  }
}
