:root {
  --bg: #121213;
  --panel: #1c1c1e;
  --panel-2: #232326;
  --border: #3a3a3c;
  --text: #f5f5f5;
  --text-dim: #9a9a9c;
  --accent: #3b82f6;
  --accent-dark: #2563eb;
  --correct: #538d4e;
  --present: #b59f3b;
  --absent: #3a3a3c;
  --danger: #d93025;
  --radius: 10px;
  --board-corner-radius: 6px;
  --board-shadow-depth: 3px;
  --deploy-pipeline-verified: 1px;
  /* R-120-1: single reading-width cap shared by .wrap and .site-header so
     they always resize together instead of drifting to independent
     numbers (was 640px vs 480px, which double-letterboxed the header
     inside the already-centered .wrap on any viewport between the old
     600px breakpoint and .wide-wrap's 1100px). */
  --content-max: 640px;
}

* {
  box-sizing: border-box;
  /* Ancestor-only touch-action is unreliable on iOS Safari for suppressing
     double-tap-to-zoom - it has to be set on every element that can be tapped. */
  touch-action: manipulation;
}

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  min-height: 100%;
}

body { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }

a { color: inherit; }

.wrap {
  /* R-115: was a hard 480px cap on every viewport, which left large empty
     gutters on anything wider than a phone (tablets, resized desktop
     windows) and starved the keyboard below of the extra width it could
     otherwise grow into. Still a phone-style single-column layout, so this
     doesn't stretch edge-to-edge on a desktop monitor - it just raises the
     ceiling enough that the keyboard (.keyboard, .key below) has real room
     to grow past its old size on viewports that have it to give. */
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 20px 16px 40px;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

/* R-114-1: on viewports narrower than a tablet, .wrap's fixed 480px cap
   letterboxes the whole app (board, keyboard, header) into a centered
   column with empty strips on both sides whenever the device is wider
   than 480 logical px. Let .wrap fill the real viewport width below this
   breakpoint instead; tablet/desktop keep the 480px cap above it. */
@media (max-width: 600px) {
  /* R-121-1: the base rule above still applies 16px horizontal padding on
     every screen sharing .wrap (setup/lobby/countdown/results) - only
     #section-playing had this cut to 6px (see the body:has() rule further
     down), leaving a visible 16px+ gutter on every other screen on phones.
     Apply the reduced 6px horizontal padding as the mobile default here so
     it isn't gameplay-only. */
  .wrap { max-width: none; margin: 0 5%; padding-left: 6px; padding-right: 6px; }
}

.center-wrap { justify-content: center; align-items: stretch; }

/* margin: 0 auto so .wide-wrap centers itself once its own 1100px cap is
   the binding constraint - previously .wrap's smaller 640px cap always won
   first, so .wide-wrap's cap never actually applied and this was latent. */
.wide-wrap { max-width: 1100px; margin: 0 auto; }

/* R-152-1: on mobile, .wide-wrap's auto-centered margin leaves the content
   at a fixed width instead of using the narrow viewport - switch to a small
   percentage-based side margin below the shared 600px breakpoint, matching
   .wrap's mobile treatment above. #section-playing.wide-wrap is untouched:
   its mobile gameplay layout comes from .wrap (via the body:has() rule
   above), and the min-width: 601px override below only applies on desktop
   anyway, so there is no overlap to worry about. */
@media (max-width: 600px) {
  .wide-wrap { margin: 0 3%; }
}

/* R-123-1: during gameplay, the shared .wide-wrap 1100px reading-width cap
   (still correct for setup/lobby/results, which keep .wide-wrap unchanged
   above) squeezes the board/opponents/keyboard into a centered column with
   large empty gutters on any monitor wider than ~1100px. Override it here,
   scoped to #section-playing only, removing the ceiling and the auto
   centering - #section-playing's parent .wrap already supplies the small
   fixed 6px side padding during gameplay (see the body:has() rule above),
   so this alone is enough to reach minimal, non-centered side margins.
   Scoped above the 600px mobile breakpoint - the R-122-1 stacked mobile
   layout already uses that same minimal .wrap padding and is untouched by
   this. */
@media (min-width: 601px) {
  #section-playing.wide-wrap {
    max-width: none;
    margin: 0;
  }
}

.display-wrap {
  max-width: none;
  width: 100%;
  height: 100vh;
  padding: 16px 24px;
  overflow-y: auto;
}

/* R-175-1: the lobby screen's layout (below) is sized to always fit within
   the viewport at any player count/screen size, so the overflow-y: auto
   fallback above - which produced a visible scrollbar whenever that sizing
   was even slightly off - is replaced with a hard clip here. Scoped with
   :has() (same pattern as the body:has(#section-playing...) rules above)
   to the moment the lobby section is actually visible, so the "playing"
   and "results" screens keep their existing scroll-fallback behavior
   untouched. */
.display-wrap:has(#section-lobby:not(.hidden)) {
  overflow: hidden;
}

.brand { text-align: center; margin-bottom: 24px; }

.logo {
  font-size: 44px;
  font-weight: 800;
  letter-spacing: 3px;
  margin: 0 0 6px;
  background: linear-gradient(135deg, #3b82f6, #60a5fa);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.tagline { color: var(--text-dim); margin: 0; font-size: 15px; }

.menu-card {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 12px 0 20px;
}

.big-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 16px 18px;
  border-radius: var(--radius);
  background: var(--panel-2);
  border: 1px solid var(--border);
  color: var(--text);
  text-decoration: none;
  font-size: 17px;
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

button.big-btn {
  font-family: inherit;
  line-height: normal;
}

.big-btn.primary {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}

.big-btn.primary:active { background: var(--accent-dark); }

.big-btn.ghost { background: transparent; }

.big-btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }

.big-btn:disabled, .big-btn.disabled { opacity: .45; cursor: not-allowed; }

.auth-bar { text-align: center; }
.auth-bar .big-btn { margin-bottom: 8px; }
.account-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.auth-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
.auth-avatar { width: 26px; height: 26px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.auth-name { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-signout {
  background: transparent;
  border: none;
  color: var(--text-dim);
  font-size: 13px;
  font-weight: 600;
  text-decoration: underline;
  cursor: pointer;
  padding: 0;
  flex-shrink: 0;
}

.hint { color: var(--text-dim); font-size: 13px; text-align: center; line-height: 1.5; }

@keyframes pulse-fade {
  0%, 100% { opacity: 1; }
  50% { opacity: .4; }
}

.waiting-pulse { animation: pulse-fade 1.8s ease-in-out infinite; }

@media (prefers-reduced-motion: reduce) {
  .waiting-pulse { animation: none; }
}

.hint-emphasis { font-size: 20px; font-weight: 700; color: var(--text); }

.field { margin-bottom: 10px; display: flex; flex-direction: column; gap: 6px; }

/* R-136-1: position: relative so the avatar popover below (.avatar-expand
   .avatar-picker) is contained relative to the full row width instead of
   the narrow avatar toggle button - see that rule for why. */
.field-row { display: flex; gap: 10px; margin-bottom: 10px; align-items: flex-end; position: relative; }
.field-row .field { flex: none; margin-bottom: 0; }
/* R-137-1: min-width: 0 overrides the flex item's default min-width: auto,
   which otherwise refuses to shrink below the input's intrinsic content
   width and pushes the fixed-width avatar toggle (.avatar-expand) outside
   the panel on narrow viewports. */
.field-row .field.field-grow { flex: 1; min-width: 0; }

.field label { font-size: 13px; color: var(--text-dim); }

.field-error { color: var(--danger); font-size: 13px; margin: 2px 0 0; }

.field input[type=text],
.field input[type=number],
.field input[type=password] {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 14px;
  font-size: 18px;
  color: var(--text);
  letter-spacing: 1px;
}

.field input[type=text]:focus,
.field input[type=number]:focus,
.field input[type=password]:focus { outline: 2px solid var(--accent); }

.field select.lang-select-inline {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 14px;
  font-size: 16px;
  color: var(--text);
}
.field select.lang-select-inline:focus { outline: 2px solid var(--accent); }

.lang-badge { text-align: center; color: var(--text-dim); font-size: 16px; margin-bottom: 12px; }

.segmented-control { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.segmented-option {
  font-size: 13px;
  font-weight: 600;
  padding: 10px 4px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text-dim);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.segmented-option:disabled { opacity: .45; cursor: not-allowed; }
.segmented-option.is-selected.difficulty-random { background: rgba(154, 154, 156, .15); border-color: var(--text-dim); color: var(--text); }
.segmented-option.is-selected.difficulty-easy { background: rgba(83, 141, 78, .15); border-color: var(--correct); color: var(--correct); }
.segmented-option.is-selected.difficulty-medium { background: rgba(181, 159, 59, .15); border-color: var(--present); color: var(--present); }
.segmented-option.is-selected.difficulty-hard { background: rgba(217, 48, 37, .15); border-color: var(--danger); color: var(--danger); }

.checkbox-field { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.checkbox-field input { width: 20px; height: 20px; }
.checkbox-field label { font-size: 14px; }
.info-icon-btn {
  background: transparent;
  border: none;
  color: var(--text-dim);
  cursor: pointer;
  font-size: 16px;
  line-height: 1;
  padding: 6px;
  margin: -6px;
  border-radius: 50%;
}
.info-icon-btn:hover, .info-icon-btn:focus-visible { color: var(--text); background: var(--panel-2); }

.panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 14px;
  margin-bottom: 12px;
}

.hint-compact { font-size: 12px; margin: 6px 0 10px; text-align: left; }

.error-banner {
  /* fixed overlay, not in normal flow, so showing/hiding it never shifts any
     other element on the page */
  position: fixed;
  top: calc(14px + env(safe-area-inset-top));
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  width: max-content;
  max-width: min(90vw, 420px);
  background: #4a1c1c;
  border: 1px solid var(--danger);
  color: #ffdcd8;
  padding: 10px 16px;
  border-radius: var(--radius);
  font-size: 14px;
  text-align: center;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35);
  display: none;
}
.error-banner.show { display: block; }

.hidden { display: none !important; }

.site-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 16px 0; max-width: var(--content-max); margin: 0 auto; }
/* R-114-1: keep .site-header's width in lockstep with .wrap (R-112-1's
   intent) - same breakpoint, same "no cap below 600px" behavior, so the
   header's edge buttons and the board/keyboard below it stay the same
   width on every viewport instead of the header re-centering on its own. */
@media (max-width: 600px) {
  /* R-121-1: same reasoning as .wrap above - the header's base 16px side
     padding wasn't being reduced outside the gameplay screen. */
  .site-header { max-width: none; padding-left: 6px; padding-right: 6px; }
}
.menu-slot { display: flex; align-items: center; gap: 8px; min-width: 0; }
.lang-select {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-dim);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 10px;
  max-width: 140px;
}
@media (max-width: 480px) {
  .lang-select { font-size: 12px; padding: 5px 8px; max-width: 96px; }
}
.display-page .site-header { padding: 4px 12px 0; }
.display-page .lang-select { font-size: 11px; padding: 3px 6px; max-width: 80px; }

/* R-175-1: the top-right account icon is dead chrome on the Big Screen -
   display.js never wires up Fordle.renderAccountMenu for it, and this is a
   read-only spectator view with no account actions to take. Scoped to
   .display-page only - templates/base.html's markup is shared by every
   other screen and must keep showing it there. */
.display-page .account-menu { display: none; }

/* ---- R-87-1: shared top-right account menu ---- */

.account-menu { position: relative; flex-shrink: 0; }
.account-details { position: relative; }

.account-icon-btn {
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--panel-2);
  border: 1px solid var(--border);
  font-size: 23px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.account-icon-btn::-webkit-details-marker,
.account-icon-btn::marker { display: none; content: ""; }
.account-details[open] .account-icon-btn { border-color: var(--accent); }
/* R-141-1: the shared "img { width: 1em; height: 1em }" rule (below) sizes
   the Google profile photo relative to font-size, so it needs to match the
   48px container to fill it edge-to-edge - the fallback glyph emoji keeps
   the smaller inherited font-size from .account-icon-btn so it doesn't look
   oversized. */
.account-icon-glyph img { font-size: 48px; }

.account-panel {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 30;
  width: 260px;
  max-width: calc(100vw - 32px);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.account-section-identity:empty { display: none; }

.account-section-lang,
.account-section-prefs,
.account-section-attack {
  padding-top: 12px;
  border-top: 1px solid var(--border);
}

.account-section-label {
  display: block;
  font-size: 11px;
  font-weight: 700;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: .5px;
  margin-bottom: 6px;
}

.account-section-lang .lang-select { width: 100%; max-width: none; }

.account-prefs-row { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 600; }
.account-prefs-avatar { font-size: 20px; }
.account-prefs-empty { font-size: 13px; color: var(--text-dim); }

.account-attack-row { display: flex; align-items: center; gap: 8px; }
.account-attack-icon { font-size: 18px; }
.account-attack-name { flex: 1; font-size: 15px; font-weight: 600; }
.account-attack-swatch {
  width: 32px;
  height: 26px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: none;
  cursor: pointer;
}
.account-attack-swatch::-webkit-color-swatch-wrapper { padding: 2px; }
.account-attack-swatch::-webkit-color-swatch { border: none; border-radius: 4px; }

@media (max-width: 480px) {
  .account-panel { width: 230px; }
}

.menu-bar {
  margin-bottom: 14px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.menu-link {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-dim);
  text-decoration: none;
  padding: 7px 12px;
  border-radius: 8px;
  background: var(--panel-2);
  border: 1px solid var(--border);
}
.menu-link:active { background: var(--panel); }

/* ---- lobby / code ---- */

.code-display {
  text-align: center;
  font-size: 46px;
  font-weight: 800;
  letter-spacing: 10px;
  color: var(--accent);
  background: var(--panel-2);
  border-radius: var(--radius);
  padding: 18px 8px;
  margin-bottom: 6px;
}

/* R-170-1: the code grew from 4 to 7 digits, which overflows the 46px/10px
   sizing tuned for 4 characters on narrow mobile widths - shrink both on
   viewports where .wrap itself goes edge-to-edge (see .wrap's own
   @media (max-width: 600px) rule above). */
@media (max-width: 600px) {
  .code-display { font-size: 32px; letter-spacing: 5px; }
}

.code-caption { text-align: center; color: var(--text-dim); font-size: 13px; margin-bottom: 18px; }

/* R-166-2: the setup screen no longer separates the "GAME CODE" heading and
   "Or scan to join" caption from the rest of the lobby, so both need to take
   up less vertical space now that they sit directly above the merged
   settings/players panels. Scoped to #section-lobby only - .section-title
   and .code-caption are shared classes used elsewhere (e.g. display.html's
   own .code-caption), which keep their normal spacing. */
#section-lobby > .section-title { margin-bottom: 4px; }
#section-lobby > .code-caption { margin-bottom: 10px; font-size: 12px; }

/* Centers "Start Game" without changing the shared .big-btn class (which
   other buttons like #btn-play-again/#btn-new-game rely on staying
   full-width in their .footer-actions rows). */
#btn-start { width: fit-content; min-width: 220px; margin: 4px auto 0; }

.qr-display {
  display: flex;
  justify-content: center;
  align-items: center;
  background: var(--panel-2);
  border-radius: var(--radius);
  padding: 12px;
  margin: 0 auto 6px;
  width: fit-content;
}

.qr-display canvas {
  /* Sized in JS (static/js/qr.js) to match its native canvas resolution
     exactly, so the browser never has to scale it - scaling would blur
     the QR's sharp module edges and make it harder for a phone camera to
     decode. Do not force a fixed width/height here. */
  display: block;
}

.player-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }

.player-list li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: var(--panel-2);
  border-radius: 8px;
  padding: 10px 14px;
  font-size: 15px;
}

.player-list .dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; margin-right: 8px; flex-shrink: 0; }
.player-list .dot.off { background: #6b7280; }
.player-list .player-avatar { font-size: 16px; margin-right: 6px; }
.player-list .tag { font-size: 11px; color: var(--text-dim); border: 1px solid var(--border); padding: 2px 6px; border-radius: 6px; }
.player-list .player-right { display: flex; align-items: center; gap: 8px; }
.player-list .sb-wins { font-size: 13px; font-weight: 700; color: var(--accent); }
.player-list li.sb-leader { border: 1px solid var(--accent); }

.handicap-btn {
  font-size: 16px;
  line-height: 1;
  padding: 4px 7px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: transparent;
  opacity: .35;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.handicap-btn.on { opacity: 1; background: rgba(59, 130, 246, .18); border-color: var(--accent); }
.tag.handicap { color: var(--accent); border-color: var(--accent); }

.advanced-settings {
  margin: 0 0 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--panel-2);
}
.advanced-settings summary {
  list-style: none;
  cursor: pointer;
  font-size: 13px;
  color: var(--text-dim);
  -webkit-tap-highlight-color: transparent;
}
.advanced-settings summary::-webkit-details-marker,
.advanced-settings summary::marker { display: none; content: ""; }
.advanced-settings[open] summary { margin-bottom: 10px; color: var(--text); }
.advanced-settings-body .field { margin-bottom: 6px; }
.advanced-settings-body .hint-compact { margin: 0; text-align: left; }

.avatar-expand { flex-shrink: 0; }

.avatar-expand summary {
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  border-radius: var(--radius);
  background: var(--panel-2);
  border: 1px solid var(--border);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.avatar-expand summary::-webkit-details-marker,
.avatar-expand summary::marker { display: none; content: ""; }

.avatar-expand[open] summary { border-color: var(--accent); }

/* Mirrors the disabled look/behavior of the other lobby settings controls
   (see setLobbySettingsDisabled in host.js) once the game has started -
   <details> has no native "disabled" attribute, so this is a manual class. */
.avatar-expand.disabled { opacity: .45; pointer-events: none; }

.avatar-summary-icon { font-size: 36px; line-height: 1; }

.avatar-expand .avatar-picker {
  /* R-136-1: was anchored (position/width/max-width) to .avatar-expand, the
     58px-wide toggle button, with a max-width guessed from the viewport
     (100vw - 48px). That guess didn't account for .wrap's side margins or
     .panel's own padding, so on narrower phones the popover's real available
     space was smaller than the guess and it spilled past the panel's left
     edge. Anchoring to .field-row (the full-width row, now position:
     relative - see above) instead means "min(264px, 100%)" is always exactly
     the setup panel's actual content width, so it can never extend past it. */
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 20;
  width: min(264px, 100%);
  max-height: 210px;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
}

.avatar-picker {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
  gap: 8px;
}
.avatar-btn {
  font-size: 24px;
  line-height: 1;
  padding: 8px 0;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--panel-2);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.avatar-btn.on {
  border-color: var(--accent);
  background: rgba(59, 130, 246, .18);
}
.avatar-btn-photo { padding: 4px; display: flex; align-items: center; justify-content: center; }
.avatar-btn-photo img { width: 100%; aspect-ratio: 1; border-radius: 6px; object-fit: cover; }

/* R-139-2: shared img rendering wherever an avatar value can be a photo
   (Fordle.renderAvatarValue) - each container below sets its own size, this
   just makes the <img> fill and crop consistently within it. */
.avatar-summary-icon img,
.account-icon-glyph img,
.account-prefs-avatar img,
.player-list .player-avatar img,
.opp-line .opp-avatar img,
.display-avatar img {
  width: 1em;
  height: 1em;
  border-radius: 50%;
  object-fit: cover;
  vertical-align: -0.15em;
}

/* ---- R-75-2: lobby "save to group" popover ---- */

.group-popover { position: relative; flex-shrink: 0; }

.group-save-btn {
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-dim);
  font-size: 13px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.group-save-btn::-webkit-details-marker,
.group-save-btn::marker { display: none; content: ""; }
.group-popover[open] .group-save-btn { border-color: var(--accent); color: var(--accent); }

.group-popover-panel {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 20;
  width: 240px;
  max-width: calc(100vw - 48px);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
}

.group-popover-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }

.group-popover-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 160px;
  overflow-y: auto;
  margin-bottom: 10px;
}

.group-popover-loading, .group-popover-empty { font-size: 13px; color: var(--text-dim); }

.group-popover-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  background: var(--panel-2);
  border-radius: 8px;
  padding: 6px 10px;
  font-size: 13px;
}
.group-popover-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.group-add-btn {
  font-size: 12px;
  font-weight: 600;
  padding: 4px 10px;
  border-radius: 6px;
  border: 1px solid var(--accent);
  background: transparent;
  color: var(--accent);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  flex-shrink: 0;
}
.group-add-btn.added { border-color: var(--border); color: var(--text-dim); cursor: default; }
.group-add-btn:disabled { opacity: .6; }

.group-popover-new { display: flex; gap: 6px; }

.group-new-input {
  flex: 1;
  min-width: 0;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 8px;
  color: var(--text);
  font-size: 13px;
}
.group-new-input:focus { outline: 2px solid var(--accent); }

.group-create-btn {
  font-size: 12px;
  font-weight: 600;
  padding: 6px 10px;
  border-radius: 8px;
  border: 1px solid var(--accent);
  background: var(--accent);
  color: #fff;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  flex-shrink: 0;
  white-space: nowrap;
}

.group-popover-feedback { font-size: 12px; color: var(--accent); margin-top: 8px; }
.group-popover-feedback.hidden { display: none; }

.section-title { font-size: 15px; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); margin: 0 0 10px; }

/* ---- countdown ---- */

.countdown-num {
  font-size: 120px;
  font-weight: 800;
  text-align: center;
  color: var(--accent);
  margin: 60px 0;
}

/* ---- board / tiles ---- */

.board {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 0 auto;
  width: 100%;
  /* R-20: sized to fit the room's word length (4-7 letters), set as
     --tile-count by Fordle.buildBoard; 44px = 40px tile + 4px gap. */
  max-width: calc(var(--tile-count, 5) * 44px - 4px);
}

.board-row { display: flex; gap: 4px; justify-content: center; }

/* R-153-1: width and height used to be two independently-hardcoded 40px
   literals with nothing tying them together - coincidentally square, not
   guaranteed square. aspect-ratio: 1 derives height from width instead, so
   they can never drift apart, and width itself now scales down (via
   --tile-count, the same custom property .board's max-width already keys
   off) so a 7-letter room's board still fits a narrow phone viewport
   instead of overflowing it. The 40px in the min() is the normal/desktop
   tile size and stays the effective size whenever the viewport has room
   for it. */
.tile {
  width: min(40px, calc((100vw - 48px - (var(--tile-count, 5) - 1) * 4px) / var(--tile-count, 5)));
  aspect-ratio: 1;
  border: 2px solid var(--border);
  border-radius: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 18px;
  font-weight: 700;
  text-transform: uppercase;
  color: var(--text);
  background: transparent;
}

.tile.filled { border-color: #565758; }
.tile.active-row { cursor: pointer; }
.tile.correct { background: var(--correct); border-color: var(--correct); color: #fff; }
.tile.present { background: var(--present); border-color: var(--present); color: #fff; }
.tile.absent { background: var(--absent); border-color: var(--absent); color: #fff; }

/* mini board used for opponents / display */

.mini-board { display: flex; flex-direction: column; gap: 3px; }
.mini-row { display: flex; gap: 3px; }
.mini-tile {
  width: 16px;
  height: 16px;
  border-radius: 3px;
  background: var(--panel-2);
  border: 1px solid var(--border);
}
.mini-tile.correct { background: var(--correct); border-color: var(--correct); }
.mini-tile.present { background: var(--present); border-color: var(--present); }
.mini-tile.absent { background: var(--absent); border-color: var(--absent); }

/* ---- keyboard ---- */

.keyboard { position: relative; display: flex; flex-direction: column; gap: 8px; max-width: 640px; margin: 0 auto; margin-bottom: 10px; }
.kb-row { display: flex; gap: 6px; justify-content: center; }
.key {
  flex: 1 1 0;
  min-width: 0;
  /* R-115: raised alongside .wrap's new 640px cap (was 46px, matched to the
     old 480px .wrap) so keys actually grow into the extra width instead of
     the container growing while keys stay pinned to their old size. */
  max-width: 64px;
  height: 54px;
  background: var(--panel-2);
  border: none;
  border-radius: 4px;
  color: var(--text);
  font-weight: 700;
  font-size: 16px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  text-transform: uppercase;
  -webkit-tap-highlight-color: transparent;
  transition: transform .06s ease-out, filter .06s ease-out, background .06s ease-out;
  /* Keys are plain text inside a <button>, so without this a mobile long-press
     (including the deliberate hold-to-lock gesture below) triggers the OS's
     native text-selection highlight/handles and, on iOS, the copy/look-up
     callout menu - user-select alone doesn't suppress the iOS callout. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
/* Higher flex-grow (not just a bigger max-width) so ENTER/BACK render
   meaningfully wider than letter keys at every viewport width - a plain
   max-width cap never kicks in on narrow phones where the row is already
   flex-compressed below that cap, which was the actual mobile bug. */
.key.wide {
  flex: 1.8 1 0;
  max-width: 112px;
  font-size: 13px;
  font-weight: 800;
}
.key-enter { background: var(--accent); color: #fff; }
.key-back { background: rgba(217, 48, 37, .18); color: #ff6b60; }
.key.correct { background: var(--correct); color: #fff; }
.key.present { background: var(--present); color: #fff; }
.key.absent { background: #2b2b2d; color: #6b6b6d; }
.key:active,
.key.pressed { filter: brightness(1.35); transform: scale(.93); }
.key-enter:active,
.key-enter.pressed { background: var(--accent-dark); }
.key-back:active,
.key-back.pressed { background: rgba(217, 48, 37, .32); }
.key.cooling,
.key-enter.cooling { background: var(--panel-2); color: var(--text-dim); font-size: 12px; }

/* ---- R-113: keys render too narrow to tap comfortably on real phones.
   This is a *width* problem, never touched by R-111 (which only fixed key
   *height* shrinking on short desktop windows) - on a real phone, 10 keys
   per row have to divide up whatever's left of the viewport after .wrap's
   side padding and the inter-key gaps, which leaves well under a
   comfortable tap target on common phone widths. The reclaim/gap rules
   that used to live here (R-113/R-116-1, tiered at 480px/360px) are now
   consolidated into the single mobile composition block below (R-122-1),
   which covers the same reclaim-the-side-padding idea with fluid sizing
   instead of two near-duplicate breakpoints. See buildKeyboard() in
   common.js for the companion fix that splits any per-language extra-key
   row longer than a normal row (fr, pt) so it isn't squeezed even
   narrower than the rest. ---- */

/* ---- play area: own board (left in the DOM) + opponents (right in the DOM),
   keyboard spans below. The two columns share the row 50/50 and each aligns
   its own content toward the shared centerline (board hugs its column's
   right edge, opponent cards center in theirs) rather than the board
   pinning to the far-left edge of the whole row with a lone gap on the
   right. ---- */

.play-columns {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  margin-bottom: 14px;
}
.board-col {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
}
.opponents-col { flex: 1 1 0; min-width: 0; }

/* R-120-1: during gameplay, .wrap and .site-header must not impose their
   own reading-width cap - .wide-wrap's 1100px (on #section-playing itself,
   in host.html/guest.html) is the only ceiling for the play screen, so the
   board/opponents/keyboard can use the full available width. On tablet and
   desktop this is the only playing-screen-specific rule that applies - the
   page scrolls normally if content runs tall. See the mobile-only block
   below for the fixed-viewport, no-scroll behavior on phones.
   R-127: .wrap's base rule also carries a `margin: 0 5%` side margin (used
   everywhere else - setup/lobby/results) which was still shrinking the
   whole play screen to 90% of the viewport and centering it, on top of the
   max-width cap already removed below - override it to 0 here so gameplay
   actually reaches the edges instead of floating in a centered column. */
body:has(#section-playing:not(.hidden)) .site-header { width: 100%; max-width: none; }
body:has(#section-playing:not(.hidden)) .wrap {
  max-width: none;
  margin: 0;
  padding: 6px 6px 8px;
}

/* R-123-1/R-127: .keyboard's base rule (still correct elsewhere, e.g. any
   non-gameplay preview) caps it at 640px and centers it with margin: 0
   auto. Once the #section-playing.wide-wrap override above removes the
   outer 1100px ceiling, that 640px cap becomes the thing squeezing the
   keyboard into its own centered box with empty gutters on wider
   viewports. Let it fill the widened play area instead, with only the
   small fixed side margins .wrap already applies during gameplay. The
   per-key max-widths (.key, .key.wide, below) are what actually keep
   individual keys from growing absurdly wide as this container widens -
   applied at every width (not just desktop) so a tablet/large-phone
   viewport in the mobile bracket below doesn't hit the base 64px/112px
   cap before the row has used the available width. */
#section-playing .keyboard { max-width: none; margin-left: 0; margin-right: 0; }
#section-playing .key { max-width: 110px; }
#section-playing .key.wide { max-width: 190px; }

/* R-124: mobile-only, fit the whole playing screen inside one phone
   viewport with zero page movement - this is a speed/action game, and
   anything drifting under a thumb mid-guess (page scroll, a resizing
   board) hurts more than it helps. Pinch/double-tap zoom is already
   blocked site-wide (see the viewport meta tag in base.html and the
   universal touch-action: manipulation rule at the top of this file);
   this block only adds the scroll lock and the fit-to-viewport sizing.
   Scoped to max-width:600px (the play screen's existing mobile boundary,
   see the .wrap media query near the top of this file) - a previous
   version of this applied unconditionally to every viewport, which is
   what squeezed the desktop/tablet layout into an unnecessarily cramped
   box; keeping it mobile-only avoids repeating that. */
@media (max-width: 600px) {
  body:has(#section-playing:not(.hidden)) {
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
    overscroll-behavior: none;
    display: flex;
    flex-direction: column;
  }
  body:has(#section-playing:not(.hidden)) .wrap {
    flex: 1 1 auto;
    min-height: 0;
    height: auto;
    overflow: hidden;
    /* R-148-1: base rule above sets padding: 6px 6px 8px - bump just the
       bottom to 16px on phones so the keyboard's bottom row isn't flush
       against the screen edge. Reclaim the extra ~8px from .play-columns'
       margin-bottom just below (6px -> 2px) so this doesn't grow the
       total height budget the brackets further down were sized for. */
    padding-bottom: 16px;
  }
  #section-playing {
    display: flex;
    flex-direction: column;
    min-height: 0;
    height: 100%;
  }
  #section-playing .top-bar { flex: 0 0 auto; margin-bottom: 6px; }
  #section-playing .own-play-area {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }
  #section-playing .play-columns {
    flex: 1 1 auto;
    min-height: 0;
    margin-bottom: 2px;
    overflow: hidden;
  }
  #section-playing .board-col {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height: 0;
    height: 100%;
  }
  #section-playing .board-col .board { flex: 0 1 auto; min-height: 0; }
  #section-playing .ghost-btn { flex: 0 0 auto; margin-top: 4px; }
  #section-playing .opponents-col {
    display: flex;
    flex-direction: column;
    min-height: 0;
    height: 100%;
    /* safety net: if an unusually large number of opponents still can't
       fit even at the smallest card size, this region scrolls on its own
       rather than the whole page scrolling and pushing the keyboard
       off-screen. */
    overflow-y: auto;
  }
  #section-playing .keyboard { flex: 0 0 auto; }
}

/* Height-based tile shrinking so the fixed-viewport mobile layout above
   actually fits short phone screens instead of just clipping overflow.
   A board can be up to 8 rows tall (MAX_GUESSES=6 + HANDICAP_BONUS_GUESSES=2),
   so these brackets are sized for that worst case. Also width-gated to
   max-width:600px so a short desktop/tablet window (e.g. a resized
   browser) never triggers phone-sized tiles. */
@media (max-height: 720px) and (max-width: 600px) {
  #section-playing .tile { width: 34px; font-size: 15px; }
  #section-playing .board { max-width: calc(var(--tile-count, 5) * 38px - 4px); }
  #section-playing .keyboard .key { height: 46px; }
  #section-playing .opp-card { --mini-base-tile: 9px; }
  /* .peeked-self-badge (further below) anchors a fixed `bottom` distance
     above the keyboard, sized for the keyboard's default 178px height -
     restate it here for each bracket's shorter keyboard so the badge still
     lands just above the keys instead of floating over the board. */
  #section-playing .peeked-self-badge { bottom: 166px; }
}
@media (max-height: 640px) and (max-width: 600px) {
  #section-playing .tile { width: 28px; font-size: 13px; }
  #section-playing .board { max-width: calc(var(--tile-count, 5) * 32px - 4px); }
  #section-playing .keyboard { gap: 6px; }
  #section-playing .keyboard .key { height: 40px; }
  #section-playing .top-bar { margin-bottom: 6px; }
  #section-playing .ghost-btn { margin-top: 4px; padding: 6px 12px; }
  #section-playing .peeked-self-badge { bottom: 144px; font-size: 13px; padding: 5px 10px; }
}
@media (max-height: 580px) and (max-width: 600px) {
  #section-playing .tile { width: 24px; font-size: 12px; }
  #section-playing .board { max-width: calc(var(--tile-count, 5) * 28px - 4px); }
  #section-playing .keyboard { gap: 4px; }
  #section-playing .keyboard .key { height: 34px; }
  #section-playing .board-col .board { gap: 3px; }
  #section-playing .peeked-self-badge { bottom: 122px; font-size: 12px; padding: 4px 8px; }
}

.opponents-panel { margin-top: 10px; }
/* Cards are capped at 65px wide and the grid packs as many per row as fit -
   with a narrow opponents column that's still 2 per row, but once there's
   room (wider viewport, fewer opponents) more line up side by side instead
   of wasting the extra width. auto-fit (not auto-fill) collapses any unused
   column tracks to 0 width instead of leaving them as blank space, and
   justify-content centers the resulting block of cards in the column - so a
   handful of opponents sit centered under/right of the board instead of
   pinned to its left edge with dead space trailing off to the right. */
.opponents-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(50px, 65px));
  justify-content: center;
  gap: 6px;
}
.opp-card {
  position: relative;
  max-width: 65px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 5px 6px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
}
.opp-card.won { border-color: var(--correct); }
.opp-card.lost { border-color: var(--danger); }
.opp-line {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 4px;
  width: 100%;
  font-size: 10px;
}
.opp-line .opp-avatar { font-size: 20px; flex-shrink: 0; cursor: pointer; }
.opp-line .opp-status { flex-shrink: 0; font-weight: 700; color: var(--text); }
.opp-card .mini-board, .opp-card .mini-row { gap: 2px; }

/* R-153-2: opp-line + the mini-board/ghost-overlay share this wrapper so the
   name overlay below (position:absolute; inset:0) sizes to that header+board
   region only - never .opp-actions, which is appended to .opp-card as a
   separate sibling after this wrapper, so Freeze/Scramble stay uncovered. */
.opp-board-wrap { position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px; width: 100%; }
/* Shown briefly on avatar click (see Fordle.renderPlayerCards in common.js),
   replacing the old small inline name-next-to-avatar reveal. z-index is
   higher than .ice-overlay's (5) so the name stays legible on top of an
   active freeze overlay too - it only shows for ~2s so a brief visual clash
   with ghost/ice tinting is an acceptable tradeoff for legibility. */
.opp-name-overlay {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 10;
  align-items: center;
  justify-content: center;
  padding: 4px;
  background: rgba(0, 0, 0, .75);
  border-radius: 6px;
  pointer-events: none;
}
.opp-name-overlay.visible { display: flex; }
.opp-name-overlay-text {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  overflow: hidden;
  text-overflow: ellipsis;
  text-align: center;
  color: #fff;
  font-size: 13px;
  font-weight: 800;
  line-height: 1.2;
  word-break: break-word;
}
/* R-102-3: --mini-base-tile is this viewport bracket's default mini-tile
   size (overridden below at short viewports); --mini-tile-w/-h are set
   per-card by Fordle.renderPlayerCards's row-equalizer in common.js so
   cards sharing a visual row in #opponents-grid render at matching sizes
   even when their guess counts/word lengths differ (handicap). */
.opp-card { --mini-base-tile: 10px; }
.opp-card .mini-tile {
  width: var(--mini-tile-w, var(--mini-base-tile));
  height: var(--mini-tile-h, var(--mini-base-tile));
  border-radius: 2px;
}

/* ---- sneak peek ---- */

.peek-btn {
  margin-top: 2px;
  font-size: 10px;
  font-weight: 700;
  padding: 3px 6px;
  border-radius: 5px;
  border: 1px solid var(--accent);
  background: rgba(59, 130, 246, .12);
  color: var(--accent);
  cursor: pointer;
}
.peek-btn:disabled,
.peek-btn.used {
  opacity: .4;
  cursor: not-allowed;
  border-color: var(--border);
  color: var(--text-dim);
  background: transparent;
}

/* Freeze and Scramble sit side by side in one row under an opponent's
   mini-board rather than stacking - the card itself is a flex column, so
   two bare buttons would otherwise land one on top of the other. */
.opp-actions { display: flex; flex-direction: row; gap: 4px; margin-top: 2px; }

/* ---- scramble (R-76): opponent-card action button, styled like .peek-btn
   but its own color so the two per-card actions stay visually distinct ---- */
/* Icon-only square button - ability rules are taught before the game
   starts, so there's no need to spell out "Scramble" during play. */
.scramble-btn {
  width: 20px;
  height: 20px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  line-height: 1;
  border-radius: 4px;
  border: 1px solid #9333ea;
  background: rgba(147, 51, 234, .12);
  color: #c084fc;
  cursor: pointer;
}
.scramble-btn:disabled,
.scramble-btn.protected {
  opacity: .4;
  cursor: not-allowed;
  border-color: var(--border);
  color: var(--text-dim);
  background: transparent;
}

/* R-150-1: sits beside the game-code input in a .field-row now, not full
   width below the panel - sized to match the avatar toggle (52x52) so it
   aligns with the input's height, and icon-only so no locale's translated
   label text can overflow the row on narrow phone widths. */
#btn-scan-qr.icon-btn {
  flex: none;
  width: 52px;
  height: 52px;
  padding: 0;
}

.scan-overlay {
  position: fixed;
  inset: 0;
  z-index: 2000;
  background: #000;
  overflow: hidden;
}
.scan-video-wrap {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100dvh;
  background: #000;
}
.scan-video { width: 100%; height: 100%; object-fit: cover; }
.scan-frame {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(62vw, 62dvh);
  height: min(62vw, 62dvh);
  border: 3px solid rgba(255, 255, 255, .85);
  border-radius: 18px;
  box-shadow: 0 0 0 2000px rgba(0, 0, 0, .35);
  pointer-events: none;
}
.scan-top-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  padding: max(16px, env(safe-area-inset-top)) 20px 16px;
  background: linear-gradient(to bottom, rgba(0, 0, 0, .65), transparent);
  text-align: center;
}
.scan-title { color: var(--text); font-size: 13px; text-transform: uppercase; letter-spacing: 1px; margin: 0; }
.scan-bottom-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 16px 20px max(16px, env(safe-area-inset-bottom));
  background: linear-gradient(to top, rgba(0, 0, 0, .75), transparent);
  text-align: center;
}
.scan-bottom-bar .hint { color: #fff; margin-bottom: 14px; }
#btn-scan-close { max-width: 200px; margin: 0 auto; }

.peek-overlay {
  position: fixed;
  inset: 0;
  z-index: 2000;
  background: rgba(0, 0, 0, .82);
  display: flex;
  align-items: center;
  justify-content: center;
}
.peek-panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 28px 20px;
  max-width: 340px;
  width: 90vw;
  text-align: center;
}
.peek-target-name {
  font-size: 15px;
  font-weight: 700;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: .5px;
  margin: 0 0 18px;
}
/* Sized/gapped like Fordle.buildBoard's own .board-row (see "board / tiles"
   above) so the reveal row visually matches the game's own board tiles at
   any of the 4 supported word lengths, instead of a second fixed tile size
   tuned for exactly 2 slots. --tile-count is set by buildPeekRow. */
.peek-tiles {
  display: flex;
  gap: 4px;
  justify-content: center;
  margin: 0 auto 20px;
  max-width: calc(var(--tile-count, 5) * 44px - 4px);
}
.tile.peek-tile.blank { background: var(--absent); border-color: var(--absent); }
.peek-timer { position: relative; height: 6px; background: var(--panel-2); border-radius: 3px; margin-bottom: 20px; overflow: hidden; }
.peek-timer-bar { position: absolute; inset: 0; width: 100%; background: var(--accent); transition: width 5s linear; }
.peek-timer-num { position: absolute; top: -22px; right: 0; font-size: 12px; color: var(--text-dim); font-weight: 700; }
.peek-close { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 8px 20px; font-size: 13px; font-weight: 600; cursor: pointer; }

.peek-notice {
  background: #1c2a4a;
  border-color: var(--accent);
  color: #d6e4ff;
  top: calc(60px + env(safe-area-inset-top));
}

/* ---- results ---- */

.results-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.results-list li {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--panel-2);
  border-radius: 8px;
  padding: 12px 14px;
}
.results-list .rank { font-weight: 800; font-size: 18px; color: var(--accent); width: 28px; text-align: center; flex-shrink: 0; }
.results-list .rname { flex: 1; font-size: 15px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.results-list .rmeta { font-size: 12px; color: var(--text-dim); flex-shrink: 0; }
.results-list .rwins { font-size: 12px; font-weight: 700; color: var(--accent); flex-shrink: 0; }
.reveal-word {
  text-align: center;
  font-size: 22px;
  font-weight: 800;
  letter-spacing: 4px;
  color: var(--correct);
  margin-bottom: 18px;
  text-transform: uppercase;
}

/* ---- display / big screen ---- */

/* R-169-1: two-column Big Screen lobby - code/QR on the left, host options +
   info badges + player tiles on the right. Scoped to #section-lobby's own
   .lobby-screen/.lobby-* classes so it doesn't touch .lang-badge/.player-list,
   which host.html, guest.html, groups.js and admin_dashboard.js still use for
   their own (unrelated) player lists. */
.lobby-screen {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.lobby-columns {
  display: flex;
  gap: 32px;
  flex: 1;
  min-height: 0;
}

.lobby-col-left {
  /* R-175-1: strictly 50/50 with .lobby-col-right - the 16px here is half of
     .lobby-columns' 32px gap, so the two 50%-basis columns plus the gap
     between them add up to exactly 100% instead of overflowing it. The QR
     canvas itself (rendered at a fixed native 350px by static/js/qr.js) is
     scaled down to fit via the .qr-display/.qr-display canvas rules below,
     so a narrower-than-350px column no longer forces horizontal overflow. */
  flex: 0 0 calc(50% - 16px);
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.lobby-col-left .code-display { width: 100%; max-width: 320px; }
/* R-175-1: lets the QR canvas shrink to fit a narrower-than-350px column
   instead of forcing it to overflow. max-width/height:auto only ever scale
   the canvas *down* from its native size (never up), so it stays crisp on
   the common case of a column wide enough for the full 350px render. */
.lobby-col-left .qr-display { max-width: 100%; box-sizing: border-box; }
.lobby-col-left .qr-display canvas { max-width: 100%; height: auto; }

.lobby-col-right {
  flex: 1 1 calc(50% - 16px);
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.lobby-options { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
/* R-175-1: brought up to the same "readable from across the room" 26px
   scale as .lobby-info-row .lang-badge / .lobby-screen .section-title
   (R-174-1) right below - the 13px/6px-12px sizing these badges had before
   was tuned for close-up phone screens, not a big screen. */
.lobby-option {
  font-size: 26px;
  font-weight: 700;
  padding: 10px 18px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--text-dim);
}
/* On = accent-colored and full opacity; off = dimmed - readable at a glance
   from across the room without relying on the badge being hidden/shown. */
.lobby-option.option-on {
  color: var(--accent);
  border-color: var(--accent);
  background: rgba(59, 130, 246, .15);
}
.lobby-option.option-off { opacity: .5; }

.lobby-info-row { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 12px; }
/* R-174-1: read from across a room, so this needs to be much larger than the
   16px shared .lang-badge default used on the phone-sized host/guest screens. */
.lobby-info-row .lang-badge { margin-bottom: 0; text-align: left; font-size: 26px; }

/* R-174-1: same reasoning as .lobby-info-row .lang-badge above - "Players (N)"
   needs to be readable from across a room, unlike host/guest's phone-sized
   .section-title usages (e.g. "Game code"), which stay at the shared default. */
.lobby-screen .section-title { font-size: 26px; }

.lobby-player-grid {
  display: grid;
  gap: 6px;
  flex: 1;
  min-height: 0;
}

.lobby-player-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* R-175-1: lowered from 4px/6px so the tile has less fixed overhead to
     shrink at high player counts - layoutLobbyPlayerGrid() (static/js/
     display.js) already scales font/avatar size dynamically, but the fixed
     gap/padding was still large enough to force overflow around ~30 tiles. */
  gap: 2px;
  background: var(--panel-2);
  border-radius: 8px;
  padding: 3px;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
}
.lobby-player-tile.sb-leader { border: 1px solid var(--accent); }

.lobby-player-avatar-wrap { font-size: var(--lobby-tile-avatar, 20px); line-height: 1; }
.lobby-player-avatar-wrap .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #4ade80; }
.lobby-player-avatar-wrap .dot.off { background: #6b7280; }

.lobby-player-name {
  font-size: var(--lobby-tile-font, 14px);
  font-weight: 600;
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.lobby-player-tags { display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; }
.lobby-player-tags .tag { font-size: 10px; color: var(--text-dim); border: 1px solid var(--border); padding: 1px 5px; border-radius: 6px; }
.lobby-player-tags .tag.handicap { color: var(--accent); border-color: var(--accent); }
/* R-175-1: the host tag was previously identical to every other tag (same
   dim-gray style), making it hard to spot at a glance across a room - give
   it a filled accent background plus a bigger, bolder label so it visually
   stands out rather than requiring close inspection. */
.lobby-player-tags .tag.host {
  color: #fff;
  background: var(--accent);
  border-color: var(--accent);
  font-size: 13px;
  font-weight: 800;
  padding: 2px 8px;
}

@media (max-width: 900px) {
  .lobby-columns { flex-direction: column; }
  /* R-175-1: previously flex: 0 0 auto here let the code/QR column take
     whatever height its content needed and pushed the player-grid column
     off-screen below the viewport whenever that content plus the grid
     didn't both fit - the actual source of the reported lobby scrollbars.
     A fixed 50/50 height split (mirroring the 50/50 width split above)
     guarantees both columns share the viewport instead. */
  .lobby-col-left { flex: 0 0 50%; min-height: 0; }
  .lobby-col-right { flex: 1 1 0; }
}

.display-playing {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.display-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-template-rows: repeat(2, 1fr);
  gap: 24px;
  width: 100%;
  flex: 1;
  min-height: 0;
}
.display-card {
  position: relative;
  background: var(--panel);
  border: 2px solid var(--border);
  border-radius: 16px;
  padding: 16px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
}
.display-card.won { border-color: var(--correct); box-shadow: 0 0 0 3px rgba(83,141,78,.4); }
.display-card.lost { border-color: var(--danger); }
.display-avatar { font-size: var(--display-avatar-size, 40px); line-height: 1; flex-shrink: 0; }
.display-name {
  font-weight: 700;
  font-size: 18px;
  text-align: center;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex-shrink: 0;
}
.display-badge {
  font-size: 13px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 1px;
  font-weight: 700;
  flex-shrink: 0;
}
.display-card .mini-board { gap: var(--tile-gap, 6px); flex-shrink: 0; }
.display-card .mini-row { gap: var(--tile-gap, 6px); }
.display-card .mini-tile {
  width: var(--tile-size, 24px);
  height: var(--tile-size, 24px);
  border-radius: 6px;
}

/* R-176-3: per-player attack status row - read-only (no click handler, see
   .freeze-btn/.scramble-btn for the interactive equivalents on host/guest's
   opponent view), one icon per attack enabled for the room. */
.display-attacks { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; }
.display-attack-icon {
  width: 18px;
  height: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  line-height: 1;
  border-radius: 4px;
  border: 1px solid var(--border);
  background: var(--panel-2);
}
/* Available = full opacity plus the same accent highlight .lobby-option.option-on
   uses; used = grayscale/dimmed, same "spent" language as .freeze-btn.used. */
.display-attack-icon.available {
  border-color: var(--accent);
  background: rgba(59, 130, 246, .15);
}
.display-attack-icon.used {
  opacity: .35;
  filter: grayscale(1);
}

.scoreboard-panel { margin-top: 14px; }
.scoreboard-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.scoreboard-list li {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--panel-2);
  border-radius: 8px;
  padding: 8px 14px;
  font-size: 14px;
}
.scoreboard-list .sb-rank { font-weight: 800; color: var(--accent); width: 22px; text-align: center; }
.scoreboard-list .sb-name { flex: 1; }
.scoreboard-list .sb-wins { color: var(--text-dim); font-size: 13px; }

/* leader = highest cumulative session wins; round-winner = solved the word this round.
   Both can land on the same row - the styles are additive (border + glow). */
.scoreboard-list li.sb-leader {
  border: 2px solid var(--accent);
  box-shadow: 0 0 0 2px rgba(59, 130, 246, .25);
}
.scoreboard-list li.sb-round-winner {
  border: 2px solid #ffd54a;
  background: rgba(255, 213, 74, .14);
}
.scoreboard-list li.sb-round-winner .sb-wins { color: #ffd54a; }

.scoreboard-strip {
  flex-direction: row;
  flex-wrap: nowrap;
  flex: 1;
  justify-content: center;
  margin-bottom: 0;
  overflow-x: auto;
  min-width: 0;
  gap: 8px;
}
.scoreboard-strip li {
  flex-direction: column;
  align-items: center;
  gap: 0;
  padding: 4px 10px;
  flex-shrink: 0;
}
.scoreboard-strip .sb-name {
  font-size: 12px;
  font-weight: 700;
  max-width: 64px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.scoreboard-strip .sb-wins { font-size: 13px; font-weight: 800; color: var(--accent); }

/* Big Screen only (host/guest reuse .scoreboard-strip on their own,
   phone-sized top bars via the same shared markup - see R-176-2) - the
   Product Owner's "way bigger" ask for names/scores in the top bar. */
.display-playing .scoreboard-strip li { padding: 6px 14px; }
.display-playing .scoreboard-strip .sb-name { font-size: 24px; max-width: 140px; }
.display-playing .scoreboard-strip .sb-wins { font-size: 20px; }

.winner-banner {
  text-align: center;
  font-size: 26px;
  font-weight: 800;
  margin-bottom: 10px;
}
.winner-banner.has-winner { color: #ffd54a; }

.playing-info-row { display: flex; flex-wrap: wrap; gap: 6px 28px; margin-bottom: 10px; }
.playing-info-item { font-size: 22px; font-weight: 700; color: var(--text-dim); }
.playing-info-item > span:last-child { color: var(--text); font-weight: 800; }

.top-bar { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.top-bar-left { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.code-chip {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 12px;
  font-weight: 700;
  letter-spacing: 3px;
  color: var(--accent);
  flex-shrink: 0;
}

.footer-actions { display: flex; gap: 10px; margin-top: 16px; }
.footer-actions .big-btn { flex: 1; }

/* Big Screen "in-game" top bar - the fonts above are sized for a shared TV,
   trim them down on narrower windows (this page also gets viewed on laptop-
   sized browsers, not only a big monitor) so the row doesn't crowd out. */
@media (max-width: 700px) {
  .playing-info-item { font-size: 16px; }
  .display-playing .scoreboard-strip .sb-name { font-size: 16px; max-width: 90px; }
  .display-playing .scoreboard-strip .sb-wins { font-size: 14px; }
}

@media (max-width: 380px) {
  .tile { width: 32px; font-size: 15px; }
  .board { max-width: calc(var(--tile-count, 5) * 36px - 4px); }
  .peek-tiles { max-width: calc(var(--tile-count, 5) * 36px - 4px); }
  .logo { font-size: 36px; }
}

/* ---- promo landing page (/about) ---- */

.promo-eyebrow {
  color: var(--text-dim);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 2px;
  text-transform: uppercase;
  margin-bottom: 10px;
  text-align: center;
}

.logo.logo-hero { font-size: 72px; text-align: center; }

.promo-tagline { font-size: 19px; max-width: 560px; margin: 0 auto; }

.promo-page .hint { max-width: 560px; margin: 8px auto 0; }

.promo-naming { font-style: italic; }

.hero-actions { max-width: 420px; margin: 24px auto 0; }

.promo-section {
  padding: 64px 0;
  text-align: center;
}

.promo-section.alt {
  background: var(--panel);
  margin: 0 calc(50% - 50vw);
  padding-left: calc(50vw - 50%);
  padding-right: calc(50vw - 50%);
}

.promo-heading {
  font-size: 26px;
  font-weight: 800;
  text-align: center;
  margin: 0 0 32px;
}

.promo-steps {
  display: flex;
  gap: 20px;
  margin-bottom: 32px;
  text-align: left;
}
.promo-step { flex: 1; }
.promo-step p { margin: 0; font-size: 15px; line-height: 1.5; }

.promo-tile-demo { margin-bottom: 14px; }

.promo-cards {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
}

.promo-card {
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}
.promo-card-icon { font-size: 30px; line-height: 1; }
.promo-card-title { font-size: 15px; font-weight: 700; }

.promo-footer-actions { max-width: 420px; margin: 16px auto 0; }

@media (max-width: 640px) {
  .promo-section { padding: 40px 0; }
  .promo-steps { flex-direction: column; gap: 24px; text-align: center; }
  .promo-cards { grid-template-columns: repeat(2, 1fr); }
  .logo.logo-hero { font-size: 44px; }
}

@media (max-width: 420px) {
  .promo-cards { grid-template-columns: 1fr; }
}

@media (max-width: 380px) {
  .logo.logo-hero { font-size: 36px; }
}

/* ---- admin dashboard ---- */

.admin-login-wrap { max-width: 360px; }

.admin-wrap { max-width: 1100px; }

.admin-top-bar { margin-bottom: 20px; }
.admin-logo { font-size: 26px; margin: 0; }
.admin-logout-btn { padding: 8px 14px; font-size: 13px; }

.admin-section { margin-bottom: 20px; }

.admin-section-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 12px;
}

.admin-error { color: var(--danger); text-align: left; margin: 8px 0 0; }

.stat-tiles {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.stat-tile {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 16px;
  text-align: center;
}

.stat-tile-label {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--text-dim);
  margin: 0 0 8px;
}

.stat-tile-value { font-size: 34px; font-weight: 800; color: var(--text); margin: 0; }

.live-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #4ade80;
  box-shadow: 0 0 0 3px rgba(74, 222, 128, .25);
  flex-shrink: 0;
}

.admin-refresh { display: flex; align-items: center; gap: 10px; }
.admin-refresh-btn { padding: 6px 12px; font-size: 12px; }

.admin-filter-tabs { display: flex; gap: 6px; }
.admin-tab {
  background: var(--panel-2);
  border: 1px solid var(--border);
  color: var(--text-dim);
  font-size: 13px;
  font-weight: 600;
  padding: 6px 12px;
  border-radius: 8px;
  cursor: pointer;
}
.admin-tab.on { color: #fff; background: var(--accent); border-color: var(--accent); }

.live-game-card, .finished-game-row {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  margin-bottom: 8px;
  overflow: hidden;
}

.live-game-head, .finished-game-summary {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 10px 14px;
  flex-wrap: wrap;
}

.finished-game-summary {
  width: 100%;
  background: transparent;
  border: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.live-game-players { margin: 0 14px 10px; }
.live-game-players li { background: var(--panel); }

.finished-game-detail {
  border-top: 1px solid var(--border);
  padding: 10px 14px;
}
.finished-game-detail .results-list { margin: 0; }
.finished-game-detail .results-list li { background: var(--panel); }

.admin-table-wrap { overflow-x: auto; }

.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.admin-table th {
  text-align: left;
  color: var(--text-dim);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 1px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--border);
}
.admin-table td {
  padding: 8px 10px;
  border-bottom: 1px solid var(--border);
}
.admin-table tr:last-child td { border-bottom: none; }

@media (max-width: 640px) {
  .stat-tiles { grid-template-columns: 1fr; }
}

/* ---- freeze ability (R-45-UX-1, design per R-09-UX-1) ----
 *
 * Class names and the markup each one expects, for R-45-Fullstack-1 to wire up:
 *
 * 1. Own board frozen (guest.html / host.html):
 *    Wrap the existing #own-board + #own-keyboard pair in a new container and
 *    toggle a `.frozen` class on it - the board/keyboard markup itself is
 *    unchanged, this is purely a wrapper:
 *      <div class="own-play-area frozen">
 *        <div class="board" id="own-board">...</div>
 *        <div class="keyboard" id="own-keyboard">...</div>
 *        <div class="ice-overlay">
 *          <span class="freeze-badge">❄️ Frozen 8s</span>
 *        </div>
 *      </div>
 *    `.ice-overlay` is the last child and absolutely positioned so it covers
 *    both board and keyboard in one pass. JS updates the badge text each
 *    second (e.g. "❄️ Frozen 8s" -> "❄️ Frozen 7s").
 *
 * 2. Opponent card frozen (opponents grid, `Fordle.renderPlayerCards` w/
 *    cardClass "opp-card"): add `.frozen` to the existing `.opp-card` and
 *    append an overlay + badge as the last children of that same card:
 *      <div class="opp-card frozen">
 *        <div class="opp-board-wrap">
 *          <div class="opp-line">...</div>
 *          <div class="mini-board">...</div>
 *        </div>
 *        <div class="ice-overlay">
 *          <span class="freeze-badge">❄️ 8s</span>
 *        </div>
 *      </div>
 *    (`.opp-board-wrap` also hosts the name-reveal overlay from R-153-2 -
 *    see the "avatar click reveal" note near `.opp-name-overlay` in this
 *    file - `.ice-overlay` stays a full-card overlay, unaffected.)
 *
 * 3. Display screen (`Fordle.renderPlayerCards` w/ cardClass "display-card"):
 *    identical pattern, scaled up:
 *      <div class="display-card frozen">
 *        ...existing display-avatar / display-name / display-badge / mini-board...
 *        <div class="ice-overlay">
 *          <span class="freeze-badge">❄️ 8s</span>
 *        </div>
 *      </div>
 *
 * 4. Freeze affordance (R-125): a dedicated `.freeze-btn` icon button on the
 *    `.opp-card`, next to `.scramble-btn`, rather than the whole card being
 *    a click target - a card-wide click made it too easy to freeze someone
 *    by accident. Default state is enabled/clickable; `title` carries the
 *    tooltip since the button is icon-only (❄️, no text - ability rules are
 *    taught before the game starts).
 *
 * 5. Freeze spent: add `.used` to `.freeze-btn` and disable it once the
 *    viewer's freeze charge is gone this round, with "Freeze used" as the
 *    button's `title` for a native tooltip.
 *
 * 5b. Freeze target currently protected: add `.protected` instead of `.used`
 *    when the viewer still has a freeze charge but this particular target is
 *    already frozen (or ghosted) by someone else. Same visual treatment as
 *    `.used`, kept as its own class so the two "can't click this" reasons
 *    stay distinguishable in the DOM even though they look identical.
 *
 * 6. Cast feedback:
 *    - Caster-only toast, appended wherever page-level toasts already mount
 *      (e.g. body or `.wrap`), auto-dismissed by JS after a couple seconds:
 *        <div class="freeze-toast">You froze Jordan!</div>
 *    - Persistent "who froze them" attribution, alongside `.freeze-badge`
 *      inside the same `.ice-overlay` (own board, opp-card, and display-card
 *      all use the same structure):
 *        <div class="ice-overlay">
 *          <span class="freeze-badge">❄️ 8s</span>
 *          <span class="freeze-by">by Jordan</span>
 *        </div>
 *
 * R-57 legibility retune: the original overlay (28%/22% gradient + 2px blur)
 * plus a grayscale/brightness filter on the guess tiles themselves made a
 * frozen player's already-placed guesses unreadable. Freeze should mark a
 * player as frozen, not hide the board state other players rely on. Fix:
 * - .ice-overlay's gradient opacity is cut roughly in half (~14%/10%) and
 *   backdrop-filter drops to a near-imperceptible .5px blur, so it reads as
 *   a frost tint instead of fog; a thin frosted border carries more of the
 *   "iced over" signal so the blur doesn't have to.
 * - The grayscale/brightness filter is removed from anything that renders
 *   guess data (.board, .mini-board) and kept only on genuinely non-data
 *   chrome (.keyboard - can't be typed on right now; opp-line/display-*
 *   name/avatar/badge - decorative header, not guess info).
 * - .freeze-badge/.freeze-by anchor to a corner instead of dead center on
 *   .opp-card/.display-card (their mini-board sits directly under the
 *   header, so center would land on guess tiles) and to the bottom of
 *   .own-play-area (over the already-dimmed keyboard, not the board).
 */

.ice-overlay {
  --freeze-color: #3b82f6;
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  text-align: center;
  background: linear-gradient(160deg,
      color-mix(in srgb, color-mix(in srgb, var(--freeze-color) 55%, white 45%) 55%, transparent),
      color-mix(in srgb, var(--freeze-color) 40%, transparent));
  border: 1px solid color-mix(in srgb, color-mix(in srgb, var(--freeze-color) 55%, white 45%) 55%, transparent);
  border-radius: inherit;
  backdrop-filter: blur(.5px) saturate(1.05);
  -webkit-backdrop-filter: blur(.5px) saturate(1.05);
  z-index: 5;
  pointer-events: none;
}

.freeze-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-weight: 800;
  color: #eaf4ff;
  background: color-mix(in srgb, color-mix(in srgb, var(--freeze-color, #3b82f6) 85%, black 15%) 55%, transparent);
  border: 1px solid color-mix(in srgb, color-mix(in srgb, var(--freeze-color, #3b82f6) 15%, white 85%) 60%, transparent);
  border-radius: 999px;
  padding: 4px 10px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
  white-space: nowrap;
}

.freeze-by {
  font-size: 11px;
  font-weight: 600;
  color: #dbeafe;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .45);
}

/* own board + keyboard, wrapped together so one overlay covers both.
   Only the keyboard (can't be typed on right now) gets dimmed - the board
   keeps its guessed tile colors legible. The badge anchors to the bottom of
   the wrapper (over the already-dimmed keyboard) so it never sits on top of
   a placed guess row. */
.own-play-area { position: relative; }
.own-play-area.frozen .keyboard { filter: brightness(.8); }
.own-play-area.frozen .key { pointer-events: none; }
.own-play-area .ice-overlay {
  border-radius: var(--radius);
  justify-content: flex-end;
  padding-bottom: 14px;
}
.own-play-area .ice-overlay .freeze-badge { font-size: 20px; padding: 8px 18px; }
.own-play-area .ice-overlay .freeze-by { font-size: 13px; }

/* opponent card, at card scale. Only the name/status header row dims -
   the mini-board (guess data) stays legible. Badge anchors top-right,
   over the header row, instead of dead-center over the guess grid. */
.opp-card.frozen .opp-line { filter: brightness(.85); }
.opp-card .ice-overlay {
  align-items: flex-end;
  justify-content: flex-start;
  padding: 3px 5px;
}
.opp-card .ice-overlay .freeze-badge { font-size: 9px; padding: 2px 6px; }
.opp-card .ice-overlay .freeze-by { font-size: 8px; }

/* Icon-only square button, styled like .scramble-btn but in freeze's own
   blue palette - clicking anywhere on the card used to cast freeze, which
   made it too easy to freeze someone by accident. */
.freeze-btn {
  width: 20px;
  height: 20px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  line-height: 1;
  border-radius: 4px;
  border: 1px solid var(--accent);
  background: rgba(59, 130, 246, .12);
  color: #93c5fd;
  cursor: pointer;
}
.freeze-btn:disabled,
.freeze-btn.used,
.freeze-btn.protected {
  opacity: .4;
  cursor: not-allowed;
  border-color: var(--border);
  color: var(--text-dim);
  background: transparent;
}

/* display / TV screen, scaled up for across-the-room legibility. Only the
   avatar/name/badge header dims - the mini-board (guess data) stays
   legible. Badge anchors top-right, over the header, not the guess grid. */
.display-card.frozen .display-avatar,
.display-card.frozen .display-name,
.display-card.frozen .display-badge { filter: brightness(.8); }
.display-card .ice-overlay {
  align-items: flex-end;
  justify-content: flex-start;
  padding: 8px 10px;
}
.display-card .ice-overlay .freeze-badge { font-size: 22px; padding: 8px 16px; }
.display-card .ice-overlay .freeze-by { font-size: 14px; }

/* caster-facing cast confirmation toast */
.freeze-toast {
  position: fixed;
  bottom: calc(24px + env(safe-area-inset-bottom));
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  background: rgba(37, 99, 235, .95);
  color: #fff;
  font-weight: 700;
  font-size: 14px;
  padding: 10px 18px;
  border-radius: 999px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35);
  white-space: nowrap;
}

@media (max-width: 380px) {
  .own-play-area .ice-overlay .freeze-badge { font-size: 16px; padding: 6px 14px; }
  .freeze-toast { font-size: 12px; padding: 8px 14px; }
}

/* ---- scramble ability (R-76) ----
 *
 * Structurally mirrors freeze above, but deliberately kept as its own set
 * of classes rather than reusing .ice-overlay/.freeze-badge/.freeze-toast:
 * a target can in principle be frozen *and* scrambled at once, and the two
 * effects need to stay legible together rather than one overwriting the
 * other's markup. Unlike freeze, scramble never blocks input, so there is
 * no equivalent of `.own-play-area.frozen .key { pointer-events: none; }` -
 * the own keyboard stays fully clickable throughout.
 */

/* Own keyboard: small non-blocking badge that floats just above the keys
   while they're shuffled. pointer-events: none so it can never intercept a
   tap meant for the keyboard underneath it. */
.scramble-badge {
  position: absolute;
  top: -34px;
  left: 50%;
  transform: translateX(-50%);
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-weight: 800;
  font-size: 13px;
  color: #f5e9ff;
  background: rgba(147, 51, 234, .6);
  border: 1px solid rgba(216, 180, 254, .6);
  border-radius: 999px;
  padding: 4px 12px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
  white-space: nowrap;
  pointer-events: none;
  z-index: 5;
}

/* Corner-chip indicator on opponent/display cards, anchored opposite the
   freeze badge's corner (top-right) so both can show at once without
   overlapping. */
.scramble-badge-chip {
  position: absolute;
  display: inline-flex;
  align-items: center;
  font-weight: 800;
  color: #f5e9ff;
  background: rgba(147, 51, 234, .75);
  border: 1px solid rgba(216, 180, 254, .6);
  border-radius: 999px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
  white-space: nowrap;
  pointer-events: none;
  z-index: 6;
}
.opp-card .scramble-badge-chip { top: 3px; left: 4px; font-size: 9px; padding: 2px 6px; }
.display-card .scramble-badge-chip { top: 8px; left: 10px; font-size: 16px; padding: 4px 10px; }

/* "Being peeked" indicator on opponent/display cards - amber to match the
   own-board .peeked-self-badge, anchored to the bottom corners so it never
   overlaps the freeze badge (top-right) or scramble chip (top-left). */
.peeked-badge-chip {
  position: absolute;
  display: inline-flex;
  align-items: center;
  font-weight: 800;
  color: #fff7e6;
  background: rgba(146, 64, 14, .92);
  border: 1px solid rgba(253, 224, 71, .6);
  border-radius: 999px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
  white-space: nowrap;
  pointer-events: none;
  z-index: 6;
}
.opp-card .peeked-badge-chip { bottom: 3px; right: 4px; font-size: 9px; padding: 2px 6px; }
.display-card .peeked-badge-chip { bottom: 8px; right: 10px; font-size: 16px; padding: 4px 10px; }

/* caster-facing cast confirmation toast */
.scramble-toast {
  position: fixed;
  bottom: calc(24px + env(safe-area-inset-bottom));
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  background: rgba(147, 51, 234, .95);
  color: #fff;
  font-weight: 700;
  font-size: 14px;
  padding: 10px 18px;
  border-radius: 999px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35);
  white-space: nowrap;
}

/* ---- ghost ability (R-77-2 design; state/protocol is R-77-1, JS wiring is
   R-77-3 - this block is spec only) ----
 *
 * Ghost is a self-cast, once-per-round dodge: for the ability's duration the
 * caster's own board keeps playing completely normally, but everyone ELSE's
 * view of that player is fully hidden (and the player can't be targeted by
 * Freeze). That's the opposite of what Freeze does, on purpose, and the two
 * must never be visually confusable:
 *
 * - Freeze's `.ice-overlay` is deliberately translucent (see the freeze
 *   comment block above - an earlier fix lightened it after discovering it
 *   was hiding a frozen player's progress, which opponents are supposed to
 *   still be able to read) and it stacks on TOP of a mini-board that stays
 *   rendered underneath.
 * - Ghost must show ZERO progress information to anyone but the caster. So
 *   on opponent/display cards, Ghost does not stack a translucent layer over
 *   the mini-board at all - it replaces the mini-board's slot in the layout
 *   with an opaque `.ghost-overlay`, so there's no guess data left in the
 *   DOM underneath to read. And it uses its own violet/grey "vanished"
 *   palette, never freeze's icy blue, so the two states read as different
 *   even at a glance.
 *
 * 1. Host setup checkbox (host.html), placed directly below the existing
 *    #host-play-along .checkbox-field:
 *      <div class="checkbox-field">
 *        <input id="host-ghost-enabled" type="checkbox" checked>
 *        <label for="host-ghost-enabled">👻 Let everyone vanish once to dodge an attack</label>
 *      </div>
 *    Optional rule hint below it, same treatment as the existing
 *    .freeze_rule_hint paragraph:
 *      <p class="hint hint-compact">👻 Each player can go ghost once per round for 10s — invisible to others, safe from freeze.</p>
 *
 * 2. Own board while ghosted (guest.html / host.html #own-play-area): NOT a
 *    blocking effect - the player keeps their real board and keyboard fully
 *    visible and interactive, same as always. Add `.ghost-active` to the
 *    existing wrapper and append a small non-blocking corner badge (never a
 *    full-cover overlay like `.ice-overlay`) as its last child:
 *      <div class="own-play-area ghost-active" id="own-play-area">
 *        <div class="play-columns">...</div>
 *        <div class="keyboard" id="own-keyboard"></div>
 *        <span class="ghost-self-badge">👻 Ghosted · 8s · protected</span>
 *      </div>
 *    JS ticks the badge text down each second, e.g. via the existing
 *    startCountdown helper already used for freeze badges.
 *
 * 3. Opponent card ghosted (opponents grid, Fordle.renderPlayerCards w/
 *    cardClass "opp-card"): add `.ghost-active` to the card. Instead of
 *    appending an overlay on top of the mini-board (freeze's approach), omit
 *    `.mini-board` entirely while ghosted and put `.ghost-overlay` in its
 *    place so no guess data exists in the DOM to read:
 *      <div class="opp-card ghost-active">
 *        <div class="opp-board-wrap">
 *          <div class="opp-line">
 *            <span class="opp-avatar">🙂</span>
 *            <span class="opp-status">👻</span>
 *          </div>
 *          <div class="ghost-overlay">
 *            <span class="ghost-icon">👻</span>
 *            <span class="ghost-badge">Ghosted · 8s</span>
 *          </div>
 *        </div>
 *      </div>
 *    The header row (`.opp-line`) is left as-is - avatar/host-tag stay
 *    visible (name is no longer shown inline; see R-153-2, it's now a
 *    large overlay shown briefly on avatar click) - but `.opp-status` must show a
 *    neutral placeholder (e.g. "👻") instead of the normal won/lost/progress
 *    text while ghosted, since that field otherwise leaks correct/present
 *    counts.
 *
 * 4. Display screen (Fordle.renderPlayerCards w/ cardClass "display-card"):
 *    identical pattern, scaled up for across-the-room legibility. Same rule
 *    for `.display-badge` as `.opp-status` above - neutral placeholder only:
 *      <div class="display-card ghost-active">
 *        <div class="display-avatar">🙂</div>
 *        <div class="display-name">Jordan</div>
 *        <div class="display-badge">👻</div>
 *        <div class="ghost-overlay">
 *          <span class="ghost-icon">👻</span>
 *          <span class="ghost-badge">Ghosted · 8s</span>
 *        </div>
 *      </div>
 *
 * 5. Ghost self-cast button, near the player's own board (guest.html /
 *    host.html, e.g. in `.board-col` below `#own-board`), visible only when
 *    the room has Ghost enabled. This is a standalone action button, not an
 *    opponent-card affordance like `.freeze-btn`:
 *      <button id="btn-ghost" class="ghost-btn" type="button">👻 Ghost</button>
 *    Once the caster's one charge is spent for the round, swap in
 *    `.ghost-used` and disable it (same spirit as `.freeze-btn.used`):
 *      <button id="btn-ghost" class="ghost-btn ghost-used" type="button" disabled title="Used this round">👻 Used</button>
 *    NOTE: unrelated to the pre-existing `.big-btn.ghost` (a transparent
 *    big-button *style* variant used elsewhere, e.g. host.html's "New Game /
 *    New Code" button) - never compose `.ghost-btn` with `.big-btn.ghost` or
 *    treat one as a substitute for the other; the shared word is
 *    coincidental naming, not a relationship.
 *
 * 6. Cast feedback: caster-only toast, appended the same way as
 *    `.freeze-toast` (e.g. to `document.body`), auto-dismissed by JS after a
 *    couple seconds:
 *      <div class="ghost-toast">You went ghost for 10s!</div>
 *
 * i18n: every string above is hardcoded English for this spec only. This
 * project translates all user-facing copy via static/i18n/*.json + data-i18n
 * (see host.html's existing `host.play_along_label` / `host.freeze_rule_hint`
 * and common.js's `common.freeze_toast` / `common.frozen_label` /
 * `common.freeze_used_title`). R-77-3 should add matching keys instead of
 * hardcoding, e.g.: host.ghost_enabled_label, host.ghost_rule_hint,
 * common.ghost_badge_active (own-board badge, "{seconds}" placeholder like
 * common.result_solved_in's "{count}"), common.ghost_badge_opponent
 * (opp-card/display-card badge), common.ghost_btn / common.ghost_btn_used,
 * common.ghost_toast ("You went ghost for {seconds}s!").
 */

.ghost-overlay {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  text-align: center;
  background: linear-gradient(160deg, #2b2440, #1c1826);
  border: 1px solid rgba(196, 181, 253, .35);
  border-radius: 6px;
  padding: 8px 4px;
}

.ghost-icon {
  font-size: 20px;
  line-height: 1;
  filter: drop-shadow(0 0 4px rgba(196, 181, 253, .5));
}

.ghost-badge {
  font-size: 9px;
  font-weight: 800;
  color: #e4d9ff;
  text-transform: uppercase;
  letter-spacing: .5px;
  white-space: nowrap;
}

/* opp-card scale: sits where .mini-board normally would, roughly matching
   its usual footprint (~6 guess rows of 10px mini-tiles) so the card doesn't
   visibly resize when it flips into/out of ghost. */
.opp-card .ghost-overlay { width: 100%; min-height: 48px; }

/* display / TV scale: bigger icon and badge for across-the-room legibility,
   filling the space the mini-board would otherwise take. */
.display-card .ghost-overlay { width: 100%; border-radius: 12px; padding: 20px 10px; gap: 8px; }
.display-card .ghost-icon { font-size: 60px; }
.display-card .ghost-badge { font-size: 16px; letter-spacing: 1px; }

/* own board + keyboard: a small, non-blocking corner badge only - never a
   full overlay. The player's real board/keyboard stay exactly as they were,
   fully visible and interactive; pointer-events:none so the badge can never
   intercept a tap even if it visually overlaps the top of the board. */
.own-play-area.ghost-active { position: relative; }
.ghost-self-badge {
  position: absolute;
  top: -10px;
  right: 6px;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 12px;
  font-weight: 800;
  color: #f3edff;
  background: rgba(91, 71, 158, .92);
  border: 1px solid rgba(196, 181, 253, .55);
  border-radius: 999px;
  padding: 4px 10px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .35);
  pointer-events: none;
  white-space: nowrap;
  z-index: 5;
}

/* R-91: persistent "you're being peeked" indicator, replacing the old
   transient toast. Anchored to the bottom-right of #own-play-area (which is
   unconditionally position:relative, see above), sitting in the gap between
   the opponents' boards and the own keyboard rather than overlapping either.
   The keyboard is exactly 3 rows of 54px keys with 8px row gaps (.key,
   .keyboard above) = 178px tall regardless of viewport width, so anchoring
   by a fixed distance from the bottom of #own-play-area reliably lands just
   above the keyboard instead of on top of it. */
.peeked-self-badge {
  position: absolute;
  right: 6px;
  bottom: 190px;
  max-width: calc(100% - 12px);
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 16px;
  font-weight: 800;
  color: #fff7e6;
  background: rgba(146, 64, 14, .92);
  border: 1px solid rgba(253, 224, 71, .6);
  border-radius: 999px;
  padding: 6px 14px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .35);
  pointer-events: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  z-index: 5;
}

@media (max-width: 380px) {
  .peeked-self-badge { font-size: 13px; padding: 5px 10px; bottom: 170px; }
}

.ghost-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  font-weight: 700;
  padding: 8px 14px;
  border-radius: 999px;
  border: 1px solid #a78bfa;
  background: rgba(167, 139, 250, .14);
  color: #cbb6ff;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  margin-top: 10px;
}
.ghost-btn:active { background: rgba(167, 139, 250, .28); }
.ghost-btn.ghost-used,
.ghost-btn:disabled {
  opacity: .45;
  cursor: not-allowed;
  border-color: var(--border);
  color: var(--text-dim);
  background: transparent;
}

/* caster-facing cast confirmation toast, same mechanics as .freeze-toast */
.ghost-toast {
  position: fixed;
  bottom: calc(24px + env(safe-area-inset-bottom));
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  background: rgba(91, 71, 158, .95);
  color: #fff;
  font-weight: 700;
  font-size: 14px;
  padding: 10px 18px;
  border-radius: 999px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35);
  white-space: nowrap;
}

@media (max-width: 380px) {
  .scramble-badge { font-size: 11px; padding: 3px 10px; }
  .scramble-toast { font-size: 12px; padding: 8px 14px; }
  .ghost-self-badge { font-size: 11px; padding: 3px 8px; }
  .ghost-toast { font-size: 12px; padding: 8px 14px; }
}

/* ---- lock letter ability (R-78-2 design; server logic R-78-1, host toggle
   R-78-3, gesture/enforcement R-78-4, display view R-78-5) ----
 *
 * Sibling ability to freeze (above) - same "spend a charge, restrict others
 * for a timed window" shape - so it reuses freeze's visual language (badge
 * pill shape, toast position/animation, lazy client-side countdown) but in
 * an amber/gold palette (🔒) instead of freeze's blue (❄️), and it decorates
 * an individual *keyboard key* rather than a player's board/card, because the
 * thing being locked is a letter, not a person. There is no clickable letter
 * grid on the display screen, so that surface gets a new standalone banner
 * instead of a key to decorate.
 *
 * Exact class names / markup / attribute contract, for R-78-4 and R-78-5 to
 * implement against without guessing:
 *
 * 1. Mid-hold progress (own key only, before the 4s threshold - purely local
 *    client state, nothing broadcast yet). Add `.holding` to the `.key`
 *    button on `pointerdown` and set a CSS custom property to the elapsed
 *    fraction (0 at press, 1 at the 4000ms threshold), updating it as often
 *    as convenient (e.g. every animation frame or every ~50ms):
 *      <button class="key holding" style="--hold-progress: 0.4">E</button>
 *    Remove `.holding` (and reset the property) on pointerup/cancel/leave if
 *    released before the threshold. `--hold-progress` is the one contract
 *    R-78-4 updates continuously; `.holding` just toggles the fill visible.
 *
 * 2. Lock success pulse (own key, the instant the 4s hold completes and the
 *    server confirms it - i.e. on receiving `letter_locked` for a letter this
 *    player just requested). Add `.lock-success` to the key for ~450ms (one
 *    animation run), then remove it - it can be added at the same moment as
 *    `.locked.locked-mine` below, which is what actually persists:
 *      <button class="key locked locked-mine lock-success">E</button>
 *
 * 3. Locked key, ongoing (every client, for as long as the lock is active -
 *    seed from `progress_snapshot`'s `locked_letters` on connect/reconnect,
 *    update from the `letter_locked` broadcast, clear via a local countdown
 *    exactly like `syncOwnFreezeOverlay`/opponent freeze badges already do -
 *    no server "unlock" message is coming). Add `.locked` plus exactly one of
 *    `.locked-mine` (this viewer placed this lock - they can still type it)
 *    or `.locked-other` (someone else did - this viewer cannot) to the `.key`
 *    button, and append a `.lock-chip` as its last child:
 *      <button class="key locked locked-mine">
 *        E
 *        <span class="lock-chip">🔒 You · 14s</span>
 *      </button>
 *      <button class="key locked locked-other">
 *        E
 *        <span class="lock-chip">🔒 Mo · 14s</span>
 *      </button>
 *    `.lock-chip`'s text is rebuilt every tick of the local countdown (same
 *    `startCountdown()` helper freeze already uses) - build it from
 *    `common.lock_chip_mine`/`common.lock_chip_other` (see i18n keys below),
 *    with the "🔒 " prefixed literally in JS, not baked into the translated
 *    string - same split already used for `common.frozen_label`. Put the
 *    locker's name/remaining-seconds directly in this one element rather
 *    than splitting it into a separate always-optional attribution node -
 *    freeze's own `.freeze-by` contract exists but nothing actually populates
 *    it today, and a single required chip is harder to accidentally ship
 *    without the "who" in it.
 *    `.locked`/`.locked-mine`/`.locked-other` intentionally do NOT set
 *    `pointer-events: none` - R-78-4 already enforces the block inside
 *    `onKey` itself (physical-keyboard input bypasses the on-screen button
 *    entirely), so CSS only needs to communicate state, not gate it.
 *
 * 4. Blocked feedback (shown to the player who just tried a letter locked by
 *    someone else - fires from the same `onKey` check that silently drops
 *    the keypress). Same fixed-position/animation pattern as `.freeze-toast`,
 *    new class so the two abilities can still be told apart at a glance:
 *      <div class="lock-toast">🔒 Locked by Mo — 13s left</div>
 *    Copy: `common.lock_toast_blocked` (see i18n keys below). Auto-dismiss
 *    the same way `showFreezeToast` does (~2.5s).
 *
 * 5. Lock success toast (shown to the locker, alongside the key pulse in #2 -
 *    two signals for one gesture, same pairing freeze already uses: a toast
 *    plus a persistent badge):
 *      <div class="lock-toast">🔒 Locked "E"!</div>
 *    Copy: `common.lock_toast_self`.
 *
 * 6. Display/spectator banner (`templates/display.html` has no keyboard, so
 *    this is a new standalone element, not attached to any `.display-card`).
 *    One stack, one entry per currently-active lock - do not assume only one
 *    letter is ever locked at a time, since different players can each
 *    spend their charge on a different letter around the same time:
 *      <div class="lock-banner-stack" id="lock-banners">
 *        <div class="lock-banner">
 *          <span class="lock-banner-icon">🔒</span>
 *          <span class="lock-banner-text">E locked by Mo — 15s</span>
 *        </div>
 *      </div>
 *    Keyed by letter (one banner per locked letter, replace-in-place on a
 *    repeat `letter_locked` for the same letter, remove on local countdown
 *    reaching 0 - same lazy-expiry pattern as the frozen-card countdown this
 *    page already runs). Copy: `display.lock_banner`, "🔒 " prefixed in JS.
 *
 * 7. Host setup hint, next to the checkbox R-78-3 builds - same
 *    `<p class="hint hint-compact">` treatment as `host.freeze_rule_hint`,
 *    no new CSS needed:
 *      <p class="hint hint-compact" data-i18n="host.lock_letter_hint">
 *        🔒 Each player gets 1 chance per round to lock a letter for 20
 *        seconds — hold a key for 4+ seconds to lock it.
 *      </p>
 *    Checkbox label copy: `host.lock_letter_label`.
 *
 * New i18n keys added in `static/i18n/en.json` for the above (English only -
 * this is a design task, not a translation pass; every other locale file
 * already has 1:1 key parity with English today, so these will render in
 * English there until translated, which `i18n.t()`'s fallback already
 * handles gracefully):
 *   host.lock_letter_label, host.lock_letter_hint,
 *   common.lock_chip_mine, common.lock_chip_other,
 *   common.lock_toast_self, common.lock_toast_blocked,
 *   display.lock_banner,
 *   errors.lock_letter_already_used, errors.invalid_lock_letter,
 *   errors.lock_letter_already_locked (the last three are copy for the
 *   server-side rejections R-78-1 defines, added here so R-78-1 doesn't have
 *   to invent English strings for its own error branches).
 *
 * `.locked`'s override of `.correct`/`.present`/`.absent` backgrounds relies
 * on the `.keyboard .key.locked...` selectors below outweighing the plain
 * `.key.correct` etc. rules on specificity (not source order) - a lock must
 * visually win even on a key already colored from an earlier guess, and this
 * makes that true regardless of where either rule block ends up in the file.
 */

.key { position: relative; }

/* R-108-1: large letter callout shown above a key while its Letter Lock
   hold is in progress, so the held letter and hold progress stay legible
   over a covering fingertip on touch devices. Lives exactly as long as
   .holding (added in the pointerdown handler, removed in clearHoldVisual)
   and reuses --hold-progress rather than tracking its own value. Named
   generically (key-callout, not lock-callout) so R-108-2 can reuse it for
   the plain-tap popup. */
.key-callout {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 10px 14px;
  min-width: 44px;
  background: rgba(41, 37, 33, .95);
  border: 1px solid rgba(255, 214, 102, .6);
  border-radius: 14px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .35);
  pointer-events: none;
}

.key-callout-letter {
  font-size: 2.6rem;
  font-weight: 800;
  line-height: 1;
  color: #fff8e6;
  text-shadow: 0 1px 3px rgba(0, 0, 0, .5);
}

.key-callout-progress {
  width: 100%;
  height: 5px;
  border-radius: 999px;
  background: rgba(255, 255, 255, .18);
  overflow: hidden;
}

.key-callout-progress::after {
  content: "";
  display: block;
  width: 100%;
  height: 100%;
  background: #d9a72c;
  transform: scaleX(var(--hold-progress, 0));
  transform-origin: left center;
  transition: transform .05s linear;
}

.key.holding::after {
  content: "";
  position: absolute;
  left: 1px;
  right: 1px;
  bottom: 1px;
  height: 4px;
  border-radius: 0 0 6px 6px;
  background: #d9a72c;
  transform: scaleX(var(--hold-progress, 0));
  transform-origin: left center;
  transition: transform .05s linear;
}

@keyframes lock-pulse {
  0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(217, 168, 44, .55); }
  40% { transform: scale(1.16); box-shadow: 0 0 0 6px rgba(217, 168, 44, .3); }
  100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(217, 168, 44, 0); }
}
.key.lock-success { animation: lock-pulse .45s ease-out; }

.keyboard .key.locked {
  border: 2px solid rgba(217, 168, 44, .85);
}
.keyboard .key.locked.locked-mine {
  background: rgba(217, 168, 44, .25);
  color: var(--text);
  cursor: pointer;
}
.keyboard .key.locked.locked-other {
  background: linear-gradient(160deg, rgba(217, 168, 44, .45), rgba(146, 64, 14, .4));
  color: rgba(245, 245, 245, .45);
  cursor: not-allowed;
}

.lock-chip {
  position: absolute;
  bottom: calc(100% + 4px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 20;
  background: rgba(146, 64, 14, .92);
  border: 1px solid rgba(255, 214, 102, .6);
  color: #fff8e6;
  font-size: 9px;
  font-weight: 800;
  line-height: 1.4;
  white-space: nowrap;
  padding: 2px 7px;
  border-radius: 999px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .4);
  pointer-events: none;
}

/* caster-facing lock feedback - same fixed position/animation as
   .freeze-toast, amber palette to read as "lock" rather than "freeze" */
.lock-toast {
  position: fixed;
  bottom: calc(24px + env(safe-area-inset-bottom));
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  background: rgba(146, 64, 14, .95);
  color: #fff8e6;
  font-weight: 700;
  font-size: 14px;
  padding: 10px 18px;
  border-radius: 999px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35);
  white-space: nowrap;
}

/* display / spectator banner stack - standalone, not attached to any
   .display-card, since display has no keyboard to decorate a key on */
.lock-banner-stack {
  position: fixed;
  top: calc(70px + env(safe-area-inset-top));
  left: 50%;
  transform: translateX(-50%);
  z-index: 40;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  pointer-events: none;
}
.lock-banner {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: linear-gradient(160deg, rgba(217, 168, 44, .92), rgba(146, 64, 14, .88));
  border: 1px solid rgba(255, 214, 102, .6);
  color: #fff8e6;
  font-weight: 800;
  font-size: 20px;
  padding: 10px 22px;
  border-radius: 999px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, .35);
  text-shadow: 0 1px 2px rgba(0, 0, 0, .4);
  white-space: nowrap;
}
.lock-banner-icon { font-size: 22px; }

@media (prefers-reduced-motion: reduce) {
  .key.lock-success { animation: none; }
}

@media (max-width: 380px) {
  .lock-chip { font-size: 8px; padding: 1px 5px; }
  .lock-toast { font-size: 12px; padding: 8px 14px; }
}

@media (max-width: 480px) {
  .lock-banner-stack { top: calc(56px + env(safe-area-inset-top)); }
  .lock-banner { font-size: 15px; padding: 8px 16px; gap: 6px; }
  .lock-banner-icon { font-size: 17px; }
}

/* ---- admin shell (sidebar nav shared by Analytics + Settings) and the
   Settings page itself (R-59) ---- */

.admin-shell { display: flex; gap: 24px; align-items: flex-start; }

.admin-sidebar {
  flex: 0 0 190px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  position: sticky;
  top: 20px;
}
.sidebar-link {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  border-radius: 8px;
  color: var(--text-dim);
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  border: 1px solid transparent;
}
.sidebar-link .sidebar-icon { font-size: 16px; line-height: 1; width: 18px; text-align: center; flex-shrink: 0; }
.sidebar-link:hover { background: var(--panel-2); }
.sidebar-link.active {
  color: var(--text);
  background: var(--panel-2);
  border-color: var(--accent);
  border-left: 3px solid var(--accent);
  padding-left: 12px;
}

.admin-main { flex: 1; min-width: 0; }

.settings-header { margin-bottom: 18px; text-align: left; }
.settings-header h2 { margin: 0 0 4px; font-size: 20px; }
.settings-header p { margin: 0; }

.lang-pill-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.lang-pill {
  font-size: 13px;
  font-weight: 600;
  padding: 7px 14px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--text-dim);
  cursor: pointer;
}
.lang-pill.on { color: #fff; background: var(--accent); border-color: var(--accent); }

.wordlen-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.wordlen-row .label { font-size: 13px; color: var(--text-dim); font-weight: 600; }

/* word-length and admin difficulty pickers are narrower than the 4-option
   host-setup difficulty control they share styling with, so they lay out
   inline instead of stretching across a fixed 4-column grid. */
#wordlen-control, #add-difficulty, #bulk-difficulty {
  display: inline-grid;
  grid-auto-flow: column;
  gap: 6px;
}
#wordlen-control .segmented-option {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
}
#wordlen-control .segmented-option.is-selected {
  border-color: var(--accent);
  background: rgba(59, 130, 246, .15);
  color: var(--text);
}
.wordlen-count { font-size: 10px; font-weight: 500; color: var(--text-dim); }
#wordlen-control .segmented-option.is-empty { border-style: dashed; }
#wordlen-control .segmented-option.is-empty .wordlen-count { font-style: italic; }

.list-toolbar { display: flex; align-items: center; gap: 10px; justify-content: space-between; margin-bottom: 12px; flex-wrap: wrap; }
.search-input {
  flex: 1;
  min-width: 180px;
  max-width: 320px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px 12px;
  font-size: 14px;
  color: var(--text);
}
.search-input:focus { outline: 2px solid var(--accent); }

.bucket-tabs { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
.bucket-tab {
  background: var(--panel-2);
  border: 1px solid var(--border);
  color: var(--text-dim);
  font-size: 13px;
  font-weight: 600;
  padding: 7px 14px;
  border-radius: 8px;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 6px;
}
.bucket-tab .count { font-weight: 800; color: var(--text-dim); }
.bucket-tab.on { color: #fff; border-color: var(--accent); background: rgba(59, 130, 246, .18); }
.bucket-tab.on .count { color: #fff; }
.bucket-tab[data-bucket="easy"].on { border-color: var(--correct); background: rgba(83, 141, 78, .18); }
.bucket-tab[data-bucket="medium"].on { border-color: var(--present); background: rgba(181, 159, 59, .18); }
.bucket-tab[data-bucket="hard"].on { border-color: var(--danger); background: rgba(217, 48, 37, .18); }

.word-list-box {
  max-height: 340px;
  overflow-y: auto;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px;
}
.word-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.word-chip {
  display: flex;
  align-items: center;
  gap: 6px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 6px 6px 12px;
  font-size: 14px;
  letter-spacing: .5px;
  font-weight: 600;
}
.word-chip .origin-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.word-chip .remove-btn {
  background: transparent;
  border: none;
  color: var(--text-dim);
  cursor: pointer;
  font-size: 15px;
  line-height: 1;
  padding: 4px 6px;
  border-radius: 6px;
}
.word-chip .remove-btn:hover { color: #fff; background: var(--danger); }
.list-empty { color: var(--text-dim); font-size: 13px; text-align: center; padding: 24px 8px; }

.list-footnote { display: flex; align-items: center; gap: 14px; margin-top: 10px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); }
.legend-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }

/* modal (add word / confirm remove) */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, .55);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
  padding: 20px;
}
.modal-overlay.hidden { display: none; }
.modal {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 22px;
  width: 100%;
  max-width: 380px;
}
.modal h3 { margin: 0 0 6px; font-size: 17px; }
.modal .modal-body-text { color: var(--text-dim); font-size: 14px; line-height: 1.5; margin: 0 0 16px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 6px; }
.word-input-preview {
  text-align: center;
  font-size: 22px;
  font-weight: 800;
  letter-spacing: 4px;
  text-transform: uppercase;
  color: var(--danger);
  margin: 0 0 16px;
}

/* bulk upload panel */
.bulk-context { font-size: 13px; color: var(--text-dim); margin-bottom: 14px; }
.bulk-context strong { color: var(--text); }
.file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.file-name { font-size: 13px; color: var(--text-dim); }
.bulk-step { display: none; }
.bulk-step.active { display: block; }
.issue-list { max-height: 120px; overflow-y: auto; font-size: 13px; color: var(--text-dim); background: var(--panel-2); border-radius: 8px; padding: 8px 12px; margin-bottom: 14px; }
.issue-list div { padding: 2px 0; }
.result-banner {
  padding: 12px 14px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 600;
  margin-bottom: 14px;
}
.result-banner.ok { background: rgba(83, 141, 78, .15); border: 1px solid var(--correct); color: #a8d5a1; }
.result-banner.err { background: rgba(217, 48, 37, .15); border: 1px solid var(--danger); color: #ffb3ad; }

/* ---- groups page ---- */
.group-card-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.group-name { margin: 0; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.group-delete-btn {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--danger);
  font-size: 12px;
  font-weight: 600;
  padding: 6px 10px;
  border-radius: 8px;
  cursor: pointer;
  flex-shrink: 0;
}
.group-delete-btn:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
.group-member-list .member-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.group-member-remove-btn {
  background: transparent;
  border: none;
  color: var(--text-dim);
  cursor: pointer;
  font-size: 15px;
  line-height: 1;
  padding: 4px 8px;
  border-radius: 6px;
  flex-shrink: 0;
}
.group-member-remove-btn:hover { color: #fff; background: var(--danger); }
.group-empty-members { text-align: left; margin: 0; }
