/* ── Mafia — a candlelit séance. ─────────────────────────────────────────────────
   The room is a table in the dark: one warm light off to the side, brass hairlines
   where other interfaces would put rounded cards, bone-white text on soot, and a
   single oxblood note for everything that kills. Day does not turn the lights on;
   it lets dusty sun in. Nothing here is an image request — the ornament is all
   pseudo-elements and gradients, and the only asset is one 40 KB font in two
   subsets. Cloudflare serves public/ as static assets, which are unmetered and
   never invoke the Worker, so the whole look costs nothing against the request
   budget. Full extraction sheet: docs/ui/2026-08-24-occult-extraction.md ────── */

/* Cinzel (SIL OFL 1.1, see fonts/OFL.txt) — display face only, self-hosted rather
   than pulled from a CDN so there is no third-party DNS+TLS hop on first paint.
   BOTH subsets are required: Serbian Latin (č ć š ž đ) lives in latin-ext, and
   shipping only `latin` would render half the sr catalogue in the fallback serif. */
@font-face {
  font-family: 'Cinzel';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/fonts/cinzel-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Cinzel';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/fonts/cinzel-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Display casing note, unchanged and still load-bearing: caps come from
   `font-variant-caps`, NOT `text-transform: uppercase`. Both look the same, but
   text-transform changes the *rendered* text, which is what `innerText` returns —
   so uppercasing a label that way quietly rewrites what every test, every
   copy-paste and some screen readers see. The only three places below that use
   text-transform are the ones nothing reads back: the wordmark, the phase veil,
   and the face of the reveal card. */

:root {
  /* ── Ground: warm near-black. Not #000 — a soot with a little brown in it holds
     a candle glow, where pure black is a dead flat surface that cannot. */
  --bg: #0a0807;
  --bg-2: #12100d;
  --panel: #16130f;
  --panel-2: #1e1a14;
  --line: #33291d;

  /* ── Ink: bone and parchment, never pure white. */
  --ink: #efe6d2;
  --ink-dim: #b3a58a;
  --ink-faint: #7c7059;

  /* ── Brass. The accent IS the gold: one hue on every active state, which is what
     keeps the page at three colours no matter how many roles are in play. */
  --gold: #c8a45c;
  --gold-hi: #f0dca6;
  --gold-dim: #8a6f38;
  --accent: var(--gold);
  --accent-ink: #1a1206;

  /* ── Oxblood. Two values on purpose: a fill dark enough to sit under bone text,
     and a lighter one for text and hairlines that has to pass contrast on soot.
     `--danger` is the text value, and it is this light for a measured reason: the
     mafia tag is 9px and the mafia name in the log is 12.5px, both small text, and
     both sit on a day-phase tile that lightens to #2f2517. The first draft's #cf4652
     came to 3.33:1 there — under AA. This one holds 4.80:1 on the lightest surface it
     ever lands on, and the deep wax colour it replaced is still here as `--blood`,
     which is what every red FILL uses. */
  --blood: #7d1420;
  --danger: #e2705f;
  --good: #4f9e7a;
  --warn: #d9a748;

  /* Near-sharp. The single change that moves this furthest from a SaaS dashboard:
     the old sheet's 14px rounding was doing more of that work than any colour. */
  --radius: 3px;
  --radius-sm: 2px;
  --radius-tile: 4px;

  --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --serif: ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif;
  --display: 'Cinzel', ui-serif, Georgia, "Times New Roman", serif;

  /* Elevation. Each step pairs a cast shadow with a foil hairline along the top
     edge — that highlight is what stops a dark panel on a dark ground from
     reading as a hole cut in the page. */
  --e1: 0 1px 2px rgba(0, 0, 0, .55), inset 0 1px 0 rgba(240, 220, 166, .07);
  --e2: 0 4px 16px rgba(0, 0, 0, .6), inset 0 1px 0 rgba(240, 220, 166, .09);
  --e3: 0 20px 56px -14px rgba(0, 0, 0, .85), inset 0 1px 0 rgba(240, 220, 166, .11);
  --shadow: var(--e2);

  /* ── The easing palette. Personality: CINEMATIC. Exactly three curves in the
     whole file, which is what makes the motion read as one hand.
       primary   — ritual beats, once-a-game reveals
       secondary — state changes: things arriving, settling, moving A→B
       utility   — hover and press, where anything slower reads as lag
     Infinite ambient loops use the `linear` keyword and are exempt. */
  --ease-dramatic: cubic-bezier(.77, 0, .175, 1);
  --spring-smooth: linear(
    0, 0.004, 0.016 2.3%, 0.063 4.7%, 0.141 7.2%, 0.25 9.9%, 0.601 16.5%,
    0.815 21.0%, 0.929 25.2%, 0.987 29.0%, 1.025 33.5%, 1.042 38.0%, 1.04 43.5%,
    1.027 50.0%, 1.013 57.5%, 1.005 67.0%, 1.001 79.0%, 1);
  --ease-snap: cubic-bezier(.22, 1, .36, 1);

  color-scheme: dark;
}

/* Day does not switch the lights on. The candle values above stay the base and a
   dusty-sun layer fades in over them (body::before), so the change is a dissolve
   rather than a cut — which is also why this block can flip instantly underneath
   without the seam showing. */
body.phase-day, body.phase-vote, body.phase-dusk {
  --bg: #17120c;
  --bg-2: #201a12;
  --panel: #251d14;
  --panel-2: #2f2517;
  --line: #4a3a22;
  --ink: #f6ecd8;
  --ink-dim: #c2b193;
  --ink-faint: #8d7f63;
  --gold: #e0b968;
  --gold-hi: #f7e6b8;
}
/* The verdict is the one moment the brass gives way. */
body.phase-ended { --accent: #5cb790; --accent-ink: #04241a; }

/* ── Ground and atmosphere ───────────────────────────────────────────────────── */

* { box-sizing: border-box; }
html, body { height: 100%; }

body {
  margin: 0;
  font-family: var(--serif);
  color: var(--ink);
  /* Vignette listed first so it sits over the glows but still behind every element:
     it deepens the room without dimming a single character of text. */
  background:
    radial-gradient(130% 105% at 50% 44%, transparent 48%, rgba(0, 0, 0, .72) 100%),
    radial-gradient(720px 560px at 22% -8%, rgba(200, 164, 92, .16), transparent 64%),
    radial-gradient(560px 460px at 104% 14%, rgba(125, 20, 32, .10), transparent 60%),
    var(--bg);
  transition: background-color 1.1s var(--spring-smooth);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* The candle. One warm source, off-centre, breathing on a slow loop — the only
   ambient motion on the page besides the timer, and the reason the ground reads as
   lit rather than as a dark colour. `linear` because a flame has no easing. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: -2;
  opacity: 0;
  transition: opacity 1.2s var(--spring-smooth);
  background:
    radial-gradient(760px 520px at 50% -14%, rgba(224, 185, 104, .20), transparent 58%),
    radial-gradient(620px 460px at 6% 104%, rgba(200, 164, 92, .11), transparent 60%);
}
body.phase-day::before, body.phase-vote::before, body.phase-dusk::before { opacity: 1; }

/* Film grain, over everything, as an inline feTurbulence — no image request and
   nothing on the wire beyond this rule. Above the content on purpose: grain under
   the text is not grain. The vignette deliberately does NOT live here, because over
   content it would dim edge text. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 40;
  opacity: .34;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
  mix-blend-mode: soft-light;
  animation: flicker 7s linear infinite;
}
/* Barely there: a 4% swing in the grain's opacity reads as a flame moving, and at
   this amplitude nobody can point at what is happening. */
@keyframes flicker {
  0%, 100% { opacity: .34; }
  22% { opacity: .30; }
  47% { opacity: .37; }
  71% { opacity: .32; }
}

button, input, select { font: inherit; color: inherit; }

/* ── Shared surface treatments ───────────────────────────────────────────────────
   `.surface` is not a class in the markup: these are the two recipes every raised
   thing on the page mixes in, kept here once so a panel, a tile and the reveal card
   cannot drift apart. */

/* Entry. Note where the hidden state lives: ONLY inside the keyframe `from`, never
   in the base rule. That is what makes `prefers-reduced-motion`'s blanket
   `animation: none` safe — killing the animation leaves the element at its natural
   opacity instead of leaving it invisible forever. Every entry animation in this
   file follows that rule. */
@keyframes rise {
  from { opacity: 0; transform: translateY(6px); }
}
@keyframes riseblur {
  from { opacity: 0; transform: translateY(9px); filter: blur(5px); }
}
@keyframes foilin {
  from { opacity: 0; transform: scaleX(.2); }
}

:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 2px;
}

/* ── Landing ─────────────────────────────────────────────────────────────────────
   Architecture: the typographic poster. There is no hero image because the wordmark
   IS the image — one focal point at viewport scale, the join controls subordinate
   beneath it on an altar panel, and a great deal of deliberate dark around both. */

.screen { min-height: 100dvh; }

#landing {
  display: grid;
  place-items: center;
  padding: 32px 18px 48px;
}

.card {
  width: 100%;
  max-width: 560px;
  position: relative;
  padding: 40px 34px 34px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background:
    linear-gradient(180deg, rgba(240, 220, 166, .035), transparent 38%),
    linear-gradient(168deg, var(--panel-2), var(--panel) 60%, #100d0a);
  box-shadow: var(--e3);
  animation: rise .5s var(--spring-smooth) both;
}
/* ── The cascade, and why it is this short ───────────────────────────────────────
   Ground first, then the wordmark, then each control in turn. The first draft ran to
   790ms — inside the 800ms budget an entry sequence is allowed — and the landing
   screenshot caught it: an empty framed panel with its contents still invisible. A
   budget is not a target. On the *first* screen a player ever sees, a cascade whose
   own tail is still arriving half a second in reads as a page that has not loaded.
   The whole sequence now finishes at 430ms, and the travel is short enough that a
   frame caught mid-flight still reads as text rather than as absence. */
.card > * { animation: riseblur .3s var(--ease-dramatic) both; }
.card > :nth-child(1) { animation-delay: .02s; }
.card > :nth-child(2) { animation-delay: .05s; }
.card > :nth-child(3) { animation-delay: .08s; }
.card > :nth-child(4) { animation-delay: .1s; }
.card > :nth-child(5) { animation-delay: .11s; }
.card > :nth-child(6) { animation-delay: .12s; }
.card > :nth-child(n+7) { animation-delay: .13s; }
/* Foil along the top edge — the light catching the leaf. */
.card::before {
  content: "";
  position: absolute;
  inset: 0 0 auto;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--gold), transparent);
  opacity: .75;
}
/* The engraved inner frame. A hairline held a few pixels inside the border is the
   whole visual language of a certificate, a tarot back and a grimoire plate. */
.card::after {
  content: "";
  position: absolute;
  inset: 8px;
  border: 1px solid color-mix(in srgb, var(--gold) 16%, transparent);
  border-radius: 1px;
  pointer-events: none;
}

.brand {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(3.2rem, 12vw, 7.5rem);
  font-weight: 700;
  line-height: .92;
  /* Positive tracking, against the usual display-type advice. Cinzel is a caps-only
     Roman inscriptional face: negative tracking collides its serifs and reads as a
     fault, where the grotesques that advice is written for want the opposite. */
  letter-spacing: .14em;
  text-indent: .14em;
  text-transform: uppercase;
  text-align: center;
  /* Gold leaf: a three-stop foil across the glyphs, with a flat gold fallback for
     any engine that will not clip a background to text. */
  color: var(--gold);
  background: linear-gradient(172deg, var(--gold-hi) 8%, var(--gold) 42%, var(--gold-dim) 78%, var(--gold-hi));
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(0 3px 18px rgba(200, 164, 92, .22));
}

/* The ornament under the wordmark: two rules and a lozenge, drawn with a border and
   a rotated square, so it costs no glyph and no request. */
.brand + .tagline::before {
  content: "";
  display: block;
  width: min(320px, 82%);
  height: 9px;
  margin: 22px auto 20px;
  background:
    linear-gradient(90deg, transparent, color-mix(in srgb, var(--gold) 55%, transparent) 22%,
      color-mix(in srgb, var(--gold) 55%, transparent) 78%, transparent) center / 100% 1px no-repeat,
    conic-gradient(from 45deg at 50% 50%, var(--gold) 0 25%, transparent 0 100%) center / 9px 9px no-repeat;
  animation: foilin 1s var(--ease-dramatic) .18s both;
}

.tagline {
  margin: 0 0 30px;
  color: var(--ink-dim);
  font-size: 15px;
  font-style: italic;
  line-height: 1.55;
  text-align: center;
  text-wrap: balance;
}

.field { display: block; margin-bottom: 16px; }
.field span {
  display: block;
  margin-bottom: 7px;
  font-family: var(--sans);
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

input[type="text"], input:not([type]), input[type="number"], select {
  width: 100%;
  padding: 11px 13px;
  font-family: var(--sans);
  border-radius: var(--radius-sm);
  border: 1px solid var(--line);
  background: rgba(0, 0, 0, .38);
  color: var(--ink);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, .45);
  transition: border-color .16s var(--ease-snap), box-shadow .16s var(--ease-snap);
}
input::placeholder { color: var(--ink-faint); }
input:focus-visible, select:focus-visible {
  outline: none;
  border-color: var(--gold);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, .45), 0 0 0 3px color-mix(in srgb, var(--gold) 22%, transparent);
}

/* ── Buttons: brass plaques ──────────────────────────────────────────────────────
   Sharp-edged, engraved, with the foil hairline on top. The press is a real
   compression (0.98 + the highlight going out), because a plaque you push should
   feel like it went down. */

.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  padding: 12px 18px;
  min-height: 44px;
  font-family: var(--sans);
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  cursor: pointer;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line);
  background: linear-gradient(180deg, var(--panel-2), var(--panel));
  color: var(--ink);
  box-shadow: var(--e1);
  transition: transform .14s var(--ease-snap), border-color .14s var(--ease-snap),
              box-shadow .14s var(--ease-snap), color .14s var(--ease-snap);
}
@media (hover: hover) and (pointer: fine) {
  .btn:hover:not(:disabled) {
    border-color: color-mix(in srgb, var(--gold) 55%, var(--line));
    color: var(--gold-hi);
    transform: translateY(-1px);
    box-shadow: var(--e2);
  }
}
.btn:active:not(:disabled) { transform: scale(.98); box-shadow: inset 0 2px 6px rgba(0, 0, 0, .5); }
.btn:disabled { opacity: .38; cursor: not-allowed; box-shadow: none; }

.btn.primary {
  border-color: color-mix(in srgb, var(--gold) 70%, transparent);
  background: linear-gradient(180deg, var(--gold-hi), var(--gold) 46%, var(--gold-dim));
  color: var(--accent-ink);
  box-shadow: 0 6px 22px -10px color-mix(in srgb, var(--gold) 80%, transparent), var(--e1);
}
@media (hover: hover) and (pointer: fine) {
  .btn.primary:hover:not(:disabled) {
    color: var(--accent-ink);
    filter: brightness(1.07);
    box-shadow: 0 10px 30px -10px color-mix(in srgb, var(--gold) 85%, transparent), var(--e2);
  }
}
.btn.ghost { background: transparent; box-shadow: none; border-color: color-mix(in srgb, var(--line) 80%, transparent); }
@media (hover: hover) and (pointer: fine) {
  .btn.ghost:hover:not(:disabled) { background: rgba(240, 220, 166, .05); box-shadow: none; }
}
.btn.small {
  width: auto;
  padding: 8px 11px;
  min-height: 32px;
  font-size: 10.5px;
  letter-spacing: .1em;
}

/* ── Divider, join row, messages ─────────────────────────────────────────────── */

.divider {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 22px 0 18px;
  font-family: var(--sans);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.divider::before, .divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--line), transparent);
}

.join-row { display: flex; gap: 10px; }
.join-row input {
  flex: 1;
  font-family: var(--display);
  font-weight: 700;
  font-size: 19px;
  letter-spacing: .42em;
  text-indent: .42em;
  text-align: center;
  text-transform: uppercase;
}
.join-row .btn { width: auto; flex: none; padding: 12px 20px; }

.error {
  margin: 16px 0 0;
  padding-left: 11px;
  border-left: 2px solid var(--danger);
  color: var(--danger);
  font-size: 13.5px;
  font-style: italic;
  animation: rise .3s var(--spring-smooth) both;
}
.hint {
  margin: 9px 0 0;
  min-height: 1em;
  font-size: 12.5px;
  font-style: italic;
  color: var(--ink-faint);
  line-height: 1.5;
}
.muted { color: var(--ink-faint); font-weight: 400; font-style: italic; }

/* ── How it works ───────────────────────────────────────────────────────────── */

.rules { margin-top: 28px; font-size: 13.5px; color: var(--ink-dim); }
.rules summary {
  cursor: pointer;
  font-family: var(--display);
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .2em;
  font-variant-caps: all-small-caps;
  color: var(--gold);
  padding: 8px 0;
  transition: color .14s var(--ease-snap);
}
.rules summary:hover { color: var(--gold-hi); }
.rules ul { padding-left: 0; margin: 14px 0; list-style: none; }
.rules li {
  margin: 9px 0;
  padding-left: 15px;
  line-height: 1.55;
  border-left: 1px solid color-mix(in srgb, var(--gold) 20%, transparent);
}
.rules b {
  font-family: var(--display);
  font-weight: 700;
  letter-spacing: .06em;
  font-variant-caps: all-small-caps;
  color: var(--gold);
}
.rules p { line-height: 1.6; }

/* ── Top bar: the brass rail ─────────────────────────────────────────────────────
   Layout mechanics kept exactly as they were — `1fr auto 1fr` with the invite word
   dropping to a glyph under 460px — because that is what holds the three groups
   inside 380px, and a browser check asserts it. Everything else here is new. */

.topbar {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 12px;
  padding: 11px 16px;
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  border-bottom: 1px solid var(--line);
  box-shadow: 0 1px 0 rgba(240, 220, 166, .05), 0 12px 34px -20px #000;
  backdrop-filter: blur(10px) saturate(1.15);
  position: sticky;
  top: 0;
  z-index: 5;
}
/* A second, brighter rule hung just under the border: two hairlines a pixel apart
   is the engraved-plate detail, and it is what makes the rail read as brass. */
.topbar::after {
  content: "";
  position: absolute;
  inset: auto 0 -3px;
  height: 1px;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--gold) 42%, transparent) 30%,
    color-mix(in srgb, var(--gold) 42%, transparent) 70%, transparent);
  pointer-events: none;
}
.topbar-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
.topbar-right { display: flex; align-items: center; gap: 10px; justify-content: flex-end; min-width: 0; }
.topbar-mid {
  display: flex;
  align-items: center;
  gap: 9px;
  min-width: 0;
}

.phase-name {
  font-family: var(--display);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: .22em;
  font-variant-caps: all-small-caps;
  color: var(--gold);
  white-space: nowrap;
}

.invite-short { display: none; }
@media (max-width: 460px) {
  .topbar { gap: 6px; padding: 10px 10px; }
  .topbar-left { gap: 6px; }
  .phase-name { font-size: 13px; letter-spacing: .12em; }
  .invite-long { display: none; }
  .invite-short { display: inline; }
}

/* The room code is the thing people read out loud, so it is set as an inscription:
   wide-tracked caps on a sunken plaque. */
.roomcode {
  font-family: var(--display);
  font-weight: 700;
  font-size: 14px;
  letter-spacing: .24em;
  text-indent: .24em;
  cursor: copy;
  padding: 5px 8px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, .42);
  border: 1px solid color-mix(in srgb, var(--gold) 32%, var(--line));
  color: var(--gold);
  box-shadow: inset 0 1px 4px rgba(0, 0, 0, .5);
  transition: border-color .14s var(--ease-snap), color .14s var(--ease-snap);
}
.roomcode:hover { border-color: color-mix(in srgb, var(--gold) 65%, var(--line)); color: var(--gold-hi); }

.phase-icon {
  font-size: 17px;
  line-height: 1;
  filter: drop-shadow(0 0 12px rgba(224, 185, 104, .35)) saturate(.85);
}
.phase-round {
  color: var(--ink-faint);
  font-family: var(--sans);
  font-weight: 500;
  font-size: 11.5px;
  letter-spacing: .04em;
  white-space: nowrap;
}

.timer {
  font-family: var(--sans);
  font-variant-numeric: tabular-nums lining-nums;
  font-weight: 800;
  font-size: 14px;
  padding: 5px 10px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, .42);
  border: 1px solid var(--line);
  box-shadow: inset 0 1px 4px rgba(0, 0, 0, .5);
  transition: color .3s var(--spring-smooth), border-color .3s var(--spring-smooth),
              box-shadow .3s var(--spring-smooth);
}
/* Ten seconds left is the one moment the interface is allowed to raise its voice. */
.timer.urgent {
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 70%, transparent);
  box-shadow: inset 0 1px 4px rgba(0, 0, 0, .5), 0 0 20px -4px color-mix(in srgb, var(--danger) 65%, transparent);
  animation: pulse 1s linear infinite;
}
@keyframes pulse { 50% { opacity: .5; } }

.conn { font-size: 20px; line-height: 1; transition: color .3s var(--spring-smooth); }
.conn[data-state="open"] { color: var(--good); }
.conn[data-state="connecting"] { color: var(--warn); }
.conn[data-state="closed"] { color: var(--danger); }

.banner {
  padding: 10px 16px;
  background: color-mix(in srgb, var(--warn) 13%, transparent);
  border-bottom: 1px solid color-mix(in srgb, var(--warn) 32%, transparent);
  font-size: 13.5px;
  font-style: italic;
  text-align: center;
  animation: rise .3s var(--spring-smooth) both;
}

/* ── Layout ──────────────────────────────────────────────────────────────────── */

.layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 18px;
  padding: 22px 18px 28px;
  max-width: 1180px;
  margin: 0 auto;
  align-items: stretch;
}
@media (max-width: 860px) {
  .layout { grid-template-columns: 1fr; align-items: start; padding: 16px 12px 24px; }
}
.col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
/* The columns are stretched to a common height, so the host's force-advance button
   would otherwise sit in the middle of whatever slack the shorter column has. Pin it
   to the foot of the column, where "skip this phase" belongs anyway. */
.host-only { margin-top: auto; }

/* ── Your card ───────────────────────────────────────────────────────────────────
   What you are is the one private fact in the game, so it gets the one surface with
   a sigil wash and a role-tinted edge. `--role-hue` is set by the variants at the
   foot of this block and falls back to bone, so a role with no colour of its own
   still gets the highlight and never a missing value. */

.you-card {
  position: relative;
  overflow: hidden;
  padding: 17px 19px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background:
    linear-gradient(180deg, rgba(240, 220, 166, .05), transparent 42%),
    linear-gradient(150deg, var(--panel-2), var(--panel) 70%, #100d0a);
  box-shadow: var(--e2);
  animation: rise .5s var(--spring-smooth) both;
}
.you-card::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(155% 115% at 0% 0%, var(--role-hue, var(--ink)), transparent 56%);
  opacity: .11;
  z-index: 0;
}
.you-role, .you-name { position: relative; z-index: 1; }
.you-role {
  font-family: var(--display);
  font-size: 21px;
  font-weight: 700;
  letter-spacing: .08em;
  font-variant-caps: all-small-caps;
  line-height: 1.15;
}
.you-name { color: var(--ink-dim); font-size: 13px; font-style: italic; margin-top: 5px; }

.you-card[data-role="mafia"],
.you-card[data-role="godfather"],
.you-card[data-role="blackmailer"],
.you-card[data-role="framer"] { border-color: color-mix(in srgb, var(--danger) 50%, var(--line)); --role-hue: var(--danger); }
.you-card[data-role="mafia"] .you-role,
.you-card[data-role="godfather"] .you-role,
.you-card[data-role="blackmailer"] .you-role,
.you-card[data-role="framer"] .you-role { color: var(--danger); }
.you-card[data-role="doctor"] { --role-hue: var(--good); }
.you-card[data-role="doctor"] .you-role { color: var(--good); }
.you-card[data-role="detective"] { --role-hue: #6fa8c9; }
.you-card[data-role="detective"] .you-role { color: #8cbcd8; }
.you-card[data-role="bodyguard"] { border-color: color-mix(in srgb, var(--good) 42%, var(--line)); --role-hue: var(--good); }
.you-card[data-role="vigilante"] { border-color: color-mix(in srgb, var(--warn) 50%, var(--line)); --role-hue: var(--warn); }
.you-card[data-role="vigilante"] .you-role { color: var(--warn); }
.you-card[data-role="changeling"],
.you-card[data-role="jester"] { border-color: color-mix(in srgb, var(--gold) 50%, var(--line)); --role-hue: var(--gold); }
.you-card[data-role="changeling"] .you-role,
.you-card[data-role="jester"] .you-role { color: var(--gold); }
.you-card[data-role="citizen"] { --role-hue: var(--ink-dim); }
.you-card[data-role="dead"] { opacity: .78; --role-hue: var(--ink-faint); }
.you-card[data-role="dead"] .you-role { color: var(--ink-faint); }

/* ── The prompt: the focal point ─────────────────────────────────────────────────
   This is the instruction — "choose who to eliminate", "you sleep through the
   night" — and in the old sheet it was 14px of body text in a tinted box, smaller
   than the room code. It is now the largest thing on the board, set as an
   inscription between two rules. Hierarchy, not decoration: at night this sentence
   is the only thing that matters. */

.prompt {
  position: relative;
  padding: 20px 18px;
  text-align: center;
  border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, var(--gold) 30%, transparent);
  background:
    radial-gradient(120% 140% at 50% 0%, color-mix(in srgb, var(--gold) 10%, transparent), transparent 62%),
    rgba(0, 0, 0, .34);
  box-shadow: inset 0 1px 0 rgba(240, 220, 166, .09);
  font-family: var(--display);
  font-size: clamp(17px, 2.6vw, 25px);
  font-weight: 700;
  line-height: 1.24;
  letter-spacing: .1em;
  font-variant-caps: all-small-caps;
  color: var(--gold-hi);
  text-wrap: balance;
  animation: riseblur .55s var(--ease-dramatic) both;
}
/* Rules above and below, inset from the edges — the plate the words are cut into. */
.prompt::before, .prompt::after {
  content: "";
  position: absolute;
  left: 22%;
  right: 22%;
  height: 1px;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--gold) 45%, transparent), transparent);
}
.prompt::before { top: 7px; }
.prompt::after { bottom: 7px; }

/* ── The players: a spread of cards ──────────────────────────────────────────────
   The single biggest structural change. The old list was fifteen horizontal rows in
   a column that was mostly empty on any real screen; this is a spread laid out on
   the table, portrait tiles that reflow by `auto-fill`. Each one is a card: engraved
   inner frame, a sigil medallion, the name beneath it, tags along the foot. */

.players {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
  /* ── Why the rows are a fixed height ──────────────────────────────────────────
     A spread of cards is not a list: tiles share a row, so with `auto` rows a vote
     badge appearing on one card changes the height of every card beside it. During a
     vote that happens continuously, which means a tile a player is reaching for keeps
     resizing under their thumb — and it is what made the end-game click loop in
     `browser.mjs` time out, Playwright refusing to click a target that never held
     still for two frames. A fixed row height decouples every card from its
     neighbours: content changes inside a tile can no longer move anything.
     186px is the worst case with room to spare — a two-line subline and two rows of
     tags come to 177px. */
  grid-auto-rows: 186px;
  gap: 9px;
}

.player {
  position: relative;
  display: grid;
  grid-template-rows: auto auto 1fr;
  justify-items: center;
  align-content: start;
  gap: 8px;
  padding: 15px 9px 11px;
  overflow: hidden;
  border-radius: var(--radius-tile);
  border: 1px solid var(--line);
  background:
    linear-gradient(180deg, rgba(240, 220, 166, .04), transparent 30%),
    linear-gradient(168deg, var(--panel-2), var(--panel) 72%, #100d0a);
  box-shadow: var(--e1);
  /* ── Why `transform` is not in this list ─────────────────────────────────────
     A tile is a tap target that `renderPlayers` destroys and rebuilds on every
     broadcast. A transform transition on it therefore fires in the middle of being
     replaced, over and over, and a target whose bounding box keeps moving cannot be
     clicked: Playwright refuses one that will not hold still for two frames, and a
     thumb reaching for a card that is drifting has the same problem. So hover and
     selection are expressed as LIGHT — border, wash, glow — none of which move the
     box. The press below is the exception, and it is safe because it happens after
     the pointer has already committed. */
  transition: border-color .15s var(--ease-snap), background .15s var(--ease-snap),
              box-shadow .15s var(--ease-snap), opacity .3s var(--spring-smooth);
}
/* ── Why the tiles have no entry animation of their own ──────────────────────────
   `renderPlayers` rebuilds every tile on every broadcast. An animation on `.player`
   therefore does not play "on arrival" — it replays on every chat message, and a card
   that is permanently mid-animation is not just noisy, it is unclickable: Playwright
   waits for an element to be stable before it clicks, and a real thumb has the same
   problem with a target that keeps sliding. The arrival is `.justseated`, granted by
   app.js only to seats it has not seen before. */
.player.justseated { animation: dealin .5s var(--spring-smooth) both; }
.player.justseated:nth-child(2) { animation-delay: .05s; }
.player.justseated:nth-child(3) { animation-delay: .1s; }
.player.justseated:nth-child(4) { animation-delay: .15s; }
.player.justseated:nth-child(5) { animation-delay: .2s; }
.player.justseated:nth-child(6) { animation-delay: .25s; }
.player.justseated:nth-child(n+7) { animation-delay: .3s; }
@keyframes dealin {
  from { opacity: 0; transform: translateY(10px) scale(.96); }
}
/* The engraved frame. Every tarot back, certificate and grimoire plate has one. */
.player::before {
  content: "";
  position: absolute;
  inset: 4px;
  border: 1px solid color-mix(in srgb, var(--gold) 13%, transparent);
  border-radius: 1px;
  pointer-events: none;
}

.player.selectable { cursor: pointer; }
@media (hover: hover) and (pointer: fine) {
  .player.selectable:hover {
    border-color: color-mix(in srgb, var(--gold) 70%, var(--line));
    background:
      linear-gradient(180deg, rgba(240, 220, 166, .09), transparent 34%),
      linear-gradient(168deg, var(--panel-2), var(--panel) 72%, #100d0a);
    box-shadow: var(--e2), 0 0 30px -12px color-mix(in srgb, var(--gold) 65%, transparent);
  }
  .player.selectable:hover::before { border-color: color-mix(in srgb, var(--gold) 30%, transparent); }
}
/* The press is the one movement a tile is allowed: by the time it fires the pointer
   has already committed, so nothing is aiming at a moving target. */
.player.selectable:active { transform: scale(.98); }

/* Chosen: the card is lifted out of the spread and lit. */
.player.chosen {
  border-color: var(--gold);
  background:
    linear-gradient(180deg, color-mix(in srgb, var(--gold) 13%, transparent), transparent 45%),
    linear-gradient(168deg, var(--panel-2), var(--panel));
  box-shadow: var(--e2), 0 0 28px -10px color-mix(in srgb, var(--gold) 75%, transparent);
}
.player.chosen::before { border-color: color-mix(in srgb, var(--gold) 45%, transparent); }

/* You. A foil bar across the top edge of your own card — distinct from `chosen`,
   which is a whole-card glow, so the two can be told apart when they coincide. */
.player.me { box-shadow: var(--e1), inset 0 3px 0 var(--gold); }

/* ── Dead: the card is turned face down ──────────────────────────────────────────
   Not an animation on `.dead`. `renderPlayers` rebuilds every tile on every
   broadcast, so anything keyed to the class alone would replay the death on every
   chat message — the same trap the vote tally documents. The steady state lives
   here; the one-shot flip is `.justdied` below, which app.js grants only on the
   render where the death is first seen. */
.player.dead {
  opacity: .5;
  filter: saturate(.25);
  border-color: color-mix(in srgb, var(--line) 70%, transparent);
}
.player.dead .pname { text-decoration: line-through; text-decoration-thickness: 1px; }
.player.dead .avatar { filter: grayscale(1); }
/* Two strokes across the card, drawn with gradients rather than glyphs so they scale
   with the tile and cost nothing. */
.player.dead::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: var(--radius-tile);
  background:
    linear-gradient(to bottom right, transparent calc(50% - 1px), color-mix(in srgb, var(--ink-faint) 45%, transparent) 50%, transparent calc(50% + 1px)),
    linear-gradient(to bottom left, transparent calc(50% - 1px), color-mix(in srgb, var(--ink-faint) 45%, transparent) 50%, transparent calc(50% + 1px));
}
/* The flip. The card turns to edge-on, goes dark, and comes back wearing the face-down
   styling above — so it reads as being turned over rather than fading out.
   It deliberately ends where it started (`rotateY(0)`) and not at 180deg: the tile is
   destroyed and rebuilt on the next broadcast, and a residual half-turn would snap
   back the moment anyone said anything in chat. */
.player.justdied { animation: turnover .8s var(--ease-dramatic) both; }
@keyframes turnover {
  0% { transform: perspective(700px) rotateY(0deg); }
  46% { transform: perspective(700px) rotateY(88deg); filter: saturate(.2) brightness(.45); }
  54% { transform: perspective(700px) rotateY(-88deg); filter: saturate(.2) brightness(.45); }
  100% { transform: perspective(700px) rotateY(0deg); }
}

.avatar {
  width: 44px; height: 44px;
  border-radius: 50%;
  display: grid; place-items: center;
  font-family: var(--display);
  font-weight: 700; font-size: 16px;
  letter-spacing: .02em;
  /* --seat is set per player in app.js; the fallback keeps a sane look without it. */
  background: linear-gradient(145deg,
    var(--seat, #3a3226),
    color-mix(in srgb, var(--seat, #241e16) 48%, #06050a));
  border: 1px solid color-mix(in srgb, var(--gold) 30%, transparent);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .2),
    0 0 0 3px rgba(0, 0, 0, .35),
    0 3px 10px -3px rgba(0, 0, 0, .7);
  color: #fff;
  text-shadow: 0 1px 3px rgba(0, 0, 0, .65);
}

.pmain { min-width: 0; width: 100%; text-align: center; }
.pname {
  font-family: var(--serif);
  font-weight: 600;
  font-size: 14px;
  line-height: 1.25;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.psub {
  font-family: var(--sans);
  font-size: 10.5px;
  color: var(--ink-faint);
  margin-top: 3px;
  line-height: 1.35;
  /* Two lines is the budget the fixed row height above is sized for. The subline is
     built in order — status, then connection, then who they voted for — so the part a
     third line would have carried is the vote, which the tally badge on the target
     already shows. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.tags { display: flex; gap: 4px; align-items: center; justify-content: center; flex-wrap: wrap; }
/* Engraved plaques, not pills: the radius language is committed everywhere. */
.tag {
  font-family: var(--sans);
  font-size: 9px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .12em;
  padding: 3px 6px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line);
  background: rgba(0, 0, 0, .45);
  color: var(--ink-dim);
  white-space: nowrap;
}
.tag.mafia { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
.tag.doctor { color: var(--good); border-color: color-mix(in srgb, var(--good) 45%, transparent); }
.tag.detective { color: #8cbcd8; border-color: #3d5f75; }
.tag.host { color: var(--gold); border-color: color-mix(in srgb, var(--gold) 45%, transparent); }
.tag.votes {
  color: var(--ink);
  font-family: var(--sans);
  font-variant-numeric: tabular-nums lining-nums;
  min-width: 22px;
  text-align: center;
  border-color: color-mix(in srgb, var(--gold) 38%, var(--line));
}
/* The tally is the one number people watch change, so it gets the only bump in the
   spread — and only when it actually changed. app.js adds `.bump` just to the counts
   that moved since the last render, because this list is rebuilt wholesale. */
.tag.votes.bump { animation: tally .34s var(--spring-smooth); }
@keyframes tally { from { transform: scale(1.5); color: var(--gold-hi); } }
.tag.off { opacity: .5; }

.kick {
  position: absolute;
  top: 3px; right: 3px;
  background: none;
  border: 0;
  cursor: pointer;
  color: var(--ink-faint);
  font-size: 15px;
  line-height: 1;
  padding: 3px 5px;
  border-radius: var(--radius-sm);
  transition: color .14s var(--ease-snap), background .14s var(--ease-snap);
}
.kick:hover { color: var(--danger); background: rgba(0, 0, 0, .5); }

/* The abstain control is inserted after the spread by app.js rather than living in the
   markup, so it is styled here where the spread is, not with the buttons. */
#abstain { align-self: center; margin: 2px auto 0; }

/* ── Panels: lobby, verdict, notes ───────────────────────────────────────────── */

.lobby-panel, .end-panel, .intel {
  position: relative;
  padding: 18px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background:
    linear-gradient(180deg, rgba(240, 220, 166, .035), transparent 34%),
    linear-gradient(168deg, var(--panel-2), var(--panel) 74%, #100d0a);
  box-shadow: var(--e2);
  animation: rise .5s var(--spring-smooth) both;
}
.lobby-panel::before, .end-panel::before, .intel::before {
  content: "";
  position: absolute;
  inset: 0 0 auto;
  height: 1px;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--gold) 55%, transparent), transparent);
}
/* Panel titles are inscriptions with a rule running out from them to the edge —
   the detail that stops a small-caps label from reading as a form legend. */
.lobby-panel h3, .intel h3 {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 15px;
  font-family: var(--display);
  font-size: 12px;
  font-weight: 700;
  font-variant-caps: all-small-caps;
  letter-spacing: .2em;
  color: var(--gold);
}
.lobby-panel h3::after, .intel h3::after {
  content: "";
  flex: 1;
  height: 1px;
  background: linear-gradient(90deg, color-mix(in srgb, var(--gold) 35%, transparent), transparent);
}

.config-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; }
.config-grid label {
  display: block;
  font-family: var(--sans);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.config-grid input {
  margin-top: 6px;
  padding: 8px 6px;
  text-align: center;
  font-variant-numeric: tabular-nums lining-nums;
  font-weight: 700;
}
.config-grid input:disabled, .check input:disabled, .choice select:disabled { opacity: .4; cursor: not-allowed; }
/* A disabled control's own label goes quiet too, so a knob that a sibling setting has
   switched off does not read as merely unfocused. */
.config-grid label:has(input:disabled), .check:has(input:disabled), .choice:has(select:disabled) { opacity: .52; }
@media (max-width: 520px) { .config-grid { grid-template-columns: repeat(2, 1fr); } }

.check {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 12px;
  font-size: 13px;
  color: var(--ink-dim);
  line-height: 1.45;
}
.check input {
  width: auto;
  flex: none;
  accent-color: var(--gold);
  box-shadow: none;
  cursor: pointer;
}
.choice { display: block; margin-top: 14px; font-size: 13px; color: var(--ink-dim); }
.choice select { margin-top: 7px; width: 100%; padding: 9px 10px; cursor: pointer; }

/* ── The verdict ───────────────────────────────────────────────────────────────── */

.end-panel { text-align: center; }
.end-result {
  font-family: var(--display);
  font-size: clamp(22px, 4vw, 30px);
  font-weight: 700;
  letter-spacing: .12em;
  font-variant-caps: all-small-caps;
  line-height: 1.2;
  margin-bottom: 16px;
  text-shadow: 0 0 34px currentColor;
  animation: verdict .8s var(--ease-dramatic) both;
}
@keyframes verdict { from { opacity: 0; transform: scale(.84); filter: blur(9px); } }
.end-result.town { color: var(--good); }
.end-result.mafia { color: var(--danger); }
.end-line + .end-line { margin-top: 8px; font-size: 15px; }
.end-line.jester { color: var(--gold); }

/* ── Your notes ────────────────────────────────────────────────────────────────── */

.intel ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 9px; }
.intel li {
  font-size: 13px;
  line-height: 1.55;
  padding: 11px 13px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, .4);
  border-left: 2px solid var(--gold);
  color: var(--ink);
  animation: rise .22s var(--spring-smooth) both;
}

/* ── The record: tabs, feed, chat ────────────────────────────────────────────────
   The log is the game's memory, so it is set as a page rather than a chat window:
   serif prose on a sunken ground, the narrator in italic behind a brass rule, the
   players in their own voice. */

.tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.tab {
  padding: 7px 13px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line);
  background: rgba(0, 0, 0, .38);
  cursor: pointer;
  font-family: var(--display);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .14em;
  font-variant-caps: all-small-caps;
  color: var(--ink-dim);
  transition: color .14s var(--ease-snap), background .14s var(--ease-snap),
              border-color .14s var(--ease-snap);
}
.tab:hover { color: var(--ink); border-color: color-mix(in srgb, var(--gold) 40%, var(--line)); }
.tab[aria-selected="true"] {
  background: linear-gradient(180deg, var(--gold-hi), var(--gold) 55%, var(--gold-dim));
  border-color: color-mix(in srgb, var(--gold) 70%, transparent);
  color: var(--accent-ink);
  box-shadow: 0 5px 18px -8px color-mix(in srgb, var(--gold) 80%, transparent);
}

.feed {
  list-style: none;
  margin: 0;
  padding: 16px 18px;
  display: flex;
  flex-direction: column;
  gap: 11px;
  flex: 1;
  min-height: min(52vh, 460px);
  max-height: min(64vh, 620px);
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background:
    linear-gradient(180deg, rgba(0, 0, 0, .4), transparent 6%),
    var(--bg-2);
  box-shadow: inset 0 3px 14px rgba(0, 0, 0, .55);
  scrollbar-width: thin;
  scrollbar-color: var(--gold-dim) transparent;
}
.feed li {
  font-family: var(--serif);
  font-size: 14px;
  line-height: 1.6;
  /* Short on purpose: the feed is rebuilt on every broadcast, so this is paid by every
     line every time anyone speaks, not once on arrival. 180ms reads as ink landing;
     anything longer reads as the whole log flickering. */
  animation: rise .18s var(--spring-smooth) both;
}
.feed .who {
  font-family: var(--display);
  font-weight: 700;
  font-size: 12.5px;
  letter-spacing: .07em;
  font-variant-caps: all-small-caps;
  color: var(--ink-dim);
}
/* Narration is the narrator's voice, not a player's: it gets the brass rule and the
   italic, which is what lets someone skim a long log for what the game itself said. */
.feed .system {
  color: var(--gold);
  font-style: italic;
  padding-left: 13px;
  border-left: 2px solid color-mix(in srgb, var(--gold) 50%, transparent);
}
.feed .mafia .who { color: var(--danger); }
.feed .ghost { color: var(--ink-faint); font-style: italic; }
.feed .ghost .who { color: #9c8fa8; }

.chat-row { display: flex; gap: 9px; }
.chat-row input { flex: 1; font-family: var(--serif); }
.chat-row .btn { width: auto; flex: none; padding: 11px 18px; }

/* ── Toast ─────────────────────────────────────────────────────────────────────
   The background and text colours are set inline by app.js (var(--danger) or
   var(--good)), so they are deliberately absent here — a colour in this rule would
   be dead code that looks live. */
.toast {
  position: fixed;
  left: 50%;
  bottom: 26px;
  transform: translateX(-50%);
  padding: 12px 20px;
  border-radius: var(--radius-sm);
  font-family: var(--sans);
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: .06em;
  box-shadow: 0 16px 44px -12px rgba(0, 0, 0, .85);
  z-index: 50;
  animation: rise .3s var(--spring-smooth) both;
  max-width: calc(100vw - 32px);
  text-align: center;
}

/* ── Phase interstitial ──────────────────────────────────────────────────────────
   The phase machine spends six seconds in `dawn` and `dusk` doing nothing but
   narrating, and night/day are the game's whole rhythm. This is that beat, ridden on
   the phase change that already arrives in the normal state broadcast — so it costs
   no extra message and no request.

   `pointer-events: none` is load-bearing, not tidiness: the veil is on screen at
   exactly the moment a player may be reaching for a target, and it must never
   intercept that tap. A browser check asserts it, by asking the browser what is
   actually under the middle of the screen. ────────────────────────────────────── */
.phase-flash {
  position: fixed;
  inset: 0;
  z-index: 30;
  pointer-events: none;
  display: grid;
  place-items: center;
  text-align: center;
  padding: 24px;
  background: radial-gradient(58% 50% at 50% 50%, rgba(6, 4, 3, .82), rgba(0, 0, 0, .95));
  animation: flashveil 2.4s var(--spring-smooth) forwards;
}
/* The ring: a hairline circle that opens out from behind the glyph. One gesture, one
   element, no image — and the reason the beat reads as a rite rather than a toast. */
.phase-flash::before {
  content: "";
  position: absolute;
  width: min(300px, 64vw);
  aspect-ratio: 1;
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--gold) 45%, transparent);
  box-shadow: 0 0 60px -20px color-mix(in srgb, var(--gold) 70%, transparent),
              inset 0 0 60px -30px color-mix(in srgb, var(--gold) 60%, transparent);
  animation: flashring 2.4s var(--ease-dramatic) forwards;
}
.phase-flash .flash-icon {
  font-size: 56px;
  line-height: 1;
  display: block;
  position: relative;
  filter: drop-shadow(0 0 30px rgba(224, 185, 104, .45)) saturate(.9);
  animation: flashicon 2.4s var(--ease-dramatic) forwards;
}
.phase-flash .flash-name {
  display: block;
  position: relative;
  margin-top: 20px;
  font-family: var(--display);
  font-size: clamp(28px, 7.5vw, 52px);
  font-weight: 700;
  letter-spacing: .34em;
  text-indent: .34em;
  text-transform: uppercase;
  color: #f6ecd8;
  text-shadow: 0 0 46px rgba(200, 164, 92, .5);
  animation: flashname 2.4s var(--ease-dramatic) forwards;
}
@keyframes flashveil {
  0% { opacity: 0; }
  14%, 62% { opacity: 1; }
  100% { opacity: 0; }
}
@keyframes flashring {
  0% { opacity: 0; transform: scale(.35); }
  30% { opacity: 1; transform: scale(1); }
  72% { opacity: .7; transform: scale(1.02); }
  100% { opacity: 0; transform: scale(1.16); }
}
@keyframes flashicon {
  0% { opacity: 0; transform: scale(.5) rotate(-12deg); }
  24% { opacity: 1; transform: scale(1) rotate(0deg); }
  70% { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.08); }
}
@keyframes flashname {
  0% { opacity: 0; letter-spacing: .8em; filter: blur(11px); }
  32% { opacity: 1; letter-spacing: .34em; filter: blur(0); }
  70% { opacity: 1; }
  100% { opacity: 0; }
}

/* ── Role reveal: the sealed card ────────────────────────────────────────────────
   Being dealt a role is the one moment in the game that is purely yours. It arrives
   as a sealed card: a wax-red back with an embossed sigil turns over onto parchment,
   the seal breaks in a ring of gold, and the card settles.

   The flip is real backface culling, not a crossfade, so the back of the card is
   genuinely the back. Like the veil above it is `pointer-events: none` for its whole
   life: it appears the instant roles are dealt, which is also the instant the night
   panel becomes tappable, and it must never eat that first tap. Total run is 3.6s
   against app.js hiding the element at 3700ms — the card must be face-up and settled
   well before 1800ms, which is when the screenshot in `browser.mjs` is taken. ──── */
.role-reveal {
  position: fixed;
  inset: 0;
  z-index: 35;
  pointer-events: none;
  display: grid;
  place-items: center;
  perspective: 1500px;
  background: radial-gradient(56% 48% at 50% 50%, rgba(8, 5, 4, .84), rgba(0, 0, 0, .96));
  animation: rrveil 3.6s var(--spring-smooth) forwards;
}
/* The seal breaking: a gold ring that snaps outward as the card lands. */
.role-reveal::before {
  content: "";
  position: absolute;
  width: min(300px, 72vw);
  aspect-ratio: 1;
  border-radius: 50%;
  border: 1px solid var(--gold);
  animation: rrseal 3.6s var(--ease-dramatic) forwards;
}

.rr-card {
  position: relative;
  width: min(262px, 62vw);
  aspect-ratio: 5 / 7.2;
  transform-style: preserve-3d;
  animation: rrdeal 3.6s var(--ease-dramatic) forwards;
}

.rr-face {
  position: absolute;
  inset: 0;
  border-radius: 6px;
  backface-visibility: hidden;
  box-shadow: 0 44px 100px -32px #000, 0 0 0 1px rgba(0, 0, 0, .7);
  overflow: hidden;
}

/* The back: sealing wax, with the sigil pressed into it. Three stacked radials do the
   embossing — a lit rim, a shadowed well, and the boss in the middle. */
.rr-back {
  transform: rotateY(180deg);
  background:
    radial-gradient(circle at 50% 44%, rgba(255, 214, 150, .16) 0 16%, transparent 17%),
    radial-gradient(circle at 50% 44%, transparent 0 21%, rgba(0, 0, 0, .45) 22%, transparent 30%),
    radial-gradient(circle at 50% 40%, #a8202e, #6d101c 46%, #4a0a13 72%),
    #4a0a13;
  border: 1px solid color-mix(in srgb, var(--gold) 40%, transparent);
}
.rr-back::after {
  content: "";
  position: absolute;
  inset: 9px;
  border: 1px solid rgba(240, 220, 166, .26);
  border-radius: 2px;
}

/* The face: aged parchment, deliberately the one bright surface in the whole app. */
.rr-front {
  display: grid;
  grid-template-rows: auto 1fr auto;
  padding: 15px;
  background:
    radial-gradient(130% 95% at 50% 0%, #fdf6e4, #f1e5c9 58%, #ddcfae);
  border: 1px solid #b3a179;
  color: #1c1610;
}
.rr-front::after {
  content: "";
  position: absolute;
  inset: 8px;
  border: 1px solid color-mix(in srgb, var(--role-hue, #1c1610) 40%, #c4b48f);
  border-radius: 2px;
  pointer-events: none;
}

/* Corner pips, as a playing card has them: same glyph, one upright, one inverted. */
.rr-pip { font-size: 17px; line-height: 1; z-index: 1; filter: saturate(.85); }
.rr-pip.rr-br { justify-self: end; transform: rotate(180deg); }

/* Flex, not grid: as a grid the two implicit rows each took half the card's middle and
   pushed the glyph and the name to opposite ends of it. Here they sit together. */
.rr-mid {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 15px;
  z-index: 1;
}
.rr-emoji {
  font-size: clamp(52px, 15vw, 74px);
  line-height: 1;
  filter: drop-shadow(0 6px 14px rgba(0, 0, 0, .28));
}
.rr-name {
  font-family: var(--display);
  font-weight: 700;
  font-size: clamp(15px, 4.4vw, 19px);
  letter-spacing: .16em;
  text-transform: uppercase;
  text-align: center;
  padding: 0 6px;
  color: color-mix(in srgb, var(--role-hue, #1c1610) 66%, #17110b);
}

/* The card takes its tint from the role, the same colours the role panel uses, so the
   reveal and the panel it settles into are visibly the same fact. Aged toward the
   palette rather than the saturated web values the old sheet used. */
.role-reveal[data-role="mafia"],
.role-reveal[data-role="godfather"],
.role-reveal[data-role="framer"],
.role-reveal[data-role="blackmailer"] { --role-hue: #8c1522; }
.role-reveal[data-role="doctor"] { --role-hue: #1a6b48; }
.role-reveal[data-role="detective"] { --role-hue: #1c5170; }
.role-reveal[data-role="bodyguard"] { --role-hue: #175f42; }
.role-reveal[data-role="vigilante"] { --role-hue: #8a5f14; }
.role-reveal[data-role="changeling"],
.role-reveal[data-role="jester"] { --role-hue: #6a4a12; }
.role-reveal[data-role="citizen"] { --role-hue: #3c3527; }

/* Two turns and a half, so it lands face-up having shown its wax back on the way
   round. The scale and the drop are one gesture: it is dealt onto the table. */
@keyframes rrdeal {
  0%   { opacity: 0; transform: translateY(-14%) scale(.16) rotateZ(-24deg) rotateY(-900deg); }
  8%   { opacity: 1; }
  44%  { transform: translateY(0) scale(1) rotateZ(0deg) rotateY(0deg); }
  88%  { opacity: 1; transform: translateY(0) scale(1) rotateY(0deg); }
  100% { opacity: 0; transform: translateY(-6%) scale(.97) rotateY(0deg); }
}
@keyframes rrseal {
  0%, 34% { opacity: 0; transform: scale(.42); }
  46% { opacity: .9; transform: scale(1); }
  62% { opacity: 0; transform: scale(1.5); }
  100% { opacity: 0; transform: scale(1.5); }
}
@keyframes rrveil {
  0% { opacity: 0; }
  10%, 82% { opacity: 1; }
  100% { opacity: 0; }
}

/* ── Motion preferences ──────────────────────────────────────────────────────────
   Someone who has asked for less motion still needs the information, so the two
   overlays keep their timing and lose their movement: they hold still, then go. The
   blanket rule strips everything else, which is safe because no entry animation in
   this file puts its hidden state anywhere but inside a keyframe `from` — killing
   the animation leaves every element at its natural opacity, never invisible. */
@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
  .phase-flash { animation: flashveil 2.4s steps(1, end) forwards !important; }
  .role-reveal { animation: rrveil 3.6s steps(1, end) forwards !important; }
  .rr-card { animation: rrhold 3.6s steps(1, end) forwards !important; }
  @keyframes rrhold { 0%, 96% { opacity: 1; } 100% { opacity: 0; } }
}

[hidden] { display: none !important; }
