* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }

html, body { height: 100%; }
body {
  background: #07050e;
  font-family: 'Georgia', serif;
  color: #c8a84e;
  user-select: none;
  -webkit-user-select: none;
  overflow-x: hidden;
}

.screen { display: none; flex-direction: column; align-items: center; min-height: 100vh; padding-bottom: 40px; }
.screen.active { display: flex; }

/* ── Home ────────────────────────────────────────────────────── */
#home { padding: calc(40px + env(safe-area-inset-top, 0px)) 16px 48px; }
.home-title-row {
  display: flex; align-items: center; justify-content: center; gap: 14px;
}
.home-logo { height: 77px; width: auto; flex-shrink: 0; }
.home-title {
  font-size: 26px; letter-spacing: 5px; text-transform: uppercase;
  text-shadow: 0 0 24px rgba(200,168,78,0.45); line-height: 1.35;
  text-align: left;
}
/* Hero card — the one thing a returning player needs: where to continue.
   Photo backdrop (district-appropriate, set in renderHome()) with a dark
   fade so the overlaid text/button stay legible regardless of the image
   underneath. Replaces the old always-visible full-level accordion grid,
   which was better suited to QA (jump to any level) than to a returning
   player (just wants "keep going") — see project notes. Full level
   browsing is deep-link only now (#l14 etc.), already supported. */
.hero-card {
  width: 100%; max-width: 380px; height: 300px; margin-top: 20px;
  border-radius: 16px; overflow: hidden; position: relative;
  border: 1px solid rgba(200,168,78,0.3);
  box-shadow: 0 10px 40px rgba(0,0,0,0.6);
}
.hero-photo { width: 100%; height: 100%; object-fit: cover; filter: brightness(0.6) saturate(0.9); display: block; }
.hero-fade {
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(7,5,14,0.1) 0%, rgba(7,5,14,0.3) 45%, rgba(7,5,14,0.96) 100%);
}
.hero-content { position: absolute; left: 0; right: 0; bottom: 0; padding: 20px 20px 22px; }
.hero-eyebrow { font-size: 12px; letter-spacing: 2px; color: #c8a84e; text-transform: uppercase; opacity: 0.85; }
.hero-name { font-size: 22px; color: #f0dca0; letter-spacing: 0.5px; margin-top: 5px; text-shadow: 0 2px 10px rgba(0,0,0,0.8); }
.hero-meta { font-size: 13px; color: #8a7040; margin-top: 3px; letter-spacing: 0.7px; }
.play-btn {
  width: 100%; margin-top: 14px; display: flex; align-items: center; justify-content: center; gap: 9px;
  background: #c8a84e; color: #1a1608; border: none; border-radius: 999px; padding: 13px 0;
  font-family: 'Georgia', serif; font-size: 13px; letter-spacing: 2.5px; text-transform: uppercase; font-weight: bold;
  box-shadow: 0 0 24px rgba(200,168,78,0.4); cursor: pointer;
}
.play-btn:active { background: #b89642; }

/* Teaser card — the other district: locked-ahead before it unlocks, or
   greyed-done once the hero above has moved on to it. Deliberately
   smaller/quieter than the hero — it's "something to look forward to /
   already handled", never the thing competing for the main action. */
.teaser-card {
  width: 100%; max-width: 380px; height: 110px; margin-top: 12px;
  border-radius: 14px; overflow: hidden; position: relative;
  border: 1px solid rgba(200,168,78,0.14);
}
.teaser-photo { width: 100%; height: 100%; object-fit: cover; filter: brightness(0.32) saturate(0.6) grayscale(0.3); display: block; }
.teaser-fade { position: absolute; inset: 0; background: rgba(7,5,14,0.35); }
.teaser-content {
  position: absolute; inset: 0; display: flex; align-items: center; gap: 12px; padding: 0 18px;
}
.teaser-icon { font-size: 20px; opacity: 0.75; flex-shrink: 0; }
/* Wall of Honour's own teaser -- a small stack of the actual predecessor
   faces instead of a generic trophy glyph, so the card previews what's
   actually behind it (a wall of real people) rather than a stock icon
   for "achievement." Built by a one-off script into a single flat PNG
   (three overlapping circular crops, same top-anchored square crop the
   real Wall grid uses) rather than composited live -- it's a fixed
   decoration, not data that changes. */
.teaser-icon-faces { height: 34px; width: auto; flex-shrink: 0; opacity: 0.92; }
.teaser-text { flex: 1; }
.teaser-label { font-size: 13px; letter-spacing: 2px; color: #8a7040; text-transform: uppercase; }
.teaser-status { font-size: 13px; color: #5a4a2a; margin-top: 3px; letter-spacing: 0.3px; }
/* A tappable teaser otherwise has no visual cue that it opens anything --
   cursor:pointer means nothing on a touch device. Only used where the
   card is actually tappable (the Wall of Honour teaser); a plain status
   teaser carries no chevron and needs none. */
.teaser-chev { font-size: 22px; color: #8a7040; flex-shrink: 0; opacity: 0.75; }
.teaser-card.tappable { cursor: pointer; }
.teaser-card.done .teaser-icon { opacity: 0.9; }

/* ── Phase carousel (native, multi-district only) ────────────────
   Redesigned from an earlier stacked-peek layout (cropped 40px slivers
   merged flush together) after that read as one blurry block rather than
   separate cards -- every phase is now a full-size card, same footprint
   as .hero-card, in its own vertically scrollable/snappable strip so
   card boundaries (real gaps, full borders) are unambiguous. Lands
   scrolled to the current phase on render (see renderStackedPhases), not
   the top of the list. */
.phase-scroll {
  width: 100%; max-width: 380px; margin-top: 20px;
  height: 380px; overflow-y: auto; -webkit-overflow-scrolling: touch;
  scroll-snap-type: y proximity;
  display: flex; flex-direction: column; gap: 18px;
}
.phase-scroll::-webkit-scrollbar { display: none; }
/* Same [hidden]-vs-author-display gotcha as .account-fab/.login-btns
   elsewhere in this file -- an explicit display: flex above always wins
   over the browser's default [hidden] { display:none } regardless of
   specificity. */
.phase-scroll[hidden] { display: none; }

/* scroll-snap-align: center (not start) so whichever card is focused
   settles in the middle of the 380px viewport with an even ~22px sliver
   of the card before and after it showing above/below -- enough to read
   "there's more here" without it looking like an accidental crop, on
   every card you scroll to, not just the one landed on at render time. */
.phase-card {
  width: 100%; height: 300px; flex-shrink: 0; scroll-snap-align: center;
  border-radius: 16px; overflow: hidden; position: relative;
  border: 1px solid rgba(200,168,78,0.3);
  box-shadow: 0 10px 40px rgba(0,0,0,0.6);
}
.phase-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The "next case" placeholder carries no photo, so it needs a ground of its
   own rather than borrowing whatever sits behind the carousel. Dimmer border
   than a real phase card too: it is a file that does not exist yet, and it
   should not compete with the one the player can actually open. */
.phase-card.phase-soon { background: #0d0b16; border-color: rgba(200,168,78,0.16); }
.phase-card.phase-current .phase-photo { filter: brightness(0.6) saturate(0.9); }
.phase-card.phase-complete .phase-photo,
.phase-card.phase-locked .phase-photo { filter: brightness(0.32) saturate(0.5) grayscale(0.4); }
.phase-fade { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(7,5,14,0.1) 0%, rgba(7,5,14,0.3) 45%, rgba(7,5,14,0.96) 100%); }
.phase-card.phase-complete .phase-fade,
.phase-card.phase-locked .phase-fade { background: rgba(7,5,14,0.55); }

/* .phase-current only -- mirrors .hero-content/.hero-eyebrow/etc exactly,
   since this card takes over the hero's job when the carousel is showing. */
.phase-content { position: absolute; left: 0; right: 0; bottom: 0; padding: 20px 20px 22px; }
.phase-eyebrow { font-size: 12px; letter-spacing: 2px; color: #c8a84e; text-transform: uppercase; opacity: 0.85; }
.phase-name { font-size: 22px; color: #f0dca0; letter-spacing: 0.5px; margin-top: 5px; text-shadow: 0 2px 10px rgba(0,0,0,0.8); }
.phase-meta { font-size: 13px; color: #8a7040; margin-top: 3px; letter-spacing: 0.7px; }

/* .phase-complete/.phase-locked only -- district name under the stamp. */
.phase-label {
  position: absolute; left: 0; right: 0; bottom: 18px; text-align: center;
  font-size: 14px; letter-spacing: 2px; color: #8a7040; text-transform: uppercase;
}
/* Rotated ink-stamp treatment so "Complete"/"Coming Soon" reads as a
   deliberate callout, not a status label -- gold for an actual
   achievement, muted brown-grey for "not yet" so the two don't compete
   for the same attention. */
.phase-stamp {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(-9deg);
  border: 3px solid; border-radius: 8px; padding: 8px 22px;
  font-size: 16px; font-weight: bold; letter-spacing: 3.5px; text-transform: uppercase; white-space: nowrap;
}
.phase-stamp-complete { color: #c8a84e; border-color: #c8a84e; text-shadow: 0 0 12px rgba(200,168,78,0.5); }
.phase-stamp-locked { color: #6a5838; border-color: #6a5838; }

/* ── Daily Challenge: in-game chrome (#55) ────────────────────── */
/* Hidden on every campaign level; .is-daily is put on #game by startDaily
   and taken off when the daily ends. */
.daily-clock-wrap { display: none; align-items: center; gap: 10px; }
#game.is-daily .daily-clock-wrap { display: flex; }
/* Permanent rule reference, top right of the header next to the level
   title -- opens #rules-reference. A single circle (border + background,
   same chrome language as .hint-btn below), not a ring-in-a-ring -- the
   glyph is plain text, no inner SVG outline competing with the button's
   own border. Hidden on Daily Challenge, same reasoning as hint/automark/
   reset: daily is one timed shot, not the moment for a helper tool. */
.rules-info-btn {
  flex-shrink: 0; margin-left: 10px;
  width: 34px; height: 34px; border-radius: 50%;
  background: rgba(38, 26, 6, 0.60); border: 1.5px solid rgba(200,168,78,0.55);
  color: #d8c078; font-family: 'Georgia', serif; font-size: 16px; font-weight: bold;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 0 10px rgba(200,168,78,0.15); cursor: pointer;
}
.rules-info-btn:active { background: rgba(60, 42, 8, 0.80); }
#game.is-daily .rules-info-btn { display: none; }
.daily-clock {
  font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 17px;
  font-variant-numeric: tabular-nums; letter-spacing: 1px; color: #e8dcc0;
  min-width: 52px; text-align: right;
}
.daily-pause {
  background: transparent; border: 1px solid rgba(200,168,78,0.45);
  color: #c8a84e; border-radius: 999px; padding: 5px 13px;
  font-family: inherit; font-size: 12.5px; letter-spacing: 1.3px;
  text-transform: uppercase; cursor: pointer;
}
.daily-pause:active { background: rgba(200,168,78,0.14); }

/* Pausing hides the board. Blurring rather than merely covering it means no
   amount of screenshotting or inspecting gives the puzzle away while the
   clock is stopped. */
#game.daily-hidden-board .grid,
#game.daily-hidden-board .rule-pills,
#game.daily-hidden-board .panel-status { filter: blur(18px); opacity: 0.35; pointer-events: none; }
.daily-paused-veil {
  position: fixed; inset: 0; z-index: 40; display: flex;
  align-items: center; justify-content: center; background: rgba(7,5,14,0.82);
}
/* Same trap as .daily-btn-lock above -- see the comment there. */
.daily-paused-veil[hidden] { display: none; }
.daily-paused-inner { text-align: center; padding: 0 30px; }
.daily-paused-title {
  font-size: 16px; letter-spacing: 3.5px; text-transform: uppercase; color: #c8a84e;
}
.daily-paused-sub {
  margin-top: 10px; font-size: 14px; line-height: 1.6; color: #9a90b0;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}
.daily-paused-btn {
  margin-top: 22px; background: #c8a84e; color: #1a1608; border: none;
  border-radius: 999px; padding: 13px 34px; font-family: inherit;
  font-size: 14px; letter-spacing: 2px; text-transform: uppercase;
  font-weight: bold; cursor: pointer;
}

/* ── Daily Challenge: the recap (#55) ─────────────────────────── */
/* Where the day ends, win or lose. Shows the solution, because there is no
   retry -- a player who lost has no other way to see what they missed. Also
   the future home of sharing, which is why the time is here and prominent. */
#daily-recap { padding: calc(20px + env(safe-area-inset-top, 0px)) 18px 40px; }
.dr-wrap { width: 100%; max-width: 420px; margin: 0 auto; text-align: center; }
.dr-eyebrow {
  font-size: 12.5px; letter-spacing: 2.5px; text-transform: uppercase; color: #7d7490;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}
.dr-title { margin-top: 8px; font-size: 24px; letter-spacing: 1px; color: #c8a84e; }
.dr-time {
  margin-top: 18px; font-family: 'Helvetica Neue', Arial, sans-serif;
  font-size: 38px; font-variant-numeric: tabular-nums; color: #e8dcc0;
}
.dr-time-label {
  margin-top: 4px; font-size: 13px; letter-spacing: 1.5px; text-transform: uppercase;
  color: #7d7490; font-family: 'Helvetica Neue', Arial, sans-serif;
}
.dr-board { margin: 24px auto 0; display: grid; gap: 3px; width: max-content; }
.dr-cell {
  width: 34px; height: 34px; border-radius: 6px; display: flex;
  align-items: center; justify-content: center;
}
.dr-dot { width: 12px; height: 12px; border-radius: 50%; background: #0d0b12; box-shadow: 0 0 0 3px rgba(255,255,255,0.5); }
.dr-caption {
  margin-top: 14px; font-size: 13px; color: #7d7490;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}
.dr-next {
  margin-top: 26px; font-size: 13px; letter-spacing: 1px; color: #8a7040;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}
/* Secondary action inside an overlay's button row. Deliberately plain: the
   primer now has a real way out, and a decline should look like a choice
   rather than a mistake. */
.quiet-btn {
  background: transparent !important; color: #8a8299 !important;
  border: 1px solid #38304c !important; box-shadow: none !important;
  font-weight: normal !important;
}

/* The reminder offer. Quiet: it is a question, not the point of the screen,
   and it must not compete with Share on a solved day. */
.dr-offer {
  margin-top: 24px; padding: 15px 16px; border: 1px solid #2b2438;
  border-radius: 12px; background: rgba(27,24,38,0.6);
}
.dr-offer[hidden] { display: none; }
.dr-offer-t {
  font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 14px;
  color: #b9b0c6; line-height: 1.5;
}
.dr-offer-b { display: flex; gap: 9px; justify-content: center; margin-top: 12px; }
/* The blocked follow-up is a correction, not a fresh pitch -- same card,
   warmer border so it reads as a response to what was just tapped. */
.dr-offer-blocked { border-color: #4a3b2a; }
.dr-offer-b button {
  font-family: inherit; font-size: 13px; letter-spacing: 1.3px;
  text-transform: uppercase; border-radius: 999px; padding: 9px 16px;
  cursor: pointer;
}
.dr-offer-yes { background: #c8a84e; color: #1a1508; border: none; font-weight: bold; }
.dr-offer-no { background: transparent; color: #8a8299; border: 1px solid #38304c; }

/* Share sits above Home and outranks it: on a solved day it is the thing
   worth doing, and Home is just the way out. Filled, not outlined -- an
   outline read as LESS prominent than Home's solid fill despite sitting on
   top, the opposite of the intended hierarchy (caught by comparing a real
   render against this comment, not by reading the rule in isolation). */
.dr-share {
  display: block; width: 100%; margin-top: 26px; padding: 14px 0;
  background: #c8a84e; color: #1a1608; border: none;
  border-radius: 999px; font-family: inherit; font-size: 13px;
  letter-spacing: 2.5px; text-transform: uppercase; font-weight: bold;
  cursor: pointer;
}
.dr-share[hidden] { display: none; }
.dr-share + .dr-done { margin-top: 12px; }

.dr-done {
  display: block; width: 100%; margin-top: 22px; padding: 14px 0;
  background: transparent; color: #c8a84e; border: 1px solid #c8a84e;
  border-radius: 999px; font-family: inherit; font-size: 13px;
  letter-spacing: 2.5px; text-transform: uppercase; font-weight: bold; cursor: pointer;
}
.dr-done:active { background: rgba(200,168,78,0.14); }

/* ── Daily Challenge button (#55) ─────────────────────────────── */
/* Above the footer, sized to rival the hero's Play rather than sit under it
   as another footer link -- it is a second way into the game, not a setting.
   Deliberately not .play-btn: that is a pill inside a photo card, this is a
   full-width block on the page background. */
.daily-btn {
  display: flex; align-items: center; justify-content: space-between;
  width: 100%; margin-top: 22px; padding: 15px 18px;
  background: linear-gradient(180deg, #191320 0%, #12101a 100%);
  border: 1px solid rgba(200,168,78,0.42); border-radius: 14px;
  font-family: inherit; text-align: left; cursor: pointer;
  box-shadow: 0 6px 22px rgba(0,0,0,0.45);
}
.daily-btn:active { background: #1d1727; }
.daily-btn-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.daily-btn-title {
  font-size: 14px; letter-spacing: 2.6px; text-transform: uppercase;
  color: #c8a84e; font-weight: bold;
}
.daily-btn-sub {
  font-size: 13px; letter-spacing: 0.4px; color: #7d7490;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}
/* The countdown, tucked in the corner as an overlay rather than another line
   of text -- it is a status on the button, not part of its label. */
.daily-btn-corner {
  flex-shrink: 0; margin-left: 12px; padding: 4px 9px; border-radius: 999px;
  font-size: 12px; letter-spacing: 1.1px; text-transform: uppercase;
  font-family: 'Helvetica Neue', Arial, sans-serif;
  color: #0f0c16; background: #c8a84e; white-space: nowrap;
}
.daily-btn-lock { flex-shrink: 0; margin-left: 12px; color: #5c5470; display: flex; }
/* [hidden] is display:none in the USER-AGENT stylesheet, and any class rule
   in this file outranks it. So an element whose class sets display ignores
   the attribute completely, and el.hidden = true silently does nothing.
   Both elements below set display, and both were doing exactly that: the
   lock stayed on an unlocked button, and the paused veil covered a board
   that was still running -- which also made Resume look dead, since the
   clock had never actually stopped.
   Specificity of .class[hidden] beats .class, so no !important needed. */
.daily-btn-lock[hidden] { display: none; }

/* Narrow phones. The pill carries a word now ("8h left", "new in 42m")
   rather than a bare duration, and at 320pt -- a 4.7" iPhone -- that pushed
   "DAILY CHALLENGE" onto two lines. Measured, not guessed: the title needs
   187px at the default tracking, and the labelled pill leaves it 145.

   Tracking is what gives way, not the words. Letter-spacing is the cheapest
   thing on this button to spend: 15 characters at 2.6px is 39px of pure
   spacing, so tightening it buys back more than shrinking the type would,
   and without making anything harder to read. */
@media (max-width: 380px) {
  .daily-btn { padding: 14px 14px; }
  .daily-btn-title { font-size: 13px; letter-spacing: 1.4px; }
  .daily-btn-sub { font-size: 11px; }
  .daily-btn-corner { padding: 4px 7px; font-size: 10px; letter-spacing: 0.8px; }
  .daily-btn-corner, .daily-btn-lock { margin-left: 8px; }
}

/* Played: dimmed, but still a button. It opens the recap, so it must not
   look or behave like something switched off -- no reduced pointer events,
   no disabled attribute. */
.daily-btn.daily-played {
  background: #131019; border-color: rgba(200,168,78,0.16);
  box-shadow: none;
}
.daily-btn.daily-played .daily-btn-title { color: #7a6738; }
.daily-btn.daily-played .daily-btn-corner { background: #2b2438; color: #9a90b0; }

/* Locked: below level 7. Genuinely inert. */
.daily-btn.daily-locked {
  background: #100e17; border-color: rgba(200,168,78,0.10);
  box-shadow: none; cursor: default;
}
.daily-btn.daily-locked .daily-btn-title { color: #5c5470; }
.daily-btn.daily-locked .daily-btn-sub { color: #4a445c; }

.home-footer {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  margin-top: 28px;
}
.footer-link {
  font-size: 11px; letter-spacing: 1.5px; color: #5a4a2a; text-transform: uppercase;
  text-align: center; text-decoration: none; background: none; border: none;
  padding: 0; cursor: pointer; font-family: inherit;
}
.footer-dot { font-size: 11px; color: #3a2f1a; }
.ghost-btn {
  margin-top: 28px; background: none;
  border: 1px solid rgba(200,168,78,0.3); color: #7a6028;
  font-size: 14px; letter-spacing: 2px; padding: 11px 22px;
  border-radius: 6px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
}
.ghost-btn:active { background: rgba(200,168,78,0.08); }

/* ── Level backdrop ──────────────────────────────────────────── */
.bg-photo {
  position: fixed; inset: 0; width: 100%; height: 100%;
  object-fit: cover; object-position: center 40%;
  z-index: 0;
}
/* Sits on top of #bg-photo (same class, later in the DOM) at opacity 0 --
   js/game.js adds .show only for the one district-transition level, fading
   the new district's photo in over the old one instead of swapping src
   outright. Inert (no src, opacity 0) everywhere else. */
/* @keyframes, not a transition -- a transition needs its "before" state
   (opacity 0) already painted before the class change that triggers it,
   which is exactly the race two separate rAF-timing attempts both lost on
   a real device (single rAF, then double rAF): the src-set, the class-
   add and the first real paint could all land in the same frame, and the
   transition never started. A keyframe animation's 0% state is self-
   contained -- adding .show is enough on its own, nothing needs to have
   painted first. */
#bg-photo-dissolve { opacity: 0; }
/* Confirmed rendering on device once the real bug (the board hiding it,
   see .board-pending-transition) was fixed -- 0.9s read as too quick
   once actually visible, both backdrops barely registering before the
   swap finished. 1.8s gives both photos real time on screen. */
#bg-photo-dissolve.show { animation: bg-dissolve-in 1.8s ease forwards; }
@keyframes bg-dissolve-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
/* Location card, same district-transition level as #bg-photo-dissolve
   above -- "Training Facility" while the old backdrop holds, swapped to
   "O'Hare International Airport" the moment the dissolve starts (see
   beginIntro() in js/game.js). Plain opacity transition, not a
   @keyframes animation -- no src/decode race here, it's just text. */
.bg-location-caption {
  position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%);
  z-index: 2; pointer-events: none; text-align: center; width: 90vw;
  font-family: 'Courier New', monospace; font-size: 15px; font-weight: bold;
  letter-spacing: 3px; text-transform: uppercase; color: #e8dcc0;
  text-shadow: 0 2px 14px rgba(0,0,0,0.85);
  opacity: 0; transition: opacity 0.3s ease;
}
.bg-location-caption.show { opacity: 1; }
.bg-overlay { position: fixed; inset: 0; background: rgba(4,3,10,0.62); z-index: 1; }
#game .game-header, #game .game-ui { position: relative; z-index: 2; }
#game .game-ui {
  background: rgba(4,3,10,0.58);
  border-radius: 18px;
  padding: 18px 14px 16px;
}

/* ── Shared game UI ──────────────────────────────────────────── */
.game-header {
  width: 100%; max-width: 440px;
  display: flex; align-items: center;
  padding: calc(12px + env(safe-area-inset-top, 0px)) 14px 10px; gap: 10px;
  margin-bottom: 16px;
  /* Same translucent backing as .game-ui below it -- without this, the
     level title/subtitle had only the global .bg-overlay dim tint behind
     them, which isn't reliably enough contrast against every backdrop
     photo. Rounded only at the bottom since the top edge sits flush
     against the safe-area/screen edge. */
  background: rgba(4,3,10,0.58);
  border-radius: 0 0 14px 14px;
}
.hdr-btn {
  background: none; border: 1px solid rgba(200,168,78,0.25);
  color: #7a6028; font-size: 14px; letter-spacing: 1.2px;
  padding: 7px 10px; border-radius: 5px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
  white-space: nowrap; flex-shrink: 0;
}
.hdr-btn:active { background: rgba(200,168,78,0.08); }
/* Mirrors .account-fab (bottom-right, every screen) on the opposite
   corner — bottom-left, game screen only, since "back to map" only makes
   sense mid-level. A fixed-position child of #game renders relative to
   the viewport same as #game's own .bg-photo/.bg-overlay already do. */
.map-fab {
  position: fixed; left: 14px; bottom: calc(14px + env(safe-area-inset-bottom, 0px));
  z-index: 3; height: 40px; border-radius: 20px; padding: 0 16px;
  background: rgba(26,22,10,0.85); border: 1.5px solid rgba(200,168,78,0.4);
  font-family: 'Georgia', serif; font-size: 14px; letter-spacing: 0.8px;
  text-transform: uppercase; color: #c8a84e; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 2px 10px rgba(0,0,0,0.35);
}
.map-fab:active { background: rgba(60,42,8,0.9); }
.header-info { flex: 1; min-width: 0; }
.header-title {
  font-size: 14px; color: #c8a84e; letter-spacing: 1.5px; text-transform: uppercase;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* The district/level-count line ("O'Hare 6/20 · 7x7"). Bumped from 11px on
   player feedback that the numbers were unreadable -- it is the only place
   the player can see where they are in a phase, so it has to survive a
   glance. The title above it is unchanged; this line does not compete with
   it at 13.5px, it just stops disappearing. */
.header-grade { font-size: 13.5px; color: #6a5020; letter-spacing: 0.6px; margin-top: 2px; }

.game-ui { display: flex; flex-direction: column; align-items: center; width: min(92vw, 400px); }
/* The one district-transition level (see beginIntro in js/game.js) hides
   the board for the brief window the backdrop is changing -- otherwise
   it's the dominant thing on screen (a mostly-opaque dark panel) and the
   backdrop change happening behind/around it is nearly invisible.
   pointer-events:none too: nothing should be tappable before Marlowe's
   line even starts. */
.game-ui.board-pending-transition { opacity: 0; pointer-events: none; }

/* One light per colour on the board (see renderColourLights in game.js) —
   replaces the old lives row now that there's only ever 1 life to show.
   Lit = that colour's button hasn't been pressed yet; a correct press
   turns its light off, like neutralizing that zone on the panel. Colour
   comes for free by reusing the same .rN classes the grid cells use
   (.theme-capone .rN sets background — see the palette above). The "ON"
   tag alongside sells the control-panel look.

   Digital-readout panel treatment (was a bare row floating on the
   translucent backdrop, unlike every other element on this screen, which
   are all raised buttons or bordered chips) -- three layered fixes:
   a recessed strip (the grid cells are raised, so a sunken strip reads as
   their physical opposite -- a console readout embedded in the casing),
   scanlines across it (LCD/LED feel), and squared-off LEDs with a socket
   housing instead of bare round dots (a circle reads analog/incandescent,
   a small square in a fitting reads as a digital status indicator). */
.panel-status {
  display: flex; align-items: center; gap: 16px; margin-bottom: 12px;
  background: repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0px, rgba(255,255,255,0.03) 1px, transparent 1px, transparent 3px), rgba(0,0,0,0.35);
  border: 1px solid rgba(255,255,255,0.07);
  border-radius: 10px; padding: 8px 16px;
  box-shadow: inset 0 2px 6px rgba(0,0,0,0.6), inset 0 -1px 0 rgba(255,255,255,0.04);
  /* Always present, not just under .preview-focus -- the SAME rule has
     to fire on the way in (settling into isolation) and on the way out
     (settling back into place), and a transition only scoped to one
     state only ever animates one direction. */
  transition: top 0.6s cubic-bezier(.4,0,.2,1), left 0.6s cubic-bezier(.4,0,.2,1),
    transform 0.6s cubic-bezier(.4,0,.2,1);
}
/* Goal preview isolation (js/game.js's playGoalPreview) -- direct
   feedback that the lights-out/on beat competed for attention with the
   header, pills, grid and buttons instead of being the whole screen.
   The real .panel-status element (not a clone) goes position:fixed and
   scales up over a dedicated backdrop while everything else dims to
   nothing; removing this class is what makes it visibly travel back to
   its normal in-flow spot afterward, via the transition above, so the
   player can see it's the same element rather than two different things
   cutting between each other.
   z-index:1, deliberately BELOW #game .game-header/.game-ui's own
   z-index:2 (see that rule elsewhere in this file) rather than some
   high arbitrary number -- .panel-status lives inside .game-ui, which
   creates its OWN stacking context at z-index:2, so .panel-status's
   z-index only ever matters relative to its OWN siblings, never against
   an element outside .game-ui: a higher z-index on the backdrop still
   painted over the (correctly positioned, confirmed via computed style)
   panel, reproduced directly with a pixel sample before finding this.
   At z-index:1 the backdrop instead sits below the header/game-ui
   layer as a whole, which is what actually lets .panel-status show
   through -- everything else in that layer is opacity:0, not literally
   absent, so the backdrop shows through THOSE the ordinary way
   (transparency compositing, not z-index) once it can't be blocked by
   game-ui's own higher context to begin with. */
.preview-focus-backdrop {
  display: none; position: fixed; inset: 0; z-index: 1;
  background: #0a0806;
  opacity: 0; transition: opacity 0.5s ease;
}
.preview-focus-backdrop.show { display: block; opacity: 1; }
body.preview-focus .panel-status {
  position: fixed; top: 50%; left: 50%;
  transform: translate(-50%, -50%) scale(1.9);
}
/* Transition declared unscoped (same reason as .panel-status's own
   above) so fading back IN, when .preview-focus is removed, animates
   too -- a transition declared only inside the scoped rule below would
   stop applying at the exact moment the opacity value it was animating
   also reverts, which cuts instead of fading. .account-fab is outside
   #game entirely (persistent on every screen) -- caught by screenshot,
   not assumed: it was the one thing still visible, bottom-right, with
   everything else correctly gone. */
.map-fab, .game-header, #game-pills, .grid, .action-row, .rules-footer, .account-fab {
  transition: opacity 0.3s ease;
}
body.preview-focus .map-fab,
body.preview-focus .game-header,
body.preview-focus #game-pills,
body.preview-focus .grid,
body.preview-focus .action-row,
body.preview-focus .rules-footer,
body.preview-focus .account-fab {
  opacity: 0; pointer-events: none;
}
.colour-lights { display: flex; gap: 16px; min-height: 22px; }
.colour-light {
  width: 14px; height: 14px; border-radius: 3px;
  box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09), 0 0 5px 1px rgba(255,255,255,0.5);
  transition: opacity 0.4s, filter 0.4s, box-shadow 0.4s;
}
.colour-light.out { opacity: 0.25; filter: grayscale(1) brightness(0.55); box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09); }
/* The last light goes out with a dying-bulb flicker rather than a plain
   fade — win() (js/game.js) delays the win overlay long enough for it to
   finish playing before covering the board. steps(1,end) + transition:none
   keeps each flicker frame a hard cut instead of blending into the next.
   The housing ring (see .colour-light above) stays present through every
   frame, including the "off" beats -- only the glow itself should flicker,
   the socket it's mounted in doesn't go anywhere. */
.colour-light.flicker-out {
  transition: none;
  animation: colour-light-flicker 0.9s steps(1, end) forwards;
}
@keyframes colour-light-flicker {
  0%   { opacity: 1;    filter: grayscale(0)   brightness(1);   box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09), 0 0 7px 2px rgba(255,255,255,0.35); }
  10%  { opacity: 0.2;  filter: grayscale(0.5) brightness(0.6); box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09); }
  20%  { opacity: 1;    filter: grayscale(0)   brightness(1.2); box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09), 0 0 10px 3px rgba(255,255,255,0.5); }
  32%  { opacity: 0.15; filter: grayscale(0.7) brightness(0.5); box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09); }
  46%  { opacity: 0.9;  filter: grayscale(0.2) brightness(1);   box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09), 0 0 6px 2px rgba(255,255,255,0.3); }
  58%  { opacity: 0.15; filter: grayscale(0.8) brightness(0.4); box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09); }
  74%  { opacity: 0.6;  filter: grayscale(0.5) brightness(0.7); box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09), 0 0 3px 1px rgba(255,255,255,0.15); }
  100% { opacity: 0.25; filter: grayscale(1)   brightness(0.55); box-shadow: 0 0 0 3px #16140f, 0 0 0 4px rgba(255,255,255,0.09); }
}
.panel-on {
  font-family: 'Courier New', monospace; font-weight: bold; font-size: 15px;
  letter-spacing: 1.6px; color: #ff4040; padding: 3px 9px;
  border: 1px solid rgba(255,64,64,0.55); border-radius: 4px;
  text-shadow: 0 0 8px rgba(255,64,64,0.7);
  box-shadow: 0 0 8px rgba(255,64,64,0.25);
}

/* Rule pills */
.rule-pills { display: flex; gap: 6px; width: 100%; margin-bottom: 10px; }
.rule-pill {
  flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px; background: rgba(255,255,255,0.07);
  border: 1px solid rgba(255,255,255,0.11); border-radius: 12px;
  padding: 8px 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.35);
  transition: border-color 0.2s, background 0.2s;
}
.rule-pill .pill-check { line-height: 1; color: #6abf7a; }
.rule-pill .pill-check svg { width: 20px; height: 20px; display: block; }
.rule-pill .pill-text {
  font-size: 12px; font-weight: bold; letter-spacing: 0.4px; text-transform: uppercase;
  color: #c8b87e; text-align: center; line-height: 1.25;
}
.rule-pills.compact .rule-pill { padding: 5px 3px; border-radius: 9px; }
.rule-pills.compact .pill-check svg { width: 16px; height: 16px; }
/* O'Hare only (js/game.js toggles .roomy per level). The pills are the
   reference a new player genuinely reads, and .compact is sized to coexist
   with a busy board rather than to be legible first. Deliberately short of
   the .trainer-spotlight size, which only works because the board is not
   in play yet at that point in the level 1 intro.
   Beats .compact by matching its specificity and coming later. */
.rule-pills.compact.roomy .rule-pill { padding: 9px 5px; border-radius: 11px; }
.rule-pills.compact.roomy .pill-check svg { width: 23px; height: 23px; }
.rule-pills.compact.roomy .pill-text { font-size: 13.5px; letter-spacing: 0.3px; }
/* Short screens (iPhone SE and friends) take a smaller step up. At 375x667
   the 7x7 O'Hare board already made the page ~4px taller than the viewport
   before this existed; the full roomy size pushed that to ~23px. The board
   itself stays well clear either way -- the overflow is page padding below
   it, not the grid -- but there is no reason to add scroll for the sake of
   2px of pill. */
@media (max-height: 700px) {
  .rule-pills.compact.roomy .rule-pill { padding: 7px 4px; }
  .rule-pills.compact.roomy .pill-check svg { width: 20px; height: 20px; }
  .rule-pills.compact.roomy .pill-text { font-size: 11px; }
}

/* 4.7" and smaller (iPhone SE/8 at 375x667, and the 320x568 devices iOS 15
   still reaches). At that height the 7x7 board pushes the page to ~706px
   against a 667px viewport, and because .map-fab/.account-fab are fixed,
   the action row and the footer hint end up UNDERNEATH them -- a real
   collision, not just a scroll.

   The header goes rather than the panel. It frees 75px where the panel
   frees only 41px, which lands at 665px against 667 -- too close to call a
   fix. The panel is also load-bearing: the colour lights track progress and
   the ON/OFF readout is what the level-complete power-down plays against.
   The title is the only thing here the player can afford to lose mid-level,
   and it is still on Home and in the win dialog.

   Deliberately a straight hide rather than a scroll-away header: the page
   only exceeds the viewport by ~39px, so there is not enough scroll for a
   hide-on-scroll to feel like anything but a glitch. */
@media (max-height: 700px) {
  #game .game-header { display: none; }
}
/* 320x568 (iPhone 6s / SE 1st gen — still within the iOS 15 deployment
   target). Hiding the header alone leaves it 10px over, with the footer
   hint still under the fixed buttons. The hint is the next least-costly
   thing to lose: it explains the tap/hold gesture, which is taught outright
   in the lesson levels long before any board this size is a problem. */
@media (max-height: 600px) {
  #game .rules-footer { display: none; }
}
.rule-pill.dim { opacity: 0.35; }
.rule-pill.dim .pill-check { color: #888; }
.rule-pill.lit { opacity: 1; }
.rule-pill.flash { animation: pill-flash 0.7s ease; }
@keyframes pill-flash {
  0%,100% { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.11); }
  25%,65% { background: rgba(224,48,48,0.30); border-color: rgba(224,48,48,0.8); opacity: 1; }
}

/* Grid */
.grid { display: grid; width: 100%; gap: 4px; position: relative; }
.cell {
  aspect-ratio: 1; position: relative; border-radius: 4px;
  cursor: pointer; touch-action: none;
  transition: filter 0.3s, opacity 0.3s, box-shadow 0.2s, transform 0.2s;
  /* Raised by default — a button that hasn't been pressed in for real yet
     (tapped/marked doesn't count, only an actual successful hold does)
     looks like it's still sitting proud of the panel. */
  box-shadow: 0 4px 0 rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.28);
}
/* Physically pressed in and staying that way — this is the one that's
   actually part of the confirmed combination, distinct from a cell
   that's merely marked with an X (ruled out, never pressed). */
.cell.correct {
  box-shadow: inset 0 4px 10px rgba(0,0,0,0.7), inset 0 -1px 0 rgba(255,255,255,0.06);
  transform: translateY(4px);
}
/* Whenever a tutorial line is pointing anywhere other than a specific
   cell (the rule pills, the hint button, or nothing at all), the board's
   own saturated colours otherwise out-compete whatever's actually meant
   to have the player's attention. See trainerAdvance() in js/game.js.
   Already-confirmed cells stay at full brightness — levels 2-5 open with
   exactly this kind of line ("Two of these are already confirmed...")
   while pointing at nothing in particular, and dimming the very cells
   being referenced would undercut it. */
.grid.dimmed .cell:not(.correct) { filter: grayscale(0.9) brightness(0.45); opacity: 0.7; }
/* A cell-targeted guided step (demo-good/demo-bad) -- lighter than
   .dimmed above on purpose: those cells still need to read as their real
   colour (the whole point of a lesson is learning to tell them apart),
   just clearly duller than the one actually being demoed, which stays
   at full brightness via the :not() exclusion. */
.grid.dimmed-soft .cell:not(.correct):not(.demo-good):not(.demo-bad):not(.reason-glow) {
  filter: grayscale(0.35) brightness(0.55); opacity: 0.75;
}
.cell.inert { cursor: default; }
.cell.pulse { animation: cell-pulse 1.4s ease-in-out infinite; }
@keyframes cell-pulse {
  0%,100% { box-shadow: 0 0 0 rgba(255,255,255,0); filter: brightness(1); }
  50%      { box-shadow: 0 0 16px rgba(255,255,255,0.55); filter: brightness(1.35); }
}

/* Lesson-level "where and why" demo highlight (see trainerSay's cell/mark
   line shape in game.js) — points at a real cell on the actual board while
   the dialogue explains it, instead of describing the rule in the abstract. */
.cell.demo-good { animation: cell-demo-good 1.1s ease-in-out infinite; z-index: 2; }
.cell.demo-bad  { animation: cell-demo-bad  1.1s ease-in-out infinite; z-index: 2; }
@keyframes cell-demo-good {
  0%,100% { box-shadow: 0 0 0 rgba(106,191,122,0); filter: brightness(1); }
  50%      { box-shadow: 0 0 20px 4px rgba(106,191,122,0.85); filter: brightness(1.3); }
}
@keyframes cell-demo-bad {
  0%,100% { box-shadow: 0 0 0 rgba(224,48,48,0); filter: brightness(1); }
  50%      { box-shadow: 0 0 20px 4px rgba(224,48,48,0.85); filter: brightness(1.3); }
}

/* Generic version of the same idea for a dialogue line that points at a
   UI element rather than a grid cell (see trainerSay's `el` line shape in
   game.js) — a plain glow that works on anything, round button or
   rectangular row alike, unlike .hint-btn.nudge which only fits buttons. */
.trainer-spotlight { animation: trainer-spotlight-pulse 1.2s ease-in-out infinite; }
@keyframes trainer-spotlight-pulse {
  0%,100% { box-shadow: 0 0 0 rgba(255,255,255,0); }
  50%      { box-shadow: 0 0 16px 5px rgba(255,255,255,0.5); }
}
/* startSweepExplain's per-stage highlight (js/game.js) -- same white glow
   as .trainer-spotlight above, but per-cell rather than whole-element, so
   it can pick out exactly the row, the column, or the two touching cells
   a stage is actually talking about instead of lighting up the whole
   board for all three. Excluded from .dimmed-soft's dimming (see that
   rule's own :not() list) the same way .demo-good/.demo-bad already are. */
.cell.reason-glow { animation: cell-reason-glow 1.2s ease-in-out infinite; z-index: 2; }
@keyframes cell-reason-glow {
  0%,100% { box-shadow: 0 0 0 rgba(255,255,255,0); filter: brightness(1); }
  50%      { box-shadow: 0 0 16px 4px rgba(255,255,255,0.55); filter: brightness(1.25); }
}
/* The rule pills specifically get bigger while spotlighted — the grid
   below isn't in play yet at this point in the level 1 intro, so there's
   room, and the normal .compact sizing (built for coexisting with an
   active board) reads too small for the one moment this row is the whole
   point. Sized up again 9 Sep 2026 (direct feedback: "make the 4 rules
   biggg") -- the previous step up still read as a minor emphasis, not a
   real "look at this" beat. */
/* 2x again, 9 Sep 2026 ("the actual rules can be 2x size") -- a straight
   doubling of the numbers above doesn't fit 4-across (an 84px icon plus
   a 32px label has nowhere to go in a quarter-width column), so the row
   becomes a 2x2 grid here instead: half as many columns gives each pill
   roughly twice the footprint to grow into along with the doubled icon/
   text/padding, rather than cramming a double-sized pill into the same
   quarter-width slot. */
.rule-pills.trainer-spotlight {
  display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 4px;
}
.rule-pills.trainer-spotlight .rule-pill { padding: 24px 10px; border-radius: 18px; }
.rule-pills.trainer-spotlight .pill-check svg { width: 56px; height: 56px; }
.rule-pills.trainer-spotlight .pill-text { font-size: 20px; letter-spacing: 0.4px; }

/* Capone palette — Okabe-Ito colorblind-safe 8-hue set (verified hex
   values; grey substitutes for the palette's usual black, which would
   vanish against this board's dark background). Replaces an earlier
   palette where two greens (bright vs dark) and red/pink sat too close
   together — real player confusion, not just a theoretical color-vision
   concern. Region assignment (map data in js/levels*.js) is unaffected;
   these are pure display colors keyed by the same r1-r8 numbers. */
.theme-capone .r1 { background: #D55E00; } /* vermilion */
.theme-capone .r2 { background: #F0E442; } /* yellow */
.theme-capone .r3 { background: #0072B2; } /* blue */
.theme-capone .r4 { background: #CC79A7; } /* reddish purple */
.theme-capone .r5 { background: #56B4E9; } /* sky blue */
.theme-capone .r6 { background: #999999; } /* grey (black substitute) */
.theme-capone .r7 { background: #009E73; } /* bluish green */
.theme-capone .r8 { background: #E69F00; } /* orange */

/* LED charge/outcome indicator -- moved to css/led.css (kept separate
   on purpose, see that file's header comment). */

/* Markers */
.cell svg.marker {
  position:absolute; inset:8%; width:84%; height:84%; pointer-events:none;
  opacity:0; transform:scale(0.5);
  transition: opacity 0.15s, transform 0.18s cubic-bezier(0.34,1.56,0.64,1);
}
.cell.marked  .svg-mark  { opacity:1; transform:scale(1); }
.cell.correct .svg-check { opacity:1; transform:scale(1); }
.cell.wrong   .svg-wrong { opacity:1; transform:scale(1); }
.cell.hinted  .svg-hint  { opacity:1; transform:scale(1); }

@keyframes shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-4px)} 40%{transform:translateX(4px)} 60%{transform:translateX(-3px)} 80%{transform:translateX(3px)} }
.cell.shaking { animation: shake 0.35s ease; }

/* Loot-found celebration — gold ring + spark burst */
.burst { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
.burst::before {
  content: ''; position: absolute; inset: -2px; border-radius: 6px;
  border: 2.5px solid rgba(245,208,96,0.9);
  animation: burst-ring 0.55s ease-out forwards;
}
.burst span {
  position: absolute; left: 50%; top: 50%;
  width: 7px; height: 7px; border-radius: 50%;
  background: #f5d060;
  box-shadow: 0 0 6px rgba(245,208,96,0.8);
  animation: burst-fly 0.65s ease-out forwards;
}
@keyframes burst-ring {
  0%   { transform: scale(0.85); opacity: 0.95; }
  100% { transform: scale(1.8);  opacity: 0; }
}
@keyframes burst-fly {
  0%   { transform: translate(-50%, -50%) scale(1); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(0.3); opacity: 0; }
}
.cell.found-flash { animation: found-flash 0.5s ease-out; }
@keyframes found-flash {
  0%   { filter: brightness(2.1) saturate(1.4); }
  100% { filter: brightness(1) saturate(1); }
}

/* ── Level-complete power-down ──────────────────────────────────
   The board doesn't die dramatically, it CLEARS THE STAGE. Everything
   except the final colour light and the ON readout surges once and sinks
   to near-black, as a wave travelling strictly bottom-to-top so the eye is
   carried up to the panel (per-row delays set in win(), js/game.js — they
   can't be expressed statically). With the stage empty, that surviving light
   then does its ordinary .flicker-out and the readout flips to OFF, so
   the beat that actually resolves the level has nothing competing with
   it. Earlier revisions flickered the grid alone, then the whole
   .game-ui at once; neither read as connected to the panel, because
   simultaneity doesn't imply causation — sequence does. */
.game-ui.powering-down .cell,
.game-ui.powering-down .rule-pills,
.game-ui.powering-down .rules-footer {
  animation: panel-recede 0.5s ease-out both;
}
/* Lights that are ALREADY out recede from where they are, not from full.
   They used to share panel-recede with the cells above, and that keyframe
   starts at opacity 1 / grayscale(0) / brightness(1) and peaks brighter
   still at 22% -- correct for a cell, which is at full brightness when the
   power-down begins, and wrong for a light that died several presses ago.
   The effect was that every dead colour snapped back to full, flared, and
   faded again on the final press: "all the colours light up again once".
   Reported from play, 3 Sep 2026. */
.game-ui.powering-down .colour-light.out {
  animation: panel-recede-light 0.5s ease-out both;
}
/* Starts exactly where .colour-light.out already sits, and ends at the same
   proportion of it that panel-recede leaves the cells at. */
@keyframes panel-recede-light {
  0%   { filter: grayscale(1) brightness(0.55); opacity: 0.25; }
  100% { filter: grayscale(1) brightness(0.13); opacity: 0.11; }
}
/* Housing only half-dims, so the surviving light still reads as mounted
   in something rather than floating in the dark. */
.game-ui.powering-down .panel-status { animation: panel-housing-recede 0.5s ease-out both; }
@keyframes panel-recede {
  0%   { filter: grayscale(0)   brightness(1);    opacity: 1;    }
  22%  { filter: grayscale(0)   brightness(1.55); opacity: 1;    }
  100% { filter: grayscale(1)   brightness(0.13); opacity: 0.45; }
}
@keyframes panel-housing-recede {
  0%   { filter: brightness(1);    }
  100% { filter: brightness(0.55); }
}
/* The survivor gains a little presence as the room empties around it.
   The :not(.flicker-out) matters: once phase 2 adds that class this rule
   stops matching entirely, which is what lets .colour-light.flicker-out
   take over. Fighting it on specificity instead silently suppresses the
   flicker — the two tie at two classes each, so load order decides. */
.game-ui.powering-down .colour-light:not(.out):not(.flicker-out) {
  animation: panel-survivor 0.5s ease-out both;
}
@keyframes panel-survivor {
  0%   { filter: brightness(1);   }
  100% { filter: brightness(1.35); }
}

/* Win overlays get an entrance -- .overlay.show is otherwise a bare
   display:none -> display:flex hard cut, which is what made level
   completion feel like it had no payoff. Scoped to the two celebration
   overlays on purpose: login/settings/confirm dialogs are utility UI and
   shouldn't grow a flourish every time they open. */
#win.show, #district-complete.show { animation: overlay-fade-in 0.3s ease-out; }
@keyframes overlay-fade-in { from { opacity: 0; } to { opacity: 1; } }
/* The photo/clipping lands like it's been tossed onto a desk -- a small
   rotation that settles straight, matching the newspaper-cutting look
   rather than a generic pop. Slight delay so the backdrop darkens first. */
#win.show .narr-scene, #district-complete.show .clipping-scene {
  animation: clipping-land 0.44s cubic-bezier(0.2, 0.9, 0.3, 1.15) 0.08s backwards;
}
@keyframes clipping-land {
  from { transform: translateY(-16px) rotate(-3.2deg); opacity: 0; }
  to   { transform: translateY(0) rotate(0deg); opacity: 1; }
}
/* Graduation only -- overrides clipping-land above (two classes + id
   beats one class + id, wins regardless of source order). The newspaper-
   toss motion is right for an actual clipping; a graduation cap gets its
   own pop-in instead, plus streamers falling past it as it settles. See
   #win-graduation-scene in index.html and GRADUATION_CAP_SVG in
   js/engine.js. */
#win.show .narr-scene.win-graduation-scene {
  animation: grad-scene-pop-in 0.5s cubic-bezier(0.2, 0.9, 0.3, 1.15) both;
}
@keyframes grad-scene-pop-in {
  from { transform: scale(0.85); opacity: 0; }
  to   { transform: scale(1); opacity: 1; }
}
.win-graduation-scene { position: relative; aspect-ratio: 1; }
.win-graduation-scene svg { width: 100%; height: 100%; display: block; }
/* Streamers fall from just above the frame and past the cap -- reads as
   the room celebrating around it, not the object itself sparkling.
   Populated fresh each time (showGraduationScene in js/game.js): random
   left/colour/rotation per streamer, so a replay doesn't repeat the exact
   same fall. */
.streamer-layer { position: absolute; top: -46px; left: 0; right: 0; height: 1px; overflow: visible; pointer-events: none; }
.streamer { position: absolute; top: 0; width: 5px; height: 16px; border-radius: 2px; opacity: 0; }
#win.show .win-graduation-scene .streamer,
#district-complete.show .scene-wrap .streamer {
  animation: streamer-fall 1.3s cubic-bezier(0.3, 0.4, 0.4, 1) both;
}
@keyframes streamer-fall {
  0%   { transform: translateY(0) rotate(0deg); opacity: 1; }
  12%  { opacity: 1; }
  100% { transform: translateY(260px) rotate(var(--rot)); opacity: 0; }
}
/* District-complete's own wrapper (O'Hare/Riverwalk/Loop endings only --
   see #district-complete-scene-wrap's own comment in index.html for why
   not Citywide). Just a positioning anchor for .streamer-layer above --
   the real clipping image and its own clipping-land animation
   (#district-complete.show .clipping-scene, below) are untouched. */
.scene-wrap { position: relative; }
/* Respect a reduced-motion preference: keep the state changes, drop the
   movement and the stuttering flicker (which is the most likely thing
   here to bother a motion-sensitive player). */
@media (prefers-reduced-motion: reduce) {
  .game-ui.powering-down .cell,
  .game-ui.powering-down .rule-pills,
  .game-ui.powering-down .rules-footer,
  .game-ui.powering-down .colour-light.out,
  .game-ui.powering-down .panel-status,
  .game-ui.powering-down .colour-light:not(.out):not(.flicker-out) {
    animation: none;
  }
  .game-ui.powering-down .cell,
  .game-ui.powering-down .rule-pills,
  .game-ui.powering-down .rules-footer,
  .game-ui.powering-down .colour-light.out { filter: grayscale(1) brightness(0.13); opacity: 0.45; }
  #win.show .narr-scene, #district-complete.show .clipping-scene { animation: none; }
  #win.show, #district-complete.show { animation: overlay-fade-in 0.2s ease-out; }
  /* Loss dialog: drop the shake and the looping lights/vignette, but keep
     a static red tint so the urgency cue isn't lost entirely -- same
     "keep the state change, drop the movement" approach as everything
     else in this block. */
  #narrative.show { animation: overlay-fade-in 0.2s ease-out; }
  #narrative.show .alert-lights, #narrative.show .vignette { animation: none; opacity: 0.4; }
  .streamer { animation: none; opacity: 0; }
}

.hint-btn {
  position: relative;
  margin-top: 14px;
  background: rgba(38, 26, 6, 0.60);
  border: 1.5px solid rgba(200,168,78,0.55);
  border-radius: 50%; width: 54px; height: 54px;
  font-size: 26px; line-height: 1; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 0 12px rgba(200,168,78,0.15);
  transition: background 0.15s;
}
.hint-btn:active { background: rgba(60, 42, 8, 0.80); }
.hint-btn[hidden] { display: none; }
.hint-btn[disabled] { opacity: 0.4; cursor: default; }
/* Out of charges is a DIFFERENT state from guided-lesson-locked (the
   [disabled] rule above, still fully inert/dimmed as a whole) — the
   button stays tappable so it has something to open (the ad-refill
   flow), so only the icon dims here, not the whole button; the add-badge
   (see below) needs to stay bright to read as the inviting part, not
   equally "off" as the icon next to it. */
.hint-btn.depleted .btn-icon { opacity: 0.35; }
/* Remaining hint/auto-mark charges (see Progress.HINT_LIMIT/AUTOMARK_LIMIT
   in app.js, updateResourceButtons() in game.js) — an app-icon-style
   notification badge on the button's corner. Always a plain number,
   0 through the limit — greyed out at 0 (depleted) so it reads as
   "spent" rather than looking the same as any other count. */
.charge-badge {
  position: absolute; top: -4px; right: -4px;
  min-width: 20px; height: 20px; padding: 0 5px;
  border-radius: 10px; background: #c8a84e; color: #1a1608;
  font-family: 'Courier New', monospace; font-weight: bold; font-size: 13px;
  display: flex; align-items: center; justify-content: center;
  border: 1.5px solid #1a1608; white-space: nowrap;
}
.hint-btn[disabled] .charge-badge,
.hint-btn.depleted .charge-badge { background: #6a6a6a; }
/* Second, independent corner badge — "add a charge by watching an ad".
   Deliberately separate from .charge-badge (opposite corner) rather than
   sharing its one spot: the count needs to always be visible/legible on
   its own, and the two ideas ("how many I have" vs "get more") read as
   two different affordances the player can act on independently, not one
   thing that alternates between three different meanings.
   Plus is drawn with two CSS bars rather than a "+" text glyph -- a bare
   glyph at this badge size rendered almost invisibly thin. */
.add-badge {
  position: absolute; bottom: -4px; right: -4px;
  width: 20px; height: 20px;
  border-radius: 10px; background: #e8c66a;
  border: 1.5px solid #1a1608;
}
.add-badge::before, .add-badge::after {
  content: ''; position: absolute; background: #1a1608;
  top: 50%; left: 50%; transform: translate(-50%, -50%);
}
.add-badge::before { width: 9px; height: 2px; }
.add-badge::after { width: 2px; height: 9px; }
/* At the charge cap there's nothing to add -- the plus becomes a "FULL"
   pill instead (greyed, non-inviting), same corner. Base .add-badge is a
   fixed 20px circle (for the plus); this overrides to a min-width pill
   that grows to fit the word, same trick .charge-badge already uses. */
.add-badge.full {
  width: auto; min-width: 20px; padding: 0 6px;
  background: #6a6a6a; display: flex; align-items: center; justify-content: center;
  font-family: 'Courier New', monospace; font-weight: bold; font-size: 12px; letter-spacing: 0.4px; color: #1a1608;
}
.add-badge.full::before, .add-badge.full::after { content: none; }
/* Ad-refill failure feedback (js/game.js's shakeBadge) -- right at the
   badge the player just tapped, not just a footer-text change elsewhere
   on screen, which is easy to miss. Red flash + shake reads as "that
   didn't work" without needing a modal. */
.add-badge.shake { animation: add-badge-shake 0.4s; }
@keyframes add-badge-shake {
  0%, 100% { transform: translate(0, 0); background: #e8c66a; }
  20%, 60% { transform: translate(-3px, 0); background: #d05050; }
  40%, 80% { transform: translate(3px, 0); background: #d05050; }
}
.action-row { display: flex; gap: 18px; margin-top: 14px; align-items: center; }
.action-row .hint-btn { margin-top: 0; }
.hint-btn.nudge { animation: hint-nudge 1.2s ease-in-out infinite; }
@keyframes hint-nudge {
  0%,100% { box-shadow: 0 0 12px rgba(200,168,78,0.15); transform: scale(1); }
  50%      { box-shadow: 0 0 22px rgba(200,168,78,0.6);  transform: scale(1.08); }
}

.rules-footer { margin-top: 10px; color: #4a3a20; font-size: 13px; letter-spacing: 0.4px; text-align: center; }

/* Signed-out-only account indicator — visible on every screen (see
   index.html). Bottom-right, fixed, so it never competes with each
   screen's own header buttons (top-left/top-right are already taken by
   Back/Reset on the game screen). Signed-in players get this hidden
   entirely ([hidden], set in renderAccountButton()) rather than shrunk to
   an icon — a persistent floating control was drawing attention out of
   proportion to what it did (nothing, pre-Settings). The signed-in
   equivalent is a plain text link on the Home screen only — see
   .footer-link / #home-settings-link below. */
.account-fab {
  position: fixed; right: 14px; bottom: calc(14px + env(safe-area-inset-bottom, 0px));
  z-index: 3; height: 40px; border-radius: 20px; padding: 0 16px;
  background: rgba(26,22,10,0.85); border: 1.5px solid rgba(200,168,78,0.4);
  font-family: 'Georgia', serif; font-size: 14px; letter-spacing: 0.8px;
  text-transform: uppercase; color: #c8a84e; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 2px 10px rgba(0,0,0,0.35);
}
.account-fab:active { background: rgba(60,42,8,0.9); }
/* Without this, [hidden] does nothing here -- .account-fab's own
   `display: flex` above is an author-stylesheet rule, which always beats
   the browser's default `[hidden] { display: none }` regardless of
   selector specificity (author origin beats user-agent origin). Same fix
   already applied to .hint-btn and .trainer-card below for the identical
   reason -- missed adding it here when renderAccountButton() switched
   from a class toggle to setting .hidden directly. */
.account-fab[hidden] { display: none; }

/* ── Overlays ────────────────────────────────────────────────── */
.overlay {
  display: none; position: fixed; inset: 0; z-index: 10;
  background: rgba(2,1,6,0.90);
  flex-direction: column; align-items: center; justify-content: center;
  padding: 32px; cursor: pointer;
}
.overlay.show { display: flex; }
.narr-scene {
  width: min(72vw, 260px); height: auto; border-radius: 14px; margin-bottom: 22px;
  filter: drop-shadow(0 0 18px rgba(240,128,20,0.35));
}
/* #narr-scene is now an inline <svg> (rotates through LOSS_SCENE_ICONS,
   js/engine.js), not an <img> -- height:auto above only works via an
   image's own intrinsic ratio, so this needs an explicit one. ID-scoped
   on purpose: .narr-scene is still shared with #win-scene's real <img>,
   which must not pick this up. */
#narr-scene { aspect-ratio: 1; display: block; }

/* ── Loss-dialog urgency: alert lights + screen shake + red vignette ──
   Direct feedback: the loss screen read as "just get out", not serious.
   All three keyed off #narrative.show, same auto-trigger pattern as
   clipping-land on #win/#district-complete -- no JS needed, the class
   transitioning to .show is enough to (re)start every animation here,
   including the one-shot shake, on every fresh fail. */
#narrative .alert-lights, #narrative .vignette { position: absolute; inset: 0; z-index: 1; pointer-events: none; opacity: 0; }
#narr-scene, #narr-text, #narr-restart { position: relative; z-index: 2; }
/* Red/blue alternating at opposite corners, like emergency lights
   reflecting off nearby walls -- not a literal light bar. Loops the whole
   time the dialog is up, same as a real light bar would keep turning. */
#narrative.show .alert-lights { animation: alert-pulse 0.55s steps(1) infinite; }
@keyframes alert-pulse {
  0%, 100% {
    opacity: 1;
    background:
      radial-gradient(ellipse 60% 40% at 0% 0%, rgba(220,30,30,0.55), transparent 70%),
      radial-gradient(ellipse 60% 40% at 100% 100%, rgba(30,70,220,0.5), transparent 70%);
  }
  50% {
    opacity: 1;
    background:
      radial-gradient(ellipse 60% 40% at 0% 0%, rgba(30,70,220,0.5), transparent 70%),
      radial-gradient(ellipse 60% 40% at 100% 100%, rgba(220,30,30,0.55), transparent 70%);
  }
}
/* A slow red pulse breathing at the screen edge, the whole time the
   dialog is up -- a constant low-grade "this is still happening" cue,
   not just a one-off flash. */
#narrative.show .vignette { animation: vignette-pulse 1.1s ease-in-out infinite; }
@keyframes vignette-pulse {
  0%, 100% { opacity: 0.5; box-shadow: inset 0 0 40px 6px rgba(200,20,20,0.35); }
  50%      { opacity: 0.9; box-shadow: inset 0 0 70px 14px rgba(200,20,20,0.6); }
}
/* One-shot shake on arrival -- the whole fixed overlay (a real screen
   shake, not just the icon), like something just went off nearby.
   Doesn't repeat; the ongoing lights/vignette above carry the sustained
   alert instead. */
#narrative.show { animation: overlay-fade-in 0.3s ease-out, dialog-shake 0.5s cubic-bezier(.36,.07,.19,.97) both; }
@keyframes dialog-shake {
  10%, 90% { transform: translateX(-1px); }
  20%, 80% { transform: translateX(2px); }
  30%, 50%, 70% { transform: translateX(-5px); }
  40%, 60% { transform: translateX(5px); }
}
.narr-text { color: #c0a060; font-size: 17px; line-height: 1.75; text-align: center; font-style: italic; max-width: 300px; margin-bottom: 24px; }
.narr-restart {
  /* Always shown now — one wrong pick is always the final outcome (see
     showNarrative() in game.js), so there's no more non-final "tap to
     continue and keep playing" state to hide this for. */
  margin-top: 20px; background: none;
  border: 1px solid rgba(200,168,78,0.35); color: #8a6030;
  font-size: 14px; letter-spacing: 2.2px; padding: 11px 24px;
  border-radius: 6px; cursor: pointer; font-family: 'Georgia', serif; text-transform: uppercase;
}
#win { gap: 14px; cursor: default; }
#win h2 { color: #c8a84e; font-size: 24px; letter-spacing: 3.5px; text-transform: uppercase; text-shadow: 0 0 28px rgba(200,168,78,0.5); }
#win p  { color: #8a7040; font-size: 15px; letter-spacing: 1.2px; text-align: center; line-height: 1.7; }
/* Small location kicker above the title -- carries the level name so the
   10 random win lines (js/game.js WIN_LINES) don't all have to spell it
   out in the same "$LEVEL — ..." shape every time. */
#win-level-name { color: #5a4a2c; font-size: 13px; letter-spacing: 2.5px; text-transform: uppercase; margin-bottom: -6px; }
.win-btns { display: flex; gap: 12px; margin-top: 8px; }
#win button {
  background: none; border: 1px solid rgba(200,168,78,0.35);
  color: #7a6028; font-size: 14px; letter-spacing: 1.2px;
  padding: 10px 16px; border-radius: 6px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
}
#win button:active { background: rgba(200,168,78,0.08); }
/* Next is what almost everyone wants; Home is the way out. They were
   identical -- same border, same colour, same size -- so the row read as two
   equal choices and the eye had to read the words to find the obvious one.
   Next now steps up.
   Deliberately NOT a gold fill, which is this app's primary-action language
   (.play-btn, .dr-done, .press-done). On a clue level the Case Board button
   has to stay the loudest thing in the overlay, and it earns that with width
   and position rather than a fill -- a filled Next would simply out-shout
   it. Brighter text and a firmer border is the step below, and is enough to
   separate two buttons sitting side by side. */
#win #btn-win-next {
  color: #c8a84e;
  border-color: rgba(200,168,78,0.75);
}
#win #btn-win-map { color: #6a5426; }
/* ...but NOT on a clue win. Measured, after this was got wrong once: the
   promoted Next ends up 12px with a 0.75 border against the Case Board
   button's 10.5px and 0.4 -- larger AND more strongly drawn than the thing
   that is supposed to lead, which only wins on width. So on clue levels Next
   drops back to parity with Home and the board button is unambiguously the
   loudest element again. */
#win.clue-win #btn-win-next {
  color: #7a6028;
  border-color: rgba(200,168,78,0.35);
}

/* district-complete reuses .win-btns for layout but never had its own
   button rule (unlike #win above) -- Save Progress/Map were silently
   falling back to the browser's plain default button style this whole
   time. Excludes .google-btn, which intentionally uses Google's own
   branded colours instead of this panel's gold/dark look. */
#district-complete button:not(.google-btn):not(.district-next-btn):not(.district-home-btn) {
  background: none; border: 1px solid rgba(200,168,78,0.35);
  color: #7a6028; font-size: 14px; letter-spacing: 1.2px;
  padding: 10px 16px; border-radius: 6px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
}
#district-complete button:not(.google-btn):active { background: rgba(200,168,78,0.08); }

#district-complete { gap: 12px; cursor: default; }

/* Next is the primary CTA (advance into the next district), Home a
   deliberately smaller, quieter secondary option below it -- stacked,
   not side-by-side, so the size difference reads clearly as a hierarchy
   rather than two equal choices. */
.district-complete-btns { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 4px; }
.district-next-btn {
  background: #c8a84e; color: #1a1608; border: none; border-radius: 999px;
  padding: 13px 30px; font-family: 'Georgia', serif; font-size: 14px;
  letter-spacing: 2.2px; text-transform: uppercase; font-weight: bold;
  cursor: pointer; box-shadow: 0 0 20px rgba(200,168,78,0.4);
}
.district-next-btn:active { background: #b89642; }
.district-home-btn {
  background: none; border: none; color: #6a5838; font-size: 13px;
  letter-spacing: 1.2px; text-transform: uppercase; cursor: pointer;
  font-family: 'Georgia', serif; padding: 6px 4px;
}

/* First-failure forgiveness. cursor:default + no tap-to-dismiss handler —
   unlike #narrative this must be read, and the undo button is the only
   way out, so a stray tap can't skip the one moment that explains the
   game doesn't require guessing. */
#first-fail { gap: 15px; cursor: default; }
#first-fail h2 {
  color: #c8a84e; font-size: 23px; letter-spacing: 3.5px; text-transform: uppercase;
  text-shadow: 0 0 28px rgba(200,168,78,0.5);
}
#first-fail p {
  font-size: 15px; letter-spacing: 0.8px; text-align: center;
  line-height: 1.75; max-width: 320px;
}
.first-fail-reason { color: #a8874a; }
/* Explicit override: a bare [hidden] would work here today, but every
   other overlay element in this file that grew a display property later
   silently started ignoring it (see .coming-soon-banner/.login-btns). */
.first-fail-reason[hidden] { display: none; }
.first-fail-principle { color: #8a7040; }
.undo-btn {
  margin-top: 6px; background: #c8a84e; color: #1a1608; border: none;
  border-radius: 999px; padding: 15px 34px; font-family: 'Georgia', serif;
  font-size: 14px; letter-spacing: 2.2px; text-transform: uppercase; font-weight: bold;
  box-shadow: 0 0 24px rgba(200,168,78,0.4); cursor: pointer;
}
.undo-btn:active { background: #b89642; }

/* The offer shown instead of the loss screen on a wrong-press fail --
   same visual language as #first-fail (.undo-btn is shared verbatim) but
   with a genuine decline path, since this one isn't a one-time freebie. */
#undo-offer { gap: 15px; cursor: default; }
#undo-offer h2 {
  color: #c8a84e; font-size: 23px; letter-spacing: 3.5px; text-transform: uppercase;
  text-shadow: 0 0 28px rgba(200,168,78,0.5);
}
#undo-offer p {
  color: #8a7040; font-size: 15px; letter-spacing: 0.8px; text-align: center;
  line-height: 1.75; max-width: 300px;
}
.undo-offer-decline {
  margin-top: 2px; background: none; border: none;
  color: #6a5838; font-size: 13px; letter-spacing: 1.2px;
  text-transform: uppercase; cursor: pointer; font-family: 'Georgia', serif;
  padding: 6px 4px;
}

#settings, #confirm-signout, #confirm-delete { gap: 14px; cursor: default; }
#settings h2, #confirm-signout h2, #confirm-delete h2 { color: #c8a84e; font-size: 24px; letter-spacing: 3.5px; text-transform: uppercase; text-shadow: 0 0 28px rgba(200,168,78,0.5); }
#settings p, #confirm-signout p, #confirm-delete p { color: #8a7040; font-size: 15px; letter-spacing: 1.2px; text-align: center; line-height: 1.7; max-width: 320px; }
#settings button, #confirm-signout button, #confirm-delete button {
  background: none; border: 1px solid rgba(200,168,78,0.35);
  color: #7a6028; font-size: 14px; letter-spacing: 1.2px;
  padding: 10px 16px; border-radius: 6px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
}
#settings button:active, #confirm-signout button:active, #confirm-delete button:active { background: rgba(200,168,78,0.08); }
/* Destructive actions' actual CONFIRM button gets a distinct warning tint
   instead of the panel's usual gold — deliberately not on #settings: the
   entry point in the Settings menu shouldn't be the most eye-catching
   thing there (see .quiet-link below) — the warning belongs at the step
   where the user has already committed to intent and is being asked to
   confirm, not the step that might get tapped by accident. */
#confirm-signout button.danger-btn, #confirm-delete button.danger-btn {
  border-color: rgba(214,90,74,0.55); color: #d65a4a;
}
#confirm-signout button.danger-btn:active, #confirm-delete button.danger-btn:active {
  background: rgba(214,90,74,0.1);
}
/* Delete Account's entry in the Settings menu — plain text, no border, no
   colour cue, distinctly quieter than Sign Out above it. Deliberately the
   least prominent thing on this screen. */
#settings .quiet-link {
  background: none; border: none; color: #5a4a2a; font-size: 11px;
  letter-spacing: 1.5px; text-transform: uppercase; padding: 4px;
  font-family: 'Georgia', serif; cursor: pointer;
}
#settings .quiet-link:active { color: #7a6028; }

/* App Store conversion CTA (web build only — the native app never shows
   these overlays). Primary gold pill matching .play-btn/.district-next-btn
   so the download reads as the main action, with a deliberately quiet
   dismiss beneath it. .quiet-link is scoped to #settings above, so this
   repeats the same treatment rather than widening that selector and
   changing Settings by accident. */
.app-cta-wrap { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 6px; }
.app-store-btn {
  display: inline-block; background: #c8a84e; color: #1a1608; text-decoration: none;
  border-radius: 999px; padding: 13px 28px; font-family: 'Georgia', serif;
  font-size: 15px; letter-spacing: 1.6px; text-transform: uppercase; font-weight: bold;
  box-shadow: 0 0 24px rgba(200,168,78,0.4); cursor: pointer;
}
.app-store-btn:active { background: #b89642; }
#coming-soon .quiet-link, #all-done .quiet-link {
  background: none; border: none; color: #5a4a2a; font-size: 13px;
  letter-spacing: 1.2px; text-transform: uppercase; padding: 4px;
  font-family: 'Georgia', serif; cursor: pointer;
}
#coming-soon .quiet-link:active, #all-done .quiet-link:active { color: #7a6028; }
/* Teaser card's status line becomes tappable once the app is live — the
   card itself already gets .tappable, this just makes the text read as a
   link rather than inert status copy. */
.teaser-status.link { color: #c8a84e; text-decoration: underline; }
#settings .login-status, #confirm-delete .login-status { min-height: 16px; }

#coming-soon { gap: 14px; cursor: default; padding: 32px; }
#coming-soon h2 { color: #c8a84e; font-size: 24px; letter-spacing: 3.5px; text-transform: uppercase; text-shadow: 0 0 28px rgba(200,168,78,0.5); }
#coming-soon p  { color: #8a7040; font-size: 15px; letter-spacing: 1.2px; text-align: center; line-height: 1.7; max-width: 320px; }
#coming-soon p b { color: #c8a84e; }
#coming-soon button {
  background: none; border: 1px solid rgba(200,168,78,0.35);
  color: #7a6028; font-size: 14px; letter-spacing: 1.2px;
  padding: 10px 16px; border-radius: 6px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
}
#coming-soon button:active { background: rgba(200,168,78,0.08); }

#all-done { gap: 14px; cursor: default; padding: 32px; }
#all-done h2 { color: #c8a84e; font-size: 24px; letter-spacing: 3.5px; text-transform: uppercase; text-shadow: 0 0 28px rgba(200,168,78,0.5); }
#all-done p  { color: #8a7040; font-size: 15px; letter-spacing: 1.2px; text-align: center; line-height: 1.7; max-width: 320px; }
#all-done button {
  background: none; border: 1px solid rgba(200,168,78,0.35);
  color: #7a6028; font-size: 14px; letter-spacing: 1.2px;
  padding: 10px 16px; border-radius: 6px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
}
#all-done button:active { background: rgba(200,168,78,0.08); }
.clipping-scene {
  width: min(90vw, 380px); height: auto; border-radius: 4px; margin-bottom: 6px;
  box-shadow: 0 0 24px rgba(0,0,0,0.6);
}
#district-complete h2 { color: #c8a84e; font-size: 24px; letter-spacing: 3.5px; text-transform: uppercase; text-shadow: 0 0 28px rgba(200,168,78,0.5); }

/* ── Feature unlock (added 14 Sep 2026) ──────────────────────────────
   Generic "a side feature just opened up" reveal, shown instead of
   silently advancing to the next level on the one win that crosses a
   feature's unlock threshold (see js/game.js's justUnlockedDaily and
   js/app.js's showFeatureUnlock). Deliberately not named after Daily
   Challenge, its first user (level 12, js/daily.js's UNLOCK_INDEX) --
   the next side feature that unlocks mid-campaign reuses this same
   overlay with new copy rather than getting its own built from scratch.
   Buttons reuse district-complete's own real classes (stacked gold-pill
   primary over bare quiet-text secondary) rather than inventing a new
   button language for what is visually the same kind of moment. */
#feature-unlock { gap: 12px; cursor: default; }
/* Clipping sits inside the shared .overlay -- .press-wrap/.paper.moon/
   .press-done below are copied verbatim from the Press module's own CSS
   (see #ohare-clip's own comment in index.html for why this can't just
   call into js/press.js directly). cursor:default overrides .overlay's
   tap-anywhere-to-dismiss cursor since only the real Continue button
   dismisses this, same as every other button-driven overlay above. */
#ohare-clip { cursor: default; }
#feature-unlock h2 { color: #c8a84e; font-size: 22px; letter-spacing: 4px; text-transform: uppercase; text-shadow: 0 0 28px rgba(200,168,78,0.5); text-align: center; }

/* ── Rule reference ───────────────────────────────────────────── */
/* Opened from .rules-info-btn in the game header. Each card stands alone
   (see index.html's own comment on the copy) -- a player opens this for
   the ONE rule they're stuck on, not to read start to finish, so nothing
   here assumes the cards above it were read first. .press-done reused
   verbatim for Got It, same reasoning as #ohare-clip's Continue button. */
#rules-reference { cursor: default; overflow-y: auto; }
#rules-reference h2 { color: #c8a84e; font-size: 20px; letter-spacing: 3px; text-transform: uppercase; text-shadow: 0 0 24px rgba(200,168,78,0.4); text-align: center; margin-bottom: 6px; }
.rules-reference-lede { color: #8a7040; font-size: 13px; letter-spacing: 0.6px; text-align: center; line-height: 1.6; max-width: 340px; margin-bottom: 18px; }
.rules-reference-cards { width: 100%; max-width: 380px; display: flex; flex-direction: column; gap: 10px; }
.rule-card {
  width: 100%; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
  border-radius: 14px; padding: 12px; display: flex; align-items: center; gap: 14px;
}
.rule-card-diagram { width: 72px; height: 72px; flex-shrink: 0; border-radius: 8px; overflow: hidden; }
.rule-card-copy { flex: 1; min-width: 0; }
.rule-card-name {
  font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 12px; font-weight: bold;
  letter-spacing: 0.6px; text-transform: uppercase; color: #e8c66a; margin-bottom: 3px;
}
.rule-card-desc { font-family: 'Georgia', serif; font-size: 12.5px; color: #b8ae94; line-height: 1.45; }
#rules-reference .press-done { max-width: 380px; }
/* The badge is deliberately NOT this app's usual gold-panel-matched
   language -- circular, own glow, unlike anything else on screen. That
   novelty is the point: a feature unlock is rare enough (one per side
   feature, ever) that it should read as a different kind of moment, not
   blend into the panel it's sitting on top of. */
.unlock-badge-wrap { position: relative; margin-bottom: 20px; }
.unlock-glow {
  position: absolute; top: -18px; left: 50%; transform: translateX(-50%); pointer-events: none;
  width: 190px; height: 190px; border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(255,244,214,0.4), transparent 68%);
}
.unlock-badge {
  position: relative; width: 78px; height: 78px; border-radius: 50%;
  background: linear-gradient(135deg, #fff5cf 0%, #f0d685 18%, #c8a84e 42%, #8a6c2e 68%, #d8b866 100%);
  border: 2px solid rgba(255,255,255,0.5); display: flex; align-items: center; justify-content: center;
  box-shadow: 0 6px 18px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.6);
}

#login, #verify-gate { gap: 14px; cursor: default; }
#verify-gate h2 { color: #c8a84e; font-size: 22px; letter-spacing: 2.5px; text-transform: uppercase; text-align: center; }
#login h2 { color: #c8a84e; font-size: 22px; letter-spacing: 2.5px; text-transform: uppercase; text-align: center; }
.login-copy { color: #8a7040; font-size: 15px; text-align: center; max-width: 280px; line-height: 1.6; }
.login-email {
  width: min(80vw, 280px); padding: 11px 14px; border-radius: 6px;
  border: 1px solid rgba(200,168,78,0.35); background: rgba(255,255,255,0.04);
  color: #e8d8a8; font-size: 16px; text-align: center; font-family: 'Georgia', serif;
}
.login-email::placeholder { color: #5a4a2a; }
/* Google's official "Dark" theme button (developers.google.com/identity/
   branding-guidelines) — one of their pre-approved variants, chosen over
   the default "Light" (white) one specifically because it actually fits
   this game's dark panel look, while still being a sanctioned style
   rather than an arbitrary custom recolour. Both #login and
   #district-complete already have an ID-scoped `button` rule (gold/
   transparent) that would otherwise win on specificity alone, so this
   needs to be qualified the same way to actually override it rather than
   just adding new properties alongside the old ones. */
.google-btn, #login .google-btn, #district-complete .google-btn {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  width: min(80vw, 280px); padding: 12px 16px; border-radius: 6px;
  background: #131314; border: 1px solid #8e918f;
  color: #e3e3e3; font-size: 15px; font-weight: 500;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  text-transform: none; letter-spacing: 0; cursor: pointer;
}
.google-btn:active, #login .google-btn:active, #district-complete .google-btn:active { background: #1e1f20; }
/* Apple's own white-button style (one of their pre-approved HIG variants).
   Originally built with the black variant, which blended into these dark
   overlays -- confirmed against Apple's own guidelines this was backwards:
   the black style is explicitly "should not be used on dark backgrounds,"
   white is the one meant for dark backgrounds (developer.apple.com/design/
   human-interface-guidelines/technologies/sign-in-with-apple/buttons).
   Google's own guidelines are the opposite case -- their dark variant is
   fine here, see .google-btn's comment above -- so the two buttons
   legitimately don't match, that's each provider's own rule, not an
   inconsistency to fix. Hidden by default in the markup and only unhidden
   for iOS native builds (see the isNative()+platform check in app.js's
   wiring), since the plugin backing this isn't usable/relevant on web or
   Android. Placed above the Google button in both overlays, not below —
   Guideline 4.8 expects Sign in with Apple to be offered with at least
   equal prominence. */
.apple-btn, #login .apple-btn, #district-complete .apple-btn {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  width: min(80vw, 280px); padding: 12px 16px; border-radius: 6px;
  background: #FFFFFF; border: 1px solid #FFFFFF;
  color: #000000; font-size: 15px; font-weight: 500;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  text-transform: none; letter-spacing: 0; cursor: pointer;
}
.apple-btn:active, #login .apple-btn:active, #district-complete .apple-btn:active { background: #e5e5e5; }
.login-password-email { color: #7a6028; font-size: 13px; letter-spacing: 0.4px; text-align: center; margin: 0; }
/* Password rule callout, live as the user types (js/app.js) -- starts
   muted, switches to .ok (matches .login-status's own green) once the
   6-char minimum is met. Only shown in "set a password" (new account)
   mode -- an existing account already met whatever rule was live when
   it was created, no need to re-nag at sign-in. */
.password-hint { color: #5a4a2a; font-size: 13px; letter-spacing: 0.2px; text-align: center; margin: -4px 0 0; min-height: 16px; }
.password-hint.ok { color: #6abf7a; }
.login-divider { color: #5a4a2a; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin: 2px 0; }
/* district-pitch reason only (js/app.js's openLogin) -- see login-copy's
   comment above for the other reasons. */
.login-pitch-list {
  list-style: none; text-align: left; width: min(80vw, 280px); margin: -2px 0 2px;
  display: flex; flex-direction: column; gap: 8px;
}
.login-pitch-list li {
  display: flex; align-items: center; gap: 10px;
  color: #c0a060; font-size: 13px; line-height: 1.4; font-family: 'Georgia', serif;
}
.login-pitch-list li::before {
  content: '\2713'; flex-shrink: 0; width: 17px; height: 17px; border-radius: 50%;
  border: 1px solid rgba(200,168,78,0.55); background: rgba(200,168,78,0.08);
  color: #c8a84e; font-size: 10px; font-family: 'Helvetica Neue', Arial, sans-serif;
  display: flex; align-items: center; justify-content: center;
}
/* Same gotcha as .account-fab/.first-fail-reason above -- the list's own
   `display: flex` is an author rule and always beats [hidden]'s default. */
.login-pitch-list[hidden] { display: none; }
.login-status { color: #6abf7a; font-size: 14px; letter-spacing: 0.4px; text-align: center; min-height: 17px; max-width: 280px; }
.login-status.error { color: #e05050; }
.login-btns { display: flex; gap: 12px; margin-top: 4px; }
/* Same gotcha as .account-fab above -- .login-btns' own display:flex is
   an author-stylesheet rule, which always beats the browser's default
   [hidden] { display:none } regardless of specificity. Without this,
   #login-btns-email and #login-btns-password (js/app.js's
   emailPasswordFlow) both stay visible at once -- confirmed via computed
   style, not just the .hidden property, which had silently looked fine. */
.login-btns[hidden] { display: none; }
#login button, #verify-gate button {
  background: none; border: 1px solid rgba(200,168,78,0.35);
  color: #7a6028; font-size: 14px; letter-spacing: 1.2px;
  padding: 10px 16px; border-radius: 6px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
}
#login button:active, #verify-gate button:active { background: rgba(200,168,78,0.08); }

/* Dialogue shield — up while #game-trainer has queued lines; any tap advances */
.tr-shield {
  display: none; position: fixed; inset: 0; z-index: 4;
  background: rgba(8,9,7,0.4);
  cursor: pointer;
}
.tr-shield.show { display: block; }

/* Compact single-bubble lesson presentation (level 1 only, lvl.useBubbleIntro
   in js/levels.js) -- sits in normal flow right above the grid, so it's
   genuinely next to the cell/rule it's talking about, instead of a
   separate bottom character card the player's attention has to travel to.
   No portrait/name -- the point is a short, direct line right where the
   action is, not a voiced narrative beat. */
.trainer-bubble[hidden] { display: none; }
/* Every use of this bubble is a "do this exact gesture right now" moment
   -- warmer and glowing on purpose, not the same muted panel as
   everything else, so it reads as the thing to look at first rather than
   one more piece of dark-panel UI. */
.trainer-bubble {
  width: 100%; max-width: 400px; margin-bottom: 12px;
  display: flex; align-items: center; gap: 10px;
  background: rgba(48,38,14,0.97); border: 1.5px solid rgba(200,168,78,0.75);
  border-radius: 10px; padding: 12px 16px;
  cursor: pointer;
  animation: bubble-pop-glow 1.7s ease-in-out infinite;
}
@keyframes bubble-pop-glow {
  0%, 100% { box-shadow: 0 4px 16px rgba(0,0,0,0.4), 0 0 16px 2px rgba(200,168,78,0.3); }
  50%      { box-shadow: 0 4px 16px rgba(0,0,0,0.4), 0 0 28px 7px rgba(200,168,78,0.6); }
}
/* Same portrait, same reasoning as .cell-tether-avatar (js/game.js's
   showCellInstruction) -- this bubble is the OTHER place Marlowe talks
   in levels 1-4 (the freeFind banner, any non-cell intro line), so it
   gets the same face for the same reason: every hint should read as
   him, not an anonymous system toast. Set once at load (see game.js,
   right by the rule-pill icons) since only Marlowe ever uses this
   bubble today -- not per-show like the bottom card's portrait, which
   really does need to vary (Riverwalk's superintendent uses that one). */
.trainer-bubble-avatar {
  flex: none; width: 34px; height: 34px; border-radius: 50%; overflow: hidden;
  border: 1.5px solid rgba(200,168,78,0.85); background: #20241c;
}
.trainer-bubble-avatar svg, .trainer-bubble-avatar img { width: 100%; height: 100%; display: block; object-fit: cover; }
.trainer-bubble-body { flex: 1; min-width: 0; text-align: left; }
.trainer-bubble p { font-size: 16px; line-height: 1.5; color: #c2c8b0; font-style: italic; margin: 0; }
.trainer-bubble p b { color: #e8c76a; font-style: normal; font-weight: 700; }
.trainer-bubble-more {
  font-size: 14px; letter-spacing: 1.2px; text-transform: uppercase;
  color: #8fae7a; margin-top: 6px !important; font-family: 'Courier New', monospace;
  font-weight: bold; font-style: normal !important;
}

/* Guided-step hand cursor -- appended as a child of the real target cell
   (see trainerAdvance() in game.js), so it's pinned to that exact button
   regardless of grid size/layout rather than computed in screen
   coordinates. Overflows the cell's bottom-right on purpose, same framing
   real reference tutorials use: the fingertip lands on the button, the
   rest of the hand trails off it. */
.hand-cursor {
  position: absolute; width: 62%; height: 74%; right: -18%; bottom: -30%;
  pointer-events: none; z-index: 3;
  filter: drop-shadow(0 3px 6px rgba(0,0,0,0.55));
}
.hand-cursor-svg { width: 100%; height: 100%; display: block; }
/* Quick double-tap rhythm -- two short presses, matching the real tap
   gesture (instant, no hold). */
.hand-cursor.tap { animation: hand-tap 1.6s ease-in-out infinite; transform-origin: 60% 20%; }
@keyframes hand-tap {
  0%, 14%  { transform: scale(1) translateY(0); }
  22%      { transform: scale(0.86) translateY(8%); }
  30%, 44% { transform: scale(1) translateY(0); }
  52%      { transform: scale(0.86) translateY(8%); }
  60%, 100% { transform: scale(1) translateY(0); }
}
/* Press-and-hold rhythm -- one slow press held for ~500ms, matching
   LONGPRESS_MS in game.js, then released, then a pause before repeating. */
.hand-cursor.hold { animation: hand-hold 2.2s ease-in-out infinite; transform-origin: 60% 20%; }
@keyframes hand-hold {
  0%, 18%  { transform: scale(1) translateY(0); }
  36%      { transform: scale(0.84) translateY(9%); }
  68%      { transform: scale(0.84) translateY(9%); }
  86%, 100% { transform: scale(1) translateY(0); }
}

/* Trainer dialogue card */
.trainer-card[hidden] { display: none; }
.trainer-card {
  position: fixed; left: 50%; bottom: 12px; transform: translateX(-50%);
  width: min(94vw, 420px); z-index: 5;
  display: flex; gap: 12px; align-items: flex-start;
  background: rgba(26,29,22,0.96);
  border: 1px solid rgba(170,176,152,0.25);
  border-radius: 10px; padding: 12px 14px;
  box-shadow: 0 6px 24px rgba(0,0,0,0.5);
  cursor: pointer;
}
.trainer-portrait { width: 62px; height: 62px; flex-shrink: 0; border-radius: 8px; overflow: hidden; background: #22261e; border: 1px solid rgba(170,176,152,0.2); }
.trainer-portrait svg, .trainer-portrait img { width: 100%; height: 100%; display: block; object-fit: cover; }
.trainer-speech { flex: 1; min-width: 0; }
.trainer-name {
  font-size: 13px; letter-spacing: 2.2px; text-transform: uppercase;
  color: #7a8068; font-family: 'Courier New', monospace; font-weight: bold; margin-bottom: 4px;
}
.trainer-text { font-size: 17px; line-height: 1.48; color: #c2c8b0; font-style: italic; }
.trainer-text b { color: #e8c76a; font-style: normal; font-weight: 700; }
.trainer-more {
  font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
  color: #8fae7a; margin-top: 6px; font-family: 'Courier New', monospace; font-weight: bold;
  animation: trainer-more-pulse 1.4s ease-in-out infinite;
}
.trainer-card.idle .trainer-more { visibility: hidden; }
@keyframes trainer-more-pulse {
  0%, 100% { opacity: 0.55; transform: translateX(0); }
  50% { opacity: 1; transform: translateX(3px); }
}

/* Lines with nothing to point at on the board (no cell reference) go full
   screen instead of the small bottom-anchored card — the old Field
   Training tutorial's lesson: a small card over a dim, half-visible board
   reads as a nagging popup when there's nothing on the board it's actually
   pointing at. Toggled in trainerAdvance() (js/game.js) per line. */
.trainer-card.fullscreen {
  position: fixed; inset: 0; left: 0; bottom: 0; transform: none;
  width: auto; z-index: 6;
  flex-direction: column; align-items: center; justify-content: center;
  text-align: center; gap: 18px;
  background: rgba(2,1,6,0.94);
  border: none; border-radius: 0; padding: 40px 32px;
  box-shadow: none;
}
.trainer-card.fullscreen .trainer-portrait { width: 96px; height: 96px; }
.trainer-card.fullscreen .trainer-speech { flex: none; max-width: 340px; }
.trainer-card.fullscreen .trainer-name { font-size: 15px; letter-spacing: 3px; }
.trainer-card.fullscreen .trainer-text { font-size: 20px; line-height: 1.6; }
.trainer-card.fullscreen .trainer-more { margin-top: 10px; }

/* The very first line a brand-new player ever sees (line.pop in
   js/levels.js) -- a warm pulsing glow on top of the standard fullscreen
   card, deliberately more eye-catching than every other trainer line in
   the game, since this one is the actual welcome moment. Uses the game's
   existing gold accent rather than a new colour, so it reads as "louder
   version of our own palette," not an unrelated flourish. */
.trainer-card.intro-pop .trainer-portrait {
  border-color: rgba(200,168,78,0.8); border-width: 2px;
  animation: intro-pop-in 0.5s cubic-bezier(0.34,1.56,0.64,1), intro-pop-glow 1.8s ease-in-out 0.5s infinite;
}
@keyframes intro-pop-in {
  0%   { transform: scale(0.7); opacity: 0; }
  100% { transform: scale(1); opacity: 1; }
}
@keyframes intro-pop-glow {
  0%, 100% { box-shadow: 0 0 18px 3px rgba(200,168,78,0.35); }
  50%      { box-shadow: 0 0 30px 7px rgba(200,168,78,0.7); }
}
.trainer-card.intro-pop .trainer-name { color: #e8c66a; }


/* ── Tablet layout ───────────────────────────────────────────────
   Gated on width AND height TOGETHER so no iPhone can match it: the
   widest is ~430pt and none are 900pt tall. Phones therefore never
   read a single rule in this block and their layout is unchanged.

   Fixes three things that compound badly on iPad, where the game was
   a small board pinned to the top of an 820px screen with Marlowe
   stranded ~700px below it:
     1. .screen had no justify-content, so content hugged the top.
     2. .game-ui was capped at 400px however large the screen got —
        more empty margin, never a bigger board.
     3. .trainer-card was fixed to the VIEWPORT bottom rather than to
        the board it's speaking about. Near-identical on a phone;
        a screen apart on a tablet. */
@media (min-width: 700px) and (min-height: 900px) {

  /* Vertical centring is only safe because the query already
     guarantees 900px+ of height — flex centring can otherwise push
     overflow above the top edge where it can't be scrolled back to. */
  #game, #home { justify-content: center; }

  /* Cells derive their size from the grid's width, so this makes the
     board physically bigger and easier to tap rather than just adding
     padding around a small one. */
  .game-ui { width: min(80vw, 560px); }
  #game .game-ui { padding: 24px 20px 22px; }
  .game-header { max-width: 560px; }

  /* Trainer joins the flow directly under the board, so the two move
     as one group instead of anchoring to opposite screen edges.
     position:relative rather than static so it keeps its z-index and
     still layers above .tr-shield (which stays fixed, covering the
     whole viewport as intended). */
  .trainer-card {
    position: relative; left: auto; bottom: auto; transform: none;
    width: min(80vw, 560px);
    margin: 16px auto 0;
  }

  /* Home's cards get the same treatment so the two screens don't
     disagree about how wide the app is. */
  .hero-card, .teaser-card, .phase-scroll, .phase-card { max-width: 560px; }
  .phase-scroll { height: 460px; }
  .phase-card { height: 380px; }
}

/* ── Case Board (phase 4) ──────────────────────────────────────
   Ported from design/mocks/suspect-board.html. The .sb-* names are kept
   verbatim so the mock and the shipped board stay diffable against each
   other -- the mock still links this same stylesheet. Only the page
   chrome (.cb-*) is new, since the mock is a standalone page and this is
   a screen inside the app. */
#caseboard { padding: calc(20px + env(safe-area-inset-top, 0px)) 12px 60px; }
.cb-wrap { width: 100%; max-width: 420px; margin: 0 auto; }

/* Back sits in the flow to the left of the titles rather than absolutely
   positioned over them, so a long "N in the mix · N ruled out" count can
   never slide under it. */
.cb-head { display: flex; align-items: center; gap: 6px; margin-bottom: 16px; }
/* 44x44 is Apple's minimum touch target; the icon inside stays 18px. The
   negative margin keeps the glyph optically aligned with the content edge
   rather than the (now larger) hit area. */
.cb-back {
  background: none; border: none; padding: 0; margin-left: -13px;
  width: 44px; height: 44px; flex-shrink: 0; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
.cb-back:active { opacity: 0.6; }
/* The titles centre on the wrap, not on the space left over beside the
   back button -- so the heading stays optically centred on the screen. */
.cb-titles { flex: 1; margin-right: 34px; }
.cb-head .sb-count { margin-bottom: 0; }

/* Retained for design/mocks/suspect-board.html, which still uses it —
   the shipped board now uses the pinned card below. */
.sb-title { color: #c8a84e; font-size: 16px; letter-spacing: 2.6px; text-transform: uppercase;
            text-align: center; margin-bottom: 4px; font-family: 'Georgia', serif; }

/* ── Case Board identity: a pinned index card ──────────────────────────
   The only light surface in the app, deliberately. Everything else here
   is gold on near-black, so a piece of paper reads instantly as a
   different KIND of object — something physical, pinned up, handled —
   which is what a corkboard is and what the previous gold-caps label was
   not. Tilted a degree and a bit: enough to look placed by hand, little
   enough that the type still reads as level. */
.sb-card {
  position: relative; margin: 6px 26px 8px; padding: 9px 12px 8px;
  background: linear-gradient(#efe7d2, #e2d9c0);
  border-radius: 2px; transform: rotate(-1.3deg);
  box-shadow: 0 5px 14px rgba(0,0,0,0.6);
}
.sb-card-title {
  color: #2a2620; font-size: 16px; letter-spacing: 2.6px; text-transform: uppercase;
  text-align: center; font-weight: bold; font-family: 'Georgia', serif;
}
/* The ruled line under the title is what makes it an index CARD rather
   than a plain rectangle of paper. */
.sb-card-rule { height: 1px; background: rgba(42,38,32,0.25); margin-top: 5px; }
.sb-pin {
  position: absolute; left: 50%; top: -7px; transform: translateX(-50%);
  width: 13px; height: 13px; border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #e2564a, #8e2b22);
  box-shadow: 0 2px 4px rgba(0,0,0,0.7);
}
.sb-count { color: #6a5838; font-size: 13px; letter-spacing: 1.2px; text-align: center;
            text-transform: uppercase; margin-bottom: 16px; font-family: 'Georgia', serif; }

/* Latest clue only -- the board itself carries the history, since every
   earlier clue's effect is already visible in the statuses. */
.sb-clue {
  background: rgba(4,3,10,0.6); border: 1px solid rgba(200,168,78,0.22);
  border-radius: 10px; padding: 13px 15px; margin-bottom: 18px;
}
.sb-clue.background { border-color: rgba(140,150,120,0.3); background: rgba(10,12,8,0.6); }
.sb-clue.background .sb-clue-label { color: #8c9678; }
.sb-clue-label { color: #8a7040; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 6px; }
.sb-clue-text { color: #c2c8b0; font-size: 15px; line-height: 1.55; font-style: italic; font-family: 'Georgia', serif; }
.sb-clue.fresh { animation: clue-in 0.45s ease-out; }
@keyframes clue-in {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: translateY(0); }
}

.sb-list { position: relative; }
.sb-row {
  display: flex; align-items: center; gap: 12px;
  background: rgba(4,3,10,0.5); border: 1px solid rgba(200,168,78,0.16);
  border-radius: 10px; padding: 10px 12px; margin-bottom: 8px;
  transition: opacity 0.45s ease, filter 0.45s ease, border-color 0.45s ease;
}
.sb-portrait { width: 44px; height: 44px; border-radius: 7px; overflow: hidden; flex-shrink: 0;
               border: 1px solid rgba(170,176,152,0.2); }
.sb-portrait svg { width: 100%; height: 100%; display: block; }
.sb-who { flex: 1; min-width: 0; }
.sb-name { color: #e8dcb4; font-size: 15px; font-family: 'Georgia', serif; }
.sb-role { color: #6a5838; font-size: 12px; letter-spacing: 0.5px; margin-top: 2px; }
/* Why this row is in the state it's in -- persists rather than showing
   only on the clue that caused it, so the board stays self-explaining:
   glance at it at any point and the whole case reads back. */
.sb-why { font-size: 12.5px; line-height: 1.4; margin-top: 5px; font-style: italic; font-family: 'Georgia', serif; }
.sb-why.for-flagged { color: #c8a84e; }
/* Ruled-out was #5a4a2a under a 0.42 row and grayscale(0.7), which
   composited to 1.30:1 against the row — not dim, effectively absent.
   Lifted here and the row lifted below; grayscale still desaturates it,
   so the line reads as finished without becoming unreadable. */
.sb-why.for-out     { color: #c9a55e; }
/* 2.87:1 before. Raised with the rest of the ladder — fixing ruled-out
   alone would have left it MORE legible than a live suspect. */
.sb-why.for-maybe   { color: #b2945e; }
.sb-why.fresh { animation: why-in 0.45s ease-out; }
@keyframes why-in {
  from { opacity: 0; transform: translateY(-3px); }
  to   { opacity: 1; transform: translateY(0); }
}

.sb-status {
  font-size: 11px; letter-spacing: 1.4px; text-transform: uppercase;
  padding: 5px 9px; border-radius: 5px; white-space: nowrap;
  font-family: 'Georgia', serif; transition: all 0.35s ease;
}
.st-maybe    { color: #8a7040; border: 1px solid rgba(138,112,64,0.4); }
.st-flagged  { color: #1a1608; background: #c8a84e; font-weight: bold;
               box-shadow: 0 0 14px rgba(200,168,78,0.45); }
/* Unlikely means WRITTEN OFF, not cleared — a suspect can come back from
   it without the game retracting anything, and one does: Bell sits here
   until the uncle entry reopens him. It was reading as eliminated, which
   quietly spends that reveal before it happens. So it now sits much nearer
   Maybe than Ruled Out: no extra opacity knock, and a colour closer to
   maybe's #8a7040 than to out's #5a4a2a. */
.st-unlikely { color: #7d6a3e; border: 1px solid rgba(125,106,62,0.45); }
.st-out      { color: #5a4a2a; border: 1px solid rgba(90,74,42,0.35); }

/* Ruled out: sinks to the bottom and recedes, same visual grammar as
   the completed districts on Home and the powered-down board. */
/* 0.88, not the old 0.66. Against maybe at 1.0 and out at 0.42, two
   thirds put this row nearer the bottom of the board than the top — the
   opposite of what the state means. No grayscale either: that is reserved
   for out, and is most of what makes a row read as finished. */
.sb-row.unlikely { opacity: 0.88; border-color: rgba(200,168,78,0.16); }
/* 0.72, not 0.42. "Finished" is now carried by grayscale and the muted
   border rather than by opacity, because opacity takes the text down with
   everything else. The row still reads as closed; the reason it closed is
   still readable, which is the point of keeping it on the board. */
.sb-row.out { opacity: 0.72; filter: grayscale(0.7); border-color: rgba(200,168,78,0.10); }

/* A status that just changed pulses once so the eye catches it even if
   the row didn't move. */
.sb-status.changed { animation: st-pop 0.5s ease-out; }
@keyframes st-pop {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.16); }
  100% { transform: scale(1); }
}

.sb-note { color: #5a4a2a; font-size: 12.5px; text-align: center; margin-top: 14px;
           line-height: 1.6; font-family: 'Georgia', serif; }
/* Second exit at the foot of the board — see the comment in index.html.
   Centred and given its own breathing room rather than inheriting
   .ghost-btn's 28px top margin, which was tuned for overlay layouts. */
.cb-close { display: block; margin: 20px auto 4px; }

/* Play + board button share a row on the Citywide card. .play-btn is
   width:100% everywhere else, so it's relaxed to flex:1 only here rather
   than changed at source. */
.phase-actions { display: flex; align-items: stretch; gap: 9px; margin-top: 14px; }
.phase-actions .play-btn { width: auto; flex: 1; margin-top: 0; }
.board-btn {
  flex-shrink: 0; width: 46px; border-radius: 999px; cursor: pointer;
  background: rgba(7,5,14,0.55); border: 1px solid rgba(200,168,78,0.5);
  display: flex; align-items: center; justify-content: center; line-height: 0;
}
.board-btn:active { background: rgba(200,168,78,0.18); }

/* ── Clue find (phase 4) ───────────────────────────────────────
   The in-board moment is deliberately quiet: a flare that fades, and a
   corner mark that stays. Nothing to read mid-puzzle -- the win dialog
   below is where it becomes a sentence. */
/* The clue find, at the cell. Three things were making this invisible:
   it is gold like the normal press burst, it has no z-index so that burst
   (z-index 3) painted over it, and both play at the same instant. Every
   correct press already flashes a gold ring, so a second gold ring under
   the first is indistinguishable from an ordinary press.

   Now: pale sage instead of gold (the case board's own evidence colour,
   #c2c8b0 -- nothing else on the board uses it), above the burst, and
   delayed until the burst has finished so the two read as two events. */
.clue-flare {
  position: absolute; inset: -8px; border-radius: 11px; pointer-events: none;
  z-index: 5;
  border: 2.5px solid rgba(194,200,176,0.95);
  box-shadow: 0 0 16px rgba(194,200,176,0.55), inset 0 0 12px rgba(194,200,176,0.3);
  opacity: 0;
  animation: clue-flare 1.5s cubic-bezier(0.2,0.8,0.3,1) 0.5s forwards;
}
@keyframes clue-flare {
  0%   { opacity: 0; transform: scale(0.6); }
  18%  { opacity: 1; transform: scale(1.16); }
  32%  { opacity: 1; transform: scale(1); }
  70%  { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.28); }
}

/* The word. Deliberately one word and deliberately short-lived -- it has to
   be readable without the player stopping to read, which a sentence is not.
   Rises out of the cell so it is legible even when the cell itself is under
   a thumb. */
.clue-tag {
  position: absolute; left: 50%; top: -6px;
  transform: translate(-50%, 0);
  z-index: 6; pointer-events: none; white-space: nowrap;
  /* Was 10px — smaller than the incidental footer text, which is not a
     size for the one moment in a level that says you found something.
     Reported as easily missed, and this was why: the flare and the sound
     were doing all the work while the word itself was a whisper. It runs
     for ~2.5s either way (nothing rebuilds the grid mid-level, so other
     taps cannot cut it short), so presence rather than duration was the
     lever. */
  font-family: 'Georgia', serif; font-size: 17px; font-weight: bold;
  letter-spacing: 2.5px; text-transform: uppercase;
  color: #dfe4d2; text-shadow: 0 2px 8px rgba(0,0,0,0.95), 0 0 14px rgba(194,200,176,0.8);
  opacity: 0;
  animation: clue-tag 1.9s ease-out 0.62s forwards;
}
/* Rise distances scale with the type so a taller glyph still clears the
   cell it came out of rather than sitting on top of it. */
@keyframes clue-tag {
  0%   { opacity: 0; transform: translate(-50%, 8px); }
  14%  { opacity: 1; transform: translate(-50%, -18px); }
  68%  { opacity: 1; transform: translate(-50%, -23px); }
  100% { opacity: 0; transform: translate(-50%, -36px); }
}

/* Persistent, so the find can be noticed at leisure after the moment has
   passed. Sage to match the flare, not the board's gold. */
.cell.clue-found::after {
  content: ''; position: absolute; top: 3px; right: 3px;
  width: 7px; height: 7px; border-radius: 50%; z-index: 4;
  background: #dfe4d2; box-shadow: 0 0 8px rgba(194,200,176,0.95);
}

#win-clue {
  width: 100%; margin: 4px 0 2px; padding: 13px 15px; text-align: left;
  background: rgba(4,3,10,0.55); border: 1px solid rgba(200,168,78,0.24);
  border-radius: 10px;
}
#win-clue.background { border-color: rgba(140,150,120,0.32); background: rgba(10,12,8,0.55); }
#win-clue.background .win-clue-label { color: #8c9678; }
/* #win p and #win button already set text-align:center / button styling at
   (0,1,1), so every rule in here has to carry the same weight or it simply
   does not apply -- a plain .win-clue-text loses and the clue renders
   centred. */
#win .win-clue-label {
  color: #8a7040; font-size: 11px; letter-spacing: 2px;
  text-transform: uppercase; text-align: left;
}
#win .win-clue-text {
  color: #c2c8b0; font-size: 15px; line-height: 1.55; margin: 7px 0 0;
  letter-spacing: 0; text-align: left;
  font-style: italic; font-family: 'Georgia', serif;
}
/* What it did to the case. Empty on the rare entry that moves nobody --
   the list simply collapses rather than printing a "no change" row. */
#win .win-clue-moves { list-style: none; margin: 11px 0 0; padding: 0; display: grid; gap: 5px; }
#win .win-clue-moves li { display: flex; align-items: center; gap: 8px; text-align: left; }
.wcm-name { flex: 1; color: #e8dcb4; font-size: 14px; font-family: 'Georgia', serif; }
.wcm-to { font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase;
          padding: 4px 8px; border-radius: 5px; font-family: 'Georgia', serif; }

/* Lives inside the clue box, not beside Next/Home -- it belongs to the
   find, and as a third peer action it competed with the two that actually
   move the player on. */
#win .win-board-btn {
  display: block; width: 100%; margin: 13px 0 0; padding: 10px 16px;
  min-height: 44px;   /* Apple minimum touch target */
  background: none; border: 1px solid rgba(200,168,78,0.4); color: #c8a84e;
  border-radius: 999px; cursor: pointer;
  font-family: 'Georgia', serif; font-size: 12.5px; letter-spacing: 1.6px;
  text-transform: uppercase; text-align: center;
}
#win .win-board-btn:active { background: rgba(200,168,78,0.14); }

/* Clue wins drop the scene photo and flavour line entirely (js/game.js's
   showClueFind), so the overlay is the banner plus the evidence. Without
   this the [hidden] elements still occupy their flex gap and the block
   floats low on the screen. */
#win.clue-win #win-scene[hidden],
#win.clue-win #win-level-name[hidden],
#win.clue-win #win-label[hidden] { display: none; }
/* The banner carries the beat now, so it gets the weight the scene had. */
#win.clue-win h2 { font-size: 24px; }

/* Home is the only action on a phase-complete screen now, so it carries the
   weight Next used to rather than staying a quiet text link. */
.district-home-btn.district-home-primary {
  background: #c8a84e; color: #1a1608; border: none; border-radius: 999px;
  padding: 13px 30px; font-family: 'Georgia', serif; font-size: 14px;
  letter-spacing: 2.2px; text-transform: uppercase; font-weight: bold;
  cursor: pointer; box-shadow: 0 0 20px rgba(200,168,78,0.4);
}
.district-home-btn.district-home-primary:active { background: #b89642; }

/* One-off reveal the first time a phase card becomes the current one
   (js/app.js's renderStackedPhases, gated on Progress.markPhaseSeen).
   Pushes IN from oversize rather than growing from small: the card reads as
   arriving at the player, which is the "new place" cue, where scaling up
   from nothing reads as a UI element loading.

   Runs after the carousel's own scroll-into-position (that is a scrollTop
   write in a rAF, not a transition), so the two do not fight. */
/* ── Unlocking a phase ─────────────────────────────────────────
   The file lands, the seal breaks, the district is live underneath. Chosen
   from six in design/mocks/phase-reveal.html.

   What this replaces scaled the card down from 1.32 with a brightness flash
   and a rim sweep. The trouble was not the polish: it animated the
   DESTINATION. By the time Home re-renders the new phase is already current,
   so there was no "before" on screen and nothing could transform. Every beat
   below starts from the card as the player last saw it -- grey, dimmed,
   stamped SEALED (drawn for the reveal only; see phaseCardHtml) -- and opens
   it.

   Fires once per phase, four times in a whole campaign, so it can afford
   2.4s. The haptic is in js/app.js, on the landing rather than the start. */
.phase-card.phase-reveal {
  animation: phase-land 780ms cubic-bezier(.3,.9,.3,1) both;
}
@keyframes phase-land {
  0%   { transform: translateY(-420px) rotate(-6deg) scale(1.06); opacity: 0; }
  55%  { opacity: 1; transform: translateY(14px) rotate(1.5deg) scale(1); }
  72%  { transform: translateY(-6px) rotate(-.6deg); }
  100% { transform: translateY(0) rotate(0) scale(1); }
}

/* Until the animation reaches them these hold the LOCKED look, which is what
   the player last saw. The `both` fill on each keyframe below is what makes
   that true from the first frame. */
.phase-card.phase-reveal .phase-photo {
  animation: phase-lightup 420ms ease-out 1260ms both;
}
.phase-card.phase-reveal .phase-fade {
  animation: phase-fade-soften 420ms ease-out 1260ms both;
}
.phase-card.phase-reveal .phase-content {
  animation: phase-land-text 620ms cubic-bezier(.3,.9,.3,1) 1580ms both;
}
.phase-seal {
  animation: phase-seal-break 900ms cubic-bezier(.3,1.4,.5,1) 860ms both;
}

/* Swells, cracks off its angle, falls away. */
@keyframes phase-seal-break {
  0%   { transform: translate(-50%,-50%) rotate(-9deg) scale(1); opacity: 1; }
  18%  { transform: translate(-50%,-50%) rotate(-9deg) scale(1.14); }
  30%  { transform: translate(-50%,-50%) rotate(-13deg) scale(1.05); }
  100% { transform: translate(-50%,120%) rotate(26deg) scale(.9); opacity: 0; }
}
/* Monotonic. It only ever gets brighter, and lands exactly on what an
   unlocked card is. An earlier attempt peaked above that and settled back,
   which read as lighting up and then dulling; another added a wash of light
   over the top, which read as an effect arriving rather than the place
   waking up. */
@keyframes phase-lightup {
  0%   { filter: brightness(0.32) saturate(0.5) grayscale(0.4); }
  100% { filter: brightness(0.6) saturate(0.9) grayscale(0); }
}
@keyframes phase-fade-soften {
  0%   { background: rgba(7,5,14,0.55); }
  100% { background: linear-gradient(180deg, rgba(7,5,14,0.1) 0%, rgba(7,5,14,0.3) 45%, rgba(7,5,14,0.96) 100%); }
}
/* The text lands too, rather than drifting up -- something placed on the
   file once the file is open. */
@keyframes phase-land-text {
  0%   { opacity: 0; transform: translateY(-26px); }
  55%  { opacity: 1; transform: translateY(5px); }
  78%  { transform: translateY(-2px); }
  100% { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .phase-card.phase-reveal,
  .phase-card.phase-reveal .phase-photo,
  .phase-card.phase-reveal .phase-fade,
  .phase-card.phase-reveal .phase-content,
  .phase-seal { animation: none; }
  /* The seal would otherwise sit on the card for good, since nothing else
     removes it. */
  .phase-seal { display: none; }
}

/* ── The press (phase 4) ───────────────────────────────────────
   Both papers are dark and separate by typography and accent, not by
   background — lifted from design/mocks/news-feed.html so the shipped
   screen and the mock stay comparable. */
#press { padding: calc(18px + env(safe-area-inset-top, 0px)) 12px 40px; }
.press-wrap { width: 100%; max-width: 460px; margin: 0 auto; }
.press-papers { display: flex; flex-direction: column; gap: 18px; }

.paper { border-radius: 12px; overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,0.6); }
.paper .mast { display: flex; align-items: center; gap: 10px; padding: 12px 15px; }
.paper .logo svg { width: 26px; height: 26px; display: block; }
.paper.moon .mast { background: #0b0c0e; border-bottom: 1px solid #23262b; }
.paper.star .mast { background: #101013; border-bottom: 3px solid #f5b21a; }
.paper.moon .brand { font-family: Georgia, serif; font-size: 19px; letter-spacing: 1.5px; color: #f2f3f5; }
.paper.moon .brand span { color: #8d949e; }
.paper.star .brand {
  font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 22px; font-weight: 800;
  letter-spacing: -0.5px; text-transform: uppercase; color: #f5b21a; font-style: italic;
}
.paper.star .brand span { color: #8b857a; }

.paper .page { padding: 15px 16px 20px; }
.paper.moon .page { background: #0f1113; }
.paper.star .page { background: #141416; }
.paper .banner {
  display: inline-block; font-size: 9.5px; letter-spacing: 1.6px; text-transform: uppercase;
  font-weight: bold; padding: 4px 9px; border-radius: 3px; font-family: 'Georgia', serif;
}
.paper.moon .banner { background: #e2564b; color: #12130f; }
.paper.star .banner { background: #f5b21a; color: #141208; }
.paper .kicker { font-size: 10px; letter-spacing: 2.2px; text-transform: uppercase; margin: 12px 0 7px; }
.paper.moon .kicker { color: #e2564b; }
.paper.star .kicker { color: #f5b21a; }
.paper.moon h2 {
  font-family: Georgia, serif; font-size: 25px; line-height: 1.2; font-weight: 400;
  color: #f7f8fa; text-wrap: balance;
}
.paper.star h2 {
  font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 29px; line-height: 1.03;
  font-weight: 800; letter-spacing: -0.8px; text-transform: uppercase; color: #fff;
  text-wrap: balance;
}
.paper .deck { margin-top: 10px; line-height: 1.5; }
.paper.moon .deck { color: #aeb4bd; font-size: 15.5px; font-family: Georgia, serif; }
.paper.star .deck {
  color: #b6b1a6; font-size: 15px; font-weight: 600;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}
.paper .byline {
  font-size: 10px; letter-spacing: 1.4px; text-transform: uppercase; color: #6d7178;
  margin-top: 13px; padding-bottom: 13px; border-bottom: 1px solid rgba(255,255,255,0.1);
}
.paper .copy p { margin-top: 13px; line-height: 1.72; }
.paper.moon .copy p { color: #c6ccd4; font-size: 15px; font-family: Georgia, serif; }
.paper.star .copy p { color: #c4c0b8; font-size: 15px; font-family: 'Helvetica Neue', Arial, sans-serif; }
/* Drop cap on the Moon only — it is the broadsheet of the two. */
.paper.moon .copy p:first-child::first-letter {
  font-family: Georgia, serif; font-size: 32px; line-height: 1; float: left;
  padding: 2px 8px 0 0; color: #f2f3f5;
}

/* ── The paper arrives ───────────────────────────────────────── */
/* The press screen used to just appear: show() swapped display:none for
   display:flex and that was the whole transition, so a story beat entered
   exactly like a screen change. This walks the eye down the page in reading
   order instead -- masthead, banner, kicker, headline, deck, byline, copy --
   which is the point: it buys the headline a moment of being looked at.

   Delays are fractions of --press-in, so retiming the whole entrance is one
   number. Beat 5 stacks two papers; --po offsets the second so they do not
   stagger in lockstep.

   Guarded on no-preference rather than merely shortened, so the accessibility
   setting switches it off outright. Everything below is decoration -- with
   the animations gone the paper is simply there, which is what shipped
   before this existed. */
@media (prefers-reduced-motion: no-preference) {
  @keyframes press-in-page { from { opacity: 0; } to { opacity: 1; } }
  @keyframes press-in-line {
    from { opacity: 0; transform: translateY(9px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  .press-in {
    --press-in: 900ms;
    --po: 0ms;
    animation: press-in-page calc(var(--press-in) * 0.30) ease-out both;
    animation-delay: var(--po);
  }
  .press-in.second { --po: 300ms; }

  .press-in .mast, .press-in .banner, .press-in .kicker, .press-in h2,
  .press-in .deck, .press-in .byline, .press-in .copy p {
    animation: press-in-line calc(var(--press-in) * 0.42)
               cubic-bezier(.16,.84,.44,1) both;
  }
  .press-in .mast   { animation-delay: calc(var(--po) + var(--press-in) * 0.02); }
  .press-in .banner { animation-delay: calc(var(--po) + var(--press-in) * 0.14); }
  .press-in .kicker { animation-delay: calc(var(--po) + var(--press-in) * 0.22); }
  .press-in h2      { animation-delay: calc(var(--po) + var(--press-in) * 0.30); }
  .press-in .deck   { animation-delay: calc(var(--po) + var(--press-in) * 0.40); }
  .press-in .byline { animation-delay: calc(var(--po) + var(--press-in) * 0.48); }
  .press-in .copy p { animation-delay: calc(var(--po) + var(--press-in) * 0.56); }
}

.press-done {
  display: block; width: 100%; margin-top: 20px; padding: 14px 0;
  background: #c8a84e; color: #1a1608; border: none; border-radius: 999px;
  font-family: 'Georgia', serif; font-size: 14px; letter-spacing: 2.2px;
  text-transform: uppercase; font-weight: bold; cursor: pointer;
  box-shadow: 0 0 20px rgba(200,168,78,0.35);
}
.press-done:active { background: #b89642; }

/* Board button on a COMPLETED Citywide card — the phase-label sits at the
   bottom, so this rides just above it rather than in the content block a
   current card uses. */
.phase-actions-done {
  position: absolute; left: 0; right: 0; bottom: 44px;
  display: flex; justify-content: center;
}
.phase-actions-done .board-btn { background: rgba(7,5,14,0.72); }
/* The labelled variant. A completed card has no Play button competing with
   it, so the icon grows its name instead of sitting as a bare circle. */
.board-btn-wide {
  width: auto; gap: 9px; padding: 0 18px; height: 44px;
  font-family: 'Georgia', serif; font-size: 13px; letter-spacing: 1.6px;
  text-transform: uppercase; color: #c8a84e; line-height: 1;
}
.board-btn-wide svg { flex-shrink: 0; }

/* Free-hint-on-return popup (js/hintgift.js). Deliberately borrows the
   #all-done / #coming-soon treatment rather than inventing a third look —
   it is the same kind of moment: a short, gold-on-dark interruption with
   one way out. cursor:default because unlike the narrative overlays this
   one is dismissed by its button, not by tapping anywhere. */
#hint-gift, #notif-primer { gap: 14px; cursor: default; padding: 32px; }
#hint-gift h2, #notif-primer h2 {
  color: #c8a84e; font-size: 24px; letter-spacing: 3.5px; text-transform: uppercase;
  text-shadow: 0 0 28px rgba(200,168,78,0.5); text-align: center;
}
#hint-gift p, #notif-primer p {
  color: #8a7040; font-size: 15px; letter-spacing: 1.2px; text-align: center;
  line-height: 1.7; max-width: 320px;
}
#hint-gift button, #notif-primer button {
  background: none; border: 1px solid rgba(200,168,78,0.35);
  color: #7a6028; font-size: 14px; letter-spacing: 1.2px;
  padding: 10px 16px; border-radius: 6px; cursor: pointer;
  font-family: 'Georgia', serif; text-transform: uppercase;
}
#hint-gift button:active, #notif-primer button:active { background: rgba(200,168,78,0.08); }


/* ── Guided instruction, anchored to the cell it is about ──────────────
   The tutorial's words used to sit in a card at the top of the screen
   while the button being talked about was rows below it, so a first-time
   player had to carry the instruction across a gap and match it to a glow.
   Reported as the reason people could not tell when to tap and when to
   hold. Both halves now live on the target: the verb printed on the
   button, the reason tethered directly above it with a tail pointing
   down at its column. */
.cell-verb {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
  z-index: 7; pointer-events: none; white-space: nowrap;
  background: rgba(10,8,4,0.86); border: 1px solid rgba(240,228,66,0.9);
  border-radius: 5px; padding: 4px 8px;
  font-family: 'Georgia', serif; font-size: 12.5px; letter-spacing: 1.6px;
  text-transform: uppercase; color: #F0E442;
}
.cell-tether {
  position: absolute; left: 0; right: 0; z-index: 6;
  display: flex; align-items: center; gap: 8px;
  background: rgba(48,38,14,0.98); border: 1.5px solid rgba(200,168,78,0.8);
  border-radius: 9px; padding: 7px 11px;
  box-shadow: 0 6px 20px rgba(0,0,0,0.6);
}
/* Same portrait as the trainer card (TRAINERS in js/engine.js) — every
   on-cell hint during the tutorial (levels 1-4) is Marlowe talking, same
   as the card is, so the payoff lands when he says he's stepping back:
   the player has seen his face on every one of these, not just the card. */
.cell-tether-avatar {
  flex: none; width: 26px; height: 26px; border-radius: 50%; overflow: hidden;
  border: 1.5px solid rgba(200,168,78,0.85); background: #20241c;
}
.cell-tether-avatar svg, .cell-tether-avatar img { width: 100%; height: 100%; display: block; object-fit: cover; }
.cell-tether-body { flex: 1; }
.cell-tether p { margin: 0; text-align: left; font-size: 14px; line-height: 1.4; font-style: italic; color: #c2c8b0; }
.cell-tether p b { color: #e8c76a; font-style: normal; font-weight: 700; }
/* Only present on a tap-to-continue instance (see showCellInstruction's
   tapToContinue option) -- same label/pulse as .trainer-more, so this
   reads as the same "waiting on you" affordance everywhere it appears,
   not a second visual language. Makes the whole tether tappable rather
   than relying on a fixed timer, for a line worth actually reading. */
.cell-tether-more {
  font-size: 11px; letter-spacing: 1px; text-transform: uppercase;
  color: #8fae7a; margin-top: 3px; font-family: 'Courier New', monospace; font-weight: bold;
  animation: trainer-more-pulse 1.4s ease-in-out infinite;
}
/* The tail is positioned per-target in JS (see showCellInstruction) —
   it has to sit under the target COLUMN, not the middle of the board. */
.cell-tether::after {
  content: ''; position: absolute; width: 11px; height: 11px; bottom: -7px;
  background: rgba(48,38,14,0.98);
  border-right: 1.5px solid rgba(200,168,78,0.8);
  border-bottom: 1.5px solid rgba(200,168,78,0.8);
  transform: rotate(45deg);
}

/* ── A press kills a light ─────────────────────────────────────────────
   The panel is already the goal — one light per colour, all of them out
   is the win — but nothing ever connected it to what the player does.
   This carries the eye from the button just pressed to the light it just
   put out, which states the whole loop without a word of explanation.
   position:fixed so it needs no positioned ancestor and can cross from
   the grid into the panel. */
.press-pulse {
  position: fixed; width: 12px; height: 12px; border-radius: 50%;
  z-index: 40; pointer-events: none;
  box-shadow: 0 0 10px 4px rgba(255,255,255,0.45);
  transition: transform 0.5s cubic-bezier(0.4,0,0.2,1), opacity 0.5s;
}
/* Goal preview (level 1): the readout drops to OFF for a moment before
   the player has done anything, then snaps back to armed. */
.panel-on.preview-off {
  color: #5e6b5e; border-color: rgba(120,140,120,0.4);
  text-shadow: none; box-shadow: none;
}

