/* Setlist Showdown — "Marquee Night" design system, reconciled onto the
 * ratified Venue Wall token set (docs/design/design-tokens.css).
 * Dark house, one spotlight: no light mode, ever. Genre color appears on
 * cards only — the chrome stays neutral.
 *
 * TOKEN LAYER — how this :root relates to the ratified file it imports below.
 *
 * The import brings in every --ss-* the ratified file declares (core colour,
 * certification ramps, the three stats, type scale, radii, spacing,
 * elevation) EXCEPT its three genre tokens — see the genre block's own
 * comment below for why those are never referenced. Everything below is
 * this file's own :root, declared AFTER the import so it wins the cascade
 * for any name both files define — which is deliberate, not redundant:
 * spec/design_token_parity_spec.rb parses this file's literal text (not
 * what the browser resolves), so a shared concept has to be restated here,
 * by hand, for the guard to have anything of ours to check against the
 * ratified value. That guard is what replaces the discipline that used to
 * be "remember to keep the transcription in sync" — the mapping below is
 * the one-time act of writing it correctly; the guard is what keeps it that
 * way.
 *
 * Mapping table (by CONCEPT, never by value — two unrelated concepts can
 * legitimately share a hex, e.g. --electronic and the ratified --ss-craft
 * both being #3CC8E5 is a coincidence, not a mapping instruction):
 *
 *   ships as                  | becomes            | why
 *   ---------------------------------------------------------------------
 *   --dim (#9C93B0)           | --ss-muted         | every usage (all ~40,
 *                             |                    | confirmed none in
 *                             |                    | app/views) was written
 *                             |                    | expecting this colour,
 *                             |                    | and it already equals
 *                             |                    | ratified --ss-muted.
 *                             |                    | Web separately gains a
 *                             |                    | true --ss-dim (#68636F)
 *                             |                    | below, which it has
 *                             |                    | never had.
 *   --spot (#FFB43A)          | --ss-gold          | same concept, different
 *                             |                    | name; value already
 *                             |                    | matches ratified.
 *   --ink (background uses)   | --ss-ink           | same concept. VALUE
 *                             |                    | CHANGES #131019 ->
 *                             |                    | #0A0A0B (ratified).
 *   --hype, --soul, --craft   | --ss-hype/soul/    | same concept. VALUES
 *                             | craft              | CHANGE to ratified.
 *   --cream, --diamond*,      | prefix only        | same name, same value
 *   --platinum*, --gold-deep, |                    | already (transcribed
 *   --gold-lit                |                    | verbatim previously).
 *   --rock, --metal, --folk,  | --ss-genre-*, kept | web's own eight
 *   --country, --electronic,  | under WEB's names  | contract genre hues
 *   --pop, --soul-genre,      | and values         | (BRANDING.md §2). The
 *   --hip_hop                 |                    | ratified file's own
 *                             |                    | three genre tokens are
 *                             |                    | illustrative mockup
 *                             |                    | names the project did
 *                             |                    | not adopt (Decision 4)
 *                             |                    | and are never used —
 *                             |                    | not renamed FROM, and
 *                             |                    | nothing below reads
 *                             |                    | them.
 *   --action-green,           | keep, prefixed     | web-only concepts with
 *   --card-shadow,            |                    | no ratified counterpart
 *   --laminate-shadow, --m    |                    | found.
 *   --panel, --inactive,      | keep, prefixed,    | see the note below —
 *   --danger                  | VALUE UNCHANGED    | BRANDING.md §2's own
 *                             |                    | 2026-08-06 amendment
 *                             |                    | already names ratified
 *                             |                    | successors for these
 *                             |                    | three (surface/border/
 *                             |                    | red); NOT adopted here,
 *                             |                    | and whether web should
 *                             |                    | move onto them is open,
 *                             |                    | not decided by this
 *                             |                    | branch.
 *
 * --ink SPLIT IN TWO, found by review after this file first shipped:
 * ratified declares both --ss-ink (#0A0A0B, background) and
 * --ss-ink-on-accent (#131019, text/icons placed on a gold or other accent
 * fill). Web's old --ink did both jobs under one name, and had drifted to
 * hold the on-accent value (#131019 == ratified --ss-ink-on-accent, not
 * --ss-ink) — which is WHY it read as "unchanged" at first glance and is
 * exactly the trap BRANDING.md §2's own iOS amendment already named for
 * this same pair. Do not assume the one-to-one shape holds for --ink
 * elsewhere: .mvp-chip, .btn, .btn--pressed/.btn.btn--pressed, and
 * .rules-wheel__dot--beats/--loses are text-on-accent and use
 * --ss-ink-on-accent (supplied by the import above, not restated in this
 * :root). Every remaining --ss-ink use below is a background, OR text on a
 * NON-accent fill — ratified documents --ss-ink-on-accent specifically as
 * text on gold or another accent fill, and a neutral is not an accent.
 * **.rules-cue is the deliberate exception**: dark text on --ss-muted (a
 * neutral chip fill, not gold), correctly left on --ss-ink rather than
 * moved — moving it would apply --ss-ink-on-accent outside what it
 * documents, for no visible gain (#0A0A0B and #131019 both read as
 * near-black on that lavender). If muted turns out to count as an accent by
 * this design system's own logic, that is a call for the owner, not a
 * silent implementation.
 *
 * --panel/--inactive's ratified status is an OPEN QUESTION, found by review,
 * not "no counterpart" as an earlier version of this comment stated:
 * rules_embed.css labelled --danger "ratified --ss-red" (#DC5641) and
 * overrode it to that value for one route, while this file denied a
 * ratified counterpart exists — and BRANDING.md §2's own 2026-08-06
 * amendment already names ratified successors for all three (panel ->
 * surface, inactive -> border, danger -> red). Tracked as #921.
 *
 * #919 found a stylesheet-glob bug had, by accident, been shipping
 * rules_embed.css's ratified --danger/--panel VALUES site-wide for weeks —
 * every page had been rendering #DC5641/rgba(15,14,16,.88), not the values
 * that used to sit on this line, including .judge-portrait (#971), reviewed
 * and merged the same night against the leaked palette. OWNER RULING,
 * 2026-09-13: ADOPT both leaked values as this file's own, rather than
 * revert 31 rules (22 --ss-panel consumers + 9 --ss-danger, counted in this
 * file) to a color nobody had been looking at. A "revert to the old default"
 * was never actually the conservative option once the leak had been live
 * for weeks — the disruptive change was undoing what every reviewer had
 * already been approving, not keeping it. --ss-danger and --ss-panel are
 * below at #DC5641/rgba(15,14,16,.88) accordingly; --ss-inactive is
 * untouched (rules_embed.css never overrode it, so it never leaked).
 *
 * #921's remaining scope for these two, post-ruling: the VALUES are settled
 * (this file's own, not an open site-wide-vs-embed question anymore) — what
 * #921 still tracks is the NAMING question, whether --ss-danger/--ss-panel
 * should formally become the ratified --ss-red/--ss-surface (i.e. whether
 * spec/design_token_parity_spec.rb should even see them as the same
 * concept). That is still not this branch's call.
 *
 * Falsified 2026-09-04: changing a shipped value on purpose made
 * spec/design_token_parity_spec.rb fail and name that exact token; restored
 * before this commit.
 *
 * THIS GUARD'S LIMITS: spec/design_token_parity_spec.rb only compares
 * tokens BOTH files declare under the IDENTICAL --ss- name — only a small
 * minority of names are shared between the two files at all (the spec's own
 * calibration example prints the exact counts on every run; this comment
 * doesn't carry a figure, on purpose — see the --danger/panel/inactive note
 * above for what happens when one does). It cannot catch a mapping error
 * where one web name should have split into two ratified concepts (the
 * --ink case above), and it cannot catch a name that differs between the
 * files entirely (the --danger/--ss-red question above). It catches value
 * drift on shared names; it is not a substitute for reviewing the mapping
 * itself. */

/* This pulls in the ratified file WHOLE, including its three illustrative
 * --ss-genre-punk/-shoegaze/-garage tokens — they arrive in the cascade but
 * are deliberately never referenced below. They are not contract genres and
 * are not a sanctioned choice to reach for; the eight contract genres are
 * BRANDING.md §2 and live below as this file's own --ss-genre-* names. */
@import url("/assets/design-tokens-bb7940f9.css");
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=Barlow+Condensed:wght@400;600;700&family=Permanent+Marker&family=Special+Elite&display=swap');

:root {
  --ss-ink: #0A0A0B;      /* ratified value — was #131019 */
  --ss-panel: rgba(15, 14, 16, .88); /* was #1E1830 — OWNER RULING, 2026-09-13:
                             adopts the value #919's stylesheet-glob leak had
                             already been shipping site-wide for weeks
                             (rules_embed.css labelled it "ratified
                             --ss-surface"), rather than revert 22 rules'
                             worth of consumers to a color nobody had been
                             looking at, including .judge-portrait (#971),
                             reviewed and merged the same night against this
                             exact leaked value. #921 still tracks whether
                             this should formally become the ratified
                             --ss-surface by NAME — the value question is
                             settled, the naming one isn't. */
  --ss-gold: #FFB43A;
  --ss-cream: #F4EFE4;
  --ss-muted: #9C93B0;    /* == ratified --ss-muted; web's --dim carried this role already */
  --ss-dim: #68636F;      /* web's own true "dim" — ratified value; web never had this token before */
  --ss-danger: #DC5641;   /* was #FF7A5C — OWNER RULING, 2026-09-13: same
                             reasoning as --ss-panel above. Adopts the value
                             #919's leak had already been shipping site-wide
                             for weeks (rules_embed.css labelled it "ratified
                             --ss-red") rather than revert 9 rules' worth of
                             consumers to a color nobody had been looking at.
                             #921 still tracks whether this should formally
                             become the ratified --ss-red by NAME. */
  --ss-inactive: #3A3450;
  --ss-action-green: #FFB43A; /* --ss-gold — OWNER RULING, 2026-08-22 (rules_embed.css):
                             no green hue exists anywhere in design-tokens.css or
                             iOS's Tokens.Color, so the wheel's "Beats" cue maps to
                             the palette's one positive accent, gold, instead.
                             #919: this ruling had only been reaching this route
                             directly; the stylesheet-glob leak fixed in this same
                             change had it reaching every OTHER page too (was
                             #35C759, plain green) for as long as rules_embed.css
                             has existed. Adopted here rather than reverted,
                             because the ruling is global — "no green hue" is not
                             a per-route exception. */
  --ss-hype: #F25CA2;     /* ratified value — was #FF7A5C */
  --ss-soul: #9B6FE0;     /* ratified value — was #C08BFF */
  --ss-craft: #3CC8E5;    /* ratified value — was #6FD3FF */
  /* Web's own eight contract genre hues (BRANDING.md §2), kept under web's
   * names and values — NOT the ratified file's --ss-genre-punk/-shoegaze/
   * -garage trio, which are illustrative mockup names the project explicitly
   * did not adopt (Decision 4) and which nothing in this file references. */
  --ss-genre-rock: #E65138; /* was #D8452E — #919: that value never matched
                             BRANDING.md §2's own (unamended, both-platform)
                             genre table, which has always listed Rock as
                             #E65138, independently confirmed by iOS's shipped
                             Design/Tokens.swift (Tokens.Color.genreRock). Plain
                             drift, not an open design question like --ss-danger/
                             --ss-panel above — rules_embed.css's override had
                             been quietly correcting it on one route only, via
                             the same leak, since rules_embed.css shipped. */
  --ss-genre-electronic: #3CC8E5;
  --ss-genre-soul: #9B6FE0;
  --ss-genre-metal: #8A8F98;
  --ss-genre-pop: #F25CA2;
  --ss-genre-folk: #7FB069;
  --ss-genre-hip-hop: #E5B93C;
  --ss-genre-country: #D98A3C;
  --ss-card-shadow: 0 3px 10px rgba(0, 0, 0, .33);
  /* The laminate's contrast shadow [laminate-on-art-corner, owner
   * instruction: "a little padding... a drop shadow to aid in contrast"].
   * Started from --ss-card-shadow (same numbers as iOS's Tokens.Elevation.card)
   * and escalated after a real render test: against a cover the same cream
   * as the laminate's own paper (art/road-wear/laminate/laminate_paper.png —
   * the worst case, since the laminate is an opaque card, not a transparent
   * sticker, so a matching-color cover is the one that can truly hide its
   * edge), --ss-card-shadow's .33 opacity/10px blur read as barely-there at
   * real size; --ss-shadow-poster's 28px blur (docs/design/design-tokens.css)
   * read as a diffuse haze with no crisp edge. This value — deeper opacity,
   * a tighter blur than the poster shadow — was the one that read as a
   * shadow (a card lifted off the surface) rather than either a smudge or
   * nothing, checked at both state 1 and state 7 against both a light and a
   * dark cover. iOS's Tokens.Elevation.laminate carries the same shadow
   * (opacity .5, radius 7 [this blur/2, matching Tokens.Elevation's own
   * documented box-shadow-blur-to-SwiftUI-radius conversion], y 8) —
   * RoadWear.swift's own doc names this file back. */
  --ss-laminate-shadow: 0 8px 14px rgba(0, 0, 0, .5);

  /* Gold Record certification disc [gold-record-certification design,
   * ratified disc treatment]. These match the ratified file's own
   * --ss-gold-deep/-lit, --ss-platinum(-deep/-lit) and
   * --ss-diamond(-deep/-lit/-alt) verbatim (confirmed above by the parity
   * guard, since both files now use the identical --ss- names for these).
   * Gold's own base is --ss-gold above, already declared, so it is not
   * repeated here. */
  --ss-gold-deep: #B87816;      /* --ss-gold-deep */
  --ss-gold-lit: #FFDFA0;       /* --ss-gold-lit */
  --ss-platinum: #C9CDD6;       /* --ss-platinum */
  --ss-platinum-deep: #767C8A;  /* --ss-platinum-deep */
  --ss-platinum-lit: #F2F5FA;   /* --ss-platinum-lit */
  --ss-diamond: #8FE6FF;        /* --ss-diamond */
  --ss-diamond-deep: #4C7FA8;   /* --ss-diamond-deep */
  --ss-diamond-lit: #EAFBFF;    /* --ss-diamond-lit */
  --ss-diamond-alt: #C7A6F5;    /* --ss-diamond-alt — woven through Diamond's own
                                 ramp below so it reads as a third material
                                 while staying in the gold/platinum family. */
}

* { box-sizing: border-box; }

body {
  margin: 0;
  color: var(--ss-cream);
  background: var(--ss-ink);
  font-family: 'Barlow Condensed', system-ui, sans-serif;
  font-size: 16px;
}

/* The viewport-fixed purple glow, on its own layer rather than the body's own
   background. This is visually identical to the former
   `radial-gradient(...) fixed, var(--ss-ink)` on `body`, but WITHOUT
   `background-attachment: fixed`, which on iOS Chrome (WKWebView) breaks
   `position: fixed` DESCENDANTS (e.g. `.match-action-bar`) — they stop
   pinning to the viewport bottom and float at content-end. Safari renders the
   old form fine; this layer fixes Chrome-iOS while keeping the glow pinned to
   the viewport, behind all content (z-index:-1), and click-through
   (pointer-events:none). [#88]

   #919: this rule had been switched OFF on every ordinary page for as long
   as rules_embed.css has existed, not by any decision about this glow —
   rules_embed.css's own `body::before { content: none; }` (its suppression
   of THIS exact rule, correctly, for its one chrome-free route) rode the
   `:app` stylesheet glob onto every other page too, same specificity, same
   selector, alphabetically second, so it always won. No page had this glow
   since rules_embed.css shipped. Closing the leak turns it back on site-wide
   — restoring the backdrop this rule was written to paint, not introducing
   a new one. /rules/embed still suppresses it correctly for itself; see
   rules_embed.css's own comment on its `body::before` override. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background: radial-gradient(1000px 500px at 50% -10%, #2A2142 0%, var(--ss-ink) 60%);
  pointer-events: none;
}

.house {
  max-width: 880px;
  margin: 0 auto;
  padding: 28px 16px 60px;
  text-align: center;
}

/* The Label — the ticket-stub style that does most of the brand */
.label {
  font-size: 11px;
  letter-spacing: 2.5px;
  text-transform: uppercase;
  color: var(--ss-muted);
  font-weight: 600;
}
.label--amber { color: var(--ss-gold); }

.marquee { margin-bottom: 26px; }
.marquee__title {
  font-family: 'Archivo Black', sans-serif;
  font-size: clamp(32px, 7vw, 54px);
  margin: 4px 0 2px;
  line-height: 1;
  text-transform: uppercase;
  text-shadow: 0 0 40px rgba(255, 180, 58, .27);
}
.marquee__sub { font-size: 15px; color: var(--ss-muted); margin: 0; }

/* The night's MVP — one gold pill under the verdict headline [match-mvp].
 * Spotlight-on-ink like .btn, pill-rounded so it reads as a laurel, not a
 * button; the reason line beneath reuses the shared .label. */
.mvp-chip {
  display: inline-block;
  font-family: 'Archivo Black', sans-serif;
  font-size: 12px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  background: var(--ss-gold);
  color: var(--ss-ink-on-accent);
  border-radius: 999px;
  padding: 6px 16px;
  margin-top: 12px;
}

.arena-prompt {
  font-family: 'Archivo Black', sans-serif;
  font-size: clamp(19px, 4vw, 26px);
  color: var(--ss-gold);
}

/* Buttons — exactly one primary per screen, always a stage verb */
.btn {
  font-family: 'Archivo Black', sans-serif;
  font-size: 15px;
  letter-spacing: 1px;
  padding: 13px 34px;
  border-radius: 10px;
  border: none;
  display: inline-block;
  background: var(--ss-gold);
  color: var(--ss-ink-on-accent);
  text-transform: uppercase;
  cursor: pointer;
  text-decoration: none;
  transition: all .15s ease;
}
/* A subdued but LIVE control — the secondary treatment. There is deliberately
   no greyed-out button CLASS here: the one we had set pointer-events: none and
   got reached for three times as if it meant "muted", shipping a dead control
   every time. A genuinely unavailable control takes the HTML `disabled`
   attribute (see the lobby's Start) or isn't rendered at all. */
.ghost {
  background: none;
  border: 1px solid rgba(255, 180, 58, .53);
  color: var(--ss-gold);
  border-radius: 6px;
  padding: 3px 10px;
  font-size: 12px;
  font-family: inherit;
  cursor: pointer;
  text-decoration: none;
  display: inline-block;
}
/* A destructive .ghost — striking a setlist/match [Task 6/7, web parity]. */
.ghost--destructive {
  border-color: var(--ss-danger);
  color: var(--ss-danger);
}

/* Compact battle card — the workhorse */
.card {
  position: relative; /* anchors .card__wear, below */
  width: 158px;
  border-radius: 10px;
  background: var(--ss-panel);
  text-align: left;
  border-left: 4px solid var(--genre, var(--ss-muted));
  box-shadow: var(--ss-card-shadow);
  padding: 10px 12px;
  transition: all .15s ease;
}

/* Road wear — mileage rungs [road wear phase 2 slice B; art/road-wear/
   manifest.json]. A NINE-SLICE overlay at cap_inset_fraction 0.24: one
   512x686 image serves the card at ANY rendered height, corners preserved
   at native scale, only the edges/middle stretch — border-image-slice: 24%
   reproduces that inset straight from the source image's own width/height
   per edge, with no per-asset pixel math on this end. The manifest's own
   note names this exact technique ("web border-image-slice"). The source's
   middle is transparent by construction (also the manifest's note), so
   there is nothing to lose by leaving off `fill` — the interior is never
   painted, only the frame. pointer-events: none keeps it decorative: clicks
   reach the card underneath unchanged. inset: 0 makes it track the card's
   OWN box regardless of content height, so a title that wraps to two lines
   doesn't leave the texture short.

   THE DESTINATION CORNER MUST MATCH THE SOURCE CORNER'S ASPECT, not just
   its area [review finding, corner distortion]. The source is 512x686 — NOT
   square — so its sliced corner region is 122.88 x 164.64 (0.24 of each
   axis independently, and this reading is MEASURED, not assumed: decoding
   all four mileage assets and checking pixel content under both readings of
   the manifest's cap_inset_fraction found zero interior ink under the
   per-axis reading on all four rungs, and residual ink under a uniform
   123px reading on two of them — with a deliberately-too-small control
   inset showing thousands of ink pixels, so the zero is not merely a glob
   that found nothing). A plain `border-width: 38px` renders that region
   into a 38x38 SQUARE regardless: 0.309x horizontal scale, 0.231x vertical,
   a 34% differential stretch baked into every corner — precisely the
   distortion a nine-slice exists to prevent, on the property that looks
   right at a glance because the number "38" came from *a* fraction of *a*
   card dimension, just not both of them at once.

   THE FIRST FIX TRIED HERE — `border-image-width: 24%` — WAS ALSO WRONG,
   and it is worth recording why rather than just replacing it silently.
   `.card` has NO fixed height (only `width: 158px` is fixed; height is
   intrinsic — the art row, a title that may wrap to one or two lines, the
   artist line, padding — and `.hand`'s flex row can stretch a short card
   taller besides), so a PERCENTAGE border-image-width does not reproduce a
   FIXED ratio at all: it makes the rendered corner's aspect track the
   CARD's own content-driven aspect on every paint, which is not 512:686
   and is not even the same as itself from one card to the next. Two
   percentages of two DIFFERENT, both-moving numbers is not "the same
   fractional geometry as the source" — it just replaced one kind of
   mismatch with a subtler one.

   THE ACTUAL FIX: an explicit PIXEL PAIR on `border-width` — vertical
   (top/bottom) = horizontal (left/right) x 1.33984, the source corner's own
   ratio (164.64 / 122.88) — is what pins the destination corner to the
   SOURCE's fixed aspect regardless of the card's own, unrelated one.
   `border-width` cannot take a percentage at all (true since CSS1), which
   is exactly why a fixed length pair is the right shape here, not a
   deficiency to work around with border-image-width.

   SIZED AGAINST A MEASURED FLOOR, not the two round numbers this comment
   used to guess: the shortest realistic card (one-line title, one-line
   artist, no winded banner — headless-Chrome-rendered against this actual
   stylesheet, spec/mileage_overlay_border_image_spec.rb's own measurement
   helper) is 109px tall, floored by the 40px art/vinyl-disc box rather than
   by text, so a shorter title cannot shrink it further. At the tempting
   38/51 pair, 2 x vertical (102px) would leave just 7px of visible middle
   on that card — technically "fits" and visually swallows the card whole.
   22px / 29.48px (same 1.33984 ratio) leaves 50px of middle on the SAME
   109px floor (46%), and even more on every taller card, since 109px is
   the floor, not the typical case. border-image-outset: 0 and
   border-image-repeat: stretch are unchanged; border-image-slice: 24% is
   unchanged and CORRECT — it resolves per-axis against the SOURCE image's
   own width/height already, which is a different property with a different
   (and here, non-defective) percentage rule than border-width's. See
   spec/mileage_overlay_border_image_spec.rb, which asserts both the ratio
   and the measured floor, and is falsified by collapsing the pair back to
   one value. */
.card__wear {
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-style: solid;
  border-width: 29.48px 22px; /* top/bottom vertical, left/right horizontal — see the comment above for the ratio and the measured floor */
  border-image-slice: 24%;
  border-image-outset: 0;
  border-image-repeat: stretch;
}
.card__wear--handled { border-image-source: url("/assets/road-wear/ship/wear_handled-512w-65453d3e.webp"); }
.card__wear--worn { border-image-source: url("/assets/road-wear/ship/wear_worn-512w-a1a324e7.webp"); }
.card__wear--road_tested { border-image-source: url("/assets/road-wear/ship/wear_road_tested-512w-cbb48388.webp"); }
.card__wear--veteran { border-image-source: url("/assets/road-wear/ship/wear_veteran-512w-be8b19db.webp"); }
.card--selected {
  outline: 1px solid var(--ss-gold);
  box-shadow: 0 0 16px rgba(255, 180, 58, .33);
  transform: translateY(-6px);
}
.card--dead { opacity: .3; }
.card__top { display: flex; justify-content: space-between; align-items: baseline; }
.card__genre {
  font-size: 10px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--genre, var(--ss-muted));
  font-weight: 700;
}
.card__cost { font-size: 12px; font-weight: 700; color: var(--ss-gold); }
.card__cost--bad { color: var(--ss-danger); }
.card__id { display: flex; gap: 8px; align-items: center; margin: 5px 0 8px; }
.card__art { width: 40px; height: 40px; border-radius: 6px; flex-shrink: 0; }
.card__title { font-family: 'Archivo Black', sans-serif; font-size: 13px; line-height: 1.15; }
.card__artist { font-size: 12px; color: var(--ss-muted); }
.card__stats { display: flex; justify-content: space-between; font-size: 13px; font-weight: 700; }
.card__winded {
  font-size: 10px;
  color: var(--ss-danger);
  margin-top: 4px;
  letter-spacing: 1px;
  text-transform: uppercase;
}

.stat--hype { color: var(--ss-hype); }
.stat--soul { color: var(--ss-soul); }
.stat--craft { color: var(--ss-craft); }
.stat--nostalgia { color: var(--ss-gold); }
.stat--genre-rock { color: var(--ss-genre-rock); }
.stat--genre-electronic { color: var(--ss-genre-electronic); }
.stat--genre-soul { color: var(--ss-genre-soul); }
.stat--genre-metal { color: var(--ss-genre-metal); }
.stat--genre-pop { color: var(--ss-genre-pop); }
.stat--genre-folk { color: var(--ss-genre-folk); }
.stat--genre-hip_hop { color: var(--ss-genre-hip-hop); }
.stat--genre-country { color: var(--ss-genre-country); }

/* Round-context ability chips — compact color-coded tags under the "tonight
   tests <stat>" label. Each chip is colored by its .stat--* class (reusing the
   stat/spot color vars); the color drives text + a faint matching border/bg. */
.kw-chips {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  margin-top: 8px;
}
.kw-chip {
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 2px 8px;
  border-radius: 999px;
  /* color comes from the .stat--* class; border/bg tint off it via currentColor */
  border: 1px solid color-mix(in srgb, currentColor 45%, transparent);
  background: color-mix(in srgb, currentColor 12%, transparent);
}

/* Vinyl-disc placeholder — art never renders empty */
.vinyl-disc {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  flex-shrink: 0;
  background: radial-gradient(circle at center,
    var(--genre, var(--ss-muted)) 0 4px,
    #0A0810 5px 12px,
    color-mix(in srgb, var(--genre, var(--ss-muted)) 20%, transparent) 13px);
  border: 1px solid color-mix(in srgb, var(--genre, var(--ss-muted)) 40%, transparent);
}

/* Genre color reaches cards only, through this one custom property */
.genre--rock { --genre: var(--ss-genre-rock); }
.genre--electronic { --genre: var(--ss-genre-electronic); }
.genre--soul { --genre: var(--ss-genre-soul); }
.genre--metal { --genre: var(--ss-genre-metal); }
.genre--pop { --genre: var(--ss-genre-pop); }
.genre--folk { --genre: var(--ss-genre-folk); }
.genre--hip_hop { --genre: var(--ss-genre-hip-hop); }
.genre--country { --genre: var(--ss-genre-country); }

.hand { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }

/* The setlist builder's row — iOS's SleeveRow shape [Task 5, web parity].
 * `.card--row` is a LAYOUT MODIFIER scoped to setlists/_card.html.erb, the
 * one call site that adds it. `.card`/`.card__*`'s base rules above stay
 * the compact portrait shape: battle_controller.js's own card templates
 * (the pick hand, the revealed round cards — see cardHtml/playedCardHtml)
 * render those SAME class names for a live match, so they cannot change
 * shape here without redrawing that screen too, which is out of this
 * task's scope. `.hand--rows` is the matching container modifier for the
 * same reason — `.hand`'s own flex-wrap grid is what the battle hand
 * renders into. */
.hand--rows { flex-direction: column; flex-wrap: nowrap; align-items: stretch; max-width: 560px; margin: 0 auto; }
.setlist-row { position: relative; }

/* Record Collection's own row list [web spokes spec "Rendering the rows"] —
 * the same vertical-stack shape `.hand--rows` gives the setlist builder,
 * without `.hand`'s pick-hand semantics (no cut form, no builder state) this
 * page has no use for. */
.record-collection { display: flex; flex-direction: column; gap: var(--ss-gap); max-width: 560px; margin: 0 auto; }
.record-collection .field { width: 100%; box-sizing: border-box; }
.record-collection__footer-cta { align-self: center; margin-top: 8px; }
.card--row {
  display: flex;
  align-items: stretch;
  width: 100%;
  min-height: 104px; /* SleeveRow.sleeveSide, floored on the info column there — the row's floor here, since this box holds both */
  padding: 0;
  border-left: none; /* the rail (below) replaces the portrait card's border-left genre cue */
  border-radius: var(--ss-radius-card);
  overflow: hidden; /* clips the sleeve to the row's own corners */
}
.card--row .card__sleeve {
  width: 104px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--ss-ink-2);
  /* [Codex P2] `.card__wear`'s own `inset: 0` (unchanged) resolves against
   * the nearest POSITIONED ancestor — without this, that's `.card--row`
   * itself (the whole row), not the sleeve, and the mileage overlay
   * stretched across the full row and painted over the info column's text.
   * SleeveRow.swift applies the mileage overlay to the sleeve, not the row;
   * this is what makes that true here too. */
  position: relative;
}
.card--row .card__art { width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
.card--row .vinyl-disc { width: 56px; height: 56px; }
.card--row .card__rail { width: 4px; flex-shrink: 0; background: var(--genre, var(--ss-muted)); }
.card--row .card__info {
  flex: 1;
  /* Without this a long, unbroken title/artist floors the flex item at its
   * min-content width and pushes .card__stats out past the row at a wide
   * viewport — the exact defect class this page was bitten by before, and
   * why it's only visible with room to distribute (a narrow phone width
   * never has slack to hide it in). See .recap-row__name's own note. */
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 4px;
  padding: var(--ss-gap) 12px;
}
.card--row .card__top { margin: 0; }
.card--row .card__genre { font-size: var(--ss-text-overline); }
.card--row .card__title { font-size: var(--ss-text-card); overflow-wrap: break-word; }
.card--row .card__artist { overflow-wrap: break-word; }
.card--row .card__stats { margin-top: 2px; }

/* The foil edge — crate tier, drawn on the row itself [Task 5 fix, Codex
 * P2]. SleeveRow's own ladder (Tokens.FoilEdge.edge(for:)/Tokens.Foil in
 * Tokens.swift): hairline at tier 0, bronze at 1, a two-stop gold gradient
 * at 2, and the same gradient plus two extra warm stops (the "prism") at 3,
 * with a soft gold glow. Values are hardcoded literals on iOS too — its own
 * comment calls this ladder a deliberate exception to the token retheme
 * ("Decision 6 keeps the heirloom Encore ladder unchanged"), so this is not
 * a token-mapping omission; it is carried the same way. */
.card--row.crate-tier--0 { border: 1px solid var(--ss-border); }
.card--row.crate-tier--1 { border: 1.5px solid #8A6A2E; }
.card--row.crate-tier--2 {
  border: 2px solid transparent;
  background:
    linear-gradient(var(--ss-panel), var(--ss-panel)) padding-box,
    linear-gradient(135deg, #FFD08A, #FFB43A) border-box;
}
.card--row.crate-tier--3 {
  border: 2px solid transparent;
  background:
    linear-gradient(var(--ss-panel), var(--ss-panel)) padding-box,
    linear-gradient(135deg, #FFD08A, #FFB43A, #FFF3D6, #FF8A5C) border-box;
  box-shadow: 0 0 5px rgba(255, 180, 58, .4); /* Tokens.Foil.prismGlowOpacity/prismGlowRadius */
}

/* An owned-but-unforged record [record-collection design §12.1: card_id can
 * be null] — Record Collection's own row state, no equivalent on a setlist
 * row. No role="button"/tabindex/data-action (see the partial), so there is
 * nothing to focus; this only overrides the base `.card { cursor: pointer }`
 * (application.css above), which would otherwise mislabel an inert row as
 * clickable. Two classes for specificity over that single-class base rule. */
.card--row.card--row--inert { cursor: default; border: 1px solid var(--ss-border); }

/* Search filters a row by setting the `hidden` ATTRIBUTE (collection_search_
 * controller.js), which the `[hidden]` UA rule normally turns into
 * `display: none` — but `.card--row { display: flex }` above has equal
 * specificity and comes later in the cascade, so it would win and leave a
 * "hidden" row still laid out. This selector's extra attribute clause gives
 * it the higher specificity that actually wins. */
.card--row[hidden] { display: none; }

/* The cut sits BESIDE the row (a sibling `<form>`, never nested inside
 * `.card`), so a click on it cannot bubble into the row's own
 * click->card-detail#openFromCard — see spec/system/card_detail_spec.rb's
 * "the cut and the row click do not fight" example. Kept relatively
 * positioned with its own stacking order regardless, per the owner's
 * standing instruction, so a future layout that brings it visually closer
 * to the row still resolves clicks to the cut, not the row underneath. */
.card__cut-form { position: relative; z-index: 2; margin-top: 6px; }
.card__cut { font-size: var(--ss-text-caption); }

/* Energy pips — always shown as a you/opponent pair */
.pips { display: flex; gap: 3px; justify-content: center; }
.pip { width: 9px; height: 14px; border-radius: 2px; background: var(--ss-gold); }
.pip--empty { background: var(--ss-inactive); }
.energy-pair { display: flex; gap: 40px; justify-content: center; }

/* Ruling card — the only amber-bordered panel in the system */
.ruling {
  background: var(--ss-panel);
  border: 1px solid rgba(255, 180, 58, .33);
  border-radius: 12px;
  padding: 14px 20px;
  max-width: 560px;
  margin: 0 auto;
  font-size: 16px;
  line-height: 1.4;
}

.detail-panel {
  background: var(--ss-panel);
  border-radius: 10px;
  border-left: 4px solid var(--genre, var(--ss-muted));
  max-width: 480px;
  margin: 12px auto 0;
  padding: 12px 16px;
  text-align: left;
}
.detail-panel__ability { color: var(--ss-gold); font-weight: 700; }
.detail-panel__themes { font-size: 12px; color: var(--ss-muted); }

.recap-row {
  background: var(--ss-panel);
  border-left: 3px solid var(--ss-gold);
  border-radius: 10px;
  padding: 12px 16px;
  font-size: 15px;
  max-width: 560px;
  margin: 10px auto;
  text-align: left;
}
.recap-row--lost { border-left-color: var(--ss-muted); }

/* The name + actions line, its own flex row so the disc and the Open/✕
 * controls sit RIGHT-ALIGNED on the same line as the record rather than
 * wrapping onto a third one — a certified row used to run visibly taller
 * than an uncertified one because the old placeholder text simply followed
 * the name inline. An uncertified row renders this flex box with just the
 * name and the actions group, so row height never depends on certification
 * status.
 *
 * [Adversarial review finding F3] Open/✕ used to be a `float: right` SPAN,
 * a sibling of this block — which worked only because everything before it
 * was still inline content sharing one line box. Turning the name+badge
 * line into its own block-level flex box broke that: the float no longer
 * shared a line with anything, floats do not contribute to their parent's
 * HEIGHT, and the row could not grow to contain it — the controls rendered
 * in the row's bottom padding and touched the next card. Moving them INTO
 * this flex row (`.recap-row__actions`, normal flow, not floated) is what
 * fixes it: the row's height now genuinely includes them, for a certified
 * row and an uncertified one alike. */
.recap-row__title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  /* A FLOOR, not a proof of equality — pinned by spec/requests/setlists_spec.rb
   * ("the certification badge" / "pins the name line to the disc's own
   * height"), which checks only that this rule exists, not any rendered
   * height. At this row's own font-size (15px, ~21px natural line box) the
   * floor is what actually governs, so a certified and an uncertified row
   * match in practice — but nothing here stops a LONGER name from wrapping
   * or a larger user font-size setting from pushing a row taller than 26px,
   * certified or not. */
  min-height: 26px;
}
/* The disc (when present) and the Open/✕ controls, grouped so they move
 * together at the row's trailing edge — same reasoning as `.recap-row__title`
 * above, one level in. `flex-shrink: 0` is deliberate: these controls must
 * never compress or wrap, only the name (below) gives up space. */
.recap-row__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* [Codex, PR #871 — the same class of bug the float fix lives inside]
 * A flex item's default `min-width: auto` floors it at its own min-content
 * width, and for a single unbroken word min-content IS the whole word — no
 * amount of `flex-shrink` (the default `1`, already in effect here) can
 * compress a flex item below that floor. `Setlist` names have no length or
 * word-boundary constraint, so a single long word (no spaces to wrap at)
 * pushed `.recap-row__actions` off the card or overlapped it, on a
 * certified AND an uncertified row alike — measured with a probe using an
 * unbroken 40+ character name, not the spaced one the earlier float
 * measurement happened to use, which wraps and never hits this floor.
 * `min-width: 0` removes the floor; `overflow-wrap: break-word` is what
 * then lets the name actually WRAP mid-word rather than simply overflow
 * once shrunk to nothing — never a `lineLimit`-style truncation, which
 * would hide characters at the default text size exactly the way this
 * branch already removed one for, on iOS. */
.recap-row__name { min-width: 0; overflow-wrap: break-word; }

/* Tappable rows [Task 6/7, web parity] — the stretched-link pattern. A row
 * that is both fully tappable and carries other content can't be an anchor
 * wrapping everything (a form can't nest inside an anchor, and the "On tour"
 * link/certification badge are siblings, not descendants, of the name).
 * Instead ONE real anchor (`.recap-row__name-link`) gets a `::after`
 * stretched over the row's whole box via `position: absolute; inset: 0`,
 * resolved against `.recap-row`'s own `position: relative` below — so the
 * entire row is clickable through a real anchor, not a synthetic handler.
 * Reused by matches/_match_row.html.erb (Task 7), which shares `.recap-row`. */
.recap-row { position: relative; }
.recap-row__name-link {
  color: inherit;
  text-decoration: none;
}
.recap-row__name-link::after { content: ""; position: absolute; inset: 0; }
/* Anything inside `.recap-row` a pointer is meant to reach — a click OR a
 * hover affordance like a `title` tooltip — needs to sit ABOVE the stretched
 * area or it gets swallowed. [Codex P2] `.recap-row__actions` (the
 * certification badge) was missed on the first pass: the badge itself has
 * no click handler, but its `title` attribute is the ONLY surface exposing
 * every tier earned (the visible glyph shows just the highest), so an
 * unreachable hover silently deletes that information rather than merely
 * degrading a nice-to-have. Enumerated here rather than left to be found a
 * third time:
 *   setlists/index.html.erb  — .recap-row__ontour ("On tour"), .recap-row__actions (cert badge, title=)
 *   matches/_match_row.html.erb — .recap-row__challenge-input (onclick), .recap-row__challenge-form ("Tear it down")
 *
 * LIFT THE CONTROL, NOT ITS CONTAINER [Codex P2, round 2]. `.recap-row__challenge`
 * itself used to carry this same treatment — but it's a plain block `<div>`
 * with no explicit width, so it sizes to the ROW's full content width by
 * default, and lifting it turned every blank pixel beside the (narrower)
 * input and button into a dead click target: measured at ~433px of a 468px
 * row on a real awaiting_opponent match. `.recap-row__ontour` and
 * `.recap-row__actions` were checked against the same test (measured, not
 * assumed) and are fine as-is — an inline `<a>` and a flex item with no
 * flex-grow both size to their own content (~50px and ~60px of a
 * 468-560px row), never the row's full width. */
.recap-row__ontour { position: relative; z-index: 1; }
.recap-row__actions { position: relative; z-index: 1; }
.recap-row__challenge-input,
.recap-row__challenge-form { position: relative; z-index: 1; }

/* Text for assistive technology only — off-screen but still IN the
 * accessibility tree, which is the whole point: `display: none` and
 * `visibility: hidden` both REMOVE an element from that tree, so neither can
 * carry a name. The standard clip/1px pattern instead: laid out, painted into
 * a 1px box, then clipped to nothing. Absolutely positioned, so it adds no
 * width, no line box, and no flex gap wherever it is dropped in. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Gold Record certification disc [gold-record-certification design,
 * ratified disc treatment]. Three stacked layers per the mockup: a spindle
 * hole, a groove texture, and a conic-gradient metal sheen — drawn once here
 * and coloured per tier below, never a shipped image. */
.cert-badge { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
.cert-badge__tier {
  font-family: 'Special Elite', monospace;
  font-size: 10px;
  letter-spacing: .08em;
}
.cert-badge__disc {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  flex-shrink: 0;
  background:
    radial-gradient(circle at 50% 50%, #0A0A0B 0 14%, transparent 14.5%),
    repeating-radial-gradient(circle at 50% 50%, rgba(0, 0, 0, .22) 0 1px, transparent 1px 3px),
    conic-gradient(from 210deg, var(--ss-m-deep), var(--ss-m-lit) 18%, var(--ss-m) 34%,
                   var(--ss-m-deep) 50%, var(--ss-m-lit) 68%, var(--ss-m) 84%, var(--ss-m-deep));
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .6), 0 3px 10px rgba(0, 0, 0, .5);
}
.cert-badge--gold .cert-badge__tier { color: var(--ss-gold); }
.cert-badge--gold .cert-badge__disc { --ss-m: var(--ss-gold); --ss-m-deep: var(--ss-gold-deep); --ss-m-lit: var(--ss-gold-lit); }

.cert-badge--platinum .cert-badge__tier { color: var(--ss-platinum); }
.cert-badge--platinum .cert-badge__disc {
  --ss-m: var(--ss-platinum); --ss-m-deep: var(--ss-platinum-deep); --ss-m-lit: var(--ss-platinum-lit);
}

.cert-badge--diamond .cert-badge__tier { color: var(--ss-diamond); }
/* Diamond starts its sweep from 200deg (not 210) and weaves --ss-diamond-alt
 * through the ramp in place of the middle deep stop — colour alone is what
 * separates Diamond from Platinum [owner ruling: no different shape or
 * object], so the alt hue has to show up IN the sweep, not just at the edge. */
.cert-badge--diamond .cert-badge__disc {
  --ss-m: var(--ss-diamond); --ss-m-deep: var(--ss-diamond-deep); --ss-m-lit: var(--ss-diamond-lit);
  background:
    radial-gradient(circle at 50% 50%, #0A0A0B 0 14%, transparent 14.5%),
    repeating-radial-gradient(circle at 50% 50%, rgba(0, 0, 0, .22) 0 1px, transparent 1px 3px),
    conic-gradient(from 200deg, var(--ss-m-deep), var(--ss-m-lit) 18%, var(--ss-m) 34%,
                   var(--ss-diamond-alt) 50%, var(--ss-m-lit) 68%, var(--ss-m) 84%, var(--ss-m-deep));
}

/* Status banner — dashed means "temporary condition", the only dashed border */
.banner {
  border: 1px dashed rgba(156, 147, 176, .4);
  border-radius: 8px;
  padding: 8px 12px;
  font-size: 14px;
  color: var(--ss-muted);
  max-width: 480px;
  margin: 10px auto;
  text-align: center;
}

.panelbox { background: var(--ss-panel); border-radius: 12px; padding: 20px 24px; }

/* Tappable "choice rows" [start-match iOS parity: StartMatchView's
 * SetlistRow/ChoiceRow] — the setlist and opponent pickers' whole
 * interaction now. Each row's entire box IS the control: a full-width <a>
 * when tapping it only navigates (a setlist opening step 2, Showdown Night
 * opening the host form), a full-width <button type="submit"> inside its own
 * `button_to` form when tapping it ACTS (books a match, posts a tour
 * poster) — never a radio input paired with a separate submit button. */
.choice-row {
  display: block;
  width: 100%;
  max-width: 560px;
  margin: 10px auto;
  text-align: left;
  background: var(--ss-panel);
  border: 1px solid var(--ss-border);
  border-radius: var(--ss-radius-card);
  padding: 14px 18px;
  font-family: inherit;
  color: var(--ss-cream);
  text-decoration: none;
  cursor: pointer;
  transition: border-color .15s ease, opacity .15s ease;
}
.choice-row:hover, .choice-row:focus-visible { border-color: var(--ss-gold); }
/* The in-flight state [start-match iOS parity: `isStarting` /
 * `.disabled(isStarting)`] — controllers/submit_once_controller.js locks the
 * WHOLE opponent section the moment any one row's booking POST is under
 * way, so no other row (button OR the Showdown Night link) can be tapped
 * into a second, different booking before the first navigates away.
 * `:disabled` is the three `button_to` rows' own state; `[aria-disabled]` is
 * the anchor's — it has no `disabled` attribute of its own to set. */
.choice-row:disabled, .choice-row[aria-disabled="true"] { opacity: .5; cursor: default; }
.choice-row[aria-disabled="true"]:hover { border-color: var(--ss-border); }
/* A setlist that can't headline yet — SHOWN, not hidden [iOS parity:
 * `setlistGap` / `SetlistRow.selectable`], but inert: a plain <div>, no href
 * and no button beneath it, so it promises no tap it can't honor. */
.choice-row--inert { cursor: default; border-color: var(--ss-border-dim); opacity: .7; }
.choice-row--inert:hover { border-color: var(--ss-border-dim); }
.choice-row__title {
  display: block;
  font-family: 'Archivo Black', sans-serif;
  font-size: 15px;
  letter-spacing: .5px;
}
.choice-row__subtitle {
  display: block;
  margin-top: 4px;
  font-size: 13px;
  color: var(--ss-muted);
}

/* The show poster — gig-poster talent, so it hangs on a panel surface.
 * Self-contained (no .panelbox composition: same-specificity padding
 * would ride on source order). The share view's sizes="280px" is
 * max-width minus the 2×10px padding — keep them in sync. */
.poster {
  background: var(--ss-panel);
  border-radius: 12px;
  padding: 10px;
  max-width: 300px;
  margin: 20px auto 0;
  box-shadow: var(--ss-card-shadow);
}
.poster img { display: block; width: 100%; height: auto; border-radius: 8px; }

/* The judge's taped portrait + catchphrase [judge-reveal-portrait-catchphrase,
 * `#sm3` step 3] — mirrors StartMatchView.swift's judgePortrait "taped
 * polaroid", re-expressed in the ratified web frame idiom rather than
 * iOS's bespoke cream stroke: --ss-frame (the same gold emphasis border
 * every framed/poster block on web already draws) and --ss-radius-card,
 * not a one-off. Unlike the show-poster block just above, this is the one
 * place --ss-font-marker ships on web — ratified for "graffiti, judge
 * quotes only", and a judge's own catchphrase is exactly that; Permanent
 * Marker is pulled into this file's Google Fonts import above for it, the
 * first use of that family outside iOS.
 * `sizes="332px"` on the view's own <img> is this card's own widest real
 * content width — max-width (360) minus 2×10px padding AND the 4px frame
 * border on each side (this file sets `box-sizing: border-box` globally
 * near its top, so max-width already includes both): 360 − 20 − 8 = 332.
 * A narrower viewport (the shared page wrapper's own side padding binds
 * before this card's max-width does) renders the photo SMALLER than that, never
 * larger, so 332px is a safe upper bound for the srcset pick, not an
 * exact fit at every width [P3 finding, adversarial review: the prior
 * comment's 340px figure forgot the border, though the resulting file
 * choice was never wrong — a two-file 340w/680w srcset only has to draw
 * one 1x/2x line, and 332px sits on the same side of it as the true value
 * everywhere this app renders]. Keep this number in sync with the box's
 * own padding/border/max-width above if any of them change. */
.judge-portrait {
  background: var(--ss-panel);
  border: var(--ss-frame);
  border-radius: var(--ss-radius-card);
  padding: 10px;
  max-width: 360px;
  margin: 20px auto 0;
}
.judge-portrait__photo {
  display: block;
  width: 100%;
  height: auto;
  border-radius: calc(var(--ss-radius-card) - 8px);
}
.judge-portrait__quote {
  margin: 10px 0 0;
  font-family: var(--ss-font-marker);
  font-size: 22px;
  line-height: 1.3;
  color: var(--ss-cream);
  text-align: center;
}

/* The login hero — the one full-width photograph in the system, reserved
 * for the "walking into the venue" moment before the sign-in form. Bottom
 * fade blends it into --ss-ink so it reads as atmosphere the page continues
 * into, not a pasted-in photo insert. */
.hero-stage {
  position: relative;
  margin: 0 0 22px;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: var(--ss-card-shadow);
}
.hero-stage img { display: block; width: 100%; height: auto; }
.hero-stage::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 45%;
  background: linear-gradient(to bottom, transparent, var(--ss-ink));
  pointer-events: none;
}

.field {
  background: var(--ss-panel);
  border: 1px solid rgba(156, 147, 176, .27);
  border-radius: 9px;
  padding: 12px 14px;
  color: var(--ss-cream);
  font-size: 16px;
  font-family: inherit;
  outline: none;
}

/* The store's per-genre crate rows [merch-store design §5, web slice 7]. One
 * row per Bundle::GENRES entry: the genre label on the left, a Buy button on
 * the right — no genre color here, matching the store's neutral-chrome stance
 * (genre color is cards-only per BRANDING, same reason .hit-list stays neutral
 * below). */
.crate-grid { display: flex; flex-direction: column; gap: 6px; }
.crate-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 8px 10px;
  border-radius: 8px;
  background: color-mix(in srgb, var(--ss-panel) 70%, transparent);
}
.crate-row__genre { text-transform: capitalize; color: var(--ss-cream); }
.crate-row .btn { padding: 6px 16px; font-size: 14px; }
.crate-row .btn:disabled { opacity: .5; cursor: default; }

/* The store's spend-confirm dialog [merch spend-confirm gap]. A native
 * <dialog>: no custom scrim, focus trap, or Escape handling to hand-roll —
 * `showModal()` supplies all three. Content only (headline, an optional
 * price line, Buy/Walk away), matching PurchaseConfirmCard's contract
 * (ios/…/Merch/PurchaseConfirmCard.swift) rather than its layout.
 *
 * PADDING LIVES ON `__panel`, NOT ON `.confirm-dialog` ITSELF — an outer
 * element with no padding of its own, wrapping a padded inner panel, for the
 * reason: `store_controller.js#dialogBackdropClick` tells a real
 * backdrop click from a content click by `event.target === dialog`, and a
 * dialog with its OWN padding would make blank space inside the panel part
 * of the dialog element itself — a tap on that whitespace would read as a
 * backdrop click and cancel the confirm out from under the player. */
.confirm-dialog {
  border: none;
  border-radius: 14px;
  padding: 0;
  max-width: 360px;
  width: calc(100% - 32px);
  overflow: hidden; /* clips __panel's corners to the dialog's own radius */
}
.confirm-dialog::backdrop { background: rgba(19, 16, 25, .82); }
.confirm-dialog__panel {
  padding: 20px 22px;
  background: var(--ss-panel);
  color: var(--ss-cream);
  box-shadow: 0 8px 40px rgba(0, 0, 0, .55);
}
.confirm-dialog__headline { margin: 0; font-size: 17px; font-weight: 700; }
.confirm-dialog__price { margin: 10px 0 0; font-size: 14px; color: var(--ss-gold); }
.confirm-dialog__actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }

/* THE ACHIEVEMENT-EARNED CELEBRATION [achievement-earned-celebration
 * design]. `.confirm-dialog`'s own shape exactly — a native <dialog>, no
 * hand-rolled scrim or focus trap, `showModal()` (achievement_celebration_
 * controller.js) supplies both via `::backdrop`. TOP-LAYER BY CONSTRUCTION:
 * a <dialog> shown modally paints outside normal document flow, so this
 * needs no `padding-top` of its own and cannot collide with the home
 * control's clearance rule (`body.home-control-reserve .marquee, .top40`)
 * however that number changes — the two features share nothing to drift
 * out of sync on.
 *
 * `.patch-grid`/`.patch` (bragging_rights/_patch.html.erb) render UNCHANGED
 * inside `__patches` — no second patch card style. */
.achievement-celebration {
  border: none;
  border-radius: var(--ss-radius-sheet);
  padding: 0;
  max-width: 420px;
  width: calc(100% - 32px);
  max-height: calc(100% - 64px);
  overflow: hidden;
}
.achievement-celebration::backdrop { background: var(--ss-scrim); }
.achievement-celebration__panel {
  display: flex;
  flex-direction: column;
  max-height: calc(100vh - 64px);
  padding: var(--ss-pad-screen);
  background: var(--ss-panel);
  color: var(--ss-cream);
  box-shadow: var(--ss-shadow-sheet);
}
.achievement-celebration__header { text-align: center; margin-bottom: 14px; }
.achievement-celebration__title {
  font-family: var(--ss-font-label);
  font-size: var(--ss-text-card);
  text-transform: uppercase;
  margin: 4px 0 0;
}
.achievement-celebration__patches { overflow-y: auto; margin: 0 0 18px; }
.achievement-celebration .btn { width: 100%; text-align: center; border: none; }

/* Song-search results — the catalog + library typeahead dropdowns.
 * Chrome, so it stays neutral with the amber spotlight as its only accent
 * (genre color is cards-only per BRANDING). The surface only paints when
 * populated (:not(:empty)) so an emptied container leaves no floating box.
 * Shared by both the catalog and "From your library" strips so they match. */
.hit-list { display: flex; flex-direction: column; gap: 4px; }
.hit-list:not(:empty) {
  margin-bottom: 10px;
  padding: 6px;
  border: 1px solid rgba(156, 147, 176, .18);
  border-radius: 10px;
  background: color-mix(in srgb, var(--ss-panel) 70%, transparent);
  box-shadow: var(--ss-card-shadow);
}
/* The in-world "crates are jammed" note sits on the same surface. */
.hit-list .label { margin: 0; padding: 4px 6px; }

/* One hit: [art on the left] + [title over artist]. Composes with .ghost but
 * overrides its amber outline/compact padding — declared after .ghost so it
 * wins at equal specificity. */
.catalog-hit {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: none;
  color: var(--ss-cream);
  text-align: left;
  cursor: pointer;
  font-family: inherit;
  transition: background .12s ease, border-color .12s ease;
}
.catalog-hit:hover,
.catalog-hit:focus-visible {
  outline: none;
  background: color-mix(in srgb, var(--ss-gold) 12%, transparent);
  border-color: color-mix(in srgb, var(--ss-gold) 40%, transparent);
}
.catalog-hit__art {
  width: 44px;
  height: 44px;
  flex-shrink: 0;
  object-fit: cover;
  border-radius: 6px;
  background: var(--ss-ink); /* placeholder tone before the lazy image paints */
}
/* min-width:0 lets the ellipsis engage instead of blowing out the 480px panel */
.catalog-hit__body { display: flex; flex-direction: column; min-width: 0; }
.catalog-hit__title,
.catalog-hit__sub {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.catalog-hit__title { font-size: 15px; font-weight: 600; color: var(--ss-cream); }
.catalog-hit__sub { font-size: 12px; color: var(--ss-muted); }

.math-line { font-size: 13px; color: var(--ss-muted); }
.math-line b { color: var(--ss-cream); }

.vs {
  font-family: 'Archivo Black', sans-serif;
  font-size: 20px;
  color: var(--ss-gold);
}

.cta-row { margin-top: 18px; }

/* Pinned bottom CTA, fixed to the viewport [owner: it scrolled off past one
 * screen of setlists and became undiscoverable]. A MODIFIER on .cta-row, not
 * a redefinition — that base rule is shared site-wide (.welcome .cta-row
 * included). Originally setlists#index alone; a second page (the judge
 * reveal screen, judge-reveal-portrait-catchphrase P2) now reuses it
 * verbatim for the same reason — its own portrait+rules content can run
 * long enough to push a forward-only CTA below the fold — rather than
 * inventing a page-specific twin. Unlike the deleted .house--tabbed/
 * .tab-bar mechanism this deliberately does not resurrect (that pinned a
 * cross-section NAV bar across every shell page: the ⌂ home control now
 * covers that job from the top-right), this is still just a page's own
 * CTA, fixed the same way .match-action-bar fixes the match stage's — same
 * safe-area idiom (iOS: keep clear of the home indicator). */
.cta-row--pinned {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 30; /* freed by the deleted .tab-bar; below the top chrome (50) */
  margin: 0;
  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
  background: var(--ss-panel);
  border-top: 1px solid rgba(156, 147, 176, .16);
  box-shadow: 0 -3px 14px rgba(0, 0, 0, .33);
}
/* Clears the pinned CTA above so whatever sits in flow right before it is
 * never hidden underneath it — same clearance figure either way, since
 * both callers pin the exact same single-button row shape. The setlists list
 * wrapper below applies it to a whole list's last row; the judge reveal
 * screen's own escape link applies the modifier further down to just that
 * one row instead, having no list wrapper of its own to hang it on. */
.setlist-list { padding-bottom: calc(72px + env(safe-area-inset-bottom)); }
.cta-row--pinned-clearance { margin-bottom: calc(72px + env(safe-area-inset-bottom)); }

.error-list { color: var(--ss-danger); font-size: 14px; list-style: none; padding: 0; }

/* Forms (auth, builder) */
.auth-form { max-width: 380px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }

/* Federated sign-in (Google/Apple) [SP-5c]. Sits BELOW the primary form's
 * `.btn` — BRANDING §5's "exactly one primary button per screen" still
 * holds, so these read as an alternate DOOR, not a second primary action.
 * Google's own renderButton draws itself (matched to the dark house via
 * theme:"filled_black" in federated_sign_in_controller.js); the Apple
 * button here is hand-styled to Apple's own minimum contrast/wording rule
 * ("Continue with Apple", not in-world copy — a provider's own sign-in
 * button is the one control this app does not reword, BRANDING §7's own
 * "announcer register" rule is about OUR copy, not a third party's mark). */
.auth-federated { max-width: 380px; margin: 14px auto 0; display: flex; flex-direction: column; gap: 10px; }
.auth-divider {
  display: flex; align-items: center; gap: 10px;
  color: var(--ss-muted); font-size: 11px; letter-spacing: 2.5px; text-transform: uppercase;
}
.auth-divider::before, .auth-divider::after { content: ""; flex: 1; height: 1px; background: rgba(156, 147, 176, .27); }
.federated-btn-slot { display: flex; justify-content: center; }
.federated-btn {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  width: 100%; box-sizing: border-box; height: 40px; border-radius: 20px;
  font-family: inherit; font-size: 15px; font-weight: 600; cursor: pointer;
}
.federated-btn--apple { background: #000; color: #fff; border: none; }

/* Large card detail view — art as hero (BRANDING §9 inverted ratio).
 * Existing tokens only; the genre strip carries the card's color. */
.card-detail {
  position: fixed;
  /* inset:0 is the pre-dvh fallback; 100dvh is the *visible* viewport, so on
   * iOS the panel fits whatever height the chrome leaves — on 100vh (the
   * chrome-RETRACTED height) it overflowed the visible area and clipped
   * content at the bottom edge. */
  inset: 0;
  height: 100dvh;
  box-sizing: border-box;
  background: rgba(19, 16, 25, .82);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: max(20px, env(safe-area-inset-top)) 20px max(20px, env(safe-area-inset-bottom));
  z-index: 20;
}
/* A live pick delegates Perform to the fixed Match Action Bar [perform-in-bar]:
   the centered panel takes extra bottom room so it clears the bar pinned
   beneath it (84px bar + a 20px gap + the home-indicator inset). Must follow
   .card-detail to win the equal-specificity padding override. */
.card-detail--barred {
  padding-bottom: calc(84px + 20px + env(safe-area-inset-bottom));
}
.card-detail__panel {
  background: var(--ss-panel);
  border-radius: 12px;
  /* The spotlight hits the talent: amber surround so the card never blends
   * into the darkened house (owner QA), genre strip stays on the left. */
  border: 1px solid rgba(255, 180, 58, .53);
  border-left: 4px solid var(--genre, var(--ss-muted));
  box-shadow: 0 0 40px rgba(255, 180, 58, .25), 0 8px 30px rgba(0, 0, 0, .5);
  max-width: 420px;
  width: 100%;
  /* Bounded by the padded overlay, which is itself the visible viewport. */
  max-height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 16px 18px;
  text-align: left;
}
/* Both labels (▶ Preview / ◼ Stop) share one footprint — no layout jump.
 * The glyphs come from different fallback fonts with different vertical
 * metrics, so the box is pinned in BOTH dimensions and flex-centered;
 * line-height 1 stops a tall glyph from inflating the line box. */
.card-detail__actions .ghost,
.card-detail__actions .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
}
.card-detail__actions .ghost { height: 32px; }
.card-detail__preview { min-width: 96px; }

/* Deliberation interstitial — visible activity while the judge model call
 * is in flight. The gentle pulse is a loading state, not decoration. */
.deliberation__quote { font-size: 16px; font-style: italic; color: var(--ss-muted); }
.deliberation__pulse { animation: deliberation-pulse 1.6s ease-in-out infinite; }
@keyframes deliberation-pulse {
  50% { opacity: .35; }
}
/* The art is the hero AND the frame for the card's controls: position:relative
 * anchors the cost pill, the ✕, and the bottom stat/preview strip over it;
 * overflow clips them to the rounded corners. */
.card-detail__hero {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  background: #0A0810;
  border-radius: 8px;
  min-height: 180px;
  overflow: hidden;
}
/* `.card-detail__hero--laminate-full { min-height: 256px; }` — REMOVED, road
 * wear phase 2 single-size pass. It existed only because a full-state (4-7)
 * placeholder badge at the old 36% fraction (~157px on the widest panel)
 * didn't fit the ordinary 180px floor once the cost pill and hero-bar were
 * accounted for (~231px needed; see git history for the exact measurement
 * this rule used to carry). Every laminate state now renders at ONE fraction
 * (18%, `.card-detail__hero-corner--laminate-half` below) — art/road-wear/
 * manifest.json's laminate.decision_record has the full account of why.
 *
 * NOT assumed from the arithmetic alone, given this exact rule's own
 * history (the height/viewport axis this feature has been wrong on four
 * separate times — see `.card-detail__hero-corner`'s max-height comment,
 * below, for the full account): a continuous inner-height sweep (380-950px,
 * 10px steps, 58 points) of state 7's placeholder hero measured EXACTLY
 * 200px at every single point, zero variance — `spec/system/
 * card_detail_laminate_corner_spec.rb`'s own placeholder examples pin this
 * number. 200px is the vinyl disc's OWN content height (160px + its 20px
 * top/bottom margins) — the placeholder hero's actual rendered height was
 * never the 180px min-height floor to begin with, content already exceeds
 * it, and content reads no viewport-relative unit at all (unlike real
 * art's `44dvh`). So the placeholder hero is provably constant regardless
 * of screen height, not merely small at whichever height got sampled —
 * and at 18% of that constant 200px-tall hero, state 7's badge clears the
 * cost pill + gap + hero-bar with room matching state 1's own margin.
 * (a card_detail_controller.js JS change removed the class that applied
 * this rule in the same pass — grep this file's history for
 * `laminateHeroSizeClass` if either needs to be reconstructed). This
 * paragraph stays as the "why it's gone" record `.card-detail__hero-corner`'s
 * own max-height comment (below) points back to.
 */
/* Cap the art so the whole card fits a phone without the footer clipping under
 * the fixed tab bar — the art is the biggest height consumer [owner rework #6].
 * cover (not contain) keeps it crisp edge-to-edge; the square source crops a
 * little top/bottom, staying centered. */
.card-detail__art {
  width: 100%;
  max-height: 44dvh;
  object-fit: cover;
  border-radius: 8px;
}
.card-detail__vinyl { width: 160px; height: 160px; margin: 20px 0; }
/* Two corner overlays over a dark scrim so they read on any art: the cost pill
 * balances the ✕ close, one in each top corner, reclaiming the info-block rows
 * they used to sit on. */
/* The top-left corner cluster [laminate-on-art-corner, owner instruction: "put
 * it in the upper left corner of the album art"]. The cost pill got there
 * FIRST and is a functional gameplay number, not decoration — it keeps the
 * literal top-left pixel, and the laminate (new, decorative) stacks directly
 * beneath it at the SAME left inset rather than evicting it. iOS's hero has
 * no cost pill at all, so RoadWearLaminateBadge sits at the literal corner
 * there — see that view's own doc. That is one client already owning that
 * pixel, not a parity gap: if the pill ever moves, the laminate slides up and
 * the two clients converge with no further change here. flex-direction:
 * column + align-items: flex-start stacks whatever is present (pill alone,
 * or pill + laminate) without hardcoding the pill's rendered height — the gap
 * is a fixed 6px regardless of how tall the pill's own box turns out to be.
 */
.card-detail__hero-corner {
  position: absolute;
  top: 8px;
  left: 8px;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  /* Never let the corner's own content exceed the room between the top
   * inset and the hero-bar strip — needed regardless of laminate size: at a
   * short/landscape viewport, REAL art's own height (`.card-detail__art`'s
   * `max-height: 44dvh`) can be short enough that the cost pill + gap +
   * badge no longer fit above the hero-bar, even though hero is correctly
   * art-sized with no letterboxing [#888 follow-up]. 100% here is hero's
   * OWN height (a flex container, so its used height is definite even
   * without an authored one) minus 8px (this element's own top inset) minus
   * the hero-bar's own height minus a 4px margin.
   *
   * THE HERO-BAR IS NOT A CONSTANT 38px — that was this reservation's own
   * defect [#888 second follow-up, Codex]: `.card-detail__hero-bar .ghost`
   * (▶ Preview, rendered whenever `c.preview_url` is present) is pinned to
   * 30px tall, taller than the H/S/C stats' own ~20px line box, so the
   * bar itself grows from 38px (stats only) to 48px (stats + Preview) —
   * confirmed by measuring both DOM shapes against the real CSS in a
   * standalone headless-Chrome fixture, not read off the button's CSS in
   * isolation: 38px and 48px exactly, matching the 30px ghost height + 18px
   * bar padding either way. A card can gain a preview button AFTER this
   * corner has already rendered — `showPreviewButton` (card_detail_
   * controller.js) appends it on late enrichment without ever touching or
   * re-measuring this element — so reserving only the narrower, no-preview
   * bar height is wrong even for a card that opens with no preview yet: the
   * bar can grow out from under an already-laid-out badge with no signal
   * back to this CSS. Reserving the WIDER (48px) bar unconditionally is the
   * only reservation that stays correct across that late transition, so
   * this counts BOTH stats-row content states as one unmeasured worst case,
   * not the narrower one — 8px inset + 48px bar + 4px margin = 60px.
   *
   * MEASURED against the real render at a real short-viewport fixture
   * (headless Chrome), not guessed: at exactly 46px reserved (the
   * ORIGINAL, no-preview-only version of this fix) the laminate's own
   * bottom edge landed EXACTLY on the hero-bar's top (zero margin, too
   * fragile against sub-pixel rounding); with the bar widened to 48px,
   * that same 46px reservation went NEGATIVE (-6px, a real 6px overlap,
   * reproduced exactly). 60px leaves the same 4px clear margin against the
   * WIDER bar, confirmed constant across a continuous sweep of inner
   * heights 380-950px (10px steps, 58 points) with a real preview button
   * present, AT THE OLD 36% full-state badge — margin held at exactly 4px
   * while the cap was the binding constraint, then only grew as the cap
   * stopped binding, never dipped, never crossed zero. The no-preview case
   * was already re-verified clear at every point in the same sweep (14px
   * margin instead of 4px, since the same 60px reservation must also hold
   * the narrower bar). DO NOT RE-DERIVE THIS NUMBER for the single-size
   * pass [road wear phase 2] — every laminate badge is smaller now (one 18%
   * fraction for every state, down from up to 36%; see
   * `.card-detail__hero-corner--laminate-half` below and art/road-wear/
   * manifest.json's laminate.decision_record), and this reservation's own
   * space is unchanged, so every one of these measured margins can only be
   * LARGER today, never smaller — a smaller badge in the same reserved
   * space is strictly more conservative, not a case that needs reproving.
   * The placeholder-hero case is simpler still now that
   * `.card-detail__hero--laminate-full`'s old 256px floor is gone (see that
   * rule's own removal note, above `.card-detail__art`): every placeholder
   * hero is the ordinary 180px floor, and the widest badge that can sit in
   * it is the same 18% fraction as everywhere else.
   * `.card-detail__laminate`'s own doc explains what actually shrinks to
   * respect this cap. */
  max-height: calc(100% - 60px);
}
/* Sized as a fraction of the corner container's own width, which is a
 * fraction of `.card-detail__hero` (the art's rendered width — the art is
 * `width: 100%` of hero, so hero's width IS the art's width). A fraction,
 * not a pixel count, so the badge stays in proportion as the art itself
 * grows or shrinks. iOS's `RoadWearLaminateBadge.widthFraction` reads the
 * SAME fraction off its hero's measured width (`RoadWear.swift`'s own doc
 * names this file back) — change one, change both.
 *
 * ONE FRACTION FOR EVERY STATE [road wear phase 2, single-size pass].
 * States 4-7 used to render at 36% (retired `.card-detail__hero-corner
 * --laminate-full`, on a full-size 1200x1360 page) while 1-3 stayed at this
 * 18% — see git history and art/road-wear/manifest.json's
 * laminate.decision_record for the full account of why the 36% variant is
 * gone: the owner found the full-sized badge overwhelming on device, and
 * every state now renders on the smaller (600x680) page this fraction was
 * already tuned for. The "-half" in this class name is a holdover from
 * when it contrasted with "-full" — kept rather than renamed to avoid
 * touching every call site for a label that's still accurate (18% IS half
 * of the old 36%), not because "half" still means anything relative to a
 * second size.
 */
.card-detail__hero-corner--laminate-half { width: 18%; }
.card-detail__cost {
  padding: 4px 9px;
  border-radius: 999px;
  background: color-mix(in srgb, #000 55%, transparent);
  font-size: 14px;
  line-height: 1;
  /* Never the thing that shrinks when `.card-detail__hero-corner`'s own
   * max-height runs out of room — the laminate absorbs all of that (see its
   * own doc); the cost pill is a functional gameplay number and keeps its
   * natural size regardless. */
  flex-shrink: 0;
}
.card-detail__close {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: none;
  border-radius: 999px;
  background: color-mix(in srgb, #000 55%, transparent);
  color: var(--ss-cream);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: background .15s ease;
}
.card-detail__close:hover { background: color-mix(in srgb, #000 75%, transparent); }
.card-detail__close:focus-visible { outline: 2px solid var(--ss-gold); outline-offset: 2px; }
/* The bottom strip: H/S/C left, ▶ Preview right, on a translucent black fill
 * so both read over the art [owner rework #1–3]. */
.card-detail__hero-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 9px 12px;
  background: rgba(0, 0, 0, .72);
}
.card-detail__hero-bar .ghost {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  height: 30px;
  flex-shrink: 0;
}
.card-detail__title {
  font-family: 'Archivo Black', sans-serif;
  font-size: 22px;
  line-height: 1.1;
  margin-top: 6px;
}
/* The H/S/C row, now the left side of the hero strip (over the 85% black fill),
 * not an info-block row. */
.card-detail__stats {
  display: flex;
  gap: 20px;
  font-size: 17px;
  font-weight: 700;
}
/* Genre eyebrow leading the info block below the art. */
.card-detail__genre { margin-top: 12px; }
.card-detail__winded {
  color: var(--ss-danger);
  font-weight: 700;
  letter-spacing: 1px;
  text-transform: uppercase;
  font-size: 13px;
  margin-top: 10px;
}
/* Keyword abilities on the large card [Task 1.7]. A quiet block under the
 * flavor move: the "Abilities" key uses the shared .label (dim, uppercase),
 * each ability's NAME in the amber spot (like .detail-panel__ability) with its
 * effect dim beside it — Marquee Night, subordinate to the hero art/stats. */
.card-detail__abilities { margin-top: 8px; }
.card-detail__ability { font-size: 13px; color: var(--ss-muted); line-height: 1.4; }
.card-detail__ability-name { color: var(--ss-gold); font-weight: 700; }

/* Win laminate — DETAIL SHEET ONLY [road wear phase 2 slice B, owner ruling:
 * at card-grid scale the stamps are unreadable, so this never appears on
 * .card]. Not a nine-slice — the laminate is one flat sticker image per
 * state, shown at its own natural aspect (192:218, the one page size every
 * state ships now — see art/road-wear/manifest.json's laminate.px).
 * Overlaid on the album art's top-left corner [laminate-on-art-corner,
 * owner instruction] — see `.card-detail__hero-corner`'s own doc for the
 * sizing fraction (one fraction, every state) and for why it stacks under
 * the cost pill rather than displacing it. width: 100% fills whatever the
 * corner container's fraction resolves to; height: auto preserves the
 * shipped aspect ratio.
 *
 * A short viewport still needs a guard even with no placeholder floor left
 * to grow: real art's own height (`.card-detail__art`'s `max-height:
 * 44dvh`) can be short enough that `.card-detail__hero-corner`'s own
 * `max-height` runs out of room. `min-height: 0` overrides a flex item's
 * default `min-height: auto` (which otherwise refuses to shrink a flex
 * child below its own CONTENT size), so when that happens THIS element —
 * never the cost pill, which is `flex-shrink: 0` — is what actually gives.
 * `object-fit: contain` is what keeps that shrink from distorting the
 * image: a flex-shrunk `<img>` with no object-fit stretches its bitmap to
 * whatever box the flex algorithm computes; contain scales the whole card
 * down UNIFORMLY instead, preserving its aspect. `object-position: left
 * top` keeps the shrunk image flush with the SAME left/top corner it
 * already occupies at full size, instead of drifting toward the box's own
 * (now larger than the image) center.
 *
 * The drop shadow is for CONTRAST, not decoration [owner instruction] — the
 * laminate is a whole opaque paper card (see art/road-wear/manifest.json),
 * not a transparent sticker, so the failure mode it guards against is a
 * LIGHT or white album cover, where the cream paper's own edge can nearly
 * disappear into it (a dark cover already separates from the cream card on
 * brightness alone — see --ss-laminate-shadow's own doc for the render test
 * that pinned this value). `filter: drop-shadow()` (not `box-shadow`)
 * because the shadow must trace the laminate's own rounded-rect+punch-hole
 * alpha shape, not its bounding box.
 */
.card-detail__laminate {
  display: block;
  width: 100%;
  height: auto;
  min-height: 0;
  flex-shrink: 1;
  object-fit: contain;
  object-position: left top;
  filter: drop-shadow(var(--ss-laminate-shadow));
}
/* Bottom row of the large view: the Spotify save button sits lower-LEFT, the
 * Preview/Perform/Close actions lower-RIGHT. margin-left:auto keeps the
 * actions hugging the right edge whether or not the Spotify block is present
 * (a card with no resolvable coordinate at all omits it, so space-between
 * can't be relied on). */
.card-detail__footer {
  display: flex;
  gap: 12px;
  align-items: flex-end;
  margin-top: 16px;
}
.card-detail__actions {
  display: flex;
  gap: 12px;
  align-items: center;
  justify-content: flex-end;
  margin-left: auto;
}
/* "Save this song to Spotify" [save-song-to-spotify-playlist spec] — the
 * .ghost pill Preview/Cut already use, stacked over a quiet outcome line
 * (column, not row, so a wrapped sentence never crowds the Perform/Preview
 * actions on the right — see .card-detail__footer's own doc). Muted by
 * default, matching iOS's non-refusal outcomes (saved, not-connected); the
 * --refusal modifier switches to the same red the match refusal banner uses,
 * for a song that couldn't be resolved on Spotify or a genuine failure. */
.card-detail__spotify {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}
.card-detail__spotify-outcome {
  font-size: 11px;
  color: var(--ss-muted);
  max-width: 220px;
}
.card-detail__spotify-outcome--refusal {
  color: var(--ss-danger);
}
/* "Save this song to Apple Music" [apple-music-playlist-design spec — web
 * slice] — the exact sibling of .card-detail__spotify above: same column
 * layout, same muted-by-default/--refusal red modifier pair. Sits in the
 * same .card-detail__footer flex row as the Spotify block (see that
 * container's own doc); footer's margin-left: auto on .card-detail__actions
 * still hugs the right edge with either, both, or neither present. */
.card-detail__apple-music {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}
.card-detail__apple-music-outcome {
  font-size: 11px;
  color: var(--ss-muted);
  max-width: 220px;
}
.card-detail__apple-music-outcome--refusal {
  color: var(--ss-danger);
}
.card { cursor: pointer; }

/* No visual role — audio_controller.js's data-only host (app/views/layouts/
   application.html.erb). An empty element with no rendered content has NO
   box at all, which reads as "not displayed" to Capybara/Selenium's default
   visibility check; 1x1px gives it a genuine box without anyone seeing it. */
.audio-boot { position: absolute; width: 1px; height: 1px; overflow: hidden; }

/* The HOME control [owner ruling: replaces the fixed bottom tab bar; owner
   follow-up 2026-09-12: "small and unlabeled and doesn't convey it's
   purpose... replace it with a button with the word HOME instead. It needs
   to be larger" — the ⌂ glyph read as a faint corner smudge, not a control,
   in production use]. Sole occupant of the top-right chrome corner — the
   Rules launcher moved to the home hub's own RULES cell and the mute
   control was removed outright [owner ruling 2026-09-05] — so it still
   needs no offset to clear a sibling.

   height: 56px, unchanged from the glyph era and still a DELIBERATE choice
   past --ss-hit-min's 44px floor (see git history on this rule for that
   derivation — it didn't stop applying just because the box grew a label).
   Width is now intrinsic (padding, not a pinned square) because the content
   is a word, not a glyph — pinning a fixed width here would either clip
   "HOME" or leave dead space depending on font substitution, which is the
   exact failure fixed-sizing avoided for height. Measured rendered width is
   ~100px at this padding/letter-spacing, which is what the clearance rule
   below is derived against.

   Legible at rest, not only on hover [owner: "doesn't convey its purpose" —
   the previous `filter: grayscale(1); opacity: 0.6` IS what read as a
   smudge]. The fix is a bordered panel on the app's own neutral surface
   tokens (`--ss-surface-soft`, `--ss-border`, `--ss-cream`), not the gold
   accent: BRANDING §1 keeps the spotlight amber scarce, and this is
   navigation chrome a player should recognize immediately, not a call to
   action competing for the one spotlight a screen gets. `--ss-gold` still
   appears once, on `:focus-visible` only, matching the outline treatment
   every other focusable control in this file uses — that is a keyboard
   affordance, not a resting accent, so it does not spend the same budget.

   The visible word IS the accessible name now — no separate `aria-label`
   on the link (app/views/layouts/application.html.erb,
   app/views/pages/rules.html.erb): a label reading "Home" on a control
   whose visible text reads "HOME" is redundant at best, and any future
   drift between the two (someone rewords one without the other) becomes a
   silent mismatch instead of impossible by construction.

   A verified aside on the box's own right/bottom edge, since it was
   checked and is worth recording rather than leaving for the next person
   to rediscover: this box renders at a fractional width (99.984375px —
   Archivo Black's real advance width for "HOME" at this padding/
   letter-spacing, not a rounding bug), and a hit-test at the LITERAL
   boundary coordinate `getBoundingClientRect()` reports (the exact pixel
   at `right`/`bottom`, not a pixel or two inside it) resolves to the
   inert page wrapper behind it (the bare `main` element every layout this
   control renders in wraps its content in) instead of
   the anchor — reproduced identically with `border-radius: 0`, so the
   rounded corner isn't the cause. THIS IS NOT A SHIPPED GAP: there is no
   inset, `clip-path`, or width override anywhere on this element, and
   none is needed — a real finger or cursor never lands on a
   mathematically exact fractional-CSS-pixel edge, and the element one
   would land on instead is an inert container, not a competing
   interactive one, so nothing is actually lost to a real tap. It is a
   sub-pixel hit-testing artefact of the fractional box, and it is
   recorded here so nobody reads a probe that samples a few pixels inside
   the edge (rather than the boundary coordinate itself) as evidence of a
   mitigation that was never built, or goes looking for an inset to
   "restore". */
.home-control {
  position: fixed; top: 12px; right: 12px; z-index: 50;
  display: flex; align-items: center; justify-content: center;
  height: 56px; padding: 0 22px;
  font: 700 15px/1 'Archivo Black', sans-serif;
  letter-spacing: 1px; text-transform: uppercase;
  border-radius: var(--ss-radius-chip);
  background: var(--ss-surface-soft);
  border: 1px solid var(--ss-border);
  color: var(--ss-cream); text-decoration: none;
  transition: all .15s ease;
}
.home-control:hover { background: var(--ss-surface); border-color: var(--ss-muted); }
.home-control:focus-visible { outline: 1px solid var(--ss-gold); }

/* While the full-page card overlay is open, the card owns the top corners
   (cost pill + ✕ close), so the fixed layout chrome that lives there must yield
   — the controller toggles this body flag on open/close [owner rework #5]. */
body.card-detail-open .home-control { display: none; }

/* Corner clearance for whatever page heading sits under `.home-control`
   [adversarial review on the 44->56 enlargement, RE-DERIVED for the
   labelled-button rework, owner follow-up 2026-09-12: "Move content down
   if you need to for page title and home button" — explicit authorisation
   to take vertical space rather than fight the button into the old
   corner]. Still VERTICAL, not horizontal, and still not `padding-right`
   on the heading itself: that approach closed every collision but wrapped
   "Your matches" (and seven of eleven other headings, at one width or the
   other) onto an extra line, and shifted centered text off-centre by up
   to 44px, because one-sided padding moves a centred box's own midpoint.
   RECORDED HERE SO A FUTURE `padding-right` FIX ISN'T RE-ATTEMPTED: it
   structurally cannot satisfy "no new line, no centring shift, no
   collision" at once, because narrowing the box is what both other
   failures come from — narrowing didn't stop being the problem just
   because the button got a label.

   The fix moves the whole header BAND down past the control's OWN band,
   so no heading row — long or short, wrapped or not — is ever at a
   y-coordinate the control also occupies, regardless of how wide any line
   renders. `padding-top` on `.marquee`/`.top40` (the header's own
   container — pushing the label above the title down with it, since
   pushing only the h1 while leaving the label in place would be one rule
   with two different behaviours nobody would remember the reason for)
   changes nothing about EITHER container's width, so wrapping and
   centring are unaffected BY CONSTRUCTION regardless of how wide the
   button itself is. Confirmed anyway, not just assumed: comparing this
   value against the prior shipped 32px in the same browser session
   (inline-overriding only `padding-top`, the one property this rework
   touches) gives IDENTICAL line counts and pixel-identical horizontal
   centres on every one of the twelve pages `.home-control` can render on,
   at both 390px and 320px.

   46px is derived, not guessed, and the tight constraint MOVED with the
   button's shape. `.home-control`'s bottom edge is still
   top:12+height:56=68px (height is unchanged from the glyph era) — but
   its WIDTH grew from 56px to ~100px (padding-driven, holding "HOME" in
   Archivo Black), and that width is what changed the binding constraint.
   The h1 on the tightest five-page group (matches#index, setlists#index,
   record_collection#show, bragging_rights#show, tournaments#index) still
   clears at the same 43px top edge it always had — a narrow 56px square
   never reached it and a 100px-wide button still doesn't, at either
   width. What DOES newly collide is the amber `.label` ABOVE the h1: at
   320px it sits at a natural (unpadded) top edge of 28px and, on four
   pages whose label text runs long enough (matches#recap "The final
   verdict", setlists#show "Setlist Builder", record_collection#show
   "Record Collection", bragging_rights#show "Bragging Rights"), it now
   extends far enough right at 320px to sit UNDER the wider button —
   something the old 56px-wide box's corner never reached regardless of
   vertical position. This is exactly the horizontal failure mode a wider
   control introduces: a 56px box only ever threatened a heading
   vertically; a 100px one can also newly reach a heading it used to clear
   sideways. The measured, falsifiable pass/fail boundary (real 2D
   rect-intersection against the label's own text, via
   Range.getClientRects(), not container boxes) is exact: 39px still
   collides on those four pages at 320px, 40px is the first value that
   clears every one of the twelve pages at both widths. 46px keeps the
   same 6px margin past the measured boundary the original 32px did — but
   that margin was RE-JUSTIFIED for this rework, not carried forward on
   faith [manager review]: the old 6px was sized against Archivo Black
   display-size substitution variance (~1px), measured on the h1. The
   element that binds now is `.label` (Barlow Condensed 600, 11px, 2.5px
   letter-spacing) — a different face at a different size, and its own
   substitution variance is NOT 1px: forcing the shared Google Fonts
   @import to fail (`.label`/`.home-control` both fall through to their
   plain `sans-serif`/`system-ui` stack tail) moves the label's measured
   RIGHT edge by up to 21px on these four pages, and the button's own
   width (padding-driven around "HOME", so it moves too under
   substitution) by about 4-5px — two edges that could in principle move
   toward each other. Neither matters: `.label`'s TOP edge — the only
   coordinate this rule's vertical-separation strategy depends on, and the
   element that actually binds on these four pages — is IDENTICAL (28px
   natural, confirmed to the pixel) whether Barlow Condensed loads, falls
   back to system-ui, or falls back to a monospace face wide enough to
   move the same label's right edge by 19px. Sweeping the actual pass/fail
   boundary under the failed-@import condition on all four pages lands on
   the same 40px it does with fonts loading normally. THIS IS A CLAIM
   ABOUT `.label` SPECIFICALLY, NOT EVERY ELEMENT IN THIS CONTAINER: a
   wider adversarial check (six fallback faces including Impact and
   Papyrus/Comic Sans, condensed/tall-cap and extreme-ascent respectively)
   found the H1'S OWN top edge DOES wobble a few px under substitution
   (~86-89px observed at padding-top:46, vs `.label`'s unmoving 74px) —
   small enough not to threaten this margin, and the h1 isn't the binding
   element on any of these four pages regardless, but real, and a
   different fact from `.label`'s zero. Read "font-invariant" below as
   bounded to the element this margin is actually measured against, not a
   general property of every child in `.marquee`. What generalizes is the
   MECHANISM, not a blanket invariance claim: once two rows are vertically
   disjoint, no amount of EITHER row's horizontal reach can make their
   rects intersect, so a face substitution that only moves glyph advances
   (not line-height enough to matter here) cannot open this collision
   regardless of which element's word grew or which direction its box is
   anchored — but "moves glyph advances, not line-height enough to
   matter" is itself an empirical finding per element, not an axiom.
   46px's 6px margin is therefore still doing the same job the original
   margin did — cross-renderer/cross-browser VERTICAL rounding this
   repo's one-browser CI can't rule out — not standing in for font-fallback
   safety, which the geometry already provides for free. (`.home-control`
   was deliberately left with an intrinsic, padding-driven width rather
   than a pinned one for this same reason a pinned 56x56 SQUARE was wrong
   for a word instead of a glyph: a fixed width either clips "HOME" or
   leaves dead space depending on which face actually rendered, and this
   measurement shows fixing it would buy no clearance safety in exchange —
   the vertical strategy already absorbs whatever a fixed width would have
   tried to pin down.) `body.home-control-reserve` (application_helper.rb's
   home_control_reserve? — true for every page `.home-control` can render
   on, gated pages and /rules alike) scopes this to exactly those pages,
   so a page without the control (the signed-out welcome screen's own
   `.welcome .marquee`, e.g.) keeps the header position it's always had.
   `.top40` is the one heading wrapper that isn't `.marquee` (top40/
   show.html.erb's own top-level div, holding a bare unclassed `<h1>`, no
   label above it) — its heading clears earlier than the rest (natural top
   edge 49px, not 28px) but shares the same fixed corner and container
   pattern, so it gets the same treatment rather than an undocumented
   exception.

   If you're re-verifying this by measuring `.marquee`'s own box on
   /rules: its top edge moves LESS than the full 46px you'd expect
   (measured 39 -> 85, a 46px move on the label — but the container's own
   border-box edge sits 11px higher again, same as it always has).
   /rules is the one page whose marquee's first child is a `<p>`
   (rules.html.erb), everywhere else it's a `<div>`; `.label` sets no
   margin, so the `<p>` keeps the UA default (`margin-top: 1em` = 11px at
   this 11px font) and, at padding-top:0, that margin collapses through
   `.marquee` and appears above its border-box. Any padding-top value
   contains it — normal box-model behaviour, nothing to fix. The heading
   text itself still moves the full padding-top delta same as every other
   page (h1 measured 61 -> 107, a 46px move); only the empty container
   edge above the text looks off if you check it instead. */
body.home-control-reserve .marquee,
body.home-control-reserve .top40 {
  padding-top: 46px;
}

/* How to Play — the rules page content [rules feature].
   Reading surface, so it opts OUT of the stage's centered text; the wheel is
   the one visual that stays centered. Tokens only — genre vars on the wheel,
   stat colors on the stats, --ss-action-green / --ss-danger for the beats/loses
   cues. Amber stays scarce: only the 30-second core wears it. */
.rules { text-align: left; max-width: 620px; margin: 0 auto; }
.rules b { color: var(--ss-cream); font-weight: 700; }
.rules-section { margin-top: 30px; }
.rules-lead { font-size: 16px; color: var(--ss-cream); margin: 6px 0 14px; }
.rules .label { display: block; margin-bottom: 4px; }

/* The 30-second core — the page's one amber focal (no CTA competes here).
   Amber arrives as a spot left-border (the recap-row pattern), NOT a full
   amber border: BRANDING §5 reserves that for the ruling card. */
.rules-core {
  list-style: none;
  margin: 4px 0 0;
  padding: 16px 18px 16px 20px;
  background: var(--ss-panel);
  border-radius: 12px;
  border-left: 3px solid var(--ss-gold);
  display: grid;
  gap: 12px;
}
.rules-core li { display: flex; align-items: baseline; gap: 12px; font-size: 18px; }
.rules-core__num {
  flex-shrink: 0;
  width: 26px; height: 26px;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: 'Archivo Black', sans-serif; font-size: 13px;
  color: var(--ss-gold);
  border: 1px solid rgba(255, 180, 58, .5); border-radius: 50%;
}
.rules-core__tag { font-size: 14px; color: var(--ss-muted); font-style: italic; margin: 10px 2px 0; }

/* Card anatomy — the compact card + inspect panel are the REAL classes, so
   they render identically; only the callout cues are new. */
.rules-anatomy { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
.rules-anatomy__card { flex: 0 0 auto; }
.rules-anatomy__card .card { cursor: default; margin: 0 auto; }
.rules-anatomy__statsnote { font-size: 12px; color: var(--ss-muted); margin: 4px 2px 0; }
.rules-anatomy__card .detail-panel { margin-top: 10px; max-width: 220px; }
.rules-anatomy__badges { margin: 8px 0 0; }
.rules-callouts { list-style: none; margin: 0; padding: 0; flex: 1 1 240px; display: grid; gap: 9px; }
.rules-callouts li { font-size: 15px; color: var(--ss-muted); line-height: 1.35; }
.rules-callouts li b { color: var(--ss-cream); }
/* The numbered cue chip — shared by the card sups and the callout list. */
.rules-cue {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 16px; height: 16px; padding: 0 3px;
  font-family: 'Barlow Condensed', sans-serif; font-size: 11px; font-weight: 700;
  line-height: 1; vertical-align: middle;
  color: var(--ss-ink); background: var(--ss-muted); border-radius: 8px;
}
sup.rules-cue { font-size: 9px; min-width: 13px; height: 13px; }
.rules-winded { color: var(--ss-danger); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }

/* The three stats */
.rules-stats { display: grid; gap: 10px; }
.rules-stat { display: flex; gap: 10px; align-items: baseline; font-size: 15px; }
.rules-stat__name { font-family: 'Archivo Black', sans-serif; font-size: 14px; min-width: 58px; }
.rules-stat__desc { color: var(--ss-muted); }

/* The Type Wheel — self-contained inline SVG (no external asset), genre color
   from the same tokens the cards use. */
.rules-wheel { display: flex; flex-wrap: wrap; gap: 20px; align-items: center; justify-content: center; }
.rules-wheel__svg { flex: 1 1 300px; max-width: 360px; height: auto; }
.rules-wheel__seg { fill: var(--genre, var(--ss-muted)); stroke: var(--ss-ink); stroke-width: 2; }
.rules-wheel__label {
  fill: var(--ss-cream);
  font-family: 'Barlow Condensed', sans-serif; font-size: 12px; font-weight: 700;
  dominant-baseline: middle;
}
.rules-wheel__hub { fill: var(--ss-ink); stroke: rgba(156, 147, 176, .4); stroke-width: 1; }
.rules-wheel__hubtext {
  fill: var(--ss-muted);
  font-family: 'Barlow Condensed', sans-serif; font-size: 8px; font-weight: 700; letter-spacing: 1px;
}
.rules-wheel__spin { fill: none; stroke: var(--ss-action-green); stroke-width: 2.5; opacity: .9; }
.rules-wheel__arrowhead { fill: var(--ss-action-green); }
.rules-wheel__key { flex: 1 1 260px; }
.rules-wheel__cue { font-size: 15px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; }
.rules-wheel__dot {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 50%; font-size: 14px; font-weight: 700;
}
.rules-wheel__dot--beats { color: var(--ss-ink-on-accent); background: var(--ss-action-green); }
.rules-wheel__dot--loses { color: var(--ss-ink-on-accent); background: var(--ss-danger); }
.rules-wheel__table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 13px; }
.rules-wheel__table th, .rules-wheel__table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid rgba(156, 147, 176, .16); }
.rules-wheel__table thead th { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--ss-muted); font-weight: 600; }
.rules-wheel__table tbody th { font-weight: 700; color: var(--ss-cream); white-space: nowrap; }
.rules-wheel__beats { color: var(--ss-action-green); }
.rules-wheel__loses { color: var(--ss-danger); }
.rules-swatch {
  display: inline-block; width: 9px; height: 9px; border-radius: 2px;
  margin-right: 7px; vertical-align: middle;
  background: var(--genre, var(--ss-muted));
}

/* Depth — one line each, secondary weight */
.rules-depth__list { margin: 0; padding-left: 18px; display: grid; gap: 8px; }
.rules-depth__list li { font-size: 14px; color: var(--ss-muted); line-height: 1.4; }
.rules-depth__list li b { color: var(--ss-cream); }

/* The fixed Match Action Bar [match-action-bar]. Fixed-bottom + safe-area
   treatment (#77): it pins the CURRENT phase's primary CTA to the bottom of
   the match so the action is unmissable and never scrolls off (owner:
   "obvious interactions that lead the user's eyes to that space"). No other
   fixed-bottom chrome ever renders on matches#show (it carries no nav
   control of any kind — out of scope, owner ruling), so this bar owns the
   bottom alone — no conflict. */
.match-action-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 15; /* above stage content, below the card-detail modal (20) */
  background: var(--ss-panel);
  border-top: 1px solid rgba(156, 147, 176, .16);
  box-shadow: 0 -3px 14px rgba(0, 0, 0, .33);
  /* iOS: keep the row clear of the home indicator. A fixed pixel height + the
     safe-area inset, never vh — the visible viewport shifts under iOS chrome
     (the dvh/modal lesson), and a vh-based bar would drift off the home bar. */
  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
}
.match-action-bar__inner {
  max-width: 560px;
  margin: 0 auto;
}
/* The primary CTA fills the bar — a big, high-contrast amber tap target that
   leads the eye (BRANDING §1 spotlight). Reuses the .btn token; only the
   full width and the comfortable tap height are bar-specific. */
.match-action-bar__cta.btn {
  display: block;
  width: 100%;
  min-height: 52px;
  padding: 15px 34px;
  text-align: center;
}
/* No primary action this phase (blind waiting, already-advanced, or a
   "tap a card" hint): the SAME bar holds a dim status so the layout never
   jumps between phases. */
.match-action-bar__status {
  min-height: 52px;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  color: var(--ss-muted);
  font-size: 13px;
  letter-spacing: 1px;
  text-transform: uppercase;
  font-weight: 700;
}
/* Clear the fixed bar so no phase's content hides behind it (the same
   fixed-bar-clearance pattern the old tab bar used). Added to the stage in
   connect() only while the flag is on. */
.battle-stage--barred { padding-bottom: calc(84px + env(safe-area-inset-bottom)); }
/* A fresh, actionable CTA glows a few times to pull the eye to the bar, then
   rests. Only a newly-available action gets the --fresh class (a poll re-render
   of the same action does not), so this fires once per real transition. */
.match-action-bar__cta--fresh { animation: action-bar-pulse 1.1s ease-in-out 3; }
@keyframes action-bar-pulse {
  0%, 100% { box-shadow: 0 0 0 rgba(255, 180, 58, 0); }
  50% { box-shadow: 0 0 22px rgba(255, 180, 58, .7); }
}
@media (prefers-reduced-motion: reduce) {
  .match-action-bar__cta--fresh { animation: none; }
}
/* When the bar carries Perform over an open card-detail large view [perform-in-bar],
   it must float above the modal backdrop (z 20) and stay tappable. The
   overlay's .card-detail--barred makes the matching bottom room so the panel
   never hides it. */
.match-action-bar--over-modal { z-index: 25; }

/* The green "action taken" flash on button interaction [owner 2026-07-17]. A
   deliberate departure from the amber-only chrome — the owner's explicit call:
   green = an action was taken. Transient (~220ms via JS), a clean confident
   green over primary and ghost buttons alike. */
.btn--pressed,
.btn.btn--pressed {
  background-color: var(--ss-action-green) !important;
  border-color: var(--ss-action-green) !important;
  color: var(--ss-ink-on-accent) !important;
  transition: background-color .06s ease, border-color .06s ease, color .06s ease;
}
/* Reduced motion: instant, no fade — the feedback still lands, just unanimated. */
@media (prefers-reduced-motion: reduce) {
  .btn--pressed,
  .btn.btn--pressed { transition: none; }
}

/* The opaque support code [beta feedback]: quiet and secondary — a dim,
   monospaced reference the tester can tap to copy. Present on both the battle
   island (injected footer) and the recap. Amber only on the code itself, so it
   reads as a copyable value without competing with the marquee. */
.match-code { text-align: center; margin: 16px 0 4px; }
.match-code__btn {
  background: none;
  border: 0;
  padding: 4px 8px;
  cursor: pointer;
  font: inherit;
  font-size: 11px;
  letter-spacing: 2px;
  text-transform: uppercase;
  color: var(--ss-muted);
  opacity: .8;
  transition: opacity .12s ease;
}
.match-code__btn:hover,
.match-code__btn:focus-visible { opacity: 1; }
.match-code__btn:focus-visible { outline: 1px solid var(--ss-gold); border-radius: 6px; }
.match-code__value {
  font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
  letter-spacing: 1px;
  color: var(--ss-gold);
  font-weight: 700;
}
.match-code__hint { margin-left: 8px; opacity: .6; }

/* ---- New-user welcome [new-user-welcome] --------------------------------
   The one-time curtain-up. Composes house tokens; the amber spotlight is
   reserved for the single CTA (BRANDING §1), so the orientation panels stay
   quiet neutral surfaces. Left-aligned body under a centered marquee reads
   like a short program note rather than a wall of centered text. */
.welcome { max-width: 560px; margin: 0 auto; text-align: left; }
.welcome .marquee { text-align: center; }

.welcome-section { margin-top: 26px; }
.welcome-section > .label { display: block; margin-bottom: 10px; }

/* The running order — a real sequence, so it's numbered. Neutral panel and
   dim numerals: the CTA below owns the amber. */
.welcome-steps {
  list-style: none;
  margin: 0;
  padding: 16px 18px;
  background: var(--ss-panel);
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.welcome-steps li { display: flex; align-items: baseline; gap: 12px; font-size: 17px; color: var(--ss-cream); }
.welcome-steps__num {
  flex-shrink: 0;
  width: 24px; height: 24px;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: 'Archivo Black', sans-serif; font-size: 12px;
  color: var(--ss-muted);
  border: 1px solid rgba(156, 147, 176, .45); border-radius: 50%;
}
.welcome-steps b { color: var(--ss-cream); }
.welcome-note { font-size: 14px; color: var(--ss-muted); font-style: italic; margin: 10px 2px 0; }

/* Onboarding's destination guide — names the real screens (Matches, Setlists,
   Merch when it exists, Backstage), not any nav chrome; the bar it once
   echoed is gone [#77 tab-bar removal]. */
.welcome-tabs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.welcome-tabs li { font-size: 16px; color: var(--ss-muted); }
.welcome-tabs b {
  color: var(--ss-cream);
  font-family: 'Barlow Condensed', sans-serif;
  letter-spacing: .5px;
}

/* Rules pointer — names the persistent chrome button, styled like the real
   thing (neutral, never amber — the button itself is scarce chrome). */
.welcome-rules {
  margin-top: 24px;
  padding: 14px 16px;
  background: color-mix(in srgb, var(--ss-panel) 70%, transparent);
  border: 1px solid rgba(156, 147, 176, .18);
  border-radius: 10px;
  font-size: 15px; color: var(--ss-cream);
}
.welcome-rules__name {
  font-family: 'Barlow Condensed', sans-serif;
  font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase;
  color: var(--ss-muted);
  font-size: 12px;
  border: 1px solid rgba(156, 147, 176, .3); border-radius: 6px;
  padding: 2px 7px;
  white-space: nowrap;
}

.welcome .cta-row { text-align: center; }

/* The read-only spectator stage [Phase 2b spec §6]. Two symmetrical sides —
   neither is "you" — and a dark slot for a card that is face down. */
.spectator__scoreboard { display: flex; gap: 16px; justify-content: space-between; text-align: left; }
.spectator__side { flex: 1; }
.spectator__facedown { display: flex; gap: 12px; margin: 10px 0; }
.spectator__card {
  flex: 1; min-height: 84px; border-radius: 10px; padding: 10px;
  background: rgba(0, 0, 0, 0.35); border: 1px solid rgba(255, 255, 255, 0.08);
}
.spectator__card--locked { border-color: var(--ss-gold); }
.spectator__card--won { border-color: var(--ss-gold); box-shadow: 0 0 0 1px var(--ss-gold) inset; }

/* Bragging Rights [achievements design §5, web slice] — achievements as
 * jacket patches, grouped by tier. No opener/headliner/legend hex here: the
 * ratified token set has no such ramp (--ss-gold-deep/-lit above belongs to
 * setlist CERTIFICATION, a different system, and one achievement is
 * literally named Gold Record) — so tier is named in the label text, never
 * in colour, and every patch shares the same neutral panel. The one colour
 * this earns is --ss-gold on the rail and name, the same "lit vs. unlit" read
 * `.card`'s own genre rail already gives a card with `--genre` unset
 * (border-left: 4px solid var(--genre, var(--ss-muted))).
 *
 * Unearned patches are NOT `.card--dead`'s 30%-opacity treatment — that
 * reads as broken/disabled, and the design intent is attainable, not
 * failed. Full opacity, legible text, just without the gold lit rail. */
.patch-tier { max-width: 640px; margin: 0 auto 28px; text-align: left; }
.patch-grid { display: flex; flex-direction: column; gap: 10px; }
.patch {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  background: var(--ss-panel);
  border-left: 4px solid var(--ss-inactive);
  border-radius: 10px;
  padding: 14px 16px;
}
.patch--earned { border-left-color: var(--ss-gold); }
/* 64px, not `.card__art`/`.catalog-hit__art`'s 40-44px — those are cropped
 * rectangular cover-art thumbnails in dense rows; a patch is a decorative
 * circular badge on its own transparent ground (`generate_patches.py`'s
 * ship-set output, alpha required, no baked shadow — see
 * `docs/design/achievement-patches/README.md`), and 64px roughly matches
 * this row's own two-line text block height at this page's 414px target
 * frame, so the badge sits proportionate to the row rather than either
 * disappearing next to it or dwarfing it. `object-fit: contain`, not
 * `cover` — the source is already square with the patch inscribed inside
 * a transparent margin; `cover` would crop the merrow border tangent to a
 * square viewport for no reason. `filter: drop-shadow(...)`, not
 * `box-shadow` — `box-shadow` boxes the transparent square canvas, not the
 * circular silhouette inside it; `drop-shadow` follows alpha the same way
 * `generate_patches.py`'s own `build_card` mockup casts its shadow off the
 * patch's alpha channel, not a fixed rect. Same numbers as `--ss-card-shadow`
 * (this file's own "resting cards & rows" register, shared with iOS's
 * `Tokens.Elevation.card`). */
.patch__art {
  width: 64px;
  height: 64px;
  flex-shrink: 0;
  object-fit: contain;
  filter: drop-shadow(var(--ss-card-shadow));
}
/* Locked/unearned: grayscale, NOT the `.card--dead` 30%-opacity "disabled"
 * treatment this page's own header above already rejects for the same
 * reason ("the design intent is attainable, not failed") — a faded patch
 * reads as broken merchandise, not as something still to be earned. Full
 * opacity, full detail, no colour: the motif and tier ring stay entirely
 * legible (`PATCH_RECIPE.md`'s own "unmistakable at thumbnail size" holds
 * for a desaturated render too), so the row still shows the player exactly
 * what they're going for — colour is the one thing earning it unlocks. */
.patch__art--locked { filter: drop-shadow(var(--ss-card-shadow)) grayscale(1); }
/* `flex: 1`, not `.patch`'s own former `justify-content: space-between`
 * [Codex P2, PR #918] — `.patch` used to have exactly two flex children
 * (body, status), so `space-between` had only one gap to put free space
 * into, and that gap happened to be the right one: it pushed `.patch__status`
 * to the far edge while leaving body flush left, which reads identically to
 * body consuming the remaining width. Adding `.patch__art` as a THIRD child
 * broke that coincidence — `space-between` now has two gaps (art↔body,
 * body↔status) and splits the free space between both, drifting the badge
 * away from the name it belongs to on any viewport wider than the content's
 * natural width. `flex: 1` here makes `.patch__body` itself consume all the
 * leftover space, which is the only element that should grow — `.patch__art`
 * stays its fixed 64px, `.patch__status` stays its natural width — so both
 * neighbours hug it via the row's own `gap` regardless of how many siblings
 * `.patch` has. `justify-content: space-between` is removed from `.patch`
 * rather than kept alongside this: with `.patch__body` absorbing all free
 * space there is none left for `space-between` to distribute, so keeping it
 * would be dead weight that silently reactivates the instant this rule is
 * removed or a fourth child is ever added.
 *
 * `min-width: 0` overrides the flex item default of `auto`, which sizes to
 * the LONGEST UNBROKEN WORD in `.patch__desc` and, on the narrow 414px
 * frame this app targets, grows `.patch` wider than the viewport — pushing
 * `.patch__status` (already `flex-shrink: 0`) off the right edge entirely
 * rather than merely cramping it. Caught headless at 414px: the status
 * column render clean at 800px and vanished at 414px, not merely wrapped
 * badly — measure the actual target frame, not just a wide one. */
.patch__body { flex: 1; min-width: 0; }
.patch__name { font-family: 'Archivo Black', sans-serif; font-size: 15px; }
.patch--earned .patch__name { color: var(--ss-gold); }
.patch__desc { font-size: 13px; color: var(--ss-muted); margin-top: 4px; }
.patch__status {
  font-size: 11px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ss-muted);
  white-space: nowrap;
  flex-shrink: 0;
  margin-top: 2px;
}
.patch--earned .patch__status { color: var(--ss-gold); }
.spectator__round { margin-top: 12px; text-align: left; }

/* ── The home hub [web-parity design "The home hub"] ────────────────────
 * Mirrors HomeView.swift's Button Bill restyle top to bottom: a filled
 * gold hero (START MATCH), a hairline strip (CONTINUE MATCH), framed
 * gold-outline blocks (SETLISTS / RECORD COLLECTION / THE MERCH TABLE and
 * the TOP 40 / BRAGGING RIGHTS / BAND PRACTICE lineup), and a quiet footer
 * (RULES / BACKSTAGE). Layout and colour only, against --ss- tokens; no new
 * font import for --ss-font-display (Anton) — the headline sizes below
 * reuse Archivo Black, already loaded for .marquee__title, which is inside
 * this branch's own "P3-level visual polish is out of scope" line. */
.hub { display: flex; flex-direction: column; gap: var(--ss-gap); text-align: center; }

.hub__header { margin-bottom: 4px; }
.hub__eyebrow {
  font-size: 11px;
  letter-spacing: 2.5px;
  text-transform: uppercase;
  color: var(--ss-gold);
  font-weight: 600;
  margin: 0 0 4px;
}
.hub__wordmark {
  font-family: 'Archivo Black', sans-serif;
  font-size: clamp(28px, 6vw, 40px);
  line-height: 1;
  margin: 0;
  text-transform: uppercase;
}
.hub__record {
  font-size: 12px;
  letter-spacing: .08em;
  color: var(--ss-muted);
  margin: 8px 0 0;
}

.hub-cell__link {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  min-height: var(--ss-hit-min);
  height: 100%;
  padding: 14px 16px;
  text-decoration: none;
  color: inherit;
}
.hub-cell__title { font-weight: 700; }
.hub-cell__caption { font-size: 13px; color: var(--ss-muted); }
.hub-cell__promise { font-size: 13px; color: var(--ss-muted); margin: 4px 16px 14px; }
.hub-cell__title--promised { color: var(--ss-dim); }

/* START MATCH — the filled gold hero. `--ss-radius-cta`, not `-pill`: this
 * CTA mirrors iOS's modestly rounded rect (Tokens.Radius.ctaPill), not a
 * true pill — see spec/design_token_radius_parity_spec.rb. */
.hub-cell--hero .hub-cell__link {
  background: var(--ss-gold);
  border-radius: var(--ss-radius-cta);
  box-shadow: var(--ss-glow-gold);
  padding: 20px;
}
.hub-cell--hero .hub-cell__title {
  font-family: 'Archivo Black', sans-serif;
  font-size: 26px;
  color: var(--ss-ink-on-accent);
  text-transform: uppercase;
}
.hub-cell--hero .hub-cell__caption { color: var(--ss-ink-on-accent); }

/* CONTINUE MATCH — a plain hairline strip, deliberately untouched by the
 * framed treatment below (HomeView.swift's own "Deliberately UNTOUCHED by
 * the Button Bill restyle"). Square, on purpose: `continueMatchRow` draws
 * `Rectangle().strokeBorder(...)`, not a `RoundedRectangle` — no
 * `Tokens.Radius` role applies here at all (owner ruling, #936). This used
 * to read --ss-radius-card, which made the strip rounder than iOS the
 * moment `card` moved off 12 — see
 * spec/design_token_radius_parity_spec.rb. */
.hub-cell--strip .hub-cell__link {
  background: var(--ss-surface-soft);
  border: 1px solid var(--ss-border);
  border-radius: 0;
  align-items: flex-start;
  text-align: left;
}
.hub-cell--strip .hub-cell__title { color: var(--ss-cream); font-size: 18px; }

/* SETLISTS / RECORD COLLECTION / THE MERCH TABLE and the lineup bar —
 * framed gold blocks; an unbuilt destination gets a dashed dim frame
 * instead, so a promise reads as quieter than a live action. `--ss-radius-
 * cta`, not `-card`: HomeView.swift's `BillFrame` draws every framed button
 * on this screen at `ctaPill`, in as many words ("the same corner every
 * framed button on this screen draws") — these cells were only ever
 * correct at 12 because `card` happened to equal `ctaPill` before the
 * owner's iOS-parity ruling moved `card` to 16. See
 * spec/design_token_radius_parity_spec.rb. */
.hub__bill { display: flex; flex-direction: column; gap: var(--ss-gap); }
.hub__lineup { display: flex; flex-wrap: wrap; gap: var(--ss-gap); }
.hub__footer { display: flex; gap: var(--ss-gap); justify-content: center; }

.hub-cell--bill .hub-cell__link,
.hub-cell--lineup .hub-cell__link {
  border: var(--ss-frame);
  border-radius: var(--ss-radius-cta);
}
/* An unbuilt cell renders no `.hub-cell__link` at all (the partial's `else`
 * branch has no anchor to put a border on), so its dashed frame goes on the
 * outer `.hub-cell` itself, with matching padding to read at the same size
 * as a built neighbour. */
.hub-cell--bill.hub-cell--unbuilt,
.hub-cell--lineup.hub-cell--unbuilt {
  border: 1px dashed var(--ss-border-dim);
  border-radius: var(--ss-radius-cta);
  padding: 14px 16px;
}
.hub-cell--bill .hub-cell__title,
.hub-cell--lineup .hub-cell__title {
  font-family: 'Archivo Black', sans-serif;
  color: var(--ss-gold);
  text-transform: uppercase;
}
.hub-cell--bill .hub-cell__title { font-size: 22px; }
.hub-cell--lineup { flex: 1 1 140px; }
.hub-cell--lineup .hub-cell__title { font-size: 15px; }

.hub-cell--footer .hub-cell__link {
  color: var(--ss-muted);
  min-height: var(--ss-hit-min);
  padding: 10px 16px;
}
.hub-cell--footer .hub-cell__title {
  font-size: 13px;
  letter-spacing: .05em;
  text-transform: uppercase;
}

/* Top 40 [web spokes design "Top 40"]. Four segment tabs at every viewport
   (owner ruling — no <select>, no stacking on narrow screens), all four
   boards already rendered by the controller; Task 6's Stimulus controller
   only toggles which `.top40-board` is visible. */
.top40 { display: flex; flex-direction: column; gap: var(--ss-gap); text-align: center; }
.top40__tabs {
  display: flex;
  gap: 4px;
}
.top40__tabs button {
  flex: 1 1 0;
  min-height: var(--ss-hit-min);
  background: none;
  border: none;
  font-size: 11px;
  letter-spacing: 1px;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--ss-muted);
  box-shadow: inset 0 -2px 0 transparent;
}
.top40__tabs button.is-active { color: var(--ss-gold); box-shadow: inset 0 -2px 0 var(--ss-gold); }
/* Bounded, with its OWN overflow [Codex review, PR #938]: Top40View.swift's
   ScrollView is the scroll region and the viewer's pinned row sits fixed
   below it, outside that scroll — without a height cap here the list grows
   to fit all 40 rows and the PAGE scrolls, so a viewer never sees the
   pinned row without scrolling past the whole chart first. `max-height`
   rather than a fixed height so a short board (well under 40 rows) never
   grows a scrollbar it doesn't need. */
.top40-board__rows {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: 2px;
  max-height: 50dvh;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.top40-row,
.top40-board__pinned {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--ss-border);
}
.top40-row__rank { width: 28px; font-weight: 700; color: var(--ss-muted); }
/* The common case for a new player, not an edge one [manager review, PR
   #938] — muted caption styling, matching Top40View.swift's own
   .foregroundStyle(Color.brand(.muted)) treatment for the identical text. */
.top40-board__empty { padding: 8px 10px; font-size: 13px; color: var(--ss-muted); }
/* min-width: 0 overrides the flex default (`auto`, i.e. "never shrink below
   my content's intrinsic width") [Codex review, PR #938] — without it, a
   name with no break opportunity (User#stage_name allows up to 40 unbroken
   characters, user.rb `length: { in: 3..40 }`, no character-class limit)
   refuses to shrink and pushes the W/L record past the row, forcing the
   chart to scroll horizontally inside itself. overflow-wrap: break-word is
   what then gives that refused space somewhere to go — the same property
   `.card--row .card__title` already uses for the identical reason. */
.top40-row__name { flex: 1 1 auto; text-align: left; min-width: 0; overflow-wrap: break-word; }
.top40-row__record { color: var(--ss-muted); }
/* The viewer's own row, gold-highlighted wherever it lands [design D1/D7] —
   same fill/border treatment SCREENS.md's #t40 mockup calls for. */
.top40-row--you {
  background: var(--ss-gold);
  color: var(--ss-ink-on-accent);
  border-radius: var(--ss-radius-cta);
}
.top40-row--you .top40-row__rank,
.top40-row--you .top40-row__record { color: var(--ss-ink-on-accent); }
.top40-board__pinned {
  margin-top: 4px;
  border: 1px dashed var(--ss-gold);
  border-radius: var(--ss-radius-cta);
}
.top40__footer { font-size: 13px; color: var(--ss-muted); }
