/*
  onboarding-gateway-flow.css — the Gateway's acts 2-5: the wall, the pass, the
  coronation and note, the fork, and the reveal (gateway-picker.js +
  gateway-flow.js). The shell, the star road and the act choreography live in
  onboarding-gateway.css.

  Conventions (invariants #27/#30/#33):
    - var(--...) tokens only, never a hardcoded brand hex. The one exception is
      the crown gold, which reuses --gw-gold from onboarding-gateway.css and is
      marked at its use.
    - .btn / .btn-gradient / .btn-ghost / .input / .spinner / .glass* come from
      components.css. Nothing here re-declares them, and nothing here declares a
      `border` shorthand on a .spinner (that would reset the shared class's
      border-top-color and kill the arc — #33).
    - Stacked declarations, one property per line.
    - Works at 375px. Every interactive target is >= 44px.

  ⚠ #40: any element carrying backdrop-filter is the containing block for its
  position:fixed DESCENDANTS, and the glass below (the field panels, the quote
  card) all carry one. Everything that must be truly viewport-fixed — the
  between-act poster flights, the within-act fly-to-shelf clone, the confetti
  canvas, and Modal.confirm's overlay — therefore lives in a layer that is a
  direct child of <body>. Nothing below may rely on position:fixed inside an
  act.

  ⚠ There is no card. The acts span a fixed full-viewport stage and cap their
  own content (onboarding-gateway.css); the room behind them is fixed cover art
  at three depths (shared/macros/gateway-backdrop.ftlh). Glass here is small
  chrome, never a container around the flow.

  ⚠ The glass in this file is now the ONLY backdrop-filter left on the page:
  the room and Act 1 carry none, deliberately (see onboarding-gateway.css). It
  also buys less than it used to — the art behind it is kept dim on purpose, so
  a plain translucent fill would read the same for none of the cost or the
  containing-block hazard. Retiring it belongs with the acts 2-5 visual pass.
*/

/* ==================== Act scaffolding ==================== */
/* The act element owns the entrance animation (onboarding-gateway.css); these
   are the content rhythms inside it. */
.gw-step__title {
    margin: 0 0 6px;
    font-size: clamp(1.125rem, 3vw, 1.5rem);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-snug);
    color: var(--text);
}

.gw-step__caption {
    margin: 0 0 20px;
    font-size: 0.875rem;
    line-height: var(--leading-normal);
    color: var(--text-muted);
}

/* The uppercase eyebrow treatment, reused inside the later acts. */
.gw-eyebrow {
    display: block;
    margin: 0 0 8px;
    font-size: 0.6875rem;
    font-weight: var(--weight-semibold);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--text-dim);
}

/* Every control group gets a title + a one-line caption (house rule). */
.gw-field {
    margin-bottom: 22px;
}

/*
  No .gw-field carries .glass-sm any more: Act 3's rate rows are bare typography
  on the title's own atmosphere, and Act 4's note is now the bespoke
  .gw-note-composer (a translucent panel with no backdrop-filter). The only
  surviving glass in this file is .gw-quote (Act 5), which has its own rule.
*/
.gw-field__title {
    display: block;
    margin: 0 0 4px;
    font-size: 0.9375rem;
    font-weight: var(--weight-semibold);
    color: var(--text);
}

.gw-field__caption {
    margin: 0 0 12px;
    font-size: 0.8125rem;
    line-height: 1.45;
    color: var(--text-muted);
}

/* ==================== Posters ==================== */
.gw-poster {
    position: relative;
    display: block;
    width: 100%;
    aspect-ratio: 2 / 3;
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--surface-3);
    border: 1px solid var(--border-subtle);
}

.gw-poster__img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.gw-poster__blank {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--text-dim);
    font-size: 1.25rem;
}

/* One line always, ellipsized. The wall grid rows are auto-height and nothing
   reserved a second line, so collapsing two lines to one only removes height
   for long titles and never reflows the grid open. */
.gw-poster-title {
    display: block;
    margin-top: 8px;
    font-size: 0.8125rem;
    font-weight: var(--weight-medium);
    line-height: 1.35;
    color: var(--text);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.gw-poster-meta {
    display: block;
    margin-top: 2px;
    font-size: 0.75rem;
    color: var(--text-dim);
}

/* A poster whose destination is being reserved while the real element is in
   flight. It holds the rect open so nothing reflows under the flyer. */
.gw-poster.is-awaiting-flight {
    visibility: hidden;
}

/* ==================== The shelf ==================== */
/*
  The endowment made visible. Slots are poster-only and identically sized, so
  the fly animation's target rect is exact for filled and empty alike.

  ⚠ The shelf is COUNT-AGNOSTIC and that is why it is `grid-auto-flow: column`
  rather than a template. It renders exactly `maxSlots` slots, and maxSlots is 3
  until the momentum fork grows it to 5 (gateway-picker.js). The old
  `repeat(5, 1fr)` template is what left the shelf visibly off-centre: three
  slots landed in columns 1-3 of a five-column track and the row's whole
  remainder pooled on the right, so the shelf sat left of everything above it.
  Implicit columns give exactly one track per slot at any count, so the shelf is
  centred at 3 and still centred at 5.

  It is also CAPPED. Uncapped, a 1fr slot across the 1080px act was a
  208px x 312px poster — the shelf was as big as the wall it feeds, and the
  three slots read as the subject rather than the destination. Capped, the slots
  stay fractional, so five slots reflow to share the same box three slots had
  instead of overflowing it. The fly-to-slot measures its target rect live, so
  the reflow costs it nothing.
*/
.gw-shelf {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    gap: clamp(6px, 1.6vw, 10px);
    width: min(560px, 100%);
    margin: 0 auto 24px;
}

.gw-slot {
    position: relative;
    aspect-ratio: 2 / 3;
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--glass-bg);
    border: 1px dashed var(--border);
    transition: border-color var(--transition-normal), transform var(--dur-slow) var(--ease-spring);
}

.gw-slot.is-filled {
    border-style: solid;
    border-color: var(--border-brand);
}

.gw-slot__empty {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.875rem;
    font-weight: var(--weight-semibold);
    color: var(--text-dim);
}

.gw-slot .gw-poster__img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.gw-slot .gw-poster__blank {
    position: absolute;
    inset: 0;
}

/* The shelf's own rank stamp: the same colour-coded star the wall card showed
   when it was tapped, so the eye can follow one title from the wall to its slot.
   A smaller sibling of the wall seal (spec item 2) — same dark stamp + rings so
   the two read as one family and the star survives busy cover art. The star and
   numeral are the shared .gw-rank__star / .gw-rank__n; the colour ladder is
   keyed off data-rank alongside the wall seal's. */
.gw-slot__rank {
    position: absolute;
    left: 4px;
    bottom: 4px;
    z-index: 2;
    width: 22px;
    height: 22px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius-circle);
    /* intentional — a stamp over photography, same as the wall seal; no token
       describes a dark stamp-over-art backing. */
    background: rgba(9, 11, 16, 0.92);
    box-shadow: inset 0 0 0 1px rgba(var(--fg-rgb, 255, 255, 255), 0.12),
                0 0 0 1.5px rgba(var(--fg-rgb, 255, 255, 255), 0.18),
                0 3px 8px rgba(0, 0, 0, 0.55);
    --gw-rank-color: var(--gw-silver);
    font-size: 13px;
    line-height: var(--leading-none);
}

.gw-slot__remove {
    position: absolute;
    top: 4px;
    right: 4px;
    width: 28px;
    height: 28px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: none;
    border-radius: var(--radius-circle);
    background: rgba(0, 0, 0, 0.6);
    color: var(--text);
    font-size: 0.75rem;
    cursor: pointer;
    opacity: 0;
    transition: background 0.25s var(--ease-spring), color 0.25s var(--ease-spring), opacity 0.25s var(--ease-spring), transform 0.25s var(--ease-spring);
}

.gw-slot:hover .gw-slot__remove,
.gw-slot__remove:focus-visible {
    opacity: 1;
}

/* Site-wide animated close treatment: rotate 90deg + danger fill.
   intentional — the literal rgba is the sanctioned house recipe for this
   treatment (CLAUDE.md close-button coherence sweep); there is no token for it. */
.gw-slot__remove:hover,
.gw-slot__remove:focus-visible {
    transform: rotate(90deg);
    background: rgba(231, 76, 60, 0.9);
    color: #fff;
}

/* Touch has no hover, so the remove affordance must always be visible there. */
@media (hover: none) {
    .gw-slot__remove {
        opacity: 1;
        width: 32px;
        height: 32px;
    }
}

.gw-slot--landed {
    animation: gw-land 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes gw-land {
    0% {
        transform: scale(0.86);
    }
    60% {
        transform: scale(1.06);
    }
    100% {
        transform: scale(1);
    }
}

/* ==================== Flights ==================== */
/*
  Two kinds, both portaled to a <body>-level layer (#40):

  .gw-flyer  — the within-act fly-to-shelf. A CLONE of the tapped wall poster,
               because the wall card it came from must stay on the wall.
  .gw-flight — the between-act flight. The REAL poster element, undocked from
               the outgoing act and re-docked in the incoming one, so a title
               the visitor chose is physically the same object all the way from
               the wall to the crown. It never re-renders; it only moves.
*/
.gw-flyer,
.gw-flight {
    position: fixed;
    z-index: 9000;
    margin: 0;
    pointer-events: none;
    transform-origin: top left;
    will-change: transform, opacity;
}

.gw-flyer {
    transition: transform 0.55s var(--ease-spring),
                opacity 0.55s ease;
}

.gw-flight {
    transition: transform 0.62s var(--ease-spring),
                opacity 0.62s ease;
}

/* A shelf slot in flight is a poster travelling, not a shelf slot. Its dashed
   frame, its rank stamp and its remove button belong to the shelf it just left,
   and a little X sailing across the page would be absurd. */
.gw-slot.gw-flight {
    border-color: transparent;
}

.gw-flight .gw-slot__remove,
.gw-flight .gw-slot__rank {
    display: none;
}

/* ==================== The wall (Act 2) ==================== */
/*
  Centred, like every act (the global rule: this page is a welcome and it is a
  centred column throughout). The act used to inherit the default left-align and
  it read as a form pinned to the left of a 1080px stage: an eyebrow, a heading,
  a caption and a shelf all hugging the left edge of a very wide act.

  The wall's CARDS keep their own left-aligned titles — .gw-result sets
  `text-align: left` for itself, above. A centred two-line title over a poster
  ragged on both edges is worse than a flush one, and that rule already existed
  precisely so this inherited value cannot reach the cards.
*/
.gw-act[data-act="2"] .gw-act__inner {
    text-align: center;
}

.gw-search-group {
    margin-bottom: 14px;
}

.gw-search-input-wrap {
    position: relative;
}

.gw-search-icon {
    position: absolute;
    left: 14px;
    top: 50%;
    transform: translateY(-50%);
    color: var(--text-dim);
    font-size: 0.875rem;
    pointer-events: none;
}

.gw-search-input {
    width: 100%;
    min-height: 46px;
    padding-left: 38px;
    /* The act centres its text; a field does not. text-align inherits into an
       <input>, and centred typing next to a left-anchored search icon reads as
       a bug. Typed text starts where the icon says it will. */
    text-align: left;
}

/* The site never shows a native search clear button. */
.gw-search-input::-webkit-search-cancel-button {
    -webkit-appearance: none;
    appearance: none;
}

.gw-results-status {
    margin: 0 0 10px;
    min-height: 1.1em;
    font-size: 0.75rem;
    color: var(--text-dim);
}

/* minmax(0, 1fr), never a bare 1fr: 1fr resolves to minmax(auto, 1fr), and the
   `auto` floor is the track's min-content. The card's `.gw-poster-title` is
   `white-space: nowrap`, so its min-content is the FULL untruncated title — a
   bare 1fr would force every column at least as wide as the longest title
   ("Fullmetal Alchemist: Brotherhood") and blow the whole grid out. minmax(0)
   lets the track shrink so the title ellipsizes inside the cell. (The shelf grid
   at grid-auto-columns already does this for the same reason.) */
.gw-result-grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 12px;
}

@media (min-width: 560.02px) {
    .gw-result-grid {
        grid-template-columns: repeat(4, minmax(0, 1fr));
    }
}

@media (min-width: 760.02px) {
    .gw-result-grid {
        grid-template-columns: repeat(5, minmax(0, 1fr));
    }
}

.gw-result {
    position: relative;
    display: block;
    padding: 0;
    border: none;
    background: none;
    color: inherit;
    font-family: inherit;
    text-align: left;
    cursor: pointer;
    border-radius: var(--radius-md);
    transition: transform 0.25s var(--ease-spring),
                opacity 0.35s ease,
                filter 0.35s ease;
}

.gw-result:hover,
.gw-result:focus-visible {
    transform: translateY(-3px);
}

/*
  Tap-order rank. First tap is #1: the badge is the visitor's own ordering, not
  a checkbox, and the whole shelf re-numbers when one is removed. That ordering
  is real — it is the order the picks are stashed and synced in, so this is the
  most meaningful mark in the act and it is allowed to be the loudest.

  It reads as a STAMPED SEAL pressed onto the art, not a tag floating over it
  (owner directive: a rank is core state, so give it weight, do not restyle a
  pill). A circular medallion, engraved with a hairline INNER ring and lifted off
  the card with an OUTER separator ring plus a drop — the ring is what keeps the
  mark readable whatever colour the poster happens to be under it (a badge with
  no ring disappears into a bright cover and shouts on a dark one).

  TIERS are a real ladder, not one digit swapped for another (spec item 2).
  data-rank is set by gateway-picker.js and only on a chosen card, so every rule
  below is scoped by it and an unchosen badge matches none of them. The seal
  grows and gains identity toward #1: #3 is the small graphite stamp, #2 steps up
  in size and picks up a faint brand-blue rim, and #1 is a different MARK — the
  biggest seal, the full brand gradient, and the only one that glows. No gold
  token is invented (#27) — the gradient is the site's existing "this is the good
  one" language.
*/
.gw-result__rank {
    position: absolute;
    top: 8px;
    right: 8px;
    z-index: 2;
    width: 28px;
    height: 28px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* A seal, not a pill: a circle. Single-digit ranks (max 5 picks) fit. */
    border-radius: var(--radius-circle);
    /* Near-black rather than a surface token: it sits on cover art, not on the
       page, so it needs to be darker than anything the art is likely to be.
       intentional — a stamp over photography; no token describes it. */
    background: rgba(9, 11, 16, 0.92);
    /* Three jobs in one shorthand: an engraved inner hairline (inset), the outer
       separator ring, and the drop that gives the seal height off the card.
       intentional — see above; no token describes a stamp-over-art seal. */
    box-shadow: inset 0 0 0 1px rgba(var(--fg-rgb, 255, 255, 255), 0.12),
                0 0 0 1.5px rgba(var(--fg-rgb, 255, 255, 255), 0.20),
                0 5px 14px rgba(0, 0, 0, 0.62);
    /* The seal now holds a medal-coloured STAR (spec item 2), not a bare numeral.
       font-size drives the star glyph; the tier rules step it up toward #1. The
       medal colour (#1 gold, #2 silver, #3 bronze) is carried by --gw-rank-color,
       set per data-rank below; the fallback is silver. */
    --gw-rank-color: var(--gw-silver);
    font-size: 17px;
    line-height: var(--leading-none);
    opacity: 0;
    transform: scale(0.5);
    transition: box-shadow var(--dur-slow) var(--ease-spring), font-size var(--dur-slow) var(--ease-spring), height var(--dur-slow) var(--ease-spring), opacity var(--dur-slow) var(--ease-spring), transform var(--dur-slow) var(--ease-spring), width var(--dur-slow) var(--ease-spring);
}

.gw-result.is-chosen .gw-result__rank {
    opacity: 1;
    transform: scale(1);
}

/*
  #2. A step up from the base: a hair larger, so the size ladder keeps doing its
  work under the colour code. The seal ring stays the uniform family ring (rank
  is now carried by the star's warm colour, not a per-tier rim), so this rule
  only grows the seal and its star. Its colour is set with the ladder below.
*/
.gw-result.is-chosen .gw-result__rank[data-rank="2"] {
    width: 32px;
    height: 32px;
    font-size: 20px;
}

/*
  #1. The one the visitor reached for first — the champion seal: the gold medal,
  the biggest of the three, and the only one that glows. The seal backing stays
  the dark stamp so the gold star reads on it whatever the poster underneath.

  ⚠ #33: the box-shadow here REPLACES the base's rings and drop (box-shadow does
  not accumulate across rules), which is why the engrave is restated rather than
  added. No `background` shorthand here, so the base's dark backing is kept.
*/
.gw-result.is-chosen .gw-result__rank[data-rank="1"] {
    width: 40px;
    height: 40px;
    font-size: 25px;
    /* intentional — the engraved inner ring, the bright outer ring, the drop,
       and the bloom that says "first": a gold glow at 0 spread (--gw-gold-dim),
       light coming off the mark rather than an outline. */
    box-shadow: inset 0 0 0 1.5px rgba(var(--fg-rgb, 255, 255, 255), 0.4),
                0 0 0 2px rgba(var(--fg-rgb, 255, 255, 255), 0.28),
                0 6px 16px rgba(0, 0, 0, 0.68),
                0 0 24px 0 var(--gw-gold-dim);
}

/* The seal grows with the card it is stamped on. Transform only. */
.gw-result.is-chosen:hover .gw-result__rank[data-rank="1"],
.gw-result.is-chosen:focus-visible .gw-result__rank[data-rank="1"] {
    transform: scale(1.08);
}

/*
  ==================== The rank star (spec item 2) ====================
  Both the wall seal (.gw-result__rank) and the shelf pill (.gw-slot__rank) show
  the SAME mark so one title can be followed by eye from the wall into its slot:
  a colour-coded star with the rank numeral riding its centre. The star is the
  treatment; the numeral keeps the rank unambiguous where colour alone across
  five ranks would not be (spec item 2). The star fills the seal; the numeral
  sits over it on a dark seal backing, so both survive busy cover art.

  The colour ladder is the medal metaphor: #1 gold, #2 silver, #3 bronze (the
  --gw-gold / --gw-silver / --gw-bronze tokens in onboarding-gateway.css). This
  is the #27-sanctioned rep-tier gold/silver/bronze one-off, deliberately NOT the
  brand scale. Ranks 4-5 (only reachable when the momentum fork grows the shelf)
  step past the podium into a muted pewter/graphite so they read as "off the
  medals" rather than inventing a fourth and fifth medal.
*/
.gw-rank__star {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 1em;
    line-height: var(--leading-none);
    color: var(--gw-rank-color, var(--gw-silver));
}

.gw-rank__n {
    position: relative;
    z-index: 1;
    font-size: 0.5em;
    font-weight: var(--weight-extrabold);
    line-height: var(--leading-none);
    color: #fff;
    font-variant-numeric: tabular-nums;
    /* Legible over any star colour or busy cover behind it. intentional — a mark
       stamped over photography; no token describes it. */
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}

/* The medal ladder, shared by both badges so they read as one family.
   intentional — medal rank colours, #27-sanctioned one-off (see the tokens). */
.gw-result__rank[data-rank="1"],
.gw-slot__rank[data-rank="1"] {
    --gw-rank-color: var(--gw-gold);
}

.gw-result__rank[data-rank="2"],
.gw-slot__rank[data-rank="2"] {
    --gw-rank-color: var(--gw-silver);
}

.gw-result__rank[data-rank="3"],
.gw-slot__rank[data-rank="3"] {
    --gw-rank-color: var(--gw-bronze);
}

/* Off the podium. intentional — muted pewter/graphite, not a fourth/fifth medal. */
.gw-result__rank[data-rank="4"],
.gw-slot__rank[data-rank="4"] {
    --gw-rank-color: #97a0b0;
}

.gw-result__rank[data-rank="5"],
.gw-slot__rank[data-rank="5"] {
    --gw-rank-color: #737b88;
}

.gw-result.is-chosen .gw-poster {
    border-color: var(--border-brand);
}

.gw-result.is-chosen .gw-poster__img {
    opacity: 0.45;
}

/*
  Once the shelf is full the wall recedes: the chosen titles are the subject
  now, and everything else is context. Deliberately not pointer-events:none —
  tapping a dimmed card still explains itself with a toast, which is kinder
  than a dead card.
*/
.gw-result.is-dimmed {
    opacity: 0.32;
    filter: saturate(0.35);
}

.gw-result.is-dimmed:hover,
.gw-result.is-dimmed:focus-visible {
    opacity: 0.6;
    filter: saturate(0.7);
}

/* ==================== The pass (Act 3) ==================== */
/*
  The act the title fills.

  It used to be three stacked --surface-2 panels, each with a label and a
  caption beside a 76px thumbnail, which is a settings form with a poster on
  it. It is now one centred column standing inside the title being rated: the
  title's own art, blurred past recognition and feathered to nothing, IS the
  room, and the act's mood therefore changes for each of the three picks. That
  change of mood per title is the entire point; do not make this act neutral.

  Every act reads left-aligned by default (onboarding-gateway.css). This one is
  a subject, not a working surface, so it centres like Act 1 does.
*/
.gw-act[data-act="3"] .gw-act__inner {
    text-align: center;
}

/*
  The room's atmosphere for this act.

  ⚠ #40: the blur below makes this img a containing block for ITS OWN fixed
  descendants — it has none, and it wraps nothing. The poster room, the flight
  layer and the confetti canvas are all <body>-level siblings of
  .gateway-stage, entirely outside this subtree, so none of them can be
  captured by it. Nothing here may ever be made an ancestor of those.

  ⚠ This element is a child of .gw-acts, NOT of an act. .gw-act is
  `overflow-y: auto`, and an absolutely positioned child of a scroll container
  scrolls with its content — the atmosphere would slide off the top of a
  scrolled act and leave the bottom of the room bare. .gw-acts is the same
  full-stage box and does not scroll, so the same `inset: 0` is stable. It is
  the FIRST child there, so every act paints over it (both are positioned,
  neither carries a z-index, so DOM order decides) — above the room, below the
  act content, exactly as specified.
*/
.gw-room-banner {
    position: absolute;
    inset: 0;
    overflow: hidden;
    pointer-events: none;
    /* No rectangle. The art has to dissolve on every side or it reads as a
       banner pasted on the page instead of as the room it is standing in. */
    -webkit-mask-image: radial-gradient(ellipse 76% 68% at 50% 46%,
                                        rgba(0, 0, 0, 1) 0%,
                                        rgba(0, 0, 0, 0.7) 44%,
                                        rgba(0, 0, 0, 0) 78%);
    mask-image: radial-gradient(ellipse 76% 68% at 50% 46%,
                                rgba(0, 0, 0, 1) 0%,
                                rgba(0, 0, 0, 0.7) 44%,
                                rgba(0, 0, 0, 0) 78%);
}

/*
  The source is the pick's cover (the only poster on the normalized pick shape
  — see the JS). At this blur it is colour and mood rather than a picture, which
  is exactly what the act needs from it.

  BLUR SMALL, THEN SCALE UP — the two numbers below are a pair, and the reason
  is cost. filter: blur() rasterizes over the element's own pixels, so a 59px
  blur across a full-viewport layer is a genuinely expensive paint, and it would
  land on the same frame as the act's entrance on a page already compositing
  fourteen backdrop posters (#45/#46: do not add GPU load). Blurring at 40% size
  and scaling 2.95x costs ~16% of the pixels for the same 20 * 2.95 = 59px of
  apparent blur, and at this radius the resample is invisible because there is
  no detail left to lose. 40% * 2.95 = 118% — change one number and you must
  change the other.

  The 118% overhang is itself deliberate: blur() samples transparency past an
  element's edge, so an exactly-sized image blurs to a dark frame around itself.
  Overhanging the box pushes that artefact past the mask's transparent zone.

  Static: it never animates, so it is one paint and then a composited layer
  nothing touches again until the next title claims the room.
*/
.gw-room-banner__img {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 40%;
    height: 40%;
    transform: translate(-50%, -50%) scale(2.95);
    object-fit: cover;
    filter: blur(20px) saturate(1.5) brightness(0.6);
    opacity: 0;
    transition: opacity 0.7s var(--ease-standard);
}

/* Low enough that the act's copy never has to fight it. If it ever competes
   with the words, this number is what is wrong. */
.gw-room-banner__img.is-shown {
    opacity: 0.42;
}

/*
  The REAL-banner treatment (spec item 3). A banner is already a wide cinematic
  still, so it is NOT hidden behind the cover's heavy blur-and-scale — the owner
  wants to see it. It fills the layer sharp-ish, the parent's radial mask
  feathers its edges to nothing (shared with the cover path), and only dimness
  keeps the copy on top: no panel behind the words, so this number is legibility.

  ⚠ #33: `transform` and `filter` are full REPLACEMENTS of the base rule's
  values (neither is a shorthand that resets siblings, but both must restate
  every function they need — the base sets scale(2.95) and a 20px blur, and a
  partial override would inherit whichever it omitted). The 1.04 scale + 2px
  blur only soften the very edge so the feather has nothing hard to bite on; the
  art itself stays legible. width/height fill the box rather than the cover's
  40%-then-scale cost trick, which a sharp image does not need.
*/
.gw-room-banner__img.is-banner {
    width: 100%;
    height: 100%;
    transform: translate(-50%, -50%) scale(1.04);
    filter: saturate(1.08) brightness(0.5) blur(2px);
}

/* A sharp banner carries more detail than a blurred cover, so it is dimmed a
   touch further to keep the act's copy the clear winner. */
.gw-room-banner__img.is-banner.is-shown {
    opacity: 0.38;
}

.gw-rate__counter {
    margin: 0 0 14px;
    font-size: 0.75rem;
    font-weight: var(--weight-semibold);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-dim);
}

/* The poster is the subject of the act, not a bullet point beside a heading. */
.gw-rate__head {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 16px;
    margin-bottom: 30px;
}

.gw-poster--rate {
    width: clamp(136px, 34vw, 178px);
    box-shadow: var(--shadow-2xl);
}

.gw-rate__title {
    margin: 0;
    max-width: 22ch;
    font-size: clamp(1.375rem, 4.4vw, 1.875rem);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-tight);
    color: var(--text);
}

/* Act 3's controls float on the atmosphere: they keep their title and their
   one-line caption (house rule) as typography, and lose the box. */
.gw-act[data-act="3"] .gw-field {
    margin-bottom: 26px;
}

/* ⚠ Load-bearing, and NOT redundant with .gw-actions' own rule: at (0,3,0) this
   deliberately outranks the (min-width: 560.02px) `justify-content: flex-end`
   (0,1,0), which is what keeps Act 3's composition centred at desktop. Deleting
   it breaks the layout at desktop only. */
.gw-act[data-act="3"] .gw-actions {
    justify-content: center;
}

/* ==================== Pills ==================== */
.gw-pills {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 8px;
}

.gw-pill {
    min-height: 44px;
    padding: 0 16px;
    border-radius: var(--radius-md);
    border: 1px solid var(--border);
    background: var(--glass-bg);
    color: var(--text-secondary);
    font-family: inherit;
    font-size: 0.8125rem;
    font-weight: var(--weight-medium);
    cursor: pointer;
    transition: background var(--transition-base), border-color var(--transition-base), color var(--transition-base);
}

.gw-pill:hover,
.gw-pill:focus-visible {
    border-color: var(--border-brand);
    color: var(--text);
}

/* Active state changes TWO things: fill and text color. */
.gw-pill.is-on {
    background: var(--brand-gradient);
    border-color: transparent;
    color: #fff;
}

/* ==================== Progress input ==================== */
/* "0 ep of 13" reads as a sentence rather than as a form field in a box, so
   this deliberately does NOT wear components.css's .input: that class IS the
   box. Everything the control needs is declared here instead, which also means
   there is no shared class whose longhands a shorthand below could reset
   (#33). */
.gw-progress-input {
    display: inline-flex;
    align-items: baseline;
    justify-content: center;
    gap: 8px;
    /* The whole line is the touch target, not the 30px-tall underline (44px). */
    min-height: 44px;
}

.gw-number {
    width: 3.2em;
    padding: 4px 0 6px;
    border: 0;
    border-bottom: 2px solid var(--border);
    border-radius: 0;
    background: transparent;
    color: var(--text);
    font-family: inherit;
    font-size: 1.375rem;
    font-weight: var(--weight-semibold);
    text-align: center;
    font-variant-numeric: tabular-nums;
    -moz-appearance: textfield;
    appearance: textfield;
    transition: border-color var(--transition-base);
}

.gw-number:focus,
.gw-number:focus-visible {
    outline: none;
    border-bottom-color: var(--brand-blue);
}

.gw-number::placeholder {
    color: var(--text-dim);
}

/* Spinners on a number input are tiny, native, and light-themed. */
.gw-number::-webkit-outer-spin-button,
.gw-number::-webkit-inner-spin-button {
    -webkit-appearance: none;
    appearance: none;
    margin: 0;
}

.gw-progress-unit {
    font-size: 0.8125rem;
    color: var(--text-muted);
}

/*
  ==================== The scrub (spec §5e) ====================
  The one control for every known-length title, short or long (owner
  2026-07-17): drag for the neighbourhood, type for the exact number. Both,
  because at 1043 chapters neither alone is usable — nobody drags to 1043 and
  nobody types 7 — and it snaps to whole episodes, so a 13-episode season reads
  the same as a 1043-chapter run. Unknown-length titles get the bare number
  field above instead, since a slider with no end is meaningless.

  ⚠ #5f — NEUTRAL: everything here is brand-blue and none of it touches the
  score scale. The rating is the only place hue carries feeling on this screen.
*/
.gw-scrub {
    display: block;
    width: 100%;
    max-width: 340px;
    margin: 0 auto 14px;
    /* The whole band is the target, not the 6px rail inside it. */
    padding: 19px 0;
    cursor: pointer;
    touch-action: none;
    outline-offset: 4px;
}

.gw-scrub__track {
    position: relative;
    height: 6px;
    border-radius: var(--radius-pill);
    background-color: var(--surface-hover);
}

.gw-scrub__fill {
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    /* Neutral (#5f). The rating is the only place hue carries feeling here. */
    background-color: var(--brand-blue);
    border-radius: var(--radius-pill);
    /* Transform-driven, so a drag composites instead of relayouting the rail on
       every pointermove (#45). scaleX off a full-width bar pinned at its left
       edge is the whole trick. */
    width: 100%;
    transform-origin: left center;
    transform: scaleX(var(--gw-scrub, 0));
}

.gw-scrub__knob {
    position: absolute;
    top: 50%;
    left: calc(var(--gw-scrub, 0) * 100%);
    width: 16px;
    height: 16px;
    border-radius: var(--radius-circle);
    background-color: #fff;
    box-shadow: var(--shadow-sm);
    transform: translate(-50%, -50%);
    pointer-events: none;
}

.gw-scrub:focus-visible .gw-scrub__knob {
    box-shadow: 0 0 0 4px var(--border-brand);
}

/* ==================== Stars ==================== */
/*
  TEN stars, whole steps: one star is one point is exactly the integer the
  server stores (@Min(1) @Max(10)).

  It drew FIVE stars with half-steps for a while, which is why the act read as
  "out of 5" — that was the honest reading of what was on screen, since the
  only thing that said 10 was a number written on interaction and therefore
  absent until the visitor had already guessed the scale. Ten stars needs no
  caption to explain it. The wire value did not change with this: valueFromX
  has always returned ceil(ratio * 10) clamped 1-10.

  There is deliberately no second control: emotionalRating is derived
  server-side from this one.
*/
/*
  ==================== The score scale (spec §5b) ====================
  The site's FIRST colour-by-score scale — verified: the existing score badge
  (media-card-styling-macros.ftlh) is a flat rgba(0,0,0,.7), and nothing else on
  the site colours a score by its value. So it is built as a real scale the rest
  of the site could adopt, not as a Gateway private joke.

  The axis is COLD / DIM / DESATURATED at 1 → WARM / BRIGHT / SATURATED at 10,
  landing on --brand-blue at 7, --brand-purple at 10, and the full
  --brand-gradient on the numeral at 10.

  ⚠ It is deliberately NOT a traffic light. --danger red at the bottom would say
  ERROR, and a 2/10 is not an error, it is an opinion — it must read as QUIET,
  never as WRONG. That is why 1-6 walk a cold desaturated slate up toward the
  brand rather than cooling down out of a red.

  intentional — the site's first score scale; candidate for design-tokens.css
  once a second consumer exists. It is scoped to .gw-stars and stays OUT of the
  token file until then (#27 sanctions an intentional one-off exactly like the
  poll hue bands; promoting it now would be YAGNI). No new gold token.

  CSS cannot index a custom property by number, so the step is selected off
  data-score (set by the JS) rather than computed from --gw-score. --gw-score
  remains for the things that genuinely are arithmetic: the readout's scale and
  the glow's opacity.
*/
.gw-stars {
    /*
      The magnetic wave's three rings (§5c). They are CUSTOM PROPERTIES rather
      than literals in the transform rules, and that is load-bearing: those rules
      are `.gw-stars__track:has(.gw-stars__sense span:nth-child(N):hover) i:nth-child(N)`,
      whose specificity is (0,5,2) — :has() contributes its most specific
      argument. No sanely-written rule in the reduced-motion block could ever
      outrank that, so a "transform: none" override there would silently lose and
      the wave would run for the people who asked it not to. A var is resolved on
      the element instead, so zeroing these three below wins on source order at
      equal specificity and the whole wave flattens with three declarations.
    */
    --gw-lift-0: -8px;
    --gw-lift-1: -4.5px;
    --gw-lift-2: -1.8px;

    --gw-score-1: #59606b;
    --gw-score-2: #5e6777;
    --gw-score-3: #626e84;
    --gw-score-4: #657793;
    --gw-score-5: #6782a6;
    --gw-score-6: #6a8dbe;
    --gw-score-7: var(--brand-blue);
    --gw-score-8: #5c86ce;
    --gw-score-9: #7d6fc2;
    --gw-score-10: var(--brand-purple);

    display: inline-flex;
    align-items: center;
    /* Wraps on purpose: at 375px the ten stars plus a slot reserved for
       "Masterpiece" do not fit on one line, so the readout drops underneath them
       rather than squeezing either. */
    flex-wrap: wrap;
    justify-content: center;
    gap: 8px 14px;
    padding: 4px 2px;
    min-height: 44px;
    cursor: pointer;
    touch-action: none;
    outline-offset: 4px;
    user-select: none;
    -webkit-user-select: none;
}

/* The number and its word travel together — see the JS. */
.gw-stars__readout {
    display: inline-flex;
    align-items: baseline;
    /* 18px, not the widget's 14px: the numeral scales up to 1.16x from its left
       edge and does not take that growth out of the layout, so the gap has to
       absorb ~10px of a 10/10 leaning into the word. */
    gap: 18px;
}

/* The one live step. Everything score-coloured reads --gw-score-color and
   nothing reads a step directly, so the scale has exactly one seam. */
.gw-stars[data-score="1"] {
    --gw-score-color: var(--gw-score-1);
}

.gw-stars[data-score="2"] {
    --gw-score-color: var(--gw-score-2);
}

.gw-stars[data-score="3"] {
    --gw-score-color: var(--gw-score-3);
}

.gw-stars[data-score="4"] {
    --gw-score-color: var(--gw-score-4);
}

.gw-stars[data-score="5"] {
    --gw-score-color: var(--gw-score-5);
}

.gw-stars[data-score="6"] {
    --gw-score-color: var(--gw-score-6);
}

.gw-stars[data-score="7"] {
    --gw-score-color: var(--gw-score-7);
}

.gw-stars[data-score="8"] {
    --gw-score-color: var(--gw-score-8);
}

.gw-stars[data-score="9"] {
    --gw-score-color: var(--gw-score-9);
}

.gw-stars[data-score="10"] {
    --gw-score-color: var(--gw-score-10);
}

/* Deliberately NOT overflow: hidden. The peak shimmer needs a clip, but putting
   it here would clip the star GLYPHS too — an icon font's ink is not guaranteed
   to stay inside its line box, and losing the tips of the stars to buy a sweep
   is a bad trade. The sheen brings its own clip instead. */
.gw-stars__track {
    position: relative;
    display: inline-block;
    line-height: var(--leading-none);
    white-space: nowrap;
}

/*
  ⚠ The vertical padding on the two star rows is HEADROOM for the magnetic wave
  (§5c) and is load-bearing, not spacing. The lit row is clipped by
  clip-path: inset(0 X% 0 0), which clips to the fill's own border box — a star
  that translates up out of an unpadded row would be guillotined at the old line
  box while the grey star underneath it rose freely. Padding grows that box
  upward, so the lift stays inside it.

  It has to be the SAME on both rows or the arc walks off the base, and it must
  stay VERTICAL only: starClip's insets are percentages of the box WIDTH, and its
  proof that a whole star is always either fully lit or untouched is a statement
  about the horizontal geometry. Never put horizontal padding here.
*/
.gw-stars__base {
    display: flex;
    gap: 3px;
    padding: 12px 0;
    font-size: clamp(1.125rem, 4.2vw, 1.5rem);
    line-height: var(--leading-none);
    color: var(--surface-hover);
}

/*
  The rated arc, and it carries the score scale (§5b): the lit stars are ONE
  flat --gw-score-color at 1-9 and the full --brand-gradient only at 10.

  That split is the point. A 2/10 drawn in the brand gradient is two vivid blue
  stars sitting over a deliberately quiet slate "2/10", which argues with itself
  — the spec's test is that a 2 reads as QUIET and a 10 reads as a summit, and a
  flat cold pair of stars is what quiet looks like. Reserving the gradient for
  the 10 also makes it mean something: it is the one value that is both of our
  colours at once.

  It stays full-width and CLIPPED rather than width-animated, and that is
  load-bearing rather than a preference: background-clip: text paints the
  gradient across this element's own box, so a width-driven fill would squeeze
  the whole blue-to-purple ramp into however many stars are lit and every star
  would change colour as you swept. Clipping leaves the box (and so the
  gradient) spanning all ten. At 10 the clip is inset(0 0 0 0) anyway, so the
  ramp spans exactly the ten stars it was drawn for and can never be squeezed.

  The clip always lands INSIDE the gap after star v: the fill edge sits at
  v*w + 0.9*v*g while star v ends at v*w + (v-1)*g, a difference of
  g*(1 - 0.1*v), which is positive and under one gap for every v in 1..9 (and
  exactly 0 at v = 10). So a whole star is always either fully lit or untouched.
*/
/* `inset: 0` rather than top/left/width/height so the border box is the track's
   content box under either box-sizing, which is exactly the base row's border
   box. Same padding as the base, so both rows' glyphs land on the same line. */
.gw-stars__fill {
    position: absolute;
    inset: 0;
    display: flex;
    gap: 3px;
    padding: 12px 0;
    font-size: clamp(1.125rem, 4.2vw, 1.5rem);
    line-height: var(--leading-none);
    color: var(--gw-score-color, var(--brand-blue));
    pointer-events: none;
    clip-path: inset(0 100% 0 0);
    transition: clip-path 0.12s linear,
                color var(--dur-base) ease;
}

/*
  The summit, and the ONLY place the stars wear the gradient.

  ⚠ Spec item 4: the gradient is re-homed onto each STAR (the <i> glyphs)
  instead of the fill's whole box. On the box, background-clip: text painted ONE
  blue-to-purple ramp across all ten stars (star 1 bluish, star 10 purplish) — a
  single container-wide ramp. Per-star, each lit star's own box carries the full
  --brand-gradient clipped to its glyph, so every star sweeps blue-to-purple
  within itself. Same trigger (data-score="10", the summit), same reserved-for-10
  meaning (§5b), same clipped full-width fill (nothing here touches the arc's
  clip-path or its cold-warm 1-9 scale) — only WHERE the gradient paints changed.

  ⚠ #33: background-IMAGE, never the `background` shorthand. The shorthand would
  reset background-clip — which the two declarations under it set. The longhand
  cannot do that to its siblings.

  Specificity (0,3,1) over .gw-stars__fill i's (0,1,1), so `color: transparent`
  wins the flat scale colour the <i> inherits. Both live in this file, so no
  cross-file cascade.
*/
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
    .gw-stars[data-score="10"] .gw-stars__fill i {
        background-image: var(--brand-gradient);
        -webkit-background-clip: text;
        background-clip: text;
        color: transparent;
    }
}

/* The clipped fill must keep its stars at natural size, or they reflow against
   the base row and the arc "walks". The transition belongs to the magnetic wave
   below; it lives here so the selector is declared once. */
.gw-stars__fill i,
.gw-stars__base i {
    flex: 0 0 auto;
    transition: transform 0.18s var(--ease-spring);
}

/*
  The magnetic wave's sensor: ten equal, tiling, transparent tenths laid over
  the whole track. It is the ONLY thing in this widget that is ever hovered, and
  it is the last child of the track so it sits above the base, the fill and the
  sheen. See the wave block below for the three separate reasons the stars
  cannot sense their own hover.

  It must never transform, never gain a gap, and never lose a cell. It is also
  deliberately NOT the pointer handler's target: pointerdown/move are bound to
  .gw-stars and reach it by bubbling, so this steals nothing.
*/
.gw-stars__sense {
    position: absolute;
    inset: 0;
    display: flex;
}

.gw-stars__sense span {
    flex: 1 1 0;
}

/*
  ==================== The magnetic wave (spec §5c) ====================
  Dock-style: the star under the cursor lifts most, its neighbours less, falling
  off with distance. At five stars this would be crude; at ten it is a wave, and
  it is the one thing the extra resolution actually buys.

  CSS-ONLY, and that is the point rather than a shortcut. A pointermove handler
  would need the row's rect on every frame — either read fresh (a JS cost on a
  page already compositing fourteen backdrop posters, #45) or cached, which is
  precisely the #46 bug, since this act scrolls and a cached rect is stale on
  every scrolled pixel. :hover has no geometry to go stale.

  ⚠ WHY A SENSOR ROW AND NOT `i:hover + i`. Three reasons, each of which alone
  would sink the obvious version:

    1. THERE ARE TWO STAR ROWS. The grey base and the clipped lit fill are
       separate identical rows, and only one of them can be hovered. A
       `.gw-stars__base i:hover + i` falloff would lift the grey stars while the
       lit ones stayed behind, tearing the arc away from its own row. The signal
       has to be hoisted to a common ancestor and re-selected by :nth-child to
       reach BOTH rows' star N. That is why this is ten blocks and not three
       lines.
    2. SELF-OSCILLATION. If the hovered element is also the one that moves, an
       8px lift can carry the star out from under the cursor that lifted it →
       :hover lost → it drops → :hover regained → a flicker loop. The sensor
       never transforms, so it cannot move away from the pointer.
    3. DEAD ZONES. The rows carry `gap: 3px`, and a bare <i> is only as wide as
       its glyph, so sweeping the row crosses a 3px strip between every pair of
       stars where NOTHING is hovered and the whole wave collapses for a frame.
       The sensor's cells tile exactly.

  ⚠ The spec's overlay warning ("if an overlay sits above the stars, :hover will
  match the overlay, not the stars") is not dodged here, it is INVERTED: the
  overlay is made the deliberate sensor and the wave is driven off it. The fill
  and the sheen stay pointer-events: none and are hovered by nothing.

  The cells are exact tenths, which is the same span valueFromX maps to each star
  (ceil(ratio * 10)) — so the star that lifts is always the star the number is
  about to show, for free and with no second source of truth.

  ⚠ THE WAVE STANDS DOWN DURING A DRAG, and that is inherent to CSS-only rather
  than a bug to hunt. starsEl.setPointerCapture() on pointerdown retargets :hover
  to the CAPTURING element and its ancestors, so the sensor cells stop matching
  for as long as a button is held (measured in Chrome: 5 stars lifted on hover,
  0 during a captured drag, 5 again on release). Reading it as a defect leads
  straight to rewriting the commit path — the most important control on the page
  — to chase a hover polish. It is better read as a hand-off: the wave is the
  "you are considering" affordance, and once a button is down the arc and the
  number (the loudest thing in the act, by design) are what the visitor is
  watching. If bring-up decides the dip on a click is wrong, the cheap fix is an
  attribute the EXISTING pointermove already has the value for
  (`starsEl.dataset.wave = valueFromX(...)`, already computed on every move for
  the fill) plus `[data-wave="N"] i:nth-child(M)` rules — NOT a new rect read and
  NOT a rework of pointer capture.

  TRANSFORM ONLY — translateY, deliberately with no scale. A scaled star would
  grow horizontally past the clip edge, and the clip edge always sits on the
  HOVERED star (paint() clips to the previewed value), so the one star being
  magnified is exactly the one whose right tip would be shaved. The spec cut the
  left-to-right size ramp for the same instinct: with the wave and the descriptor
  already moving, a third size language is noise, and this act repeats three
  times in thirty seconds.
*/
.gw-stars__track:has(.gw-stars__sense span:nth-child(1):hover) i:nth-child(1),
.gw-stars__track:has(.gw-stars__sense span:nth-child(2):hover) i:nth-child(2),
.gw-stars__track:has(.gw-stars__sense span:nth-child(3):hover) i:nth-child(3),
.gw-stars__track:has(.gw-stars__sense span:nth-child(4):hover) i:nth-child(4),
.gw-stars__track:has(.gw-stars__sense span:nth-child(5):hover) i:nth-child(5),
.gw-stars__track:has(.gw-stars__sense span:nth-child(6):hover) i:nth-child(6),
.gw-stars__track:has(.gw-stars__sense span:nth-child(7):hover) i:nth-child(7),
.gw-stars__track:has(.gw-stars__sense span:nth-child(8):hover) i:nth-child(8),
.gw-stars__track:has(.gw-stars__sense span:nth-child(9):hover) i:nth-child(9),
.gw-stars__track:has(.gw-stars__sense span:nth-child(10):hover) i:nth-child(10) {
    transform: translateY(var(--gw-lift-0));
}

/* Distance 1. */
.gw-stars__track:has(.gw-stars__sense span:nth-child(1):hover) i:nth-child(2),
.gw-stars__track:has(.gw-stars__sense span:nth-child(2):hover) i:nth-child(1),
.gw-stars__track:has(.gw-stars__sense span:nth-child(2):hover) i:nth-child(3),
.gw-stars__track:has(.gw-stars__sense span:nth-child(3):hover) i:nth-child(2),
.gw-stars__track:has(.gw-stars__sense span:nth-child(3):hover) i:nth-child(4),
.gw-stars__track:has(.gw-stars__sense span:nth-child(4):hover) i:nth-child(3),
.gw-stars__track:has(.gw-stars__sense span:nth-child(4):hover) i:nth-child(5),
.gw-stars__track:has(.gw-stars__sense span:nth-child(5):hover) i:nth-child(4),
.gw-stars__track:has(.gw-stars__sense span:nth-child(5):hover) i:nth-child(6),
.gw-stars__track:has(.gw-stars__sense span:nth-child(6):hover) i:nth-child(5),
.gw-stars__track:has(.gw-stars__sense span:nth-child(6):hover) i:nth-child(7),
.gw-stars__track:has(.gw-stars__sense span:nth-child(7):hover) i:nth-child(6),
.gw-stars__track:has(.gw-stars__sense span:nth-child(7):hover) i:nth-child(8),
.gw-stars__track:has(.gw-stars__sense span:nth-child(8):hover) i:nth-child(7),
.gw-stars__track:has(.gw-stars__sense span:nth-child(8):hover) i:nth-child(9),
.gw-stars__track:has(.gw-stars__sense span:nth-child(9):hover) i:nth-child(8),
.gw-stars__track:has(.gw-stars__sense span:nth-child(9):hover) i:nth-child(10),
.gw-stars__track:has(.gw-stars__sense span:nth-child(10):hover) i:nth-child(9) {
    transform: translateY(var(--gw-lift-1));
}

/* Distance 2. The wave stops here: a third ring is below the noise floor at
   this star size and only widens the blast radius of the hover. */
.gw-stars__track:has(.gw-stars__sense span:nth-child(1):hover) i:nth-child(3),
.gw-stars__track:has(.gw-stars__sense span:nth-child(2):hover) i:nth-child(4),
.gw-stars__track:has(.gw-stars__sense span:nth-child(3):hover) i:nth-child(1),
.gw-stars__track:has(.gw-stars__sense span:nth-child(3):hover) i:nth-child(5),
.gw-stars__track:has(.gw-stars__sense span:nth-child(4):hover) i:nth-child(2),
.gw-stars__track:has(.gw-stars__sense span:nth-child(4):hover) i:nth-child(6),
.gw-stars__track:has(.gw-stars__sense span:nth-child(5):hover) i:nth-child(3),
.gw-stars__track:has(.gw-stars__sense span:nth-child(5):hover) i:nth-child(7),
.gw-stars__track:has(.gw-stars__sense span:nth-child(6):hover) i:nth-child(4),
.gw-stars__track:has(.gw-stars__sense span:nth-child(6):hover) i:nth-child(8),
.gw-stars__track:has(.gw-stars__sense span:nth-child(7):hover) i:nth-child(5),
.gw-stars__track:has(.gw-stars__sense span:nth-child(7):hover) i:nth-child(9),
.gw-stars__track:has(.gw-stars__sense span:nth-child(8):hover) i:nth-child(6),
.gw-stars__track:has(.gw-stars__sense span:nth-child(8):hover) i:nth-child(10),
.gw-stars__track:has(.gw-stars__sense span:nth-child(9):hover) i:nth-child(7),
.gw-stars__track:has(.gw-stars__sense span:nth-child(10):hover) i:nth-child(8) {
    transform: translateY(var(--gw-lift-2));
}

/*
  The peak moment. Landing a 9 or a 10 is the visitor telling us their
  favourite, so it is acknowledged — once, briefly, and with a sweep of light
  rather than with confetti. Confetti is Act 5's and has to stay unique to it
  or arriving at the reveal means nothing.

  transform + opacity only: the gradient itself never moves, the element
  carrying it does. The sheen owns the clip (see .gw-stars__track) and is
  otherwise inert — no filter, no will-change, nothing to leave running.
*/
.gw-stars__sheen {
    position: absolute;
    inset: 0;
    overflow: hidden;
    pointer-events: none;
}

/*
  ⚠ Spec item 3 (round 4): the sheen rides the star SHAPES, not a rectangle over
  the whole box. It carries its own copy of the ten glyphs and paints a single
  narrow white highlight band clipped to that text (background-clip: text — the
  same "paints the stars" move round-3 §4 used for the colour gradient), so the
  gaps between stars no longer flash. The band sweeps left-to-right by moving its
  background-position: a translateX would carry the glyphs with it, so a
  glyph-clipped highlight can only travel across stationary text via its
  position. Single-shot on .is-peak (no idle GPU, #45); killed under
  prefers-reduced-motion below. Same 12px vertical padding and font-size as the
  two star rows so the glyphs land exactly over the lit arc.

  ⚠ #33: background-IMAGE, never the `background` shorthand — the shorthand would
  reset the two background-clip lines under it.
*/
.gw-stars__sheen-row {
    display: flex;
    gap: 3px;
    padding: 12px 0;
    font-size: clamp(1.125rem, 4.2vw, 1.5rem);
    line-height: var(--leading-none);
    color: transparent;
    background-image: linear-gradient(100deg,
                                      rgba(var(--fg-rgb, 255, 255, 255), 0) 42%,
                                      rgba(var(--fg-rgb, 255, 255, 255), 0.85) 50%,
                                      rgba(var(--fg-rgb, 255, 255, 255), 0) 58%);
    background-repeat: no-repeat;
    background-size: 250% 100%;
    background-position: 130% 0;
    -webkit-background-clip: text;
    background-clip: text;
    opacity: 0;
}

.gw-stars__sheen-row i {
    flex: 0 0 auto;
}

.gw-stars.is-peak .gw-stars__sheen-row {
    animation: gw-star-shimmer 0.9s cubic-bezier(0.4, 0, 0.2, 1) 1;
}

@keyframes gw-star-shimmer {
    0% {
        background-position: 130% 0;
        opacity: 0;
    }
    18% {
        opacity: 1;
    }
    82% {
        opacity: 1;
    }
    100% {
        background-position: -30% 0;
        opacity: 0;
    }
}

/*
  The number, and it is the loudest thing in the act on purpose: it is the one
  element that states the scale. It is therefore present from the first frame,
  reading "-/10" before any input — the old widget wrote it only inside the
  commit path, so an untouched control showed blank stars and no number at all
  and the scale was genuinely unknowable until after the first guess.

  --gw-score (0 when unrated, else 1-10) is set on .gw-stars by the JS and
  inherits down. Weight grows with the score: a 10 is visibly bigger and
  brighter than a 3.
*/
.gw-stars__value {
    position: relative;
    display: inline-flex;
    align-items: baseline;
    /* Wide enough for the two-digit case, so 9 -> 10 does not shove the stars
       sideways. tabular-nums keeps every other step the same width. */
    min-width: 3.9em;
    justify-content: flex-start;
    color: var(--text-secondary);
    font-variant-numeric: tabular-nums;
    transform: scale(calc(1 + var(--gw-score, 0) * 0.016));
    transform-origin: left center;
    transition: transform 0.25s var(--ease-spring),
                color 0.25s ease;
}

/*
  The glow. Static per value (nothing animates it), driven off the same score
  var, and behind the digits. It carries the scale too: a 2 glows a dim slate
  rather than a brand gradient, so a low score reads as QUIET rather than as an
  error (§5b).

  ⚠ #33: background-IMAGE, never the `background` shorthand — the shorthand
  would reset background-clip, which the numeral's 10 state below depends on,
  and a flat colour is expressed as a two-stop gradient purely so that both the
  1-9 case and the 10 case set the same longhand and neither can clobber the
  other's siblings.
*/
.gw-stars__value::before {
    content: '';
    position: absolute;
    inset: -40% -20%;
    z-index: -1;
    border-radius: var(--radius-pill);
    background-image: linear-gradient(var(--gw-score-color, transparent),
                                      var(--gw-score-color, transparent));
    filter: blur(16px);
    opacity: calc(var(--gw-score, 0) * 0.05);
    pointer-events: none;
}

/* The summit. Only a 10 gets the full brand gradient, on the glow and on the
   numeral alike — it is the one value that is both of our colours at once. */
.gw-stars[data-score="10"] .gw-stars__value::before {
    background-image: var(--brand-gradient);
}

.gw-stars.is-rated .gw-stars__value {
    color: var(--text);
}

.gw-stars__num {
    font-size: 1.75rem;
    font-weight: var(--weight-bold);
    line-height: var(--leading-none);
    /* The scale's loudest voice. Falls back to the inherited colour while
       unrated, which is where the resting "-" lives. */
    color: var(--gw-score-color, inherit);
    transition: color 0.25s ease;
}

/* #33: background-IMAGE + the two clip longhands, declared together. No
   `background` shorthand anywhere near this rule. */
.gw-stars[data-score="10"] .gw-stars__num {
    background-image: var(--brand-gradient);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}

.gw-stars__den {
    font-size: 0.9375rem;
    font-weight: var(--weight-semibold);
    color: var(--text-dim);
}

/*
  ==================== The live descriptor (spec §5a) ====================
  The word beside the number. It answers what the scale cannot: what a 7 means.
  On hover it previews the hovered value; on leave the JS puts the committed
  value's word (or the resting one) straight back.

  ⚠ ITS SPACE IS RESERVED, and that is the whole reason it is a fixed line box
  with a min-width rather than plain flowing text. "Masterpiece" is more than
  twice the width of "Rough" and the words change on every star you sweep past —
  an act that reflows under the cursor is the exact opposite of cinematic. The
  min-width fits the longest word in the table at this size; if the table ever
  changes, this number changes with it.
*/
.gw-stars__word {
    display: inline-flex;
    align-items: center;
    justify-content: flex-start;
    min-width: 7.5em;
    min-height: 1.5em;
    font-size: 0.875rem;
    font-weight: var(--weight-semibold);
    line-height: var(--leading-tight);
    text-align: left;
    color: var(--gw-score-color, var(--text-dim));
    transition: color 0.25s ease;
}

/* ==================== The coronation (Act 4) ==================== */
/*
  The emotional peak, redesigned (spec item 6). The highest-rated pick is
  crowned and lit by its OWN banner (the room atmosphere, restored to this act —
  see setRoomBanner in gateway-flow.js), and it is the title the flow then asks a
  note about. It is not a form: it is one centered column — the runners behind,
  the crowned poster with its crown and halo, its title and score, then a note
  composer addressed to the title by name. Everything centers; no drifting or
  mismatched boxes (the owner's complaint).
*/
.gw-act[data-act="4"] .gw-act__inner {
    text-align: center;
}

/* Act 4's actions center like Act 3's — (0,3,0) outranks the (min-width: 560.02px)
   `justify-content: flex-end`, which would otherwise shove the buttons right and
   reopen the misalignment this act was rebuilt to fix. */
.gw-act[data-act="4"] .gw-actions {
    justify-content: center;
}

.gw-coronation {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    margin: 0 0 26px;
}

.gw-coronation__label {
    margin: 0 0 8px;
    font-size: 0.6875rem;
    font-weight: var(--weight-semibold);
    letter-spacing: 0.16em;
    text-transform: uppercase;
    /* intentional — the champion's own gold, matching the crown and score below;
       design-tokens.css has no gold token and #27 forbids inventing one, so this
       reuses the gateway-local --gw-gold the crown already established. */
    color: var(--gw-gold);
}

/*
  The dais: the champion centered, the runners peeking from behind it. A flex row
  centers the plinth; the runners are absolutely positioned off the centre line
  so they sit behind and below, and the plinth's own z-index keeps the champion
  in front of them. `padding-top` is the crown's headroom.
*/
.gw-crown-dais {
    position: relative;
    display: flex;
    justify-content: center;
    align-items: flex-end;
    width: 100%;
    padding-top: 20px;
    margin-bottom: 6px;
}

.gw-crown-plinth {
    position: relative;
    z-index: 2;
    width: clamp(130px, 31vw, 158px);
}

/*
  The runners — the other picks, smaller and dimmed, the field the champion won.
  They inherit .gw-poster (aspect, radius, cover), and only their size, place and
  dimming are set here.
  intentional — the deep drop lifts them off the atmosphere; no shadow token.
*/
.gw-crown-runner {
    position: absolute;
    bottom: 0;
    z-index: 0;
    width: clamp(78px, 19vw, 98px);
    opacity: 0.5;
    filter: brightness(0.6) saturate(0.85);
    box-shadow: var(--shadow-xl);
}

.gw-crown-runner--left {
    left: 50%;
    transform: translateX(-140%) translateY(2px) rotate(-8deg);
}

.gw-crown-runner--right {
    left: 50%;
    transform: translateX(40%) translateY(2px) rotate(8deg);
}

/* intentional — gold bloom behind the crowned poster; stars/crown read as gold
   universally and design-tokens.css has no gold or shadow token. */
.gw-halo {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 260px;
    height: 260px;
    margin: -130px 0 0 -130px;
    border-radius: var(--radius-circle);
    background: radial-gradient(circle, rgba(255, 194, 75, 0.30) 0%, rgba(255, 194, 75, 0.10) 45%, rgba(255, 194, 75, 0) 70%);
    pointer-events: none;
    opacity: 0;
    transform: scale(0.6);
    animation: gw-bloom 1.1s cubic-bezier(0.4, 0, 0.2, 1) 0.15s forwards;
}

@keyframes gw-bloom {
    0% {
        opacity: 0;
        transform: scale(0.6);
    }
    55% {
        opacity: 1;
        transform: scale(1.06);
    }
    100% {
        opacity: 0.75;
        transform: scale(1);
    }
}

/* The hero poster, at size, lifted off the atmosphere.
   intentional — a deep drop; no shadow token describes it. */
.gw-crown-poster {
    position: relative;
    z-index: 1;
    border-color: var(--gw-gold-dim);
    box-shadow: var(--shadow-2xl);
}

.gw-crown {
    position: absolute;
    left: 50%;
    top: -18px;
    z-index: 2;
    font-size: 1.75rem;
    color: var(--gw-gold);
    /* intentional — a warm glow off the crown; no shadow token. */
    text-shadow: 0 4px 16px rgba(255, 194, 75, 0.5);
    transform: translateX(-50%) rotate(-12deg) translateY(-6px);
    opacity: 0;
    animation: gw-crown-drop 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) 0.35s forwards;
}

@keyframes gw-crown-drop {
    from {
        opacity: 0;
        transform: translateX(-50%) rotate(-12deg) translateY(-22px) scale(0.7);
    }
    to {
        opacity: 1;
        transform: translateX(-50%) rotate(-12deg) translateY(-6px) scale(1);
    }
}

.gw-crown-title {
    margin: 18px 0 6px;
    font-size: clamp(1.25rem, 3.4vw, 1.5rem);
    font-weight: var(--weight-bold);
    line-height: 1.25;
    color: var(--text);
}

.gw-crown-score {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 0.9375rem;
    font-weight: var(--weight-semibold);
    color: var(--gw-gold);
    font-variant-numeric: tabular-nums;
}

/* ==================== Note composer (Act 4) ==================== */
/*
  The one place the visitor writes something personal about their favourite, so
  it is an invitation, not a textarea in a grey box. It is capped and centered
  under the champion, wears a brand accent at its head (the champion's colour),
  and its prompt names the crowned title. No .glass-sm / backdrop-filter — the
  banner behind it is already dim, so a plain translucent fill reads cleanly and
  keeps the page's no-backdrop-filter ethos.
*/
.gw-note-composer {
    position: relative;
    width: 100%;
    max-width: 464px;
    margin: 0 auto;
    padding: 22px 20px;
    text-align: center;
    background: var(--surface-grad);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-lg);
    overflow: hidden;
    /* intentional — a soft lift off the atmosphere; no shadow token. */
    box-shadow: var(--shadow-xl);
}

/* The brand accent at the composer's head: this panel belongs to the champion. */
.gw-note-composer::before {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    height: 3px;
    background-image: var(--brand-gradient);
    opacity: 0.9;
}

.gw-note-composer__prompt {
    display: block;
    margin: 0 0 6px;
    font-size: 1.0625rem;
    font-weight: var(--weight-semibold);
    line-height: 1.35;
    color: var(--text);
}

/* The crowned title, spoken back to the visitor in the brand gradient.
   #33: background-IMAGE, never the `background` shorthand — the shorthand would
   reset the two background-clip lines under it. */
.gw-note-composer__title {
    background-image: var(--brand-gradient);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    font-weight: var(--weight-bold);
}

.gw-note-composer__hint {
    margin: 0 0 14px;
    font-size: 0.8125rem;
    line-height: 1.45;
    color: var(--text-muted);
}

.gw-textarea {
    width: 100%;
    /* Without this the border box is 100% + 28px horizontal padding + borders,
       so the field overflows the composer's content box and clips on the right.
       There is no global border-box reset in this file. */
    box-sizing: border-box;
    min-height: 88px;
    padding: 12px 14px;
    line-height: var(--leading-normal);
    resize: vertical;
    font-family: inherit;
    /* The composer centers its prompt and hint, but typed text reads left. */
    text-align: left;
}

/* ==================== Reveal (Act 5) ==================== */
.gw-reveal__body {
    min-height: 120px;
    margin-bottom: 20px;
}

.gw-spinner {
    margin: 32px auto;
}

/* Non-clickable by design: a reveal, not a navigation surface. */
.gw-teaser-rail {
    display: flex;
    gap: 12px;
    margin: 0;
    padding: 0 0 8px;
    list-style: none;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    -ms-overflow-style: none;
}

.gw-teaser-rail::-webkit-scrollbar {
    display: none;
}

.gw-teaser-card {
    flex-shrink: 0;
    width: 116px;
    scroll-snap-align: start;
}

.gw-teaser-card__title {
    display: -webkit-box;
    margin-top: 8px;
    font-size: 0.8125rem;
    font-weight: var(--weight-medium);
    line-height: 1.35;
    color: var(--text);
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    /* Reserve two lines always: a one-line title keeps the second line's space so
       its card lines up with two-line neighbours in the flex rail (and whatever
       sits under the title does not jump row to row). 2 x line-height. */
    min-height: calc(1.35em * 2);
}

.gw-because {
    display: block;
    margin-top: 4px;
    font-size: 0.6875rem;
    line-height: 1.35;
    color: var(--brand-blue);
}

/* ==================== The quote card ==================== */
/*
  Their note, read back to them at the end. The copy around it says exactly
  what it is — a note on their top pick that saves to that title's entry on
  their shelf. It is NOT a post, and nothing here may imply one exists.
*/
/* .glass-sm supplies background/backdrop-filter/border/border-radius. #33: do
   NOT re-declare a `border` shorthand here — it would reset the class's border
   longhands (this file loads after components.css, so the shorthand would win)
   even when it looks like it is setting the same value. Only the radius differs,
   and a radius is not part of the border shorthand. */
.gw-quote {
    display: flex;
    gap: 14px;
    align-items: flex-start;
    margin: 0 0 20px;
    padding: 16px;
    border-radius: var(--radius-lg);
}

.gw-quote__poster {
    flex: 0 0 48px;
    width: 48px;
}

.gw-quote__body {
    flex: 1 1 auto;
    min-width: 0;
}

.gw-quote__text {
    margin: 0 0 8px;
    font-size: 0.9375rem;
    font-style: italic;
    line-height: var(--leading-normal);
    color: var(--text);
    overflow-wrap: anywhere;
}

.gw-quote__attrib {
    margin: 0;
    font-size: 0.75rem;
    line-height: 1.45;
    color: var(--text-dim);
}

/* ==================== Actions ==================== */
.gw-actions {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
    margin-top: 22px;
}

/* The wall's action bar is empty until the first pick lands; without this it
   still reserves its margin and the wall sits oddly high. */
.gw-actions:empty {
    display: none;
}

.gw-cta {
    width: 100%;
    min-height: 48px;
    border-radius: var(--radius-pill);
    font-size: 0.9375rem;
    font-weight: var(--weight-semibold);
}

/* intentional — the brand-blue glow is the sanctioned .btn-primary:hover recipe
   (CLAUDE.md design system); design-tokens.css defines no shadow token. */
.gw-cta--ready {
    box-shadow: var(--lift-blue);
}

/* components.css defines NO :disabled state, so without this the "Next title"
   button looks fully enabled while doing nothing until a rating exists, which
   reads as a dead button. Deliberately opacity-only: a `background` shorthand
   here would reset .btn-gradient's fill (#33). */
.gw-cta:disabled {
    opacity: 0.4;
    cursor: not-allowed;
    box-shadow: none;
    transform: none;
}

.gw-back {
    min-height: 44px;
    border-radius: var(--radius-pill);
}

/* The CTA's sub-label. Always present on the wall, so the count is a running
   commentary rather than a warning that only appears when something is wrong. */
.gw-actions__hint {
    margin: 0;
    text-align: center;
    font-size: 0.75rem;
    color: var(--text-dim);
    transition: color var(--transition-normal);
}

.gw-actions__hint.is-ready {
    color: var(--text-secondary);
}

@media (min-width: 560.02px) {
    .gw-actions {
        flex-direction: row-reverse;
        justify-content: flex-end;
        align-items: center;
    }
    .gw-cta {
        width: auto;
        min-width: 180px;
    }
    .gw-actions__hint {
        flex: 1 1 100%;
        text-align: left;
    }
}

/* ==================== Reduced motion ==================== */
/* The flow must still WORK; nothing flies. The crown and halo still arrive,
   they just arrive already there. */
@media (prefers-reduced-motion: reduce) {
    .gw-slot--landed {
        animation: none;
    }
    .gw-flyer,
    .gw-flight {
        transition: none;
    }
    .gw-halo {
        animation: none;
        opacity: 0.75;
        transform: scale(1);
    }
    .gw-crown {
        animation: none;
        opacity: 1;
        transform: translateX(-50%) rotate(-12deg) translateY(-6px);
    }
    .gw-result,
    .gw-result__rank,
    .gw-stars__fill,
    .gw-actions__hint {
        transition: none;
    }

    /*
      Act 3 keeps every pixel of its layout and loses every moment of motion.
      The atmosphere still arrives (it is a static image either way), the arc
      still lands on the right star, the number is still sized by the score —
      they simply do not travel there. The peak shimmer is the one thing that
      IS the motion, so it is the one thing that goes entirely.
    */
    .gw-room-banner__img,
    .gw-number,
    .gw-stars__value,
    .gw-stars__num,
    .gw-stars__word {
        transition: none;
    }

    .gw-stars.is-peak .gw-stars__sheen-row {
        animation: none;
    }

    /*
      The magnetic wave IS the motion, so it goes entirely rather than arriving
      instantly: a star that teleports 8px on hover is worse than one that never
      moves. Zeroing the three rings flattens the wave at the source — a
      `transform: none` here could not do it, because the wave's selectors are
      (0,5,2) (:has() donates its most specific argument) and would outrank
      anything reasonable written in this block. The word, the number, the
      scale's colour and every star's position are all exactly where they would
      otherwise be; only the travel is gone.
    */
    .gw-stars {
        --gw-lift-0: 0px;
        --gw-lift-1: 0px;
        --gw-lift-2: 0px;
    }

    .gw-stars__fill i,
    .gw-stars__base i {
        transition: none;
    }
}
