/* Urbanist is served from our own site (src/assets/fonts) rather than Google
   Fonts: a school's web filter that blocks fonts.googleapis.com/gstatic.com
   silently swaps in a wider fallback font, which made the campaign screen's
   buttons taller and clipped some of them on short Chromebook windows. One
   variable font file per character set covers every weight used (400-700). */
@font-face {
  font-family: "Urbanist";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("assets/fonts/urbanist-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Urbanist";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("assets/fonts/urbanist-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  --navy: #1b2a4a;
  --navy-dark: #101a30;
  --red: #b3352c;
  --red-bright: #d94a3f;
  /* "Old Glory Blue" — the US flag's official dark blue (Pantone 281C, per
     GSA flag color specs). Used everywhere "Blue tier" shows up: the tier
     button, its stars/badges, and every blue-branded control on the home
     screen. */
  --flag-blue: #0a3161;
  --flag-blue-bright: #14448c;
  /* Was a warm cream (#f6f1e7), then a very light gray (#f0f0f0) — nudged
     one step darker per direct follow-up. Every page picks this up
     through --bg (body) plus the 3 other direct usages below. */
  --bg-tint: #e2e2e2;
  /* Was gold (#c9a227) — every button/accent that used it changed to
     silver per direct request; renamed from --gold since that name would
     be misleading now. Every var(--silver) usage was a general UI accent
     (hover borders, the Hint/How to Play/mute/sign-in buttons, minigame
     CTAs) — this game's actual medal-rank system uses Red/White/Blue, not
     bronze/silver/gold, so there's no tier-semantics conflict here. */
  --silver: #b0b0b8;
  /* Trial font (2026-08-17) for a small first batch of buttons — Sign Out/
     Sign In, Hint, Give Feedback, How to Play — chosen to complement the
     button/title graphics' bold beveled look without competing with it the
     way a louder display font would. Not applied anywhere else yet; see
     the Google Fonts <link> in index.html and this variable's usages
     below. */
  /* Was Poppins (trial), then Urbanist scoped to a few screens (2nd trial),
     now the site's one font — direct request to replace Georgia everywhere.
     Loaded at 400/600/700 (see index.html): 400 for body copy, 600/700 for
     headings and buttons. */
  --display-font: "Urbanist", sans-serif;
  --ink: #1a1a1a;
  --bg: var(--bg-tint);
  --surface: #ffffff;
  --text: var(--ink);
  --muted: #6b6b6b;
  /* Was a beige/tan (#ddd4c0) — direct request to replace any remaining
     beige with light gray, matching the gray/silver theme everywhere else
     (see --bg-tint/--silver above). Used as almost every hairline border
     sitewide, plus 2 backgrounds (the campaign-categories scrollbar thumb,
     the Heckler minigame's timer-bar track) that read as beige for the same
     reason. */
  --border: #d0d0d0;
}

* {
  box-sizing: border-box;
}

/* Dynamic backdrop for every play page (campaign, job term, minigames,
   etc.) — replacing the flat --bg-tint gray, direct request, reference
   image supplied: a distinctly bright off-white center glow fading to a
   noticeably darker warm gray at the edges, with a little organic
   mottling so it doesn't read as perfectly flat. First pass at this
   (center #f6f6f4 fading to edge #d2d2ce) was too close to the old flat
   #e2e2e2 to actually read as changed once real page content (mostly
   white cards) sat on top of it — direct report, confirmed live on the
   deployed site that the new gradient WAS rendering, just too low-
   contrast to notice. Pushed the center much brighter (near-white) and
   the edge much darker so the transition is unmistakable even with most
   of the page covered by opaque white UI. Still more restrained than the
   home screen's premium silver/starburst treatment (see
   body:has(.intro-screen) below) — no rays, no hard spotlight — since
   these are content-dense, text/form-heavy pages. Two low-opacity radial
   "mottling" layers (not centered, not symmetric) give it the reference
   image's slightly organic, non-uniform paper-like quality; the base
   radial-gradient is the actual center-bright-to-edge-dark structure.
   Left on the `body` SELECTOR, not the --bg/--bg-tint custom properties —
   those are reused as flat colors by several small unrelated UI pieces
   (scrollbar tracks, disabled buttons) that shouldn't suddenly render a
   giant gradient at their own tiny scale. */
body {
  margin: 0;
  font-family: var(--display-font);
  /* Trial (2026-08-25): a faint fractal-noise tile layered on top of the
     gradient below, in response to a ChatGPT-generated reference image
     that had a slightly more "photographic" grained look than this pure
     gradient does. Self-contained inline SVG (feTurbulence), not an image
     file — no extra network request, still scales to any screen. Kept
     low opacity (see the SVG rect's own opacity below) since this is meant to read as a faint paper/
     metal grain, not a visible texture competing with page content —
     easy to revert to the plain 3-layer gradient below if it doesn't
     actually read as an improvement. */
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.5' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"),
    radial-gradient(ellipse 55% 46% at 38% 30%, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0) 68%),
    radial-gradient(ellipse 50% 42% at 68% 70%, rgba(0, 0, 0, 0.06) 0%, rgba(0, 0, 0, 0) 68%),
    radial-gradient(ellipse 95% 85% at 50% 38%, #fcfcfb 0%, #e8e8e4 38%, #c5c5bf 78%, #aaaaa4 100%);
  background-repeat: repeat, no-repeat, no-repeat, no-repeat;
  background-attachment: fixed;
  color: var(--text);
  min-height: 100vh;
}

#app {
  max-width: 720px;
  margin: 0 auto;
  padding: 2rem 1.25rem 6rem;
}

/* The home screen is played in landscape and should use the full width of
   the screen — everywhere else (campaign, trivia, etc.) keeps the narrower
   720px reading column, which is why this is scoped to just .intro-screen
   via :has() rather than raising #app's max-width globally. */
#app:has(.intro-screen) {
  max-width: min(1400px, 96vw);
  /* The narrower screens keep the taller bottom padding as scroll clearance
     under the fixed corner buttons — the home screen is meant to fit a
     landscape viewport without scrolling, so it doesn't need nearly as much. */
  padding: 0.15rem 1.25rem 0.15rem;
}

/* Character creation is played in landscape too and was stuck in the same
   narrow 720px column — widened so the archetype cards have real room. */
#app:has(.create-screen) {
  max-width: min(1200px, 94vw);
  /* The default #app padding (2rem/6rem) is scroll-clearance sized for
     screens that are expected to scroll under the fixed corner buttons —
     this screen is meant to fit a landscape viewport without scrolling,
     so it doesn't need nearly that much. Bottom raised from a flat 0.5rem
     (2026-08-15) — the new bottom-center "Back to Home" button (shown on
     this screen too, see homeNavBtn in main.js) was sitting directly on
     top of "Enter the Race" with the old, tighter value, confirmed via a
     live screenshot. 3.5rem clears it with a little room to spare; the
     archetype grid below just flexes to whatever's left, same as always,
     rather than hard-overflowing. */
  padding: 0.5rem 1.25rem 3.5rem;
  /* Gives .issue-picker-grid below a real, bounded height to flex-fill —
     without this, a fixed vh-percentage guess either overflowed the
     viewport or left a large empty gap depending on screen size (see that
     rule's own comment). Height, not min/max-height, so .create-screen's
     own height:100% below has something definite to fill. Harmless on the
     OTHER screen sharing .create-screen (candidate creation) since nothing
     there uses flex-grow — it just leaves any leftover space unused,
     same as before. */
  height: 100vh;
}

/* The monthly campaign-actions screen was stuck in the same narrow 720px
   column as every other (scrollable) screen even though it's the screen
   players spend the most time on, in landscape, every month — widened so
   the action groups (see .action-groups below) have room to tile
   side-by-side instead of one long portrait-style stack. Bottom padding cut
   from the old scroll-clearance 6rem down to a plain small margin (2026-07-19,
   direct user request: every level's campaign screen must fit on one
   screen with no scrolling) — see .action-groups/.action-group below for
   the rest of what makes that fit even on the content-heaviest levels
   (US House onward, which add Opposition Research/extra ad+event tiers/more
   endorsement groups). */
/* Both mini-games (Handle the Hecklers, Ping the Opponent) were stuck in
   the default 720px reading column like every other narrow screen, making
   their play areas cramped — widened generously (matching .campaign-screen's
   own width below) so the game board actually reads as a substantial part
   of the screen rather than a small strip in the middle (2026-07-26, direct
   user request, 2nd pass after "a little bigger" wasn't nearly enough:
   "should take up a substantial portion of the screen... use the space"). */
#app:has(.minigame-screen) {
  max-width: min(1300px, 96vw);
  /* The default #app padding (2rem/6rem bottom) is scroll-clearance sized
     for screens expected to scroll under the fixed corner Hint button —
     both mini-games are meant to fit one screen with no scrolling, so that
     6rem reserve was actively eating into the height budget instead of
     helping (contributed to the "still doesn't fit" report even after
     .heckler-audience itself was properly capped). Bottom raised from a
     flat 0.5rem (2026-08-15) — same reasoning as .create-screen above: the
     new bottom-center "Back to Home" button sits on this screen too, and
     would otherwise land on top of each minigame's own full-width bottom
     button (e.g. "Start Throwing"). */
  padding: 0.5rem 1.25rem 3.5rem;
}

/* The job briefing screen (renderServingIntro/renderServingIntroWhite) was
   stuck in the default 720px reading column, so its instructional
   paragraphs wrapped into many lines and pushed the "Take Office" button
   below the fold, requiring a scroll (direct user report). Widened enough
   that each paragraph wraps into fewer lines — not as wide as the
   button-grid screens below, since long unbroken text lines get harder to
   read past a point. */
#app:has(.tutorial-screen) {
  max-width: min(950px, 92vw);
}

#app:has(.campaign-screen) {
  /* Raised from 1500px (2026-07-30) — the content-heaviest offices
     (President: 7 categories, 4 ad tiers, 7 endorsements) need real extra
     width to pack enough columns and stay scroll-free on a wide monitor;
     97vw still caps it well short of full-bleed on ultrawide screens. */
  max-width: min(1800px, 97vw);
  /* Bottom padding needs to clear the fixed corner Hint button (bottom:
     1.25rem, plus its own ~2.5rem height) — 1.5rem wasn't enough at the
     content-heaviest offices (President's full set of action groups: ad/
     rally/event tiers, endorsements, opposition research, fundraising
     events), where the last row of buttons ran directly under the Hint
     button (direct user report). Still far short of the old 6rem
     scroll-clearance default, so the "fits on one screen" fix from
     2026-07-19 holds everywhere else. */
  padding: 0.5rem 1.25rem 4rem;
}

.screen {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

/* Tighter than .screen's default 1rem gap — shifts everything (starting
   with the "This month's focus" box) up a bit, which in turn gives the
   bottom of the tallest action-group column that much more room to clear
   the viewport before scrolling is needed.

   Explicit height (not just min-height) is what lets .campaign-body's two
   fixed columns below stretch to fill exactly the remaining space, with a
   deliberate gap left above the Hint button — 6rem is #app:has(.campaign-
   screen)'s own 0.5rem+4rem top/bottom padding PLUS an extra 1.5rem of
   breathing room, so the pink log box's bottom edge sits clear of the
   button instead of running flush against it (direct user report: "give it
   a little space"). Direct user request: two fixed columns that never
   reflow when something is clicked, instead of the old CSS multi-column
   balance layout — see .campaign-col-right below for why that caused
   categories to visually jump between columns. */
.campaign-screen {
  gap: 0.6rem;
  height: calc(100vh - 6rem);
}

h1, h2, h3 {
  margin: 0;
  line-height: 1.2;
}

/* Premium cool-silver/pewter background, replacing the flat --bg-tint gray
   (direct request, reference image supplied: brushed-platinum tone, a
   bright starburst spotlight behind the White House, a lighter zone behind
   the logo, and a vignette darkening toward the edges). Set on BODY, not
   .intro-screen itself — .intro-screen lives inside #app, which is
   centered and width-capped (min(1400px, 96vw), see #app:has(.intro-screen)
   below), so on any monitor wider than that the old flat body gray was
   still visible as a border on both sides of the new background, exactly
   the "laid on top of the existing homepage instead of replacing it" bug
   reported live. Scoped via :has() so every other screen keeps the plain
   --bg. Layered background, painted top to bottom:
   1. repeating-conic-gradient: faint radiating "rays" centered on the
      White House photo — a rough sunburst without an actual image, kept
      very low-opacity so it reads as texture, not a literal graphic.
   2. radial-gradient: brighter silver directly behind the logo, so the
      red/white/blue title stays easy to read against it.
   3. radial-gradient: the main bright spotlight behind the White House.
   4. linear-gradient: a very subtle diagonal sheen (brushed-metal quality
      without a literal metal texture).
   5. radial-gradient (base): the overall light-center-to-dark-pewter-edge
      transition, doubling as the vignette — no separate box-shadow layer
      needed since this same gradient already darkens the corners. */
body:has(.intro-screen) {
  background:
    repeating-conic-gradient(from 0deg at 50% 44%, rgba(255, 255, 255, 0.07) 0deg 2deg, rgba(255, 255, 255, 0) 2deg 12deg),
    radial-gradient(ellipse 46% 26% at 50% 6%, rgba(255, 255, 255, 0.78) 0%, rgba(255, 255, 255, 0) 78%),
    radial-gradient(circle at 50% 46%, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.35) 24%, rgba(255, 255, 255, 0) 48%),
    linear-gradient(128deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 30%, rgba(255, 255, 255, 0) 70%, rgba(0, 0, 0, 0.06) 100%),
    radial-gradient(ellipse 120% 100% at 50% 38%, #eef0f3 0%, #d3d6dc 38%, #aeb2bc 70%, #82868f 92%, #64686f 100%);
  background-attachment: fixed;
}

.intro-screen {
  text-align: center;
  /* Direct report: the logo sat flush against the very top edge with no
     breathing room. Was a flat 0 — a small amount now, not enough to
     meaningfully affect anything below it, and taken out of padding-
     bottom below (4.5rem -> 3.75rem) rather than added on top of it, so
     the column's total height budget is unchanged and this doesn't
     reopen the overflow/scroll this screen is built to avoid (see
     min-height:100vh below).
     Direct request (2026-08-26): nudge everything down ~3/8in (2.25rem)
     to feel more vertically centered — added HERE, not taken from
     padding-bottom below, deliberately: that bottom padding is the
     reserved, non-negotiable clearance for the fixed corner Hint/How-to-
     Play buttons (see its own comment) and was already tight. Safe to
     just add here instead because .white-house-bg below is the ONLY
     flex-grow child with min-height:0 (see its own comment) — it
     absorbs the extra 2.25rem by shrinking itself, not by growing the
     column past 100vh, so this can't reopen the scroll/overflow this
     whole layout exists to avoid. */
  padding-top: 3rem;
  /* Lets .tagline's font-size below scale off this element's real rendered
     width (via cqw) instead of an approximate vw formula — needed because
     #app's width isn't a clean fraction of the viewport (fixed side padding
     eats a bigger share of it at narrow widths), so vw alone drifts off the
     real available space the further you get from whatever width it was
     tuned against. */
  container-type: inline-size;
  /* Reserved, non-negotiable clearance for the fixed bottom-corner Hint/How
     to Play buttons (2026-07-21) — previously the photo's size was tuned
     against guessed viewport dimensions (1280x720, 1366x768, etc.), which
     kept being wrong on the user's actual screen in both directions (too
     crowded, then too small). Replaced that whole guessing game: this
     screen now has a real `min-height: 100vh` so the flex column has
     definite space to distribute, the photo (.white-house-bg) is the only
     flex-grow child and fills whatever's left over after everything else's
     natural size, and this padding-bottom reserves real space at the very
     bottom no matter how tall or short the actual window is — the corner
     buttons are ~1.25rem off the bottom edge and ~2.3rem tall, so 4.5rem
     leaves a comfortable margin past that. */
  padding-bottom: 3.75rem;
  align-items: center;
  min-height: 100vh;
  /* The general spacer between this screen's top-level blocks (photo →
     Resume button → the tier/action grid) — deliberately a bit more than
     a hairline so each block reads as "a bit of white" between it and the
     next, not jammed together. Title/tagline and photo/tagline use their
     own tighter, more specific spacing below instead of this value. */
  gap: 0.4rem;
}

.intro-screen > h1,
.intro-screen > .tagline,
.intro-screen > .home-grid,
.intro-screen > .home-bottom-row {
  /* Only the photo should ever shrink/grow to fit available space —
     everything else keeps its natural size. */
  flex-shrink: 0;
}

/* Wraps Resume Your Campaign + the admin-only Teacher Dashboard button so
   they sit side by side (direct request: the admin button should appear
   "alongside" Resume, not stacked below it) instead of each being its own
   full-width row in .intro-screen's flex column. Either can appear alone
   (a non-admin with a saved game, or an admin with none yet) — flex still
   centers a single child correctly. */
.home-bottom-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  flex-wrap: wrap;
  /* Extra breathing room above this row — the 6 buttons above it were
     pulled up (see .home-grid's negative margin-top), and this is where
     that freed-up space goes instead of sitting unused at the very
     bottom. Was margin-top on #resume-btn alone (below) — moved here once
     the admin-only Teacher/Admin Dashboard button became a second item in
     this same row: a per-button margin only pushed Resume down, not its
     sibling, so the two looked vertically misaligned (direct report,
     confirmed real once measured) even though both images are the exact
     same size. Margin on the row affects both equally. */
  margin-top: 0.5rem;
}

/* Ambient background: small flags drift down behind the home screen's
   content for as long as the player sits on it. position: fixed takes it
   out of .intro-screen's flex flow (so it doesn't affect spacing/layout of
   the real content) and a negative z-index keeps it behind everything else
   on the page without needing to touch every other element's stacking. */
.flag-rain {
  position: fixed;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: -1;
}

.flag-rain .flag {
  position: absolute;
  top: -10vh;
  will-change: transform;
  animation-name: flag-fall;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
  user-select: none;
}

@keyframes flag-fall {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(120vh);
  }
}

.white-house-bg {
  /* Redesigned from a guessed fixed/vw/vh size to a true flex-grow fill
     (2026-07-21) — every prior approach (fixed px, vw, then adding a vh
     clamp) required guessing the actual viewport dimensions, and got it
     wrong in both directions on the user's real screen. This is the fix
     that stops needing a guess at all: .intro-screen is a flex column with
     a real `min-height: 100vh` and reserved bottom padding for the corner
     buttons (see .intro-screen), every OTHER child is flex-shrink:0 (fixed
     size), and this is the one flex-grow child — it simply fills whatever
     vertical space is actually left over, on any screen, automatically.
     `flex-basis: 0` (not auto) is required — with `auto`, the browser falls
     back to content-based sizing (the image's own intrinsic pixel size)
     instead of actually growing to fill the leftover space, which is what
     went wrong on the first attempt at this (confirmed via
     getBoundingClientRect: it rendered at the image's native ~680x453
     regardless of how much vertical room was actually free).
     `aspect-ratio: 3 / 2` matches white-house-v6.png's real 1536x1024
     dimensions exactly — this lets height come from flex-grow while width
     is DERIVED from that height via the ratio (not from content), so the
     box is always exactly the image's proportions with no letterboxing,
     which in turn keeps the blurred ::before layer (sized to inset:0 of
     this box) perfectly aligned with the sharp image inside it. max-width
     is just a sanity cap so it can't become absurdly wide on a very
     tall/narrow window; it is not the primary sizing mechanism.
     A 15% `transform: scale()` bump (2026-07-22 - 2026-08-16) used to sit
     here — removed once the title/tagline switched from text to taller
     graphics (see .title-graphic/.tagline-graphic below): the transform
     only enlarged this element's PAINT past its actual flex-allocated box,
     never its real size, and that overflow started visually colliding
     with the tagline banner above it (confirmed via getBoundingClientRect
     on the live page — the scaled box's top edge sat above the tagline
     image's own bottom edge). Without it, max-width below is once again
     the real, only sizing lever, and raising it actually makes the photo
     bigger instead of just painting a collision. */
  flex: 1 1 0;
  min-height: 0;
  /* A positive margin here (tried briefly) to force extra gap above/below
     was a mistake — margin on a flex item counts toward its outer main
     size, so it came straight out of the SAME flex-grow height the box
     would otherwise fully claim, making the photo smaller on EVERY
     screen — direct report: "you made it smaller." What actually reads as
     "space" between the photo and the tagline/"Start a Campaign" isn't
     real CSS gap at all — the photo fades out (see the mask-image rules
     below) well before its own box edge, so a soft, mostly-transparent
     margin was already sitting there, just baked into the image instead
     of the layout. Direct follow-up: pull the box up/down with a NEGATIVE
     margin so that fade zone tucks underneath the tagline/label instead
     of sitting in open space — the opaque part of the photo (including
     the flag — nothing here touches its color, only position/stacking)
     ends up close to the text, with only its already-transparent edge
     hidden behind it. z-index below keeps the tagline/label painted on
     top of that overlap so they stay fully legible. Pushed further still
     (direct follow-up: "go bigger... 'Start a Campaign' [should] have the
     bottom of the White House behind it") — past just hiding the fade
     zone now, into genuinely overlapping the opaque photo itself, and
     since a more negative margin also means more of the flex-grow height
     gets rendered instead of "given back" to margin, this makes the photo
     bigger at the same time. */
  margin: -3.5rem 0;
  position: relative;
  z-index: 1;
  /* Direct follow-up: same size, shifted down — a transform (not more
     margin) since it only moves where this box PAINTS, not its layout
     size or the flex-grow math above that determines that size, so it
     can't accidentally change how big the photo renders. Was 3/8", direct
     follow-up report that was too much — halved to 3/16". */
  transform: translateY(0.1875in);
  /* max-width is deliberately generous here — high enough that it's
     essentially never the binding constraint on any realistic screen
     width, so aspect-ratio below is always deriving width FROM the real
     flex-grow height (matching how this box behaved reliably before any
     of today's back-and-forth). A tighter cap (820px, then several wider
     attempts) kept clipping width first on a taller-than-usual window —
     confirmed via getBoundingClientRect that once that happens, the
     browser does NOT shrink height back down to match, so the box's real
     on-screen ratio silently stopped matching 3/2 and either stretched
     the photo or (paired with object-fit experiments) cropped it —
     exactly the "flag cut off" / "empty space above and below" reports.
     Letting height stay the one true sizing lever removes that failure
     mode entirely: the photo now grows AS the window gets taller, using
     more of the vertical room a taller screen actually has, instead of
     hitting a fixed ceiling and leaving the extra room empty. */
  max-width: min(1300px, 90vw);
  aspect-ratio: 3 / 2;
  width: auto;
}

/* A blurred duplicate of the same photo sits behind the sharp one, visible
   only in a ring between where the sharp copy fades out and the very edge
   — so the transition away from the building is a soft blur, not just a
   fade to transparency, and the rectangle outline is never crisp.
   Both masks below explicitly use `farthest-side` sizing, not the default
   `farthest-corner` — with the default, an ellipse centered in a WIDE box
   (this photo is ~1.5:1) has to stretch out to reach the far corners,
   which leaves the straight top/bottom/left/right edges only ~70% faded
   by the time you reach them (the fade only finishes right at the
   corners) — that residual opacity along the straight edges is exactly
   what read as "still rectangular." `farthest-side` sizes the ellipse to
   finish fading exactly at the edge midpoints instead, so the corners
   (farther from center than the edges) end up fully transparent too —
   no straight edge or corner stays visible anywhere.
   The stop percentages below are deliberately much further out than a
   first pass at this used (which put the opaque zone's edge around
   50-65%) — the source photo's building fills almost the ENTIRE frame
   (columns/trees run nearly edge-to-edge, the water reflection almost
   reaches the bottom), so under `farthest-side` sizing (which fades to
   transparent by the edge, not the corner) anything less than a very high
   percentage here visibly erodes into the building itself, not just the
   sky/grass border. Confirmed by viewing the source image directly. */
.white-house-bg::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url("assets/white-house-v6.png");
  background-size: 100% 100%;
  filter: blur(10px);
  mask-image: radial-gradient(ellipse farthest-side at center, transparent 78%, black 90%, transparent 100%);
  -webkit-mask-image: radial-gradient(ellipse farthest-side at center, transparent 78%, black 90%, transparent 100%);
}

.white-house-bg img {
  position: relative;
  /* Container is now locked to the image's own aspect-ratio (see
     .white-house-bg), so the image can just fill it exactly — no
     letterboxing, no percentage-height circularity. */
  width: 100%;
  height: 100%;
  display: block;
  /* The White House building itself fills almost the entire frame (columns
     and trees run nearly edge-to-edge in the source photo) — the opaque
     zone below is deliberately pushed way out (85%, not the ~55% a first
     pass used) so the fade only ever touches the outermost sliver of sky/
     grass/water, never the building. Only the very last 15% before the
     edge fades at all, handing off to the blurred layer above. */
  mask-image: radial-gradient(ellipse farthest-side at center, black 85%, transparent 99%);
  -webkit-mask-image: radial-gradient(ellipse farthest-side at center, black 85%, transparent 99%);
}

.intro-screen h1 {
  font-size: clamp(2rem, 4.8vw, 3.5rem);
  color: var(--navy);
  white-space: nowrap;
  /* Tighter than the global h1/h2/h3 default (1.2) — at this large a font
     size, the default line-height's built-in leading below the glyphs
     read as a much bigger gap to the tagline than the actual CSS gap. */
  line-height: 1.05;
}

/* Title/tagline swapped from plain text to supplied graphics (2026-08-16) —
   the h1/p wrappers and their spacing above/below are untouched (still
   real text on the Sign In screen, which reuses .intro-screen but keeps
   the old text h1/.tagline), only what's INSIDE them here changed. Sized
   by width with the image's own intrinsic aspect ratio driving height, same
   clamp-between-a-floor-and-a-viewport-relative-value approach the old
   font-size clamps used, so it still scales smoothly across window widths
   instead of jumping between fixed breakpoints. */
/* Found the actual bug behind this whole back-and-forth: the logo file's
   "tight" crop was only excluding fully-transparent pixels — it still
   included a huge, mostly-invisible glow/blur halo around the letters
   (alpha 1-50ish) that isn't part of what anyone actually sees as "the
   logo." Measured directly: the real solid lettering is only 196px tall
   within what was a 616px-tall canvas — most of that box was dead glow
   space, not content. That's why matching box-height to the old logo kept
   producing a logo that LOOKED small with tons of space around it: most
   of its allotted height was invisible padding, not visible letters.
   Re-cropped to the actual letter content (alpha>50 threshold, +6px
   buffer to keep the glow's soft edge intact) — real ratio is ~10:1, even
   wider than the original logo's 5.85:1, not narrower. Sized by width to
   match the tagline graphic's own clamp just below (420px, 52vw, 800px):
   at this ratio that only needs 42-80px of height, LESS than the 89px
   this row already had, so nothing else on the page needs to move. */
.title-graphic {
  display: block;
  /* Direct request: bigger than the tagline banner below it, not matched
     1:1 — was clamp(420px, 52vw, 800px), the same as .tagline-graphic. */
  width: clamp(460px, 58vw, 880px);
  height: auto;
  margin: 0 auto;
}

/* The Sign In screen reuses .intro-screen but has only two children (logo +
   the Google button), so with the home screen's top-aligned flex column it
   sat in a thin strip at the very top of an otherwise empty page (direct
   user report, with a screenshot: "let's fill up the screen a bit").
   This modifier centers the pair vertically and enlarges both, rather than
   changing .intro-screen itself — the home screen is a packed landscape
   layout that depends on the top alignment and would be wrecked by it.

   Content is nudged ABOVE true center (per request) by the bottom padding,
   which is deliberately larger than .intro-screen's own 4.5rem corner-button
   clearance and therefore also keeps that clearance intact. */
.signin-screen {
  justify-content: center;
  padding-bottom: 14vh;
  gap: clamp(1.75rem, 4vh, 3rem);
}

/* Roughly 1.7x the home screen's logo. The height cap is what keeps a short
   landscape window (Chromebook, projector) from pushing the button off the
   bottom edge; it's generous because the logo is a wide ~6:1 banner, so at
   the full 1200px it's still only ~205px tall and the cap never bites at
   normal window heights — it exists for the genuinely short ones. The
   1200px ceiling sits under the source PNG's intrinsic 1600px width, so
   the art never upscales into softness on a wide monitor. */
/* Restored to this screen's original, generous pre-replacement sizing —
   see .title-graphic above for the real story (the logo file's crop was
   including a huge invisible glow halo, making it look tiny; fixed at the
   source by re-cropping to the actual letter content, ~10:1 ratio). This
   screen was never actually space-constrained the way the home screen is
   (just the logo + one button, vertically centered with room to spare),
   so it doesn't need the careful height-matching the home screen's clamp
   does. */
.signin-screen .title-graphic {
  width: clamp(300px, 64vw, 1200px);
  max-height: 34vh;
  object-fit: contain;
}

/* Scaled to match the much larger logo — the default .primary-btn is sized
   for in-game screens where it sits among other controls; here it's one of
   only two things on the page. */
.signin-screen .primary-btn {
  font-size: clamp(1.15rem, 2.2vw, 1.65rem);
  padding: 0.9rem 2.75rem;
  border-radius: 10px;
}

/* Overrides .field-error's own negative top margin — that's sized for
   sitting directly under a tight form field, not this screen's much more
   spacious centered flex column (see .signin-screen's own generous `gap`
   above), where a negative margin would just look like a rendering glitch. */
.signin-error {
  margin: 0;
  text-align: center;
  max-width: 32rem;
}

.tagline-graphic {
  display: block;
  /* The REAL bug behind "there's a huge gap under the title" (direct
     report, confirmed via getBoundingClientRect showing the CSS boxes
     touching at ~1.6px while the screenshot showed a big visual gap):
     tagline-rise-la-school-board.png had a large, nearly-invisible
     alpha-blur/glow region above the actual ribbon graphic that a plain
     "any non-zero alpha" bounding-box crop had included as if it were
     real content — the CSS box was tight, but the VISIBLE art sat far
     down inside its own box. Re-cropped with an alpha>=10 threshold
     (source PNGs re-processed, not just this file) to strip that haze;
     the image's own aspect ratio is now ~4x flatter as a result, so it
     reads as a properly thin banner instead of a tall block even before
     touching this width. Also dialed back from clamp(560px, 66vw,
     1040px) — "a little smaller" per direct follow-up once the actual
     rendered width was visible without the crop bug masking it. */
  /* Direct request: smaller than the logo above it, not matched 1:1 — was
     clamp(420px, 52vw, 800px), the same as .title-graphic. */
  width: clamp(360px, 44vw, 680px);
  height: auto;
  margin: 0 auto;
}

/* Home-screen-only modifier (see .tagline below, still shared with the
   Sign In screen's real text tagline) — a bare <p> carries a browser
   default ~1em bottom margin that was never explicitly zeroed. It didn't
   matter while this was small text, but it's real reclaimable space now
   that both the tagline graphic above and the White House photo below
   compete for the same leftover flex height — direct request to make the
   photo bigger. Scoped to this modifier, not the shared .tagline class,
   so the Sign In screen's spacing is untouched. */
.tagline-home {
  margin-bottom: 0;
  /* .white-house-bg now overlaps this element on purpose (negative margin
     — see its own comment) so its already-transparent fade edge tucks
     underneath instead of leaving visible empty space. Stacked above it
     so the tagline graphic itself stays fully crisp/legible through that
     overlap rather than the photo painting over it. */
  position: relative;
  z-index: 2;
}

.tagline {
  color: var(--muted);
  /* Direct request: shrink to fit rather than wrap. 1.85cqw is calibrated
     (via real getBoundingClientRect measurement of the actual Sign In
     sentence, the longest text this class ever holds) to stay just under
     the width that would overflow .intro-screen at every container width
     tested from ~614px to ~1360px — see the container-type note on
     .intro-screen above. Floors out at 0.75rem on very narrow (phone-width)
     screens, where the sentence is long enough that "fits on one line" and
     "still legible" stop both being possible at once. */
  font-size: clamp(0.75rem, 1.85cqw, 1.6rem);
  white-space: nowrap;
  /* Pulled up close under the title, per explicit homepage layout note.
     The full requested 1/4in (24px) genuinely doesn't fit here — the real
     gap between the h1 and this element (confirmed via
     getBoundingClientRect, toggling this margin on/off directly in the live
     page) is only ~6.4px, just the flex `gap` on .intro-screen, with no
     extra line-height slack hiding behind it. A full -24px (or even -18px,
     tried first) pulls the tagline up well past the h1's own bottom edge
     into real, visible overlap. -0.3rem (4.8px) is the largest pull that
     stays clear, landing the tagline right up against the title — as
     tight as physically possible without touching. The remaining ~67px of
     the requested 72px (3/4in) total budget is made up on the photo's side
     instead (see .white-house-bg / .home-grid). */
  margin-top: -0.3rem;
}

.primary-btn {
  font-family: inherit;
  font-size: 1rem;
  font-weight: bold;
  background: var(--red);
  color: #fff;
  border: none;
  border-radius: 6px;
  padding: 0.65rem 1.75rem;
  cursor: pointer;
  transition: background 0.15s ease, transform 0.1s ease;
  /* On short screens, a full-width final CTA can land in the same viewport
     corners as the fixed Hint/How to Play buttons (z-index 100). Outrank
     them so the CTA never gets visually sliced by those utility buttons. */
  position: relative;
  z-index: 150;
}

.primary-btn:hover:not(:disabled) {
  background: var(--red-bright);
}

.primary-btn:active:not(:disabled) {
  transform: scale(0.98);
}

/* The "Move onto White/Blue Level" button on a tier's final-win screen
   reuses .primary-btn (red, like every other primary CTA) — direct user
   request to make THIS one white instead, since it's a celebratory "move
   forward" action rather than the usual "confirm/continue" red. */
#next-tier-btn {
  background: var(--surface);
  color: var(--navy);
  border: 2px solid var(--navy);
}
#next-tier-btn:hover:not(:disabled) {
  background: var(--bg-tint);
}

/* Direct request: Teacher Dashboard's own "Back to Home" reuses .primary-btn
   (red, like every other CTA) by default — made black/white instead, same
   scoped-override pattern as #next-tier-btn above. */
#dashboard-back-btn {
  background: var(--ink);
  color: #fff;
}
#dashboard-back-btn:hover:not(:disabled) {
  background: #000;
}

.primary-btn:disabled {
  background: var(--border);
  color: var(--muted);
  cursor: not-allowed;
}

/* Same image-replaces-chrome treatment as .home-grid-btn--img above, for
   the one .primary-btn that also got a supplied graphic (Resume Your
   Campaign) — background/padding stripped so the artwork is the only
   visible content, hover background neutralized so var(--red-bright)/
   #3a3a3a can't peek out from behind the image's own rounded corners. */
.primary-btn--img {
  background: transparent;
  padding: 0;
}

/* Direct report: "the red/white/blue tier buttons lift on hover but Resume
   Your Campaign doesn't" — Resume is a .primary-btn (only ever gets a
   background-color hover, no motion) wearing the --img modifier, unlike
   the tier buttons (.home-grid-btn--img, which explicitly keeps its
   base class's translateY(-2px) hover lift — see that rule's own comment).
   .primary-btn never had a hover lift to inherit from, so this adds the
   same lift here directly rather than restructuring which base class
   Resume uses. */
.primary-btn--img:hover:not(:disabled) {
  background: transparent;
  transform: translateY(-2px);
}

.primary-btn--img img {
  display: block;
  /* Direct request: sized to 80% of the tier-select buttons (.home-grid-btn
     --img above) — those resolve to min(355px, 32vw - 111px) at their
     current .home-grid layout (3 equal columns inside #app's own
     min(1400px, 96vw) max-width, minus its 1.25rem side padding and the
     grid's own 9rem side padding and 0.4rem column gap — measured directly
     at three viewport widths rather than left as a guess, since the old
     flat min(210px, 45vw) cap drifted from matching them at all: smaller
     than a tier button on a wide screen, bigger than one on a narrow
     screen). 80% of that same two-part formula keeps the ratio correct at
     every width instead of just the one it was tuned at. */
  width: min(284px, 25.6vw - 89px);
  height: auto;
}

.field-hint {
  font-size: 0.85rem;
  color: var(--muted);
  margin: -0.5rem 0 0;
}

/* The "I choose not to answer" checkbox on White tier's debate/forum/policy
   vote screens (see renderExchange in ui.js) — sits between the word-count
   hint and the Submit button. */
.exchange-opt-out-label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.9rem;
  color: var(--muted);
  cursor: pointer;
}

/* Direct request: white text, larger, "most clearly seen" — needs a dark
   background to actually be legible, since the page itself is light gray.
   Navy (not red, which is reserved for the actual validation error right
   below it) since this is a standing requirement, not an error state. */
.name-requirement-notice {
  background: var(--navy);
  color: #fff;
  font-size: 1.05rem;
  font-weight: bold;
  text-align: center;
  padding: 0.6rem 1rem;
  border-radius: 8px;
  margin: -0.25rem 0 0;
}

.field-error {
  font-size: 0.85rem;
  color: var(--red);
  font-weight: bold;
  margin: -0.5rem 0 0;
}

.field-error.hidden {
  display: none;
}

.field-label {
  font-weight: bold;
  margin-bottom: -0.5rem;
}

/* The home screen's whole 2-row, 3-column button block: tier selection on
   top (Red/White/Blue), Leaderboard/Progress/Awards below it — sized and
   shaped identically (see .home-grid-btn) so the two rows read as one
   cohesive grid, colors matching column-for-column with the tier row
   above them. */
.home-grid {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  flex-shrink: 0;
  /* Reserves room on the right for the stacked How to Play/Give Feedback
     buttons (both fixed, bottom-right) — without this, "Awards Collected"
     (the rightmost button in the grid's own second row) sat directly under
     them, confirmed via direct measurement at a real 1280px-wide viewport.
     SYMMETRIC (matching left padding, not just padding-right) — a
     right-only inset shifted the whole grid visibly off-center (direct
     report, screenshot), even though the outer #app box was still properly
     centered; the grid itself needs equal insets to actually look centered. */
  padding: 0 9rem;
}

.tier-select-label {
  margin: 0;
  /* Same overlap as .tagline-home above — .white-house-bg's negative
     margin now reaches down into this element's space on purpose, and
     this needs to paint on top of that for the same reason. */
  position: relative;
  z-index: 2;
}

/* Was clamp(140px, 18vw, 180px) — sized to match the old text's ~21px
   height. Direct follow-up request: 50% bigger; every number here scaled
   by 1.5 to keep the same responsive clamp shape (min/preferred/max) at
   the new size, rather than just raising the max and changing how it
   scales in between. */
.tier-select-label-graphic {
  display: block;
  width: clamp(210px, 27vw, 270px);
  height: auto;
  margin: 0 auto;
}

.home-grid-row {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.4rem;
  width: 100%;
}

/* Lean, thin bars rather than chunky cards — both rows (tier select and
   leaderboard/progress/awards) share this exact shape, differing only in
   color, so the two rows read as one cohesive 3x2 grid. */
.home-grid-btn {
  display: flex;
  flex-direction: row;
  align-items: baseline;
  justify-content: center;
  gap: 0.35rem;
  min-height: 1.9rem;
  font-family: inherit;
  font-weight: bold;
  font-size: calc(1rem + 6px);
  background: var(--surface);
  border: 2px solid var(--border);
  border-radius: 6px;
  padding: 0.4rem 0.75rem;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  /* Outranks the fixed corner Hint/How-to-Play buttons so they never
     visually slice this one. */
  z-index: 150;
  transition: transform 0.1s ease, border-color 0.15s ease, filter 0.15s ease;
}

.home-grid-btn:hover:not(:disabled) {
  transform: translateY(-2px);
}

.home-grid-btn:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

/* Direct request: replace the 6 home-grid buttons' plain-color/text look
   with the supplied button graphics (each one a complete, pre-styled pill —
   background, border, bevel, and label baked into the artwork). This
   modifier strips the button's own chrome down to nothing so the image is
   the only visible content; :hover keeps .home-grid-btn's translateY lift
   above (still a nice affordance) but must neutralize the background too,
   since the button's rounded rect is now taller/wider than the image's own
   rounded corners in a few px — without this, a colored ring would peek out
   from behind the image on hover. */
.home-grid-btn--img {
  background: transparent;
  border: none;
  padding: 0;
  min-height: 0;
}

.home-grid-btn--img:hover:not(:disabled) {
  background: transparent;
  border-color: transparent;
}

.home-grid-btn--img img {
  display: block;
  width: 100%;
  height: auto;
}

.tier-badge {
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  margin-top: 0.1rem;
}

.progress-tier-group {
  margin-bottom: 1.1rem;
  text-align: left;
}

.progress-tier-group h4 {
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  margin: 0 0 0.4rem;
}

/* Sub-header inside "This Game's Scores" grouping each tier's own office
   list (see the scoresByTier grouping in renderProgressOverlay) — smaller/
   quieter than .awards-section-label above it since it's one level deeper
   (a section within a section), but the same idea. */
.progress-tier-label {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  margin: 0 0 0.3rem;
  text-align: center;
}

/* One column per tier reached so far, laid out side by side instead of
   stacked — direct request, both for a more compact overlay (fits one
   screen without scrolling: the tallest column is ~8 rows instead of up to
   24 stacked across 3 tiers) and for a specific look: 1 tier centers
   itself, 2 center as a pair, 3 spread left/center/right within that
   centered group. justify-content: center on a plain flex row gets all
   three cases right with no per-count branching. */
.progress-columns {
  display: flex;
  justify-content: center;
  gap: 1.75rem;
  flex-wrap: wrap;
}

.progress-column {
  min-width: 170px;
}

/* Direct request: each tier's awards now sit directly under that tier's own
   score list (inside the same .progress-column) instead of every award in
   the game being dumped into one shared list at the bottom — a thin rule
   plus a little top space is enough to read as "a second list", without a
   whole extra section heading repeated 3 times. */
.progress-column-awards {
  margin-top: 0.5rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--border);
  font-size: 0.82rem;
}
.progress-column-awards .progress-item {
  text-align: center;
}

/* Office name over score, both centered, instead of the base
   .progress-item's side-by-side layout — a narrow ~130px column has no
   room for a long office name ("President Reelection") and its score on
   the same line. Scoped to .progress-column specifically so the wider,
   single-list usages of .progress-item elsewhere (Leaderboard, Awards
   Collected, the final-win recap) keep their original side-by-side row. */
.progress-column .progress-item {
  flex-direction: column;
  align-items: center;
  gap: 0;
  text-align: center;
}
.progress-column .progress-score {
  text-align: center;
  font-size: 0.8rem;
}

.progress-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.progress-item {
  display: flex;
  justify-content: space-between;
  gap: 0.75rem;
  font-size: 0.9rem;
}

.progress-office {
  font-weight: bold;
}

.progress-score {
  color: var(--muted);
  text-align: right;
}

.progress-tier-total {
  margin: 0.5rem 0 0;
  font-size: 0.85rem;
  color: var(--muted);
  text-align: center;
}

.award-stars {
  text-align: right;
  letter-spacing: 0.05em;
  font-size: 1.8rem;
}

.award-stars-blue {
  color: var(--flag-blue);
}

.award-stars-red {
  color: var(--red);
}

.award-stars-white {
  color: #9a9aa2;
}

.award-badges {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.award-item {
  padding: 0.5rem 0;
}

.award-list {
  gap: 0.6rem;
}

.medal-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 4.6rem;
  height: 4.6rem;
  border-radius: 50%;
  border: 5px solid transparent;
  line-height: 1;
  position: relative;
}

.medal-badge-red {
  border-color: var(--red);
}

/* A real medal photo (user-supplied reference art) instead of the 🥉🥈🥇
   emoji — no numeral baked into the art this time, so the old
   .medal-number-mask patch trick is gone entirely. Already circular with a
   transparent surround (same crop technique as the gem images), so it just
   fills the ring. */
.medal-badge-img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.awards-section-label {
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  margin: 1.1rem 0 0.3rem;
  text-align: center;
}

.gems-row {
  display: flex;
  justify-content: center;
  gap: 1.5rem;
  flex-wrap: wrap;
  margin: 1.25rem 0 1.75rem;
  padding: 1.25rem;
  background: rgba(0, 0, 0, 0.03);
  border-radius: 12px;
}

.gem-slot {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  min-width: 6rem;
}

/* Medals grid: a strict 3-column x 3-row box (one row per tier: bronze,
   silver, gold in that order), not a wrapping flex list — the source order
   from renderAwardsOverlay is already grouped in threes per tier, so a fixed
   3-column grid lines each tier up as its own clean row instead of letting
   items wrap wherever the container width happens to break. */
.medals-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.25rem 1rem;
  margin: 1.25rem 0 1.75rem;
  padding: 1.25rem;
  background: rgba(0, 0, 0, 0.03);
  border-radius: 12px;
}

.medals-grid .gem-slot {
  min-width: 0;
}

/* A real image now (user-supplied reference art), not the 💎 emoji + a
   hue-rotate filter — sized in rem instead of font-size since an <img>
   doesn't respond to that. */
.gem-icon {
  width: 4.6rem;
  height: 4.6rem;
  object-fit: contain;
}

/* These per-tier tints are ONLY for .gem-icon-inline below now — the big
   .gem-icon above is a real, already-colored image and no longer carries a
   gem-icon-{tier} class at all. Darker, richer tint than a flatter filter
   would give — more saturation with a touch of reduced brightness so each
   inline gem still reads as a real deep-colored stone, while keeping enough
   brightness that the emoji's own built-in facets/highlights (its natural
   "gleam") still show instead of crushing to near-black. */
.gem-icon-red {
  filter: hue-rotate(160deg) saturate(5) brightness(0.7) contrast(1.45);
}

.gem-icon-white {
  filter: grayscale(0.45) brightness(1) contrast(1.4) saturate(1.5);
}

.gem-icon-blue {
  filter: saturate(3.2) brightness(0.78) contrast(1.35);
}

.gem-icon-emerald {
  filter: hue-rotate(230deg) saturate(4) brightness(0.78) contrast(1.35);
}

/* Smaller variant for use inline within paragraph text (the How to Play
   Awards Collected legend, describeCareerRunAward) — still the plain 💎
   emoji + the tint filters just above, unlike the big .gem-icon. A real
   photo at this tiny inline size would just look like a blurry smudge. */
.gem-icon-inline {
  font-size: 1.15rem;
  vertical-align: -0.15em;
}

/* Inline sizing for a tier-colored star used within paragraph text (the
   "Track Your Progress" award log — see describeCareerRunAward in ui.js).
   Pair with .award-stars-red/white/blue (below) for the actual color — this
   class only overrides those classes' big standalone-row font-size down to
   inline scale. Deliberately a plain text star glyph, not the ⭐ emoji: emoji
   ignore CSS `color` and would always render the same fixed yellow
   regardless of which tier the star was actually earned on. */
.award-star-inline {
  font-size: 1.15rem;
  vertical-align: -0.1em;
}

.gem-name {
  font-weight: bold;
  font-size: 1.05rem;
}

.gem-tier-label {
  font-size: 0.75rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

/* A locked award (gem, medal, star row, gauntlet, ...) just dims — real
   photo images already show their true tier color, so unlike the old
   emoji-based gems there's no separate per-tier "locked" filter needed
   anymore to keep that color visible while faded. */
.gem-locked {
  opacity: 0.5;
}

/* Career Total high score, shown right under a tier gem's label (2026-07-21)
   — only red/white/blue have one (emerald is a flawless-run flag, not a
   score). Set the moment that tier's gem is first earned, then only ever
   updated by a later run that beats it (see recordTierBestScore). */
.gem-high-score {
  font-size: 0.7rem;
  color: var(--muted);
  font-weight: bold;
  margin-top: 0.1rem;
}

/* Shared box for Level Stars and Perfect Reputation (2026-07-22 revision) —
   one rectangle, one row per tier, the tier name on the left and the
   award's value on the right, rather than 3 side-by-side icon columns (the
   previous Stars layout) or a flat ungrouped row (the previous Perfect
   Reputation layout). See tierListBox in ui.js. */
.tier-list-box {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin: 1.25rem 0 1.75rem;
  padding: 1.1rem 1.4rem;
  background: rgba(0, 0, 0, 0.03);
  border-radius: 12px;
}

.tier-list-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

.tier-list-row-empty {
  opacity: 0.5;
}

.tier-list-label {
  font-weight: bold;
  font-size: 0.95rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  min-width: 4.5rem;
}

/* Same per-tier colors as .award-stars-red/white/blue and the medal rings,
   so this box's labels read as the same "level color" used everywhere else. */
.tier-list-label-red {
  color: var(--red);
}

.tier-list-label-white {
  color: #9a9aa2;
}

.tier-list-label-blue {
  color: var(--flag-blue);
}

.tier-list-value {
  font-size: 1.4rem;
  line-height: 1;
}

/* One per Perfect Reputation mark — see the comment in ui.js above
   HUNDRED_IMG for why this is an image, not the 💯 emoji or CSS-colored
   text. Height-constrained, width auto, so each tier's crop (slightly
   different source dimensions) still lines up the same height in a row. */
.hundred-mark-img {
  height: 1.7rem;
  width: auto;
  vertical-align: middle;
  margin-left: 0.2rem;
}

/* The Champion's Gauntlet badge — a real illustrated image (see the comment
   in ui.js above gauntletBlock for why this is an <img>, not emoji+CSS like
   every other award here). The badge itself is now just a responsive
   circular frame; .gauntlet-fist-img does all the actual visual work. */
.gauntlet-badge {
  position: relative;
  display: inline-block;
  width: min(20rem, 60vw);
  height: min(20rem, 60vw);
  border-radius: 50%;
  background: radial-gradient(circle at 50% 42%, rgba(212, 160, 44, 0.32), rgba(212, 160, 44, 0) 68%);
  /* A beveled bronze/gold medallion ring, like a heraldic shield boss
     framing the artwork, built from stacked shadow rings rather than a
     border-image asset. Kept gold on direct request even after every
     other gold UI accent in the app moved to silver (see --silver) —
     the rarest award in the game keeps its own distinct color. */
  box-shadow:
    0 0 0 4px #6b4a1e,
    0 0 0 7px #d4af37,
    0 0 0 9px #6b4a1e,
    inset 0 0 22px rgba(0, 0, 0, 0.3);
}

.gauntlet-fist-img {
  position: absolute;
  inset: 8%;
  width: 84%;
  height: 84%;
  object-fit: contain;
  filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.35));
}

.gauntlet-slot {
  min-width: 0;
  max-width: 100%;
}

/* The requirement text is a full sentence, not a short label like the other
   awards' tier-level tags — drop the uppercase/letter-spacing treatment and
   let it wrap, centered, instead of forcing it onto one cramped line. */
.gauntlet-slot .gem-name {
  font-size: 1.5rem;
  margin-top: 0.5rem;
}

.gauntlet-slot .gem-tier-label {
  text-transform: none;
  letter-spacing: normal;
  font-size: 0.9rem;
  max-width: 19rem;
  text-align: center;
}

/* Inline sizing for the glove glyph used in paragraph text (How to Play,
   Track Your Progress award log) — same pattern as .gem-icon-inline. */
.gauntlet-icon-inline {
  font-size: 1.15rem;
  vertical-align: -0.15em;
}

/* The Champion's Gauntlet's own dedicated full-screen moment (see
   renderGauntletVictory in ui.js) — a dark backdrop with a warm gold glow
   behind the badge, distinct from every other screen in the game, since
   this is the one moment meant to feel like nothing else does. */
.gauntlet-victory-screen {
  position: relative;
  overflow: hidden;
  align-items: center;
  text-align: center;
  min-height: 80vh;
  justify-content: center;
  background: radial-gradient(ellipse at 50% 38%, rgba(212, 160, 44, 0.28), rgba(20, 16, 8, 0.94) 62%);
  border-radius: 16px;
  padding: 2.5rem 1.5rem 3rem;
  color: #fff;
}

/* Full-cover flash triggered at the 3-second mark (see
   wireGauntletVictoryScreen in main.js) — a quick red→white→blue→clear
   strobe right before the reveal group below fades in. Transparent and
   inert until .gauntlet-flash--active plays the one-shot animation once. */
.gauntlet-flash {
  position: absolute;
  inset: 0;
  z-index: 5;
  opacity: 0;
  pointer-events: none;
}
.gauntlet-flash.gauntlet-flash--active {
  animation: gauntlet-flash-strobe 0.7s ease-out;
}
@keyframes gauntlet-flash-strobe {
  0% {
    opacity: 0;
    background: #d94a3f;
  }
  22% {
    opacity: 1;
    background: #d94a3f;
  }
  45% {
    opacity: 1;
    background: #ffffff;
  }
  68% {
    opacity: 1;
    background: #14448c;
  }
  100% {
    opacity: 0;
    background: #14448c;
  }
}

/* Title/badge/copy/button all fade in together right after the flash
   (.gauntlet-revealed added by wireGauntletVictoryScreen) — hidden and
   inert before that so nothing spoils the flash-then-reveal beat. */
.gauntlet-reveal-group {
  position: relative;
  /* Higher than .celebrate-overlay's own z-index:9999 (see celebrate.js) —
     that fixed overlay is what renders every confetti/firework piece in
     the game, always on top by design. Scoped to just this content group
     (not the whole .gauntlet-victory-screen) so confetti still visibly
     falls over the screen's own dark backdrop — it only tucks behind the
     actual title/badge/copy/button, instead of drawing over them. */
  z-index: 10000;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  opacity: 0;
  transform: scale(0.94);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.gauntlet-victory-screen.gauntlet-revealed .gauntlet-reveal-group {
  opacity: 1;
  transform: scale(1);
}

.gauntlet-victory-eyebrow {
  margin: 0;
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: #e0c169;
}

.gauntlet-victory-title {
  margin: 0;
  font-size: 2.1rem;
  color: #f7d774;
  text-shadow: 0 0 18px rgba(247, 215, 116, 0.45);
}

/* Bigger stage than the Awards Collected slot's own .gauntlet-badge — this
   is the hero of a whole dedicated screen, not one item in a grid — plus a
   slow breathing glow and a one-time spin-in on arrival so it actually
   feels like the moment the screen exists for. */
.gauntlet-badge-hero {
  width: min(16rem, 55vw);
  height: min(16rem, 55vw);
  /* No entrance animation here — .gauntlet-reveal-group's own fade+scale
     already covers "the gauntlet appears," and playing a second animation
     underneath a parent that's still opacity:0 just wastes a run of it
     before anyone can see it. The idle glow keeps breathing continuously;
     the periodic full spin (.gauntlet-badge-hero--spin, JS-toggled by
     wireGauntletVictoryScreen every several seconds) layers on top of it. */
  animation: gauntlet-hero-glow 2.4s ease-in-out infinite;
}
.gauntlet-badge-hero.gauntlet-badge-hero--spin {
  animation: gauntlet-hero-glow 2.4s ease-in-out infinite, gauntlet-hero-spin 1.4s cubic-bezier(0.45, 0.05, 0.35, 1) 1;
}
@keyframes gauntlet-hero-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

@keyframes gauntlet-hero-glow {
  0%,
  100% {
    box-shadow:
      0 0 0 6px rgba(212, 160, 44, 0.5),
      0 0 40px 10px rgba(212, 160, 44, 0.35);
  }
  50% {
    box-shadow:
      0 0 0 6px rgba(212, 160, 44, 0.7),
      0 0 60px 18px rgba(212, 160, 44, 0.55);
  }
}

.gauntlet-victory-copy {
  max-width: 34rem;
  font-size: 1.05rem;
  line-height: 1.55;
  color: #f1e6cc;
}

.gauntlet-victory-copy strong {
  color: #f7d774;
}

.gauntlet-victory-copy-muted {
  font-size: 0.95rem;
  font-style: italic;
  color: #cbbb8e;
  margin-top: -0.4rem;
}

@media (prefers-reduced-motion: reduce) {
  .gauntlet-badge-hero,
  .gauntlet-flash.gauntlet-flash--active,
  .gauntlet-reveal-group {
    animation: none !important;
    transition: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
}

.medal-badge-white {
  /* Same silver-gray tone as .award-stars-white, so a White-level medal and
     a White-level star read as the same "level color" rather than a medal
     ring that looks gold (this used to be #b8860b, an actual gold shade —
     a leftover mismatch, not the intended tier color). */
  border-color: #9a9aa2;
}

.medal-badge-blue {
  border-color: var(--flag-blue);
}

#name-input {
  font-family: inherit;
  font-size: 1rem;
  padding: 0.6rem 0.8rem;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
}

/* White tier's debate/forum/policy-vote free-text response box — same look
   as #name-input above, just resizable and multi-line. */
#exchange-response-input {
  width: 100%;
  font-family: inherit;
  font-size: 1rem;
  padding: 0.6rem 0.8rem;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  resize: vertical;
  box-sizing: border-box;
}

.archetype-grid {
  display: grid;
  gap: 0.6rem;
}

.archetype-card {
  text-align: left;
  font-family: inherit;
  background: var(--surface);
  border: 2px solid var(--border);
  border-radius: 10px;
  padding: 0.55rem 0.75rem;
  cursor: pointer;
  color: var(--text);
  /* Native <button> elements vertically center block content when the grid
     stretches them taller than their own content needs (e.g. a shorter card
     sharing a row with a much longer one) — without this, the shorter
     card's text visibly starts lower than its row-mates instead of lining
     up at the top like every other card. */
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
}

.archetype-card:hover {
  border-color: var(--silver);
}

.archetype-card.selected {
  border-color: var(--red);
  background: color-mix(in srgb, var(--red) 8%, var(--surface));
}

.archetype-card h3 {
  color: var(--navy);
  font-size: 0.98rem;
}

.archetype-card p {
  margin: 0.2rem 0 0;
  color: var(--muted);
  font-size: 0.78rem;
  line-height: 1.22;
}

.archetype-bonus {
  font-weight: bold;
  color: var(--red) !important;
}

/* The campaign issue picker reuses .archetype-grid/.archetype-card, always
   2 fixed columns with equal-height rows (direct user spec: "two even
   sides"). --issue-font-scale is a single fixed value now, not a formula
   or a runtime auto-fit — both were tried (a row-count/viewport-height
   formula, then a JS binary search that grew text to the largest
   non-overflowing size) and both were rejected: on Blue's 14-18 issue
   offices they either left dead space in every card or shrank text down
   to an illegibly small size just to dodge a scroll. Direct user
   decision: keep the box/font size big and comfortable to read on EVERY
   tier, and let a long list (Blue) simply scroll inside this grid instead
   — the `overflow-y: auto` below is the real, intended behavior for that
   case now, not a fallback. 0.85 is calibrated so White's 10-issue
   offices still fit with zero scroll at a normal viewport.

   Rows use grid-auto-rows (not a hardcoded grid-template-rows row count)
   so this adapts to any office's issue count — a hardcoded `repeat(5,
   1fr)` (sized for a 10-issue office) silently broke Blue School Board's
   14-issue picker: items 1-10 got squeezed into 5 undersized explicit
   rows and had their text clipped by .issue-card's overflow:hidden below,
   while items 11-14 spilled into auto-sized implicit rows that rendered
   fine — exactly the "some cards cut off, others fine" bug a real
   playtest caught. A first fix tried plain `grid-auto-rows: 1fr` (equal
   rows for any count) — better, but still wrong: with 14 issues (7 rows)
   the available height divided 7 ways is shorter than a card's actual
   content needs, so EVERY card ended up clipped instead of just some.
   `minmax(auto, 1fr)` is the real fix: each row is never forced below its
   own content's natural height (the `auto` floor) but still grows to
   share any leftover space evenly (the `1fr` ceiling) when there's room
   to spare, and a longer list simply grows taller than the box and
   scrolls. */
.issue-picker-grid {
  grid-template-columns: repeat(2, 1fr);
  grid-auto-rows: minmax(auto, 1fr);
  gap: calc(1rem * var(--issue-font-scale));
  flex: 1;
  min-height: 0; /* same grid/flex shrinking gotcha as .issue-card below, one level up */
  overflow-y: auto;
  --issue-font-scale: 0.85;
}

/* Always 2 columns (direct user spec: "two even sides"). Kept as a real
   custom property (not a hardcoded 2 in the rule above) only so a future
   office count can
   still override it if ever needed; today renderCampaignIssuePicker always
   passes 2. */
@media (min-width: 600px) {
  .archetype-grid.issue-picker-grid {
    grid-template-columns: repeat(var(--issue-cols, 2), 1fr);
  }
}

.issue-picker-grid .issue-card {
  /* No longer needs overflow:hidden or a min-height:0 fight against the
     row track — grid-auto-rows: minmax(auto, 1fr) above already
     guarantees every row is at least as tall as its content, so a card's
     natural height is never clipped in the first place. */
  /* No padding here on purpose — see .issue-select-btn below for why it
     moved there instead of staying on this outer div. */
  padding: 0;
}

/* Fixed, generous size (see --issue-font-scale on .issue-picker-grid) —
   same size on every tier/office, White and Blue alike. A long Blue list
   (14-18 issues) simply scrolls inside the grid instead of shrinking text
   to dodge that scroll; direct user decision after both a row-count
   formula and a runtime "shrink to fit" pass left text on some offices
   too small to comfortably read. */
.issue-picker-grid .issue-card h3 {
  font-size: calc(1.5rem * var(--issue-font-scale, 1));
  margin: 0 0 0.25rem;
  line-height: 1.15;
  /* Pure black, not the shared .archetype-card rule's --navy — direct
     user report that the default was hard to read against this card's
     white background. */
  color: #000;
}

.issue-picker-grid .issue-card p {
  font-size: calc(1.05rem * var(--issue-font-scale, 1));
  line-height: 1.3;
  margin-top: 0.2rem;
  /* Pure black, not the shared .archetype-card rule's --muted gray — same
     readability report as h3 above. */
  color: #000;
}

/* .issue-card is now a plain div wrapping two real buttons (a button can't
   nest inside another button) — the select button carries the card's own
   look with none of its own button chrome, so it still reads as one
   cohesive clickable card; Learn More is deliberately small/quiet so it
   doesn't compete with the actual pick. */
.issue-select-btn {
  background: none;
  border: none;
  margin: 0;
  text-align: left;
  font-family: inherit;
  color: inherit;
  cursor: pointer;
  width: 100%;
  /* Was 0 padding, with the visual inset coming from the outer .issue-card
     div's own padding instead — that meant the card's padding strip (on
     every side) sat OUTSIDE this button, on the plain, unclickable div
     underneath. A direct report ("clicked the top-left issue, nothing
     happened, repeatedly") traced to exactly that: the card's own edges
     and corners — precisely where a player's eye and cursor land first —
     were never actually part of the clickable button. Moving the card's
     old padding here instead means the button now spans the ENTIRE
     visible card, edge-to-edge, with the same visual inset for the title/
     blurb text as before. No bottom padding — flex: 1 below already
     grows the button to fill all vertical space up to the Learn More
     button's own top margin, so the button's clickable area still reaches
     every remaining pixel instead of stopping short at a fixed padding
     value. */
  padding: calc(0.5rem * var(--issue-font-scale, 1)) calc(1.1rem * var(--issue-font-scale, 1)) 0;
  /* Grows to fill whatever vertical space is left inside the card instead
     of staying sized to just its own title+blurb text. Without this, a
     card whose grid row got stretched taller than its content needs (see
     .issue-picker-grid's own comment on grid-auto-rows: minmax(auto, 1fr))
     left a real, silently unclickable gap between the button and the
     bottom of the visible card — measured live: up to 42px of the card's
     own visible area belonged to no clickable element at all. flex: 1
     makes the button itself absorb that leftover space, so the entire
     visible card is clickable edge-to-edge again, matching what a player
     reasonably expects a "card" to be. */
  flex: 1;
  display: flex;
  flex-direction: column;
}

/* Direct request: every "Learn More" button in the game (this one, its
   exchange-screen size variant below, and .position-learn-more-btn further
   down) is soft yellow with black text. */
.issue-learn-more-btn {
  align-self: flex-start;
  /* .issue-card no longer has its own padding (see .issue-select-btn
     above) — this button needs its own left/bottom inset now to land in
     the same visual spot it always has, since it's a sibling of the
     select button, not inside it. */
  margin: calc(0.2rem * var(--issue-font-scale, 1)) 0 calc(0.3rem * var(--issue-font-scale, 1)) calc(1.2rem * var(--issue-font-scale, 1));
  background: #f7e9a0;
  border: 1px solid #d9c467;
  border-radius: 6px;
  padding: calc(0.15rem * var(--issue-font-scale, 1)) calc(0.55rem * var(--issue-font-scale, 1));
  font-size: calc(0.72rem * var(--issue-font-scale, 1));
  font-family: inherit;
  color: #000;
  cursor: pointer;
}

.issue-learn-more-btn:hover {
  background: #f2df7c;
  border-color: #c2ab4d;
}

/* Bigger than the issue-picker's own Learn More (direct request, live during
   a White debate) — this one stands alone on the exchange screen instead of
   sitting quietly next to a whole grid of other cards, so it can afford to
   read as a more substantial button. */
.exchange-learn-more-btn {
  padding: 0.35rem 0.9rem;
  font-size: 0.95rem;
  margin-left: 0;
}

/* Choose Your Position screen (renderPositionPicker) — two labeled columns,
   5 options each. Distinct colors from the tier-branding --red/--flag-blue
   variables on purpose: this is a political-stance label, not a tier color. */
/* An earlier pass made this screen's gaps/text bigger (only 5 fixed
   options per column, so "more content" was never the fix) but overshot —
   at 5 rows, that size forced a scroll (direct user report + screenshot).
   This screen must always fit one screen, no scrolling, so these values
   are recalibrated smaller: binary-searched against the real rendered
   overflow at 1366x768 and 1024x700 with real position text, not just
   guessed down. */
.position-picker-columns {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}

.position-column {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

.position-column-label {
  text-align: center;
  margin: 0 0 0.2rem;
  padding: 0.6rem;
  border-radius: 6px;
  font-size: 1.2rem;
}

/* Matches the homescreen's own Red/Blue tier button colors (.tier-red/
   .tier-blue, --red/--flag-blue) — direct user request, so Liberal/
   Conservative read using the same red/blue the rest of the game already
   associates with those two colors, white text for contrast. */
.position-column-liberal {
  background: var(--flag-blue);
  color: #fff;
}

.position-column-conservative {
  background: var(--red);
  color: #fff;
}

.position-option {
  display: flex;
  align-items: stretch;
  gap: 0.4rem;
}

.position-select-btn {
  flex: 1;
  text-align: left;
  background: var(--surface);
  border: 2px solid var(--border);
  border-radius: 8px;
  padding: 1rem 1.2rem;
  font-family: inherit;
  font-size: 0.96rem;
  line-height: 1.28;
  color: var(--text);
  cursor: pointer;
}

.position-select-btn:hover {
  border-color: var(--silver);
}

.position-learn-more-btn {
  flex-shrink: 0;
  background: #f7e9a0;
  border: 1px solid #d9c467;
  border-radius: 6px;
  padding: 0.4rem 0.75rem;
  font-size: 0.72rem;
  font-family: inherit;
  color: #000;
  cursor: pointer;
}

.position-learn-more-btn:hover {
  background: #f2df7c;
  border-color: #c2ab4d;
}

.subhead {
  color: var(--muted);
  margin-top: -0.75rem;
}

.create-screen h2 {
  text-align: center;
  font-size: clamp(1.4rem, 2.4vw, 1.8rem);
}

.create-screen .subhead {
  text-align: center;
  font-size: clamp(0.85rem, 1.2vw, 1rem);
}

/* A little breathing room below the whole screen's content, above the
   fixed corner Hint/How-to-Play buttons — this screen's own bottom-most
   element (.primary-btn) already sits above them via z-index, but that's
   not the same as visual spacing. Tighter overall gap than the default
   .screen so everything fits a landscape viewport without scrolling. */
.create-screen {
  padding-bottom: 1rem;
  gap: 0.5rem;
  height: 100%; /* fills #app's now-definite height above, so .issue-picker-grid's flex:1 has something real to grow into */
  box-sizing: border-box;
}

/* Puts the title/subhead and the actions-remaining counter on one row
   instead of two, saving a full line of vertical space that used to sit
   above the fold for no reason on a landscape screen. */
.campaign-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  /* The fixed mute button (3rem circle, 1rem from the right edge) floats
     over this same top-right corner — reserve room so "actions left this
     month" never runs underneath it. */
  padding-right: 4.5rem;
}

.campaign-header .actions-remaining {
  white-space: nowrap;
  padding-top: 0.2rem;
}

/* .subhead's -0.75rem margin-top only makes sense where it's counteracting
   a parent's 1rem flex gap (e.g. .screen) — .campaign-header's title/subhead
   pair sits in a plain div with no such gap, so that same negative margin
   instead pulls the subhead straight into the title's descenders. */
.campaign-header .subhead {
  margin-top: 0.2rem;
}

/* Two fixed columns filling whatever height .campaign-screen leaves after
   the header (see .campaign-screen's height comment) — flex row's default
   align-items:stretch makes both columns exactly as tall as .campaign-body
   itself, with no explicit height needed on either child. */
.campaign-body {
  display: flex;
  flex: 1 1 auto;
  min-height: 0;
  gap: 1.1rem;
}

/* Column 1: the blue info box on top, then the pink Campaign Trail log
   filling everything below it down to just above the Hint button (direct
   user request) — .log-panel's flex:1 (see below) is what actually does
   the filling; .stats-bar keeps its own natural content height above it. */
.campaign-col-left {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  /* Narrowed from 320px (direct user report: wider than the stats/log
     content actually needs) — the freed width goes to .campaign-col-right,
     giving every category card more room. */
  flex: 0 0 260px;
  min-height: 0;
}

/* Column 2 is a flex COLUMN with exactly 2 children: .campaign-categories
   (the 5 base categories + Opposition Research) on top, and Endorsements
   below it. Direct user request, in two parts that turned out to be in
   tension: first, Endorsements should "stretch all the way across the
   screen, flush with the bottom of the pink box" (framing the screen on the
   bottom the same way the left column's pink log frames it on the left);
   then, after seeing that done by making Endorsements ITSELF grow tall to
   fill the gap: "the size of the buttons is wrong, they should be thin and
   wide and towards the bottom, that way the five action categories have
   room to grow as more options are added at higher offices." So the flex
   GROWTH lives on `.campaign-categories` (below), not on Endorsements —
   Endorsements stays its own natural/thin height (see .action-group-wide),
   full width, pinned to the bottom by categories' box expanding to fill
   whatever space categories' own (comparatively sparse, top-aligned)
   content doesn't use. The gap this opens up between the category cards
   and Endorsements is deliberate reserved growing room, not a bug — future
   offices with more buttons/categories fill it in instead of the screen
   needing a resize. */
.campaign-col-right {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.12rem;
}

/* The 5 base categories (Fundraise & Recruit, Advertise, Rally, Host an
   Event, Organize) plus Opposition Research where the level defines it — a
   FIXED 5-column grid, not auto-fill/auto-fit off a min-width, which was
   computing a different column count at different viewport widths and,
   combined with a category count that doesn't always divide evenly, left an
   entire category alone in its row with empty columns beside it on at least
   one real viewport (direct user report + screenshot). 5 regular cards
   divide evenly by 5 columns with NO spanning needed — they fill exactly 1
   row. Opposition Research (when present) sits at column 3, under Hold a
   Rally (see .action-group-under-rally) rather than needing its own
   full-width row. This is fully static, computed once from level config at
   render time — NOT a live pixel measurement, and not something that can
   silently drift out of sync with a viewport resize.
   flex: 1 1 auto — THIS is the section that grows to fill whatever height
   Endorsements (flex: 0 0 auto, see .action-group-wide) doesn't use, giving
   room for more categories/buttons at higher offices without needing a
   layout change later. align-content:start + align-items:start keep the
   actual category CARDS packed at their own natural size at the TOP of that
   expanded box — align-items:start specifically stops a shorter card from
   being force-stretched to match the tallest card in its row (that force-
   stretch was leaving real dead space at the BOTTOM of shorter cards,
   which is what a direct user report meant by "advertise, rally, and event
   buttons should all be aligned and the same size": the cards were already
   the same height, forced there by stretch, but the buttons inside didn't
   grow to fill it, so they looked inconsistent against a card like Organize
   that had enough of its own content to fill its card naturally). */
.campaign-categories {
  flex: 1 1 auto;
  /* Without this, a flex item's default min-height is its own content's
     natural height, so once an office has enough buttons to exceed even
     this section's expanded share of the column, it just grows past its
     bounds and pushes Endorsements + the Hint button off-screen (caught
     live at President White once it had the most buttons of any office —
     6 ad tiers, 6 event tiers, 4 fundraising tiers). min-height: 0 lets
     flexbox actually cap this box's height to its allotted space;
     overflow-y: auto then scrolls WITHIN it when content still doesn't
     fit, so Endorsements/Hint stay reachable without the whole page
     needing to scroll. */
  min-height: 0;
  overflow-y: auto;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-auto-rows: min-content;
  align-content: start;
  align-items: start;
  gap: 0.08rem;
}

/* The internal scroll above is invisible by default on most systems (macOS/
   mobile overlay scrollbars only appear while actively scrolling), so a
   shorter browser window — not just a content-heavy office — can cut a
   button off mid-sentence with zero visual sign there's more below it
   (direct user report/screenshot: Bundler Outreach's own text looked
   truncated; reproduced at a shorter window height, not a wider content
   set — the earlier min-height/overflow-y fix was working, just invisibly).
   A permanently-visible, styled scrollbar makes "there's more, scroll for
   it" obvious instead of reading as missing/broken content. */
.campaign-categories::-webkit-scrollbar {
  width: 8px;
}
.campaign-categories::-webkit-scrollbar-track {
  background: transparent;
}
.campaign-categories::-webkit-scrollbar-thumb {
  background: var(--border);
  border-radius: 4px;
}
.campaign-categories::-webkit-scrollbar-thumb:hover {
  background: var(--silver);
}
.campaign-categories {
  scrollbar-width: thin;
  scrollbar-color: var(--border) transparent;
}

.actions-remaining {
  margin: 0;
  font-size: 0.85rem;
  font-weight: bold;
  color: var(--muted);
}

.reputation-status {
  margin: 0;
  font-size: 1.15rem;
  font-weight: bold;
  color: var(--muted);
}

/* The job-term screen's month label (see renderIssue/renderJobMc in ui.js)
   — sized up well past body-text scale on direct request: this screen
   naturally leaves a lot of unused vertical space on most viewports (a
   month label, a status line, a scenario, and 2-4 choice buttons rarely
   fill it), so there's real room to make the two lines players check most
   often — which month they're in, and their live percent-of-perfect — much
   more prominent. */
.trivia-progress {
  margin: 0;
  font-size: 1.5rem;
  font-weight: bold;
  color: var(--navy);
}

.level-score-box {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.9rem 1.2rem;
  margin: 0.5rem 0;
}

.level-score-total {
  margin: 0;
  font-size: 1.3rem;
  color: var(--navy);
}

.level-score-breakdown {
  margin: 0.25rem 0 0;
  font-size: 0.85rem;
  color: var(--muted);
}

/* Reminds the player exactly what policy they're pushing for on the Policy
   Vote's question screen (see renderExchange in ui.js) — the Policy Vote is
   answered once a year, months after the issue was picked at campaign time,
   so a bare title alone (fine for the fresher debate/forum subhead) isn't
   enough context by then. */
.policy-proposal-box {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.7rem 1rem;
  margin: 0.5rem 0;
  text-align: left;
}

.policy-proposal-label {
  margin: 0;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}

.policy-proposal-title {
  margin: 0.15rem 0 0;
  font-size: 1.05rem;
  font-weight: 700;
  color: var(--navy);
}

.policy-proposal-blurb {
  margin: 0.3rem 0 0;
  font-size: 0.85rem;
  color: var(--muted);
}

/* Compact reminder of the player's own chosen position on this issue, on
   every White/Blue debate/forum screen — a small tag rather than a boxed
   callout like .policy-proposal-box, since this is a quick memory-jog, not
   the headline of the screen. */
.exchange-position-reminder {
  display: inline-block;
  margin: 0.3rem 0;
  padding: 0.4rem 0.9rem;
  font-size: 1.15rem;
  font-weight: 600;
  /* Direct user report: too small and too light (--navy) to read easily. */
  color: #000;
  background: var(--bg-tint);
  border-radius: 999px;
}

.stats-bar {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  /* Light blue shading (2026-07-19, direct user request) so this box reads
     as a distinct "summary" panel — the fixed top piece of the left column,
     with the pink Campaign Trail log below it (see .log-panel). */
  background: color-mix(in srgb, var(--flag-blue) 10%, var(--surface));
  border: 1px solid color-mix(in srgb, var(--flag-blue) 35%, var(--border));
  border-radius: 8px;
  padding: 0.75rem;
  flex: 0 0 auto;
}

.stats-row {
  display: flex;
  gap: 0.5rem;
}

.stats-row-primary {
  padding-bottom: 0.6rem;
  border-bottom: 1px solid var(--border);
}

.stat {
  display: flex;
  flex: 1 1 0;
  flex-direction: column;
  align-items: center;
  gap: 0.2rem;
  /* Flex items default to min-width: auto, which lets a wide value
     (e.g. "December", "$8,500,000") overflow its share of the row and
     visually bleed into the neighboring cell instead of shrinking to fit. */
  min-width: 0;
}

.stat-label {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  text-align: center;
  line-height: 1.15;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  max-width: 100%;
}

.stat-value {
  font-weight: bold;
  color: var(--navy);
  max-width: 100%;
  white-space: nowrap;
  text-align: center;
}

/* Only two stats share this row (Month, Funds), so each gets roughly half
   the box's width — plenty of room to size back up from the cramped
   five-across layout this replaced. */
.stats-row-primary .stat-value {
  font-size: clamp(0.95rem, 4.2vw, 1.2rem);
}

/* Always 4 stats (Awareness/Popularity/Civic Knowledge/Financing on Red,
   Awareness/Popularity/Financing/Debate on White/Blue — Variety briefly had
   its own 5th slot here, removed per direct user request, see
   whiteStatsRow's own comment in ui.js) — squeezing all 4 into one flex row
   (the original 3-stat layout) left labels like "Awareness" and
   "Popularity" running into each other with no room to breathe. A 2x2 grid
   gives every label its own full-width cell instead. */
.stats-row-secondary {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.3rem 0.5rem;
}

/* "Civic Knowledge" wraps to 2 lines while "Awareness"/"Popularity" fit on
   1, so without a reserved height its value sat a whole line lower than
   the other two (2026-07-19 fix, direct user request: "bump up civic
   knowledge a row so all the numbers are parallel"). Reserving room for 2
   lines here, combined with .stat-label's existing flex-end alignment,
   bottom-aligns every label (whether 1 or 2 lines) so all values in the same
   row start at the same height. */
.stats-row-secondary .stat-label {
  min-height: 1.66rem;
}

.stats-row-secondary .stat-value {
  font-size: clamp(0.85rem, 3.4vw, 1.05rem);
}

/* Each category sits in its own fixed grid cell of .campaign-col-right
   (direct user request: "all categories should have their own area... and
   they should not move") — a plain header + its own button grid, NOT a
   bordered/backgrounded card. An earlier bordered-card version (border,
   background, real padding) was the single biggest reason button text had
   to be shrunk to fit President without scrolling — removing that card
   chrome (7 categories' worth of border+background+padding+header margin)
   freed roughly 200px of vertical space on its own, verified by direct
   before/after measurement, letting button text go back to a genuinely
   readable size instead. Individual buttons (.action-btn below) still have
   their own border/background, so each one still reads as a clickable box —
   only the CATEGORY-level wrapper lost its box, per direct user request to
   "spread the other buttons out" once cards stopped needing to compete for
   space with a redundant outer border. */
.action-group {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
  grid-template-rows: auto;
  grid-auto-rows: min-content;
  align-items: stretch;
  gap: 0.08rem;
  background: none;
  border: none;
  padding: 0;
}

/* Endorsements can run up to 7 buttons (President/Reelection) — the single
   tallest category by far, and the one direct user request specifically
   wanted to visually "frame" the screen: "endorsement buttons should be in
   two even rows of three rectangle buttons each and should stretch all the
   way across the screen, flush with the bottom of the pink box." Two parts:
   (1) flex: 1 1 auto makes this card grow to fill however much height
   .campaign-categories (the 5 base categories + Opposition Research, see
   there) doesn't use, all the way down to match .campaign-col-left's full
   height — instead of just hugging its own content and leaving blank space
   below, which is what happened when Endorsements was still a member of the
   same grid as everything else. (2) width alone was handled by simply not
   spanning anymore — this card is now a flex ITEM of .campaign-col-right,
   not a grid child needing to span every column of a shared grid, so
   stretching across the full row's width falls out naturally from being the
   only thing in that flex row. */
.action-group-wide {
  flex: 0 0 auto;
}

/* 3 columns by default — direct user request: "two even rows of three
   rectangle buttons," true for the common 6-endorsement case (2 clean rows).
   Overridden per-office via an inline style in ui.js (see endorsementCols)
   when the count divides evenly into 4 instead — President/Reelection have
   8 (direct user request: added an 8th endorsement, American Farm Bureau
   Federation, specifically so "it can be an even 4 in two rows"). Governor
   still has 7 (not asked to change) — stays on the 3-column default, and
   the last button gets an inline `grid-column: span 3` (computed in ui.js,
   see endorsementCloseGapSpan) to fill its own row 3 alone instead of
   sitting next to 2 empty cells.
   grid-auto-rows: min-content (NOT 1fr) — direct user correction after
   seeing the flex-grow version: "the size of the buttons is wrong, they
   should be thin and wide... that way the five action categories have room
   to grow." This card no longer stretches (see .action-group-wide's
   flex:0 0 auto above), so its rows just take their own natural/thin
   height — the "wide" part comes from the fixed 3 (or 4) column width, not
   from any extra vertical stretch. grid-template-rows: auto (inherited
   from .action-group) keeps the <h3> heading at its own natural height,
   same as always. */
.action-group-wide.action-group {
  grid-template-columns: repeat(3, 1fr);
  grid-auto-rows: min-content;
  gap: 0.1rem;
}
.action-group-wide .action-btn {
  padding: 0.35rem 0.55rem;
}

/* Same "everything fits on one screen without scrolling" pass as the dense
   .btn-content/.action-btn rules below — Endorsements' own padding is part
   of what squeezes .campaign-categories's remaining share of the column
   (Endorsements is flex: 0 0 auto, so shrinking it hands its savings
   straight to .campaign-categories, see that rule's flex: 1 1 auto
   comment). Direct user request: "there is room to narrow the endorsement
   buttons... so everything is visible on one screen." Scaled by the same
   --dense-scale variable as everything else below. */
.campaign-screen--dense .action-group-wide .action-btn {
  padding: calc(0.35rem * var(--dense-scale, 1)) calc(0.55rem * var(--dense-scale, 1));
}

/* Opposition Research (only present at US House and up) sits directly below
   Hold a Rally instead of spanning its own full-width row — direct user
   request: "when the dig up dirt button appears... it should appear below
   the rally buttons, where there's more room." Rally is always the 3rd of
   the 5 base categories in .campaign-categories's fixed 5-column grid (see
   there), so column 3 reliably lands under it. This also saves a whole
   dedicated full-width row's worth of height compared to the old
   `.action-group-wide` treatment it used before Endorsements moved out
   into its own flex sibling.

   grid-row is now explicit (was left unset, relying on the browser to
   auto-place it in the next row with column 3 free) — a direct user
   report of this button being entirely missing on a real Mac Chrome
   session, un-reproducible across every viewport/state this project
   tried, pointed at implicit grid auto-placement's own interaction with
   .campaign-categories's overflow-y: auto (added the same session, for
   the President White overflow fix) as the likely culprit: an item
   positioned on only ONE axis (column) inside a grid container that's
   ALSO a new scroll/block-formatting context is exactly the kind of
   combination that can render inconsistently across otherwise-identical
   browser sessions. Pinning grid-row: 2 explicitly (always correct,
   since the 5 base categories always fill row 1 first) removes the
   ambiguity entirely rather than chasing the exact repro. */
.action-group-under-rally {
  grid-column: 3;
  grid-row: 2;
}

/* .action-btn's own height:100% depends on its .action-group parent having
   a definite, unambiguous height to resolve the percentage against — fine
   in every OTHER category, whose .action-group sits directly in
   .campaign-categories's own grid-auto-rows: min-content sizing. This one
   card is different: it's a grid (.action-group) nested inside a specific
   cell (grid-column 3 / grid-row 2, both ALSO min-content-sized) of a grid
   that's furthermore a scroll container (.campaign-categories's
   overflow-y: auto) — three stacked layers of ambiguous/auto sizing for a
   percentage height to resolve through, still unreproducible locally but
   matching a live, repeated user report of this exact button rendering
   with zero height (header visible, nothing below it). Overriding to
   height: auto removes the percentage entirely, so there's nothing left
   for any layout pass to resolve inconsistently — sized directly by its
   own content, identical in appearance to every environment where 100%
   already happened to work. */
.action-group-under-rally .action-btn {
  height: auto;
}

.action-group h3 {
  grid-column: 1 / -1;
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  margin: 0 0 0.05rem 0;
}

.action-btn {
  display: flex;
  align-items: center;
  width: 100%;
  min-width: 0;
  height: 100%;
  box-sizing: border-box;
  overflow: hidden;
  font-family: var(--display-font);
  text-align: left;
  /* White, not the page's own cream background — plain (uncolored) buttons
     were the exact same shade as the screen behind them, so only their
     border read as a button at all. Direct user request: "should be white
     against the beige background so they stand out more." The already-
     color-coded variants below (raises-money green, endorsement pink/
     green/red states) set their own, more specific background and win the
     cascade over this — untouched. */
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.18rem 0.35rem;
  cursor: pointer;
  color: var(--text);
}

.btn-content {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  width: 100%;
  min-width: 0;
  overflow-wrap: break-word;
  font-size: 1rem;
  line-height: 1.1;
}

.action-btn:hover:not(:disabled) {
  border-color: var(--silver);
}

/* Used to be a flat opacity:0.45 on the whole button — which also faded
   its own white background, letting whatever sits behind it show through.
   That was invisible against the old flat page background, but once the
   page background itself became a gradient (see body above), the SAME
   disabled button read differently depending on where it happened to sit
   on screen — lighter over the bright center, darker/muddier near the
   edges — direct report with a live screenshot showing exactly that.
   Fixed by splitting the two jobs apart: this rule now paints an opaque,
   page-independent light gray (same everywhere, regardless of what's
   behind it) instead of fading toward transparency, and the dimmed
   "unavailable" look moves to .btn-content below instead, which has no
   background of its own to bleed. */
.action-btn:disabled {
  background: #e7e7e4;
  cursor: not-allowed;
}

.action-btn:disabled .btn-content {
  opacity: 0.45;
}

/* Any action that raises money (Fundraiser Call, Fundraising Events, Lead
   Backer recruitment) gets a light green tint so players can spot "this
   makes money" at a glance, distinct from the spend-money actions below.
   Fundraise & Recruit is always the first category card (see renderCampaign
   in ui.js), so these are always the top-left buttons in the right column —
   direct user request that their text stay large and easy to read even
   though the rest of the buttons went back to a plain, simple size. */
.action-btn.raises-money {
  background: #e6f7e9;
}
.action-btn.raises-money .btn-content {
  font-size: 1.1rem;
}

/* Senate/Governor/President (actionsPerMonth 8-10, see renderCampaign's
   `dense` in ui.js) have grown Fundraise & Recruit to 6 buttons — the
   deliberately-larger 1.1rem above was sized for that category's original
   2 buttons and now makes the whole column disproportionately tall,
   squeezing every other category and crowding Endorsements below (direct
   user report: "green buttons aren't big enough... columns are closing in
   on the endorsements"). Scoped smaller here rather than changing the
   base rule, so School Board through US House (where 2 big buttons still
   look and fit fine) are untouched. Scaled by --dense-scale (see below) so
   it shrinks/grows in lockstep with every other dense button instead of
   being a second independent guess. */
.campaign-screen--dense .action-btn.raises-money .btn-content {
  font-size: calc(1.1rem * var(--dense-scale, 1));
}

/* Second dense pass, this time covering EVERY button's text (not just the
   raises-money ones) — President White specifically still needed a real
   scroll inside .campaign-categories at a live reported viewport
   (1366x768): Advertise/Host an Event's 6 stacked tiers plus Opposition
   Research's own row summed to more than that section's available height
   (direct user report + screenshot, "tighten the other buttons too so
   everything is visible on one screen without scrolling").

   First attempt here used ONE hardcoded shrunk rem value for every dense
   office — that's wrong in the other direction: at a wider/taller real
   viewport (also a direct user report + screenshot, same day) there was
   tons of unused space below Endorsements and the fixed-small text just
   looked needlessly tiny ("there is still plenty of space to make these
   buttons bigger... make it as big as you can without requiring
   scrolling"). No single rem value is ever right for every screen size at
   once — narrower/shorter screens need small text to avoid a scroll, wider/
   taller ones have room to spare. Replaced with a `--dense-scale` custom
   property, driven live by fitDenseCampaignButtons() in main.js: it binary-
   searches (each candidate applied, then .campaign-categories's
   scrollHeight vs clientHeight checked) for the LARGEST scale that still
   avoids a scroll on THIS viewport, then sets the variable once. Every
   dense rule below multiplies its own base size by that one variable, so
   text/padding/gaps all grow or shrink together in proportion — a
   plausible mechanism, verified by direct re-measurement across several
   viewport sizes (see that function's own comment) rather than picking
   another single number and hoping it holds everywhere. The `, 1` fallback
   only matters before that JS runs on a very first paint. */
.campaign-screen--dense .btn-content {
  font-size: calc(1rem * var(--dense-scale, 1));
  line-height: 1.08;
}
.campaign-screen--dense .action-btn {
  padding: calc(0.18rem * var(--dense-scale, 1)) calc(0.35rem * var(--dense-scale, 1));
}
.campaign-screen--dense .action-group h3 {
  font-size: calc(0.85rem * var(--dense-scale, 1));
}
.campaign-screen--dense .campaign-categories,
.campaign-screen--dense .action-group {
  gap: calc(0.08rem * var(--dense-scale, 1));
}

.action-btn.raises-money:hover:not(:disabled) {
  border-color: var(--silver);
  background: #d9f2de;
}

/* Endorsement status colors — persist for the whole campaign instead of the
   button disappearing on success (direct user report: "it doesn't
   disappear"). Won is disabled (no reason to ask again) but overrides the
   generic dimmed :disabled look below since this is a success, not an
   unavailable action. Rejections are purely cosmetic history — the button
   stays enabled and re-askable either way (a refusal isn't final). */
.action-btn.endorsement-won {
  background: #d7f7dc;
  border-color: #4caf6b;
}
.action-btn.endorsement-won:disabled {
  opacity: 1;
  cursor: default;
}
.action-btn.endorsement-won:disabled .btn-content {
  opacity: 1;
}
/* Rejection redness is inline now (continuous escalation per rejection —
   see endorsementRejectionStyle in ui.js), not these fixed 2-tier classes. */

.action-btn .cost {
  color: var(--red);
  font-weight: bold;
  margin-left: 0.4rem;
}

.action-btn .hint {
  display: block;
  color: var(--muted);
  font-size: 0.86em;
  margin-top: 0.1em;
}

.bonus-badge {
  display: inline-block;
  margin-top: 0.3rem;
  background: var(--silver);
  color: var(--ink);
  font-size: 0.75rem;
  font-weight: bold;
  border-radius: 999px;
  padding: 0.15rem 0.55rem;
}

.muted {
  color: var(--muted);
}

/* The "waiting for AI-generated questions" screen (renderExchange's
   "generating" status) — direct request: bigger so it's easier to read
   while a player is staring at it for up to ~20s with nothing else to do. */
.exchange-generating-status {
  font-size: 1.2rem;
}
.exchange-generating-hint {
  font-size: 1.05rem;
}

/* This is "the pink actions box" from the layout redesign — the user's own
   name for this log panel, filling column 1 from right under the blue info
   box down to just above the Hint button (flex:1 inside .campaign-col-left,
   which is itself stretched to .campaign-body's full fixed height — see
   .campaign-screen/.campaign-body/.campaign-col-left). Internal overflow-y
   scroll is a safety net for its own content only (capped at 8 entries) —
   it never causes the page itself to scroll. */
.log-panel {
  background: color-mix(in srgb, #d6478f 14%, var(--surface));
  border: 1px solid color-mix(in srgb, #d6478f 40%, var(--border));
  border-radius: 8px;
  padding: 0.9rem 1rem;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

.log-panel h3 {
  font-size: 0.95rem;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 0.6rem;
}

.log-panel ul {
  margin: 0;
  padding-left: 1.2rem;
  font-size: 1.05rem;
  line-height: 1.5;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.forum-screen, .trivia-screen, .election-screen {
  text-align: center;
  align-items: center;
  padding-top: 2rem;
}

.recap-screen {
  text-align: left;
  padding-top: 1.5rem;
}

.recap-log {
  margin: 0;
  padding-left: 1.1rem;
  font-size: 0.92rem;
  color: var(--muted);
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.recap-section {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.85rem 1rem;
}

.recap-section h3 {
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  margin: 0 0 0.5rem;
}

.recap-section p {
  margin: 0;
}

.choice-grid {
  display: grid;
  gap: 0.6rem;
  width: 100%;
}

.choice-btn {
  font-family: inherit;
  font-size: 1rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.75rem 1rem;
  cursor: pointer;
  color: var(--text);
}

.choice-btn:hover {
  border-color: var(--silver);
}

.gained {
  font-weight: bold;
  color: var(--red);
}

.issue-why {
  text-align: left;
  background: var(--bg);
  border: 1px solid var(--border);
  border-left: 4px solid var(--red);
  border-radius: 6px;
  padding: 0.75rem 1rem;
  font-size: 0.95rem;
  line-height: 1.4;
}

.issue-why-correct {
  border-left-color: var(--green, #3f7d4f);
}

.poll-bar {
  width: 100%;
  height: 28px;
  border-radius: 14px;
  background: var(--red);
  overflow: hidden;
  border: 1px solid var(--border);
}

.poll-bar-fill {
  height: 100%;
  background: var(--navy);
  transition: width 0.4s ease;
}

.poll-caption {
  font-weight: bold;
}

.result-screen {
  text-align: center;
  align-items: center;
  padding-top: 2.5rem;
}

/* Tighter than the plain win/lose screen above — this one still has an
   awards-earned section coming below the buttons, so every bit of vertical
   room saved up here (shorter padding-top, smaller gap, a leaner score box)
   matters more than it does on a normal result screen. */
.result-screen.final-win {
  padding-top: 0.9rem;
  gap: 0.4rem;
}

.result-screen.final-win .level-score-box {
  padding: 0.5rem 1.2rem;
  margin: 0.15rem 0;
  /* Wide enough that "President (Reelection)" — the longest office label in
     this list — has real breathing room before its score, instead of the
     box hugging its content width (the default here since .result-screen
     centers its items rather than stretching them) and squeezing the two
     together. */
  width: min(600px, 92vw);
}

.result-screen.final-win h1 {
  font-size: 1.6rem;
}

.result-screen.final-win p {
  margin: 0;
  font-size: 0.92rem;
}

.result-screen.final-win .level-score-total {
  font-size: 1.05rem;
  margin: 0 0 0.15rem;
}

.result-screen.final-win .progress-list {
  gap: 0.12rem;
}

.result-screen.final-win .progress-item {
  font-size: 0.8rem;
}

/* Empty until main.js's scheduleFinalAwardsReveal injects renderFinalAwards'
   markup into it partway through the victory screen (see ui.js) — reserves
   no space of its own beforehand, so the screen doesn't show a mystery gap
   during the confetti/fireworks part. */
.final-awards {
  margin-top: 0.4rem;
}

.final-awards-row {
  display: flex;
  justify-content: center;
  gap: 2.25rem;
  flex-wrap: wrap;
}

/* Stars/perfect-score badges earned anywhere in this tier's climb (see
   renderFinalAwards in ui.js) — reuses .progress-list/.progress-item's
   existing dark-screen styling (see .result-screen.final-win overrides
   above), just needs breathing room from the gem/medal row above it. */
.final-awards-extra {
  margin-top: 0.6rem;
}

.final-award-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.2rem;
  /* Fixed width, equal for every slot (including the empty placeholder
     below) — this is what keeps the gem slot dead-center regardless of
     whether the star and/or medal slots on either side are filled in. */
  width: 7rem;
  /* Starts invisible — each item's own animation (below) is what brings it
     in, staggered via the inline animation-delay renderFinalAwards sets. */
  opacity: 0;
  animation-fill-mode: forwards;
}

/* An unearned star/medal slot: same width as a real slot (see above), no
   content, permanently invisible (no animation class, so it never gets a
   chance to fade in) — it's a spacer, not a hidden reveal. */
.final-award-placeholder {
  opacity: 0;
}

.final-award-label {
  font-weight: bold;
  font-size: 0.85rem;
  color: var(--navy);
}

.final-award-icon.gem-icon {
  width: 5.2rem;
  height: 5.2rem;
}

/* Plain text star glyph (not the ⭐ emoji) specifically so `color` below
   actually tints it — same trick already used for .award-stars-* in the
   Awards Collected overlay, and the same reasoning applies here: this
   star's color should match whichever tier (red/white/blue) it was earned
   on, not always be gold. Sized up from 3.4rem to read as a comparably
   major element next to the 5.2rem gem, rather than a smaller afterthought
   (direct user request: "stars... should be bigger"). */
.final-award-star {
  font-size: 4.4rem;
  line-height: 1;
  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
}

/* A row of stars can wrap to more than one line at typical widths once a
   player's replayed several offices — extra top margin clears the gem/
   medal row above, and the row's own centered flex-wrap (inherited from
   .final-awards-row) keeps a ragged multi-star count looking tidy instead
   of stretching edge to edge. */
.final-awards-stars {
  margin-top: 0.9rem;
}

.final-award-star-red {
  color: var(--red);
}

.final-award-star-white {
  color: #9a9aa2;
}

.final-award-star-blue {
  color: var(--flag-blue);
}

/* Gem and star spin a couple of full turns before landing — ending rotation
   is a multiple of 360deg, so it settles looking identical to no rotation
   at all, just having visibly spun to get there. A small overshoot-then-
   settle bounce at the end (scale past 1, then back to 1) adds a bit more
   punch to the landing — direct user request for "a little more flair" on
   the stars specifically, applied to the shared keyframe so the gem picks
   it up too rather than looking inconsistent next to a star that now lands
   with more energy than it does. */
.final-award-item.award-spin-in {
  animation-name: final-award-spin-in;
  animation-duration: 1.1s;
  animation-timing-function: cubic-bezier(0.2, 0.8, 0.3, 1);
}

@keyframes final-award-spin-in {
  0% {
    opacity: 0;
    transform: scale(0.2) rotate(0deg);
  }
  55% {
    opacity: 1;
  }
  80% {
    transform: scale(1.22) rotate(720deg);
  }
  92% {
    transform: scale(0.94) rotate(720deg);
  }
  100% {
    opacity: 1;
    transform: scale(1) rotate(720deg);
  }
}

/* The medal doesn't spin (a circular badge looks the same at every angle,
   so spinning it would read as nothing happening) — it fades/scales in,
   then gets a bright diagonal shine sweeping across it once. */
.final-award-item.award-shine-in {
  animation-name: final-award-fade-in;
  animation-duration: 0.5s;
  animation-timing-function: ease-out;
}

@keyframes final-award-fade-in {
  0% {
    opacity: 0;
    transform: scale(0.5);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

.final-award-medal {
  position: relative;
  overflow: hidden;
}

.final-award-medal::after {
  content: "";
  position: absolute;
  top: 0;
  left: -60%;
  width: 40%;
  height: 100%;
  background: linear-gradient(115deg, transparent, rgba(255, 255, 255, 0.9), transparent);
  animation: final-award-shine 1.1s ease-in-out 0.5s;
}

@keyframes final-award-shine {
  0% {
    left: -60%;
  }
  100% {
    left: 160%;
  }
}

.result-screen h1 {
  font-size: 2.2rem;
}

.result-screen.win h1 {
  color: var(--red);
}

.result-screen.lose h1 {
  color: var(--muted);
}

.next-stop {
  font-style: italic;
  color: var(--navy);
}

@media (min-width: 600px) {
  .archetype-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

.tutorial-screen {
  text-align: left;
  padding-top: 2rem;
}

.tutorial-list {
  margin: 0;
  padding-left: 1.2rem;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  font-size: 1rem;
}

.small {
  font-size: 0.85rem;
}

/* Home screen only (part of .intro-screen's own markup in renderIntro, not
   a persistent sibling of #app like mute-btn/hint-btn/help-btn) — direct
   request: a way to sign out (or sign back in, on the rare path where the
   home screen is reachable while signed out) right from the home screen,
   rather than only via the forced sign-in gate or the 15-minute idle
   auto-sign-out. */
.home-auth-btn {
  position: fixed;
  top: 1rem;
  left: 1rem;
  font-family: var(--display-font);
  font-weight: bold;
  font-size: 0.8rem;
  background: transparent;
  color: var(--flag-blue);
  border: 2px solid var(--silver);
  border-radius: 999px;
  padding: 0.4rem 0.85rem;
  cursor: pointer;
  z-index: 100;
}

.home-auth-btn:hover {
  background: #000;
  color: #fff;
  border-color: #000;
}

/* Bottom-right (direct request) — offset above How to Play's own bottom:
   1.25rem/right: 1.25rem rather than sharing its exact spot, since that
   button ALSO lives at bottom-right but only on the home screen, which is
   the one place both are visible together (Give Feedback shows on every
   page, unlike How to Play). Everywhere else How to Play is hidden, so the
   extra height just means this sits a little above the true corner instead
   of exactly in it. Kelly green background, white text, per direct
   request — see renderFeedbackForm/openFeedback for the modal this opens. */
/* Default position matches How to Play's own bottom:1.25rem/right:1.25rem
   exactly — every screen OTHER than the home screen already has to keep
   that exact corner clear for How to Play's own sake (even though it's
   hidden there), so it's already proven safe. Only the home screen (where
   How to Play IS shown, the one place both buttons are visible together)
   needs Feedback shifted up — see .feedback-btn--stacked below, toggled in
   render(). */
.feedback-btn {
  position: fixed;
  bottom: 1.25rem;
  right: 1.25rem;
  font-family: var(--display-font);
  font-weight: bold;
  font-size: 0.85rem;
  background: #4cbb17;
  color: #fff;
  border: none;
  border-radius: 999px;
  padding: 0.6rem 1rem;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
  z-index: 100;
}

.feedback-btn:hover {
  filter: brightness(0.92);
}

.feedback-btn--stacked {
  bottom: 4.5rem;
}

.mute-btn {
  position: fixed;
  top: 1rem;
  right: 1rem;
  font-family: inherit;
  line-height: 1;
  /* Outlined, not filled — a solid gold circle behind the icon made it
     harder to see which state was showing, not easier. Icon color (see
     the inline SVGs in main.js, which use stroke/fill="currentColor")
     comes from this color property. */
  color: var(--flag-blue);
  background: transparent;
  border: 3px solid var(--silver);
  border-radius: 999px;
  width: 3rem;
  height: 3rem;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
  z-index: 200;
}

.mute-btn:hover {
  background: rgba(176, 176, 184, 0.15);
}

.help-btn {
  position: fixed;
  bottom: 1.25rem;
  right: 1.25rem;
  font-family: var(--display-font);
  font-weight: bold;
  font-size: 0.85rem;
  background: #fff;
  color: #1a1a1a;
  border: none;
  border-radius: 999px;
  padding: 0.6rem 1rem;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
  z-index: 100;
}

.help-btn:hover {
  filter: brightness(0.92);
}

.hint-btn {
  position: fixed;
  bottom: 1.25rem;
  left: 1.25rem;
  font-family: var(--display-font);
  font-weight: bold;
  font-size: 0.85rem;
  background: var(--silver);
  color: var(--flag-blue);
  border: none;
  border-radius: 999px;
  padding: 0.6rem 1rem;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
  z-index: 100;
}

.hint-btn:hover {
  filter: brightness(0.92);
}

/* Bottom-center — both corners are already taken (Hint at left, How to Play
   at right), and same fixed-sibling-of-#app idiom as those two, so it
   survives every mount() call and is toggled in render() instead of being
   part of any one screen's own markup. */
.home-nav-btn {
  position: fixed;
  bottom: 1.25rem;
  left: 50%;
  transform: translateX(-50%);
  /* Missed in the original Poppins trial (only Sign Out/Hint/Feedback/How to
     Play were in scope then) — left on inherited Georgia ever since. Matched
     to its Hint/Give Feedback neighbors, which sit right next to it on every
     screen. */
  font-family: var(--display-font);
  font-weight: bold;
  font-size: 0.85rem;
  background: #1a1a1a;
  color: #fff;
  border: none;
  border-radius: 999px;
  padding: 0.6rem 1rem;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
  z-index: 100;
}

.home-nav-btn:hover {
  filter: brightness(0.92);
}

/* Direct request: on the campaign issue-picker screen specifically (the
   grid of clickable issue cards, which fills nearly the whole screen —
   see .issue-picker-grid), move out of bottom-center into the corner
   instead, clear of the card grid. Toggled in render() alongside the
   button's own show/hide logic. */
.home-nav-btn--corner {
  bottom: auto;
  left: 1.25rem;
  top: 1rem;
  transform: none;
}

.leaderboard-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 1rem 0;
}

.leaderboard-tab-btn {
  font-family: inherit;
  font-size: 0.85rem;
  font-weight: bold;
  background: var(--bg);
  color: var(--navy);
  border: 2px solid var(--border);
  border-radius: 999px;
  padding: 0.4rem 0.9rem;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}

.leaderboard-tab-btn:hover {
  border-color: var(--navy);
}

.leaderboard-tab-btn.active {
  background: var(--navy);
  border-color: var(--navy);
  color: #fff;
}

.leaderboard-page.hidden {
  display: none;
}

.briefing-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 1rem 0;
}

.briefing-tab-btn {
  font-family: inherit;
  font-size: 0.85rem;
  font-weight: bold;
  background: var(--bg);
  color: var(--navy);
  border: 2px solid var(--border);
  border-radius: 999px;
  padding: 0.4rem 0.9rem;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}

.briefing-tab-btn:hover {
  border-color: var(--navy);
}

.briefing-tab-btn.active {
  background: var(--navy);
  border-color: var(--navy);
  color: #fff;
}

.briefing-page.hidden {
  display: none;
}

.career-ladder {
  list-style: none;
  margin: 0.5rem 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.career-step {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  /* Tightened from 0.75rem/0.9rem once each row grew a description line
     (direct request) — at the widened modal's full 7-office height, the
     original padding needed more vertical room than a typical laptop/
     Chromebook browser window's viewport has before needing to scroll. */
  padding: 0.3rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg);
}

.career-step-soon {
  opacity: 0.65;
}

.career-step-num {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.6rem;
  height: 1.6rem;
  flex-shrink: 0;
  border-radius: 50%;
  background: var(--navy);
  color: #fff;
  font-size: 0.8rem;
  font-weight: bold;
  /* Nudged down to align with the name's own line, not the taller
     two-line block's vertical center, now that a description sits
     underneath the name (direct request to add per-office info). */
  margin-top: 0.1rem;
}

.career-step-soon .career-step-num {
  background: var(--muted);
}

.career-step-text {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

.career-step-name {
  font-weight: bold;
}

.career-step-desc {
  margin: 0;
  font-size: 0.95rem;
  color: var(--text);
  line-height: 1.35;
}

.career-step-check {
  color: var(--green, #3f7d4f);
  font-weight: bold;
  margin-top: 0.1rem;
}

/* Sized up substantially (direct user request: "the font size for players
   with top scores should be much larger... there's plenty of space
   available") — was 0.92rem, tuned back when this list held more tabs'
   worth of dense content on screen at once; there's real room to spare at
   this size. margin-bottom clears space above the bottom Close button
   (direct user request: "a gap between the bottom of the top scores and
   the close button") rather than the list running flush into it. */
.leaderboard-list {
  margin: 0 0 1.25rem;
  padding-left: 1.4rem;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  font-size: 1.3rem;
}

.leaderboard-score {
  font-weight: bold;
  color: var(--red);
  margin-left: 0.4rem;
}

.hint-popover {
  position: fixed;
  bottom: 4.5rem;
  left: 1.25rem;
  width: min(320px, calc(100vw - 2.5rem));
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 0.9rem 1rem;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
  /* Higher than every other z-index in this file (home-grid-btn/.primary-btn
     at 150, .mute-btn/.briefing-overlay at 200) — the hint needs to be
     readable and closeable no matter what else is on screen underneath it. */
  z-index: 500;
}

.hint-popover.hidden {
  display: none;
}

.hint-popover-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-weight: bold;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  margin-bottom: 0.4rem;
}

#hint-content {
  margin: 0;
  font-size: 0.95rem;
  line-height: 1.4;
}

.briefing-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 2rem 1rem;
  overflow-y: auto;
  z-index: 200;
}

.briefing-overlay.hidden {
  display: none;
}

.briefing-modal {
  background: var(--surface);
  color: var(--text);
  border-radius: 10px;
  max-width: 560px;
  width: 100%;
  padding: 1.5rem 1.75rem 2rem;
  font-family: var(--display-font);
}

/* The How to Play modal specifically gets a lot more width than the other
   overlays sharing .briefing-modal (leaderboard/progress/awards) — it holds
   far more text and was designed narrow, portrait-style, wasting most of a
   landscape screen. Also just wide enough (measured, not guessed — the 6
   tabs need ~941px of content width once padding is subtracted) that all 6
   How to Play tabs sit on one row instead of wrapping. */
#briefing-modal {
  max-width: min(1020px, 94vw);
}

/* Direct request: wider than the shared 560px so each tier's score column
   (now with its own awards list stacked underneath, see
   .progress-column-awards) has real room for 3 columns side by side
   without cramming — not as wide as How to Play's 1020px, this page has
   far less text per column. */
#progress-modal {
  max-width: min(700px, 94vw);
}

/* Awards Collected and How It Works (see the briefing-modal-wide toggle in
   main.js's wireBriefingTabs and openBriefing) both have more, and longer,
   paragraphs than the other How to Play tabs — wide enough at the shared
   1020px width to wrap onto many lines and force the whole overlay to
   scroll. Widening just these two tabs lets their paragraphs wrap into far
   fewer lines, fitting each tab on screen without scrolling on a typical
   laptop/Chromebook, without touching the other 4 tabs' carefully-measured
   single-row tab width above. */
#briefing-modal.briefing-modal-wide {
  max-width: min(1320px, 96vw);
}

/* Awards Collected is meant to feel like a big trophy case, not a status
   readout — deliberately much bigger than the other .briefing-modal
   overlays, with room for a large gem row up top. */
#awards-modal {
  max-width: min(1100px, 95vw);
  padding: 2rem 2.5rem 2.5rem;
}

/* Wide enough that every leaderboard tab sits on one row instead of
   wrapping to a second line — up to 10 for an admin (Complete Game/Red/
   White/Blue/Most 100s/Most Stars/Shred the Attacks/Ping the Opponent/
   Cast Your Ballot/Player Activity), 9 for everyone else. Widened from
   700px once the Handle the Hecklers (since replaced by Shred the Attacks)
   and Player Activity tabs pushed past what fit at
   that width; widened again from 960px once Ping the Opponent added a 9th
   tab; widened again from 1060px once Cast Your Ballot added a 10th —
   measured this time (not guessed): the 9-tab student case needs ~1162px
   of modal width and fits at this size on any screen ~1280px+ wide (a
   typical Chromebook); the 10-tab admin case needs ~1367px and only fully
   fits one row on a wider screen (~1425px+) — an acceptable tradeoff since
   that extra tab is admin-only. */
#leaderboard-modal {
  max-width: min(1400px, 96vw);
}

.briefing-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

.briefing-modal h3 {
  color: var(--navy);
  margin-top: 1.25rem;
  margin-bottom: 0.4rem;
  font-size: 1.05rem;
}

.briefing-modal p {
  margin: 0.3rem 0;
  line-height: 1.4;
}

/* How It Works now has 4 sub-headers and 13 paragraphs — the most of any
   tab — and even at the shared briefing-modal-wide width (see that class'
   own comment above) still overflowed a typical 1280x800 Chromebook by
   ~35px, measured directly. Slightly tighter heading/paragraph spacing
   just on this one tab closes that gap without touching the other 5 tabs'
   (already-fitting) spacing. */
#briefing-page-overview h3 {
  margin-top: 0.7rem;
}

#briefing-page-overview p {
  margin: 0.18rem 0;
  line-height: 1.25;
}

/* Campaigning is even longer than How It Works above (3 sub-headers, 2
   dense multi-sentence paragraphs, an 11-item combined list across two
   sections, plus a closing note) — direct request after it still needed
   scrolling at the wide modal width alone. Same scoped tightening
   approach as #briefing-page-overview, applied here too. */
#briefing-page-campaign h3 {
  margin-top: 0.4rem;
  margin-bottom: 0.2rem;
  font-size: 0.98rem;
}

#briefing-page-campaign p {
  margin: 0.18rem 0;
  line-height: 1.2;
}

#briefing-page-campaign .briefing-list {
  gap: 0.2rem;
  line-height: 1.2;
}

/* Give Feedback's comment box — direct request: "much bigger" and moved
   above the instructional caption text (was below it) so the player can
   start typing immediately. min-height (not just the rows attribute)
   guarantees the bigger size regardless of the modal's own font-size. */
#feedback-input {
  width: 100%;
  min-height: 220px;
  font-family: inherit;
  font-size: 1rem;
  padding: 0.6rem 0.8rem;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  resize: vertical;
  box-sizing: border-box;
  margin-bottom: 0.5rem;
}

.briefing-aside {
  font-size: 0.9rem;
  color: var(--muted);
  font-style: italic;
}

.briefing-goal {
  font-size: 1.2rem;
  font-weight: bold;
  font-style: normal;
  color: var(--text);
}

.briefing-list {
  margin: 0;
  padding-left: 1.2rem;
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
}

.close-btn {
  font-family: inherit;
  font-size: 1.4rem;
  line-height: 1;
  background: none;
  border: none;
  color: var(--muted);
  cursor: pointer;
  padding: 0.2rem 0.5rem;
}

.close-btn:hover {
  color: var(--red);
}

#close-briefing-btn-bottom {
  margin-top: 1.5rem;
  width: 100%;
}

/* Narrower than the default .briefing-modal (560px) — this is a short
   yes/no prompt, not a content-heavy overlay, so a full-width modal would
   just leave the message and buttons awkwardly stretched out. */
.confirm-modal {
  max-width: 420px;
}

.confirm-actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.6rem;
  margin-top: 1.25rem;
}

.secondary-btn {
  font-family: inherit;
  font-size: 1rem;
  font-weight: bold;
  background: var(--surface);
  color: var(--text);
  border: 2px solid var(--border);
  border-radius: 6px;
  padding: 0.6rem 1.25rem;
  cursor: pointer;
  transition: border-color 0.15s ease, filter 0.15s ease;
}

.secondary-btn:hover {
  border-color: var(--muted);
}

/* Reserved for actions that permanently destroy data (erasing a saved
   campaign) — deliberately the same alarming red as everywhere else in the
   app that means "this cannot be undone," so it reads as dangerous even
   without stopping to read the button's label. */
.danger-btn {
  font-family: inherit;
  font-size: 1rem;
  font-weight: bold;
  background: var(--red);
  color: #fff;
  border: none;
  border-radius: 6px;
  padding: 0.6rem 1.25rem;
  cursor: pointer;
  transition: background 0.15s ease;
}

.danger-btn:hover {
  background: var(--red-bright);
}

/* --- Cast Your Ballot: Blue tier's optional mini-game break. Shares
   Handle the Hecklers' generic layout classes (heckler-hud,
   heckler-timerbar-*, heckler-rules/rule, heckler-howto-*,
   heckler-scoreline, heckler-verdict, heckler-offer-actions) — only the
   stage/ballot visuals below are new, prefixed lever- (kept from this
   game's 1st design pass, "Pull the Lever", to avoid a churny rename
   across main.js/firebase.js/firestore.rules — the internal name outlived
   the mechanic it described, twice now). 3rd design pass, 2026-07-26,
   direct user spec: continuously swipe upward to throw a ballot toward the
   box at the top of the stage — the swipe's own DISTANCE and SPEED (not a
   moving target this time) both have to land in range for it to go in;
   too short/slow falls short, too long/fast overshoots. A fresh ballot is
   ready the instant one is thrown, so the player can throw as fast as they
   can physically swipe. */
.lever-booth {
  position: relative;
  width: 100%;
  max-width: 900px;
  height: min(52vh, 560px);
  border-radius: 10px;
  overflow: hidden;
  cursor: crosshair;
  touch-action: none;
  background: linear-gradient(180deg, var(--flag-blue) 0%, #123a72 55%, #0d2c58 100%);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
}

.lever-booth::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.035) 0 26px, rgba(0, 0, 0, 0.05) 26px 52px);
  pointer-events: none;
}

.lever-status {
  position: absolute;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 1.3rem;
  font-weight: bold;
  color: #fff;
  min-height: 2.2rem;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
  white-space: nowrap;
  pointer-events: none;
  z-index: 3;
}
.lever-status.lever-status-good { color: var(--silver); }
.lever-status.lever-status-bad { color: var(--red-bright); }

/* The box now moves back and forth across the top of the stage exactly
   like .ping-opponent (left% driven by JS every 60ms, smoothed by this same
   0.1s linear transition) instead of sitting fixed at center — see
   wireLeverGameScreen's officeBoost/speed comment for the shared scaling. */
.lever-box {
  position: absolute;
  top: 40px;
  left: 50%;
  width: 150px;
  height: 99px;
  transform: translateX(-50%);
  transition: left 0.1s linear;
  border-radius: 8px;
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
  pointer-events: none;
  overflow: hidden;
}
/* Real photo asset (src/assets/lever-box.jpg) replacing the old CSS-gradient
   box + a separate circular "hole" div — direct request, this box's slot is
   a horizontal cutout baked into the photo itself, not a shape drawn in CSS.
   object-fit: cover crops any excess without distorting the wood-grain/gold
   corner details; the container's own border-radius (above) clips the
   image's corners to match. */
.lever-box-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.lever-box.lever-box-hit {
  animation: lever-box-flash-green 0.4s ease-out;
}
.lever-box.lever-box-miss {
  animation: lever-box-flash-red 0.4s ease-out;
}
@keyframes lever-box-flash-green {
  0% { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4), 0 0 0 0 rgba(80, 220, 120, 0.9); }
  30% { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4), 0 0 24px 10px rgba(80, 220, 120, 0.85); }
  100% { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4), 0 0 0 0 rgba(80, 220, 120, 0); }
}
@keyframes lever-box-flash-red {
  0% { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4), 0 0 0 0 rgba(220, 70, 60, 0.9); }
  30% { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4), 0 0 24px 10px rgba(220, 70, 60, 0.75); }
  100% { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4), 0 0 0 0 rgba(220, 70, 60, 0); }
}

/* The player's on-screen avatar — tracks the pointer's X continuously (see
   setHandPct), independent of whether a throw is being wound up. Real photo
   asset (src/assets/lever-hand.png, a pinching hand with real transparency
   around its silhouette) replacing the old palm+4-fingers+thumb built from
   plain CSS shapes — direct request. Sized to the image's own aspect ratio
   (420:331) rather than the old box's, so it isn't stretched. */
.lever-hand {
  position: absolute;
  bottom: 14px;
  left: 50%;
  width: 96px;
  height: 76px;
  transform: translateX(-50%);
  transition: left 0.05s linear;
  pointer-events: none;
  z-index: 1;
  object-fit: contain;
}

/* The "square white frisbee" the spec asked for. Rides along with the hand
   (its left% is synced to handPct) until a throw is in flight. Flight is a
   real @keyframes animation (not a plain transition) so it can fake actual
   airborne motion — a fast spin, a mid-arc squash simulating the disc
   tilting edge-on, and a slight sideways wobble — rather than the earlier
   version's flat vertical slide, which a direct playtest report said didn't
   read as "flying through the air like a frisbee." The per-throw specifics
   (start/end height, spin amount, end scale/opacity, wobble direction) are
   set as inline custom properties by throwBallot() since each of the 4
   outcomes needs a different target. */
.lever-ballot {
  position: absolute;
  left: 50%;
  bottom: 48px;
  width: 38px;
  height: 38px;
  transform: translate(-50%, 0) scale(1, 1) rotate(0deg);
  background: #fdfaf2;
  border: 1.5px solid #c9c2a8;
  border-radius: 5px;
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4);
  cursor: grab;
  touch-action: none;
  z-index: 2;
}
.lever-ballot.lever-ballot-grabbed {
  cursor: grabbing;
  box-shadow: 0 5px 14px rgba(0, 0, 0, 0.5);
}
.lever-ballot.lever-ballot-flying {
  animation: lever-ballot-fly 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
@keyframes lever-ballot-fly {
  0% {
    top: var(--fly-from, 0px);
    transform: translate(-50%, 0) rotate(0deg) scale(1, 1);
    opacity: 1;
  }
  55% {
    transform: translate(calc(-50% + var(--fly-wobble, 8px)), 0) rotate(calc(var(--fly-rotate, 720deg) * 0.6)) scale(1.12, 0.72);
  }
  100% {
    top: var(--fly-to, 0px);
    transform: translate(-50%, 0) rotate(var(--fly-rotate, 720deg)) scale(var(--fly-end-scale, 1), var(--fly-end-scale, 1));
    opacity: var(--fly-opacity, 1);
  }
}

.lever-text {
  position: absolute;
  transform: translateX(-50%);
  font-size: 1.3rem;
  font-weight: bold;
  color: var(--silver);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
  pointer-events: none;
  animation: lever-float-up 0.6s ease-out forwards;
  z-index: 3;
}
@keyframes lever-float-up {
  from { opacity: 1; transform: translate(-50%, 0); }
  to { opacity: 0; transform: translate(-50%, -40px); }
}

@media (prefers-reduced-motion: reduce) {
  .lever-ballot, .lever-text, .lever-box, .lever-hand { animation: none !important; transition: none !important; }
}

/* --- Shared generic layout classes reused by every mini-game's intro/
   playing/end screens (Ping the Opponent, Cast Your Ballot, Shred the
   Attacks) — HUD, timer bar, rules/how-to-play blocks, score line, verdict,
   offer-actions. Prefixed heckler- because Handle the Hecklers (since
   replaced by Shred the Attacks on Blue tier) was the first of these games
   and these classes were never renamed once the others started reusing
   them. --- */

/* The mini-game CTA on a victory/term-complete screen needs to visually
   outrank the plain "Continue"/"Begin Your Term" button next to it (2026-07-26,
   direct user request) — bigger type and padding than .primary-btn, plus a
   silver fill so it reads as the more exciting, optional choice rather than a
   secondary option below the required continue action. */
.minigame-cta-btn {
  font-family: inherit;
  font-weight: bold;
  font-size: 1.3rem;
  background: var(--silver);
  color: var(--navy-dark);
  border: none;
  border-radius: 8px;
  padding: 1.1rem 2rem;
  cursor: pointer;
  transition: background 0.15s ease, transform 0.1s ease;
  position: relative;
  z-index: 150;
}
.minigame-cta-btn:hover {
  background: #ddb62f;
}
.minigame-cta-btn:active {
  transform: scale(0.98);
}

.heckler-offer-actions {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin-top: 1rem;
}

.heckler-rules {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.6rem;
  margin: 1.1rem 0 1.3rem;
}

.heckler-rule {
  background: var(--bg-tint);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 0.6rem 0.5rem;
  text-align: center;
}

.heckler-rule .num {
  display: block;
  font-size: 1.3rem;
  font-weight: bold;
  color: var(--red);
  font-variant-numeric: tabular-nums;
}

.heckler-rule .label {
  display: block;
  font-size: 0.68rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 0.15rem;
}

.heckler-howto-title {
  margin: 1.1rem 0 0.5rem;
  font-size: 0.78rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  border-top: 1px solid var(--border);
  padding-top: 0.9rem;
}

.heckler-howto-list {
  margin: 0;
  padding-left: 1.15rem;
  color: var(--text);
  font-size: 0.88rem;
  line-height: 1.5;
}
.heckler-howto-list li { margin-bottom: 0.45rem; }
.heckler-howto-list li:last-child { margin-bottom: 0; }
.heckler-howto-list strong { color: var(--navy); }

/* .forum-screen centers its children (align-items: center) rather than
   stretching them, so these need an explicit width to actually use the room
   #app:has(.minigame-screen) above just made available — widening #app
   alone didn't do anything, these were still shrink-to-fit sizing
   themselves off their own tiny content. */
.heckler-hud, .heckler-timerbar-track, .ping-stage, .shred-desk {
  width: 100%;
  max-width: 900px;
}

.heckler-hud {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  background: #eef1f6;
  border: 1px solid var(--border);
  border-radius: 10px;
  margin-bottom: 1rem;
}
.heckler-hud .stat {
  padding: 0.55rem 0.4rem;
  text-align: center;
  border-right: 1px solid var(--border);
}
.heckler-hud .stat:last-child { border-right: none; }
.heckler-hud .stat-label {
  display: block;
  font-size: 0.66rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}
.heckler-hud .stat-value {
  display: block;
  font-weight: bold;
  color: var(--navy);
  font-size: 1.15rem;
  font-variant-numeric: tabular-nums;
}
.heckler-hud .stat-value.combo-hot { color: var(--red); }

.heckler-timerbar-track {
  height: 8px;
  background: var(--border);
  border-radius: 5px;
  overflow: hidden;
  margin-bottom: 1rem;
}
.heckler-timerbar-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--silver), var(--red));
  width: 100%;
  transition: width 0.2s linear;
}

/* heckler-float-up/heckler-flash-out kept (Handle the Hecklers itself is
   gone, replaced by Shred the Attacks) — .shred-text and
   .shred-shredder-flash both still reference these two keyframes by name. */
@keyframes heckler-float-up {
  from { opacity: 1; transform: translateY(0) scale(0.9); }
  to { opacity: 0; transform: translateY(-28px) scale(1.15); }
}
@keyframes heckler-flash-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

.heckler-scoreline {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  background: var(--bg-tint);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 0.9rem 1.1rem;
  margin-bottom: 1.1rem;
}
.heckler-scoreline .big {
  font-size: 2.1rem;
  font-weight: bold;
  color: var(--navy);
  font-variant-numeric: tabular-nums;
}
.heckler-scoreline .cap {
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}
.heckler-verdict {
  color: var(--red);
  font-weight: bold;
  font-size: 1rem;
  margin: 0 0 1rem;
}

/* --- Ping the Opponent: White tier's optional mini-game break, offered
   right after an election result and after the 2nd forum of each job year.
   Shares Handle the Hecklers' generic layout classes above (heckler-hud,
   heckler-timerbar-*, heckler-rules/rule, heckler-howto-*, heckler-
   scoreline, heckler-verdict, heckler-offer-actions) — only the stage/
   opponent visuals below are new, prefixed ping- to keep this self-
   contained. Ported from a standalone prototype. --- */

.ping-stage {
  position: relative;
  width: 100%;
  /* Was a flat 260px regardless of screen size — now scales with viewport
     height (capped, with a floor for short screens) so it reads as a
     substantial part of the screen like .heckler-audience's now-bigger
     grid does, rather than a thin strip (2026-07-26, 2nd sizing pass). */
  height: clamp(260px, 55vh, 560px);
  border-radius: 10px;
  overflow: hidden;
  cursor: crosshair;
  background: linear-gradient(180deg, var(--flag-blue) 0%, #123a72 55%, #0d2c58 100%);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
}
.ping-stage::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.035) 0 26px, rgba(0, 0, 0, 0.05) 26px 52px);
  pointer-events: none;
}

.ping-floor {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 60px;
  background: linear-gradient(180deg, #5b4326, #3c2c19);
  border-top: 3px solid var(--silver);
}

/* Anchored from the stage's BOTTOM (like .ping-opponent below), not its top
   — the stage's height is now responsive (see .ping-stage), and a bubble
   pinned to a fixed distance from the top would drift away from the
   opponent's head on a taller stage instead of staying just above it. */
.ping-bubble {
  position: absolute;
  bottom: 204px;
  transform: translateX(-50%);
  background: #fff;
  color: var(--navy);
  font-size: 1rem;
  font-weight: bold;
  padding: 0.4rem 0.7rem;
  border-radius: 10px;
  white-space: nowrap;
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25);
  transition: left 0.1s linear;
  pointer-events: none;
}
.ping-bubble::after {
  content: "";
  position: absolute;
  bottom: -7px;
  left: 50%;
  transform: translateX(-50%);
  border: 7px solid transparent;
  border-top-color: #fff;
}
.ping-bubble.ping-negated {
  background: var(--red);
  color: #fff;
}
.ping-bubble.ping-negated::after { border-top-color: var(--red); }

/* Scaled up ~1.6x from the original 46x82 (head 26, body 38x50, tie 6x22)
   to match the stage's own much bigger footprint above — otherwise the
   character reads as a small, lost figure on a now-substantial board. */
.ping-opponent {
  position: absolute;
  bottom: 60px;
  width: 74px;
  height: 132px;
  transform: translateX(-50%);
  transition: left 0.1s linear;
  pointer-events: none; /* clicks are handled by the stage, not the sprite, so a fast-moving target never dodges a click by animating mid-event */
}
.ping-opponent .ping-head {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #ffffff, #d8d8d8);
  box-shadow: inset 0 0 0 1.5px rgba(27, 42, 74, 0.25);
  margin: 0 auto;
}
.ping-opponent .ping-body {
  width: 61px;
  height: 80px;
  margin: -5px auto 0;
  border-radius: 19px 19px 10px 10px;
  background: radial-gradient(circle at 35% 20%, #f2f2f2, #cfcfcf 85%);
  box-shadow: inset 0 0 0 1.5px rgba(27, 42, 74, 0.2);
  position: relative;
}
.ping-opponent .ping-tie {
  position: absolute;
  top: 3px;
  left: 50%;
  transform: translateX(-50%);
  width: 10px;
  height: 35px;
  background: var(--red);
  border-radius: 0 0 3px 3px;
}
.ping-opponent.ping-stunned .ping-head,
.ping-opponent.ping-stunned .ping-body {
  animation: ping-stun-shake 0.35s ease;
}
@keyframes ping-stun-shake {
  0%, 100% { transform: translateX(0) rotate(0deg); }
  25% { transform: translateX(-3px) rotate(-6deg); }
  75% { transform: translateX(3px) rotate(6deg); }
}

.ping-wad {
  position: absolute;
  width: 14px;
  height: 14px;
  background: #fdfaf2;
  border: 1.5px solid #c9c2a8;
  border-radius: 3px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
  transition: left 0.16s ease-out, bottom 0.16s ease-out, transform 0.16s ease-out;
  pointer-events: none;
}

.ping-cannon {
  position: absolute;
  bottom: 46px;
  left: 50%;
  width: 20px;
  height: 20px;
  transform: translate(-50%, 50%);
  background: var(--silver);
  border-radius: 4px 4px 2px 2px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
  pointer-events: none;
}

.ping-text {
  position: absolute;
  font-weight: bold;
  font-size: 1rem;
  color: var(--silver);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
  pointer-events: none;
  animation: ping-float 0.6s ease-out forwards;
}
@keyframes ping-float {
  from { opacity: 1; transform: translateY(0) scale(0.85); }
  to { opacity: 0; transform: translateY(-32px) scale(1.15); }
}

@media (prefers-reduced-motion: reduce) {
  .ping-opponent.ping-stunned .ping-head, .ping-opponent.ping-stunned .ping-body, .ping-text, .ping-wad { animation: none !important; transition: none !important; }
}

/* --- Shred the Attacks: Blue tier's optional mini-game break, replacing
   Handle the Hecklers. Shares the same generic layout classes (heckler-hud,
   heckler-timerbar-*, heckler-rules/rule, heckler-howto-*, heckler-
   scoreline, heckler-verdict, heckler-offer-actions) — only the desk/paper/
   shredder visuals below are new, prefixed shred-. Unlike the single-target
   games (Ping/Lever), several papers are alive and independently draggable
   at once — each is its own absolutely-positioned DOM node driven by JS,
   not one shared sprite moved via left/top. --- */

.shred-desk {
  position: relative;
  height: clamp(260px, 55vh, 560px);
  border-radius: 10px;
  overflow: hidden;
  touch-action: none;
  background: linear-gradient(180deg, var(--flag-blue) 0%, #123a72 40%, #123a72 42%, #6b4a2e 43%, #7a5535 100%);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
}
/* Two faint "corkboard" rectangles on the wall portion — just enough set
   dressing to read as a campaign office without competing with the papers
   themselves (direct spec: "do not make the background overly busy"). */
.shred-desk::before,
.shred-desk::after {
  content: "";
  position: absolute;
  top: 8%;
  width: 15%;
  height: 22%;
  border: 2px solid rgba(255, 255, 255, 0.1);
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.04);
}
.shred-desk::before { left: 6%; }
.shred-desk::after { right: 6%; transform: rotate(-3deg); }

.shred-paper {
  position: absolute;
  top: 0;
  left: 50%;
  width: 150px;
  min-height: 84px;
  transform: translate(-50%, 0) rotate(0deg);
  background: #fdfaf2;
  border: 2px solid var(--border);
  border-radius: 6px;
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
  padding: 0.4rem 0.5rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.2rem;
  cursor: grab;
  touch-action: none;
  transition: left 0.1s linear, top 0.1s linear;
  z-index: 1;
}
.shred-paper.shred-paper--attack {
  border-color: var(--red);
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(179, 53, 44, 0.25);
}
.shred-headline {
  font-size: 0.74rem;
  font-weight: bold;
  line-height: 1.2;
  color: var(--navy);
  /* No line-clamp/overflow-hidden here — direct request that every headline
     read as a complete sentence, never truncated with an ellipsis. Cards
     grow (see .shred-paper's min-height, not a fixed height) to fit
     whatever a template + the player's real last name produces. */
}
/* Headline text is deliberately the SAME color on every paper (direct
   request) — the red border + "ATTACK AD" stamp are the only visual tell,
   so a player can't just pattern-match on text color without reading. */
.shred-stamp {
  align-self: flex-start;
  font-size: 0.56rem;
  font-weight: bold;
  letter-spacing: 0.05em;
  color: #fff;
  background: var(--red);
  border-radius: 3px;
  padding: 0.08rem 0.3rem;
  transform: rotate(-4deg);
}
/* Legitimate documents get the exact same stamp treatment as attack ads,
   same red included (direct request — "should also be red so they are hard
   to tell the difference") — .shred-stamp--legit exists only as a semantic
   hook, not a visual one; the stamp's TEXT is what has to be read. */
.shred-stamp.shred-stamp--legit {
  background: var(--red);
}
.shred-paper.shred-paper--dragging {
  cursor: grabbing;
  transform: translate(-50%, 0) rotate(var(--shred-tilt, 0deg)) scale(1.08);
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
  transition: none;
  z-index: 20;
}
.shred-paper.shred-paper--shredding {
  animation: shred-pull-in 0.32s ease-in forwards;
  pointer-events: none;
}
@keyframes shred-pull-in {
  0% { transform: translate(-50%, 0) rotate(0deg) scale(1); opacity: 1; }
  100% { transform: translate(-50%, var(--shred-pull-dist, 40px)) rotate(0deg) scale(0.25, 0.1); opacity: 0; }
}
.shred-paper.shred-paper--bounce {
  animation: shred-bounce-back 0.32s ease-out;
}
@keyframes shred-bounce-back {
  0%, 100% { transform: translate(-50%, 0) rotate(0deg); }
  25% { transform: translate(calc(-50% - 6px), -6px) rotate(-4deg); }
  60% { transform: translate(calc(-50% + 5px), 3px) rotate(3deg); }
}

/* Real photo asset (src/assets/paper-shredder.jpg) replacing the old
   CSS-drawn body/slot/bin shapes — direct request. object-fit: cover crops
   any excess without distorting it; the container's own border-radius clips
   the image's corners to match, same pattern as .lever-box/.lever-box-img. */
.shred-shredder {
  position: absolute;
  bottom: 14px;
  left: 50%;
  transform: translateX(-50%);
  width: 190px;
  aspect-ratio: 1409 / 958;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.45);
  pointer-events: none;
  z-index: 2;
}
.shred-shredder-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.shred-shredder.shred-shredder--active {
  animation: shred-shake 0.32s ease;
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.45), 0 0 16px 5px rgba(80, 220, 120, 0.75);
}
@keyframes shred-shake {
  0%, 100% { transform: translateX(-50%); }
  25% { transform: translateX(calc(-50% - 2px)); }
  75% { transform: translateX(calc(-50% + 2px)); }
}
/* Boosted opacity/glow (direct follow-up — "the red should be easier to
   see") — the original 0.35-alpha wash barely read over the photo. */
.shred-shredder-flash {
  position: absolute;
  inset: -10px;
  border-radius: 12px;
  background: rgba(217, 74, 63, 0.7);
  box-shadow: 0 0 20px 8px rgba(217, 74, 63, 0.85);
  animation: heckler-flash-out 0.4s ease-out forwards;
  pointer-events: none;
}

.shred-strip {
  position: absolute;
  width: 4px;
  height: 16px;
  background: #e8e2cf;
  border-radius: 1px;
  pointer-events: none;
  animation: shred-strip-fall 0.5s ease-in forwards;
}
@keyframes shred-strip-fall {
  0% { opacity: 1; transform: translateY(0) rotate(var(--strip-rot, 0deg)); }
  100% { opacity: 0; transform: translateY(26px) rotate(var(--strip-rot, 0deg)); }
}

.shred-text {
  position: absolute;
  transform: translateX(-50%);
  font-size: 1.15rem;
  font-weight: bold;
  color: var(--silver);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
  pointer-events: none;
  animation: heckler-float-up 0.6s ease-out forwards;
  z-index: 25;
  white-space: nowrap;
}
.shred-text.shred-text-bad { color: var(--red-bright); }

@media (prefers-reduced-motion: reduce) {
  .shred-paper, .shred-shredder, .shred-strip, .shred-text, .shred-shredder-flash { animation: none !important; transition: none !important; }
}

/* Teacher Dashboard (2026-08-14) — a full-screen admin view (see
   showTeacherDashboard in main.js), not a modal like the Leaderboard. Wider
   than the default 720px reading column since the roster is a multi-column
   table and the detail view has several data-dense sections; naturally
   scrolls (no "fit one screen" constraint like .campaign-screen — this is
   a reference view, not something a student clicks through every month). */
#app:has(.dashboard-screen) {
  max-width: min(1500px, 96vw);
}

/* At this screen's own wide max-width (see above), the header's "x" close
   button lands close enough to the true viewport edge to visually collide
   with the always-on-top fixed .mute-btn (top:1rem, right:1rem, 3rem wide
   — so it occupies right:1rem through right:4rem of the viewport). Direct
   user report: looked like a stray "x" floating on the speaker icon itself.
   Reserving that same width as right padding on just this screen's header
   keeps its own close button clear of the fixed one without touching
   .briefing-header's shared layout used elsewhere. */
.dashboard-screen .briefing-header {
  padding-right: 4rem;
}

/* Teacher-controlled global sound switch (2026-08-26) — sits above the
   roster table rather than in it, since it's a class-wide setting, not
   per-student data like everything below it. */
.dashboard-sound-control {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  background: #f4f4f4;
  border-radius: 0.5rem;
  padding: 0.75rem 1rem;
  margin-bottom: 1rem;
}
.dashboard-sound-control span {
  font-size: 0.9rem;
  color: var(--navy);
}
.dashboard-sound-control button {
  flex-shrink: 0;
  padding: 0.5rem 1rem;
  font-size: 0.9rem;
}

.roster-table {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  /* 11 columns is a lot for a narrow window — scrolls instead of clipping
     or wrapping into an unreadable mess on anything under ~1100px wide. */
  overflow-x: auto;
}

.roster-row {
  display: grid;
  /* Name/Email need real room — a full name like "Alexandria
     Rodriguez-Martinez" or a long school email measures ~210-235px at this
     font, which the old 1.1fr/1.5fr split didn't give them (wrapped to 3
     lines). The 7 middle stat columns only ever hold a short number/percent/
     duration and Gems/Stars/100s only a 1-2 digit number, so both give up
     width here (direct request, 2026-09-07). */
  grid-template-columns: 2.2fr 1.7fr repeat(7, 0.7fr) repeat(3, 0.5fr);
  gap: 0.5rem;
  align-items: center;
  padding: 0.4rem 0.5rem;
  border-radius: 6px;
  font-size: 0.88rem;
  min-width: 900px;
}

.roster-row:nth-child(even):not(.roster-header) {
  background: var(--surface);
}

.roster-header {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  font-weight: bold;
}

.roster-sort-btn {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.roster-sort-btn:hover,
.roster-sort-btn.active {
  color: var(--navy);
}

.roster-sort-arrow {
  display: inline-block;
  min-width: 1em;
  margin-left: 0.2em;
  font-size: 0.8em;
}

.roster-name-cell {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

/* Header's "Name" label lines up with the names, not the dots. */
.roster-header .roster-sort-btn:first-child {
  padding-left: 1.3rem;
}

.presence-dot {
  display: inline-block;
  width: 0.8rem;
  height: 0.8rem;
  border-radius: 50%;
  flex-shrink: 0;
  background: transparent;
}

.presence-dot.idle {
  background: #ffd400;
  box-shadow: 0 0 0 1px #a88a00;
}

.presence-dot.playing {
  background: #22c55e;
  box-shadow: 0 0 0 1px #15803d;
}

.presence-legend {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  font-size: 0.85rem;
  color: var(--muted);
}

.presence-legend .presence-dot:not(:first-child) {
  margin-left: 0.8rem;
}

.roster-name-btn {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  text-align: left;
  font-family: inherit;
  font-size: inherit;
  font-weight: bold;
  color: var(--navy);
  cursor: pointer;
  text-decoration: underline;
  text-decoration-color: transparent;
}

.roster-name-btn:hover {
  text-decoration-color: var(--silver);
}

.roster-email {
  font-size: 0.8rem;
  color: var(--muted);
  overflow-wrap: break-word;
}

.writing-result-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.7rem 1rem;
  margin: 0 0 0.6rem;
  text-align: left;
}

.writing-result-meta {
  margin: 0 0 0.4rem;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}

.writing-result-round {
  margin: 0 0 0.5rem;
}

.writing-result-question {
  margin: 0;
  font-weight: bold;
  font-size: 0.92rem;
}

.writing-result-response {
  margin: 0.2rem 0 0;
  font-size: 0.9rem;
  color: var(--text);
  white-space: pre-wrap;
}

.writing-result-score {
  margin: 0.4rem 0 0;
  font-size: 0.85rem;
  color: var(--muted);
}

/* Shown only when a newer version of the game has been deployed and this tab
   is somewhere it isn't safe to reload on its own (mid-game, a popup open) —
   see applyUpdateWhenSafe in main.js. Top-center keeps clear of the Hint
   (bottom-left), Back to Home (bottom-center), Feedback (bottom-right) and
   mute (top-right) buttons. */
#update-banner {
  position: fixed;
  top: 0.6rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 3000;
  display: flex;
  align-items: center;
  gap: 0.8rem;
  padding: 0.5rem 0.5rem 0.5rem 1rem;
  background: var(--navy);
  color: #fff;
  border-radius: 999px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
  font-size: 0.9rem;
  white-space: nowrap;
}

#update-banner button {
  background: #ffd400;
  color: #1a1a1a;
  border: none;
  border-radius: 999px;
  padding: 0.4rem 0.9rem;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
