/* ════════════════════════════════════════════════════════════
   CSS VARIABLES — colours, tile sizes (JS updates tile sizes on resize)
   ════════════════════════════════════════════════════════════ */
:root{
  --jade:#1a6b45;--jl:#2a9e67;--jd:#0d3d28;
  --gold:#c9a84c;--gl:#f0d080;--gd:#8a6b20;--cream:#f5edd8;
  /* Responsive tile sizes via CSS vars — JS updates these on resize */
  --tlg-w:40px;--tlg-h:56px;
  --tmd-w:28px;--tmd-h:36px;
  --tsm-w:16px;--tsm-h:22px;
}
/* ════ RESET & BASE ════ */
*{margin:0;padding:0;box-sizing:border-box;}
html,body{height:100%;overflow:hidden;}
body{background:#060c07;color:var(--cream);font-family:'Noto Sans JP',sans-serif;user-select:none;-webkit-user-select:none;touch-action:manipulation;}
/* Themed scrollbar — replaces the OS-default bar with the gold-on-dark
   look everywhere content can scroll (About/User Profile/etc. modals via
   .about-modal, the Settings side panel, in-game chat history). Global
   (`*`) rather than listing each scrollable selector individually so any
   future scrolling container picks it up automatically; harmless on
   non-scrolling elements since these only render where a scrollbar
   actually appears. Firefox uses the scrollbar-width/color shorthand;
   everything Chromium/WebKit-based (including this app's target browsers)
   uses the ::-webkit-scrollbar-* pseudo-elements instead. */
*{scrollbar-width:thin;scrollbar-color:rgba(201,168,76,.5) rgba(0,0,0,.3);}
*::-webkit-scrollbar{width:8px;height:8px;}
*::-webkit-scrollbar-track{background:rgba(0,0,0,.3);}
*::-webkit-scrollbar-thumb{background:rgba(201,168,76,.5);border-radius:4px;}
*::-webkit-scrollbar-thumb:hover{background:var(--gold);}
/* ════ BACKGROUND LAYERS ════ */
.bg{position:fixed;inset:0;z-index:0;background:radial-gradient(ellipse at 20% 85%,rgba(26,107,69,.3),transparent 55%),radial-gradient(ellipse at 80% 15%,rgba(201,168,76,.14),transparent 55%),radial-gradient(ellipse at 50% 50%,rgba(12,35,20,.85),#040a05 100%);}
.screen{position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;}
.screen.hidden{display:none!important;}
#s-end.hidden{display:none!important;}

/* ════════════════════════════════════════════════════════════
   LOBBY SCREEN
   ════════════════════════════════════════════════════════════ */
/* Same clamped scale-unit approach as #s-start (see .logo-k/#bstart notes
   below) — every font-size/letter-spacing/padding in the hall-entry card
   multiplies this ONE variable, so they all stay proportional to each
   other at any resolution instead of each hitting its own px cap at a
   different screen size. */
#s-lobby{--ui-vmin:clamp(6px,1vmin,10px);flex-direction:column;gap:18px;}
#s-start{flex-direction:column;align-items:center;justify-content:center;gap:32px;}
.logo{text-align:center;animation:float 5s ease-in-out infinite;}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
/* Plain vmin (no clamp min/max), same fix as the game banners: logo-k was
   capped at 72px above a 720px-wide viewport while logo-s kept scaling on
   vw until 560px — in between, the two drifted out of proportion with each
   other. Coefficients chosen so a ~720px-vmin screen (e.g. 1280x720) still
   renders at the old capped 72px/14px look. */
.logo-k{font-family:'Noto Serif JP',serif;font-size:10vmin;font-weight:900;letter-spacing:1.7vmin;
  background:linear-gradient(135deg,#ffe680,#c9a84c,#8a6b20);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;filter:drop-shadow(0 0 24px rgba(201,168,76,.7));}
/* Start-press kanji burst — a brief brighten+scale flash on the 雀心
   characters, layered on top of the screen-out fade (see enterLobby()).
   Lives on .logo-k (not .logo, which already owns the infinite float
   animation) so the two animations don't fight over the shorthand. */
.logo-k.burst{animation:logoBurst .45s ease;}
@keyframes logoBurst{
  0%{filter:drop-shadow(0 0 24px rgba(201,168,76,.7));transform:scale(1);}
  40%{filter:drop-shadow(0 0 48px rgba(255,224,102,1)) brightness(1.3);transform:scale(1.1);}
  100%{filter:drop-shadow(0 0 24px rgba(201,168,76,.7));transform:scale(1);}
}
.logo-s{font-family:'Cinzel Decorative',serif;font-size:2vmin;color:var(--jl);letter-spacing:1.1vmin;margin-top:2px;}
.card{background:linear-gradient(155deg,rgba(20,55,35,.95),rgba(8,20,12,.98));border:1px solid rgba(201,168,76,.12);border-radius:0;
  --pg:calc(var(--ui-vmin) * 5.5);--pw:calc(var(--ui-vmin) * .2);
  padding:calc(var(--ui-vmin) * 4.5) calc(var(--ui-vmin) * 6.9);
  /* Was a flat min(440px,96vw) — the box stayed capped at 440px while its
     own font-size/padding kept scaling up to --ui-vmin's 10px ceiling, so
     on a 4K screen the (now larger) text read as cramped into a box that
     never grew. Tying width to the same --ui-vmin (61.11 * 7.2 = 440, so
     it still renders at 440px at the same ~720px-vmin reference screen)
     lets it grow in step with its own content instead of stalling. */
  width:min(calc(var(--ui-vmin) * 61.11), 96vw);
  box-shadow:0 0 80px rgba(0,0,0,.7),inset 0 1px 0 rgba(201,168,76,.25);position:relative;overflow:hidden;}
/* Diagonal-stagger gold frame, same as #bstart and the .about-modal family
   — the lobby card doesn't scroll internally, so ::before/::after at the
   edge work with no flex refactor. ::before = outer rule cut top-left +
   bottom-right, ::after = inner rule cut the other two. Replaces the old
   gradient top-bar. */
.card::before,.card::after{content:'';position:absolute;pointer-events:none;z-index:2;background-repeat:no-repeat;}
.card::before{inset:0;
  background-image:linear-gradient(115deg,#b5903f 0%,#ffe9b0 48%,#cfa74d 100%);
  -webkit-mask:
    linear-gradient(90deg,transparent 0 var(--pg),#000 var(--pg)) top/100% var(--pw) no-repeat,
    linear-gradient(90deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) bottom/100% var(--pw) no-repeat,
    linear-gradient(180deg,transparent 0 var(--pg),#000 var(--pg)) left/var(--pw) 100% no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) right/var(--pw) 100% no-repeat;
  mask:
    linear-gradient(90deg,transparent 0 var(--pg),#000 var(--pg)) top/100% var(--pw) no-repeat,
    linear-gradient(90deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) bottom/100% var(--pw) no-repeat,
    linear-gradient(180deg,transparent 0 var(--pg),#000 var(--pg)) left/var(--pw) 100% no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) right/var(--pw) 100% no-repeat;}
.card::after{inset:calc(var(--ui-vmin) * .6);opacity:.7;
  background-image:linear-gradient(115deg,#a37f35 0%,#f4e0ac 48%,#bd9740 100%);
  -webkit-mask:
    linear-gradient(90deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) top/100% var(--pw) no-repeat,
    linear-gradient(90deg,transparent 0 var(--pg),#000 var(--pg)) bottom/100% var(--pw) no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) left/var(--pw) 100% no-repeat,
    linear-gradient(180deg,transparent 0 var(--pg),#000 var(--pg)) right/var(--pw) 100% no-repeat;
  mask:
    linear-gradient(90deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) top/100% var(--pw) no-repeat,
    linear-gradient(90deg,transparent 0 var(--pg),#000 var(--pg)) bottom/100% var(--pw) no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) left/var(--pw) 100% no-repeat,
    linear-gradient(180deg,transparent 0 var(--pg),#000 var(--pg)) right/var(--pw) 100% no-repeat;}
.card h2{font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin) * 1.528);letter-spacing:calc(var(--ui-vmin) * .694);color:var(--gold);text-align:center;}
/* Back button, header, and Rankings/Match Settings all on one row/
   baseline — align-items:center is what actually lines up the back
   button's/icon buttons' vertical centres with the header text's,
   regardless of their differing box heights (a fixed height on the icon
   buttons alone, matched against the header's line-height, wouldn't by
   itself guarantee the same centre — flex centring here does).
   Negative side margins pull just THIS row out past .card's own
   (deliberately generous, for the rest of the card's content — inputs,
   buttons, mode pickers) padding so the corner icons can sit at their own
   tighter inset instead of being stuck at that same wide padding — shrinking
   .card's padding itself to fit the icons cramped every other element in
   the card along with them. The row's own small padding below re-adds
   just enough inset to keep the icons off the literal card edge. */
.card-header-row{display:flex;align-items:center;justify-content:space-between;
  margin:0 calc(var(--ui-vmin, 7.2px) * -5) 18px;
  padding:0 calc(var(--ui-vmin, 7.2px) * 1.389);}
.card-header-row h2{flex:1;min-width:0;margin:0;}
/* Other active rules, directly below the header row (#lobby-header covers
   mode/player-count) — same element/copy whether it's reflecting live
   Match Settings picks (updateMatchSummary) or a joined room's frozen
   ones (renderWaitingRules), so it stays put across that handoff instead
   of two separate readouts in two different spots. */
.lobby-rules-summary{font-size:calc(var(--ui-vmin) * 1.5);color:rgba(245,237,216,.5);text-align:center;margin:-10px 0 16px;letter-spacing:calc(var(--ui-vmin) * .069);}
/* var(--ui-vmin, 7.2px) — same fallback convention as .mode-btn/.about
   .settings-icon-btn: --ui-vmin is only ever defined on #s-lobby/#s-start,
   so .ig used inside a .about-modal (Profile Picker, Developer Settings)
   has no ancestor defining it — without the fallback, the calc() goes
   invalid and padding collapses to 0, leaving input text flush against
   the left edge. */
.ig{margin-bottom:16px;}.ig label{display:block;font-size:calc(var(--ui-vmin, 7.2px) * 1.389);letter-spacing:calc(var(--ui-vmin, 7.2px) * .417);color:var(--gold);opacity:.9;margin-bottom:6px;text-transform:uppercase;}
.ig input{width:100%;background:rgba(0,0,0,.5);border:1px solid rgba(201,168,76,.2);border-radius:0;
  padding:calc(var(--ui-vmin, 7.2px) * 1.528) calc(var(--ui-vmin, 7.2px) * 1.944);color:var(--cream);
  font-size:calc(var(--ui-vmin, 7.2px) * 2.222);outline:none;transition:border-color .3s;font-family:'Noto Sans JP',sans-serif;}
.ig input:focus{border-color:var(--jl);}
.ig select{width:100%;background:rgba(0,0,0,.5);border:1px solid rgba(201,168,76,.2);border-radius:0;
  padding:calc(var(--ui-vmin, 7.2px) * 1.528) calc(var(--ui-vmin, 7.2px) * 1.944);color:var(--cream);
  font-size:calc(var(--ui-vmin, 7.2px) * 2.222);outline:none;transition:border-color .3s;font-family:'Noto Sans JP',sans-serif;}
.ig select:focus{border-color:var(--jl);}
.ig input[readonly]{cursor:pointer;opacity:.85;}
/* Error text for the profile-picker/dev-panel forms — small, unobtrusive
   until something actually goes wrong. */
.profile-error{color:#ff8080;font-size:calc(var(--ui-vmin, 7.2px) * 1.667);min-height:calc(var(--ui-vmin, 7.2px) * 2.222);margin:calc(var(--ui-vmin, 7.2px) * .833) 0;}
/* "Stay logged in" — a plain small checkbox+label sitting right under the
   code field (see #profile-code-section in index.html), not the bigger
   sp-toggle switch used for settings elsewhere — this is a minor one-off
   option on a login form, not something that deserves its own row's worth
   of visual weight. */
.profile-remember-row{display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * .694);
  font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:var(--cream);opacity:.8;
  margin:calc(var(--ui-vmin, 7.2px) * -.556) 0 calc(var(--ui-vmin, 7.2px) * 1.111);cursor:pointer;}
.profile-remember-row input{width:calc(var(--ui-vmin, 7.2px) * 1.944);height:calc(var(--ui-vmin, 7.2px) * 1.944);
  accent-color:var(--gold);cursor:pointer;flex-shrink:0;}
/* Room List (see #roomlist-modal/renderRoomList) — one card per open room,
   same rgba(42,58,42,...)+gold-border language as .icon-btn/.theme-btn
   elsewhere in this modal family, just a row instead of a grid cell since
   each entry needs a title line, a sub line, AND a Join button. */
.room-card{display:flex;align-items:center;justify-content:space-between;gap:calc(var(--ui-vmin, 7.2px) * 1.111);
  background:rgba(42,58,42,.3);border:1px solid rgba(201,168,76,.2);border-radius:2px;
  padding:calc(var(--ui-vmin, 7.2px) * 1.25) calc(var(--ui-vmin, 7.2px) * 1.389);
  margin-bottom:calc(var(--ui-vmin, 7.2px) * .833);}
.room-card-title{font-size:calc(var(--ui-vmin, 7.2px) * 1.528);color:var(--cream);}
.room-card-sub{font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:var(--gold);opacity:.85;margin-top:calc(var(--ui-vmin, 7.2px) * .278);}
.room-card-join{flex-shrink:0;padding:calc(var(--ui-vmin, 7.2px) * .833) calc(var(--ui-vmin, 7.2px) * 1.667);
  background:linear-gradient(135deg,var(--jade),var(--jd));border:1px solid var(--jl);border-radius:2px;
  color:var(--cream);font-size:calc(var(--ui-vmin, 7.2px) * 1.389);cursor:pointer;font-family:'Noto Sans JP',sans-serif;}
.room-card-join:disabled{opacity:.4;cursor:default;}
.bp{width:100%;padding:calc(var(--ui-vmin) * 1.806);background:linear-gradient(135deg,var(--jade),var(--jd));border:1px solid var(--jl);border-radius:2px;color:var(--cream);
  font-size:calc(var(--ui-vmin) * 1.806);font-family:'Cinzel Decorative',serif;letter-spacing:calc(var(--ui-vmin) * .417);cursor:pointer;transition:all .25s;}
/* Lobby's two front-screen actions (Quick Match / Room List, see
   index.html) — side by side instead of one ambiguous button. Smaller
   font + letter-spacing than a lone .bp so "⚔ Quick Match"/"Room List"
   both fit comfortably at half width down to a narrow phone screen. */
.lobby-actions{display:flex;gap:calc(var(--ui-vmin, 7.2px) * 1.111);}
.lobby-actions .bp{width:auto;flex:1;min-width:0;
  font-size:calc(var(--ui-vmin) * 1.6);letter-spacing:calc(var(--ui-vmin) * .278);}
.bp:hover{transform:translateY(-2px);box-shadow:0 10px 28px rgba(26,107,69,.55);border-color:var(--gold);}
.bp:disabled{opacity:.4;cursor:default;transform:none;}
.bs{width:100%;padding:calc(var(--ui-vmin) * 1.389);background:transparent;border:1px solid rgba(201,168,76,.3);border-radius:2px;color:rgba(245,237,216,.65);
  font-size:calc(var(--ui-vmin) * 1.667);font-family:'Cinzel Decorative',serif;letter-spacing:calc(var(--ui-vmin) * .417);cursor:pointer;transition:all .25s;margin-top:8px;}
.bs:hover{border-color:var(--gold);color:var(--gl);}
.wa{display:none;margin-top:14px;}
.wm{text-align:center;color:var(--jl);font-size:calc(var(--ui-vmin) * 1.667);letter-spacing:calc(var(--ui-vmin) * .278);animation:pulse 1.5s infinite;}
@keyframes pulse{0%,100%{opacity:.5}50%{opacity:1}}
@keyframes ld-bounce{0%,80%,100%{transform:scale(0);opacity:.3}40%{transform:scale(1);opacity:1}}
.ld-dot{width:10px;height:10px;border-radius:50%;background:var(--gold);animation:ld-bounce 1.2s ease-in-out infinite;}
.slots{display:flex;gap:8px;justify-content:center;margin-top:12px;}
/* flex-shrink instead of a rigid 80px: on a narrow phone, .card can end up
   narrower than 4*80px + gaps (e.g. 96vw at 320px wide is well under the
   344px the four slots need), so they'd wrap unpredictably into 2x2 —
   changing the panel's layout depending on screen width. flex:0 1 80px
   keeps the current 80px look on any screen with room for it (matches
   .mode-btn's flex:1 above, already used for this same "N equal items in
   a row" problem) while letting them shrink together, in one row, rather
   than wrap, when space is tight. */
.slot{position:relative;flex:0 1 80px;min-width:0;min-height:64px;border-radius:2px;border:2px solid rgba(201,168,76,.2);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:3px;background:rgba(0,0,0,.4);transition:border-color .4s,background .4s,box-shadow .4s;padding:6px 4px;}
.slot .si{font-size:calc(var(--ui-vmin) * 2.778);line-height:1;}
/* Real player icon (see iconSvg in script.js) — bots keep the plain
   letter-avatar .si above instead, since they have no profile icon. */
.slot .s-icon{width:calc(var(--ui-vmin) * 7y);height:calc(var(--ui-vmin) * 7);display:block;}
.slot .s-icon svg{width:100%;height:100%;display:block;}
.slot .sn{font-size:calc(var(--ui-vmin) * 1.25);color:rgba(245,237,216,.7);font-weight:600;}
.slot.filled{border-color:var(--jl);background:rgba(26,107,69,.25);}
.slot.bot{border-color:rgba(201,168,76,.35);background:rgba(50,35,5,.4);}
/* Ready state is communicated purely by frame colour/glow now — no more
   "Not ready"/"✓ Ready" text (see updateSlots in script.js). */
.slot.rdy{border-color:#4eff9a;box-shadow:0 0 14px rgba(78,255,154,.3);}
/* One-shot pulse the moment a slot actually TRANSITIONS to ready (added
   only on that specific render — see the prevReady tracking in
   updateSlots, which prevents this from replaying on every unrelated
   room_update, e.g. another seat's difficulty change). Ends on exactly
   .slot.rdy's steady box-shadow/scale so it blends into the persistent
   glow instead of visibly snapping when the animation finishes. */
@keyframes rdyFlash{
  0%{box-shadow:0 0 0 rgba(78,255,154,0);transform:scale(1);}
  35%{box-shadow:0 0 26px rgba(78,255,154,.85);transform:scale(1.07);}
  100%{box-shadow:0 0 14px rgba(78,255,154,.3);transform:scale(1);}
}
.slot.rdy-flash{animation:rdyFlash .5s ease-out;}
.slot.clickable-bot{cursor:pointer;}
.slot .sd{font-size:calc(var(--ui-vmin) * 1.0);color:var(--gold);opacity:.75;margin-top:1px;}
/* Small inline Easy/Normal/Hard picker anchored under a clicked bot slot —
   populated in renderDiffPopup() (script.js). */
.diff-popup{position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:6px;
  display:flex;gap:4px;background:#0a160c;border:1px solid rgba(201,168,76,.4);border-radius:8px;
  padding:6px;box-shadow:0 8px 24px rgba(0,0,0,.6);z-index:50;}
.diff-popup-btn{padding:5px 8px;font-size:10px;white-space:nowrap;}
.cd{font-size:calc(var(--ui-vmin) * 1.389);color:rgba(201,168,76,.9);text-align:center;margin-top:8px;letter-spacing:calc(var(--ui-vmin) * .139);}
/* Invite Players — a plain text link (see #binvite in index.html), not a
   third full-width button alongside Ready/Cancel below it. */
.invite-link{display:block;text-align:left;padding-left:2vmin;font-size:calc(var(--ui-vmin) * 1.389);
  color:rgba(245,237,216,.55);text-decoration:none;letter-spacing:calc(var(--ui-vmin) * .139);cursor:pointer;transition:color .2s;}
.invite-link:hover{color:var(--gold);}
/* Create Room's Invite Players link sits in an .about-section wrapper
   (see #rules-invite-section in index.html) purely so JS can show/hide it
   as one unit — keeps that class's own top margin/divider-line/padding (a
   thin rule + gap, same as every other .about-section) so it reads as its
   own section below Rules rather than butting right up against the toggle
   list, but drops the BOTTOM margin .about-section doesn't otherwise have
   an opinion on — "+ Create Room" right below (see
   #rules-modal-create-btn) sits snug against it/its invited-chip list
   instead of leaving a gap. */
#rules-invite-section{margin-bottom:0;}
/* Fallback (7.2px), same value/reason as .about-close below: .mode-btn is
   used both in #s-lobby (defines --ui-vmin) and, since Game Mode/Players
   moved into #rules-modal, inside a modal that never defines it — without
   the fallback, --ui-vmin resolves to nothing there, calc() goes invalid,
   and padding (not an inherited property) collapses to 0, pinning the
   label text against the button edges. */
.mode-btn{flex:1;padding:calc(var(--ui-vmin, 7.2px) * 1.389) calc(var(--ui-vmin, 7.2px) * .833);background:rgba(0,0,0,.3);border:1px solid rgba(201,168,76,.2);border-radius:2px;color:rgba(245,237,216,.9);font-size:calc(var(--ui-vmin, 7.2px) * 1.806);font-family:'Noto Serif JP',serif;font-weight:700;cursor:pointer;transition:all .2s;line-height:1.6;text-align:center;}
.mode-btn.active{background:rgba(26,107,69,.4);border-color:var(--jl);color:var(--cream);box-shadow:0 0 12px rgba(42,158,103,.3);}
.mode-btn:hover:not(.active){border-color:rgba(201,168,76,.4);color:var(--cream);}
.ready-btn{width:100%;padding:calc(var(--ui-vmin) * 1.667);background:linear-gradient(135deg,#0a5a2a,#1a8840);border:2px solid #4eff9a;border-radius:2px;color:#c0ffd8;font-size:calc(var(--ui-vmin) * 1.944);font-family:'Cinzel Decorative',serif;letter-spacing:calc(var(--ui-vmin) * .417);cursor:pointer;transition:all .25s;margin-top:12px;position:relative;overflow:hidden;}
.ready-btn:hover{transform:translateY(-2px);box-shadow:0 0 20px rgba(78,255,154,.4);}
.ready-btn.pressed{opacity:.6;cursor:default;transform:none;}
.ready-btn.pressed::after{content:'✓ Ready!';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#0a5a2a,#1a8840);font-size:calc(var(--ui-vmin) * 1.944);letter-spacing:calc(var(--ui-vmin) * .417);}

/* ════════════════════════════════════════════════════════════
   GAME SCREEN — LAYOUT
   ════════════════════════════════════════════════════════════ */
#s-game:not(.hidden){display:block;}
/* --ui-vmin: same clamped-scale-unit approach as #s-start/#s-lobby/
   #dora-panel/.chat-bar, but with a lower ceiling (7.5px vs 10px) — the
   topbar's own height stays a fixed 36px (.table hardcodes top:36px to
   sit right below it, so growing the bar means re-plumbing that too),
   so its contents need to stop scaling well before a 10px unit would
   make .bl (height 3.611 * unit) crowd a 36px-tall strip. At 7.5px the
   bar renders almost identically to the original fixed-px sizes on any
   normal-to-large screen, only shrinking further on small phones (floor
   still 6px). */
.topbar{--ui-vmin:clamp(6px,1vmin,7.5px);
  position:fixed;top:0;left:0;right:0;height:36px;background:rgba(3,10,5,.97);border-bottom:1px solid rgba(201,168,76,.18);display:flex;align-items:center;justify-content:space-between;padding:0 10px;z-index:50;}
.tb-title{font-family:'Noto Serif JP',serif;font-size:calc(var(--ui-vmin) * 1.944);color:var(--gold);letter-spacing:calc(var(--ui-vmin) * .278);}
.tb-st{font-size:calc(var(--ui-vmin) * 1.944);color:rgba(245,237,216,.5);max-width:40vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tb-st.my{color:#ffdd44;font-weight:700;animation:pulse 1.5s infinite;}
.bl{padding:0 calc(var(--ui-vmin) * 1.389);height:calc(var(--ui-vmin) * 3.611);background:rgba(120,20,20,.8);border:1px solid rgba(200,60,60,.4);border-radius:6px;color:rgba(255,180,180,.85);font-size:calc(var(--ui-vmin) * 1.806);cursor:pointer;font-family:'Noto Sans JP',sans-serif;white-space:nowrap;flex-shrink:0;}
.bl:hover{background:rgba(160,30,30,.9);}

/* ── Table container ── */
.table{position:fixed;top: 36px;;left:0;right:0;bottom:0;overflow:hidden;
  display:flex;align-items:center;justify-content:center;}

/* Sanma (3-player): no seat sits directly across (toimen), so that
   player-area is hidden — script.js toggles this class on .table once per
   game, from game_start's numPlayers. The centre score panel's top strip
   (.ctr-score-top) is deliberately NOT included here: it's one of four
   ridge-bordered segments that visually close into a single rectangular
   frame (see .ctr/.ctr-score), not a per-player element on its own —
   hiding the whole strip leaves that frame open on one side. Its content
   (the score value and riichi-stick icon) IS hidden below, via
   visibility:hidden rather than display:none, so the frame segment stays
   in place but reads as empty rather than showing a "—" placeholder or a
   stray riichi stick for a seat nobody occupies. */
.table.sanma .pa-t,
.table.sanma .np-wrap-t,
.table.sanma .ml-t,
.table.sanma .kita-t,
.table.sanma .dzt{display:none;}
.table.sanma #ctr-s-t,
.table.sanma .rs-top{visibility:hidden;}

/* ── Sizing variable — single source of truth ── */
/* Plain 110vmin assumes .table's box is the full viewport, but .table
   sits below the 36px-tall .topbar (top:36px), so its real height is
   (100vh - 36px). Since vmin = min(vw, vh), using raw vh under-reports
   how little vertical room the container actually has whenever height
   is the limiting dimension — which is most landscape/widescreen
   viewports, not just short mobile-landscape windows. That let the felt
   (and everything anchored to its edge — seat rows, emoji reactions)
   render partly outside .table's clipped bounds. Swapping the vh side
   for the container's real height keeps the portrait/width-limited case
   byte-for-byte identical (min(vw,...) still wins there) while fixing
   every height-limited one. */
:root{
  --table-size: min(110vw, calc((100vh - 36px) * 1.1));
  --felt-size: calc(var(--table-size) * 0.86);
}

/* ── Wood table — sits behind the felt ── */
.wood-table{
  width:var(--table-size);
  height:var(--table-size);
  background:radial-gradient(ellipse at 40% 30%,#462727,#241006 50%,#1a0a04);
  box-shadow:0 8px 48px rgba(0,0,0,.8),0 2px 0 rgba(255,255,255,.04) inset;
  border:2px solid #0d0502;
  flex-shrink:0;}

/* ── Felt (green play area) ── */
.felt{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
  width:var(--felt-size);
  height:var(--felt-size);
  background:radial-gradient(ellipse at 40% 35%,#27774a,#155230 60%,#0e3b24);
  border:none;
  box-shadow:inset 0 0 60px rgba(0,0,0,.4);}

/* ── Centre info panel (round/wind/scores) ── */
.ctr{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
  z-index:1;pointer-events:none;
  background:#1a1a1a;
  width:min(26vmin,26%);height:min(26vmin,26%);
  display:flex;flex-direction:column;align-items:center;justify-content:center;
  padding:0;overflow:hidden;
  border-radius:1px;}
/* Score strip rows — double line separator using border + box-shadow.
   `solid`, not `ridge` — ridge fakes a bevel by splitting the border into
   algorithmically lightened/darkened halves, which is barely visible at
   a sub-2px width on a phone (reads as a flat line, the intended look)
   but becomes an obviously two-toned, visibly LIGHTER band once this
   vmin-scaled width grows past a few px on a large/4K screen. Same color
   and width, just without the fake-3D split, so it reads as the same
   flat rgb(72,72,72) at every screen size instead of correct-looking on
   mobile and washed-out on desktop. */
.ctr-score{width:100%;height:20%;text-align:center;
  border: solid 0.4vmin rgb(72, 72, 72);background:#1a1a1a;}
.ctr-mid{display:flex;align-items:center;justify-content:center;
  width:calc(min(26vmin,26%) - 2 * min(5vmin,38px));
  height:calc(min(26vmin,26%) - 2 * min(5vmin,38px));
  flex-shrink:0;position:relative;box-sizing:border-box;}
/* Centre info */
.ctr-score-val{display:flex;font-size:2.1vmin;font-weight:700;align-items:center;
  color:rgba(240,208,128,.9);letter-spacing:0;justify-content:center;padding-top:2%}
.ctr-info{display:flex;flex-direction:column;align-items:center;flex-shrink:0;}
.cw{font-family:'Noto Serif JP',serif;font-weight:900;
  color:#f0d080;line-height:1;position:relative;z-index:1;
  text-shadow:0 0 14px rgba(201,168,76,.6),0 0 4px rgba(0,0,0,.8);
  display:flex;flex-direction:row;align-items:baseline;gap:3px;}
#cw-wind{font-size:clamp(28px,5vmin,5vmin);}
#cw-kyoku{font-size:clamp(12px,2vmin,2vmin);opacity:0.75;letter-spacing:1px;}
.ci{font-size:clamp(7px,1.5vmin,15px);letter-spacing:2px;color:rgba(245,237,216,.35);
  margin-top:2px;position:relative;z-index:1;}
.cwl{font-size:clamp(9px,1.8vmin,15px);color:#2a9e67;font-weight:700;
  margin-top:1px;position:relative;z-index:1;}
/* ── Dora indicator strip ── */
.dora-slot{width:var(--tlg-w,46px);height:var(--tlg-h,64px);border-radius:5px;border:1px dashed rgba(201,168,76,.25);
  background:rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;}
.dora-slot.filled{border:none;background:none;padding:0;}
/* Ankan face-down tiles */
.kan-back{
  width:var(--tmd-w,26px);height:var(--tmd-h,36px);
  background:linear-gradient(135deg,#1a3a6a,#0d1f3a);
  border:1px solid #2a4a8a;border-radius:0.4vmin;
  flex-shrink:0;display:inline-flex;
  box-shadow:1px 2px 4px rgba(0,0,0,.5);
}
@keyframes bounce{0%,100%{transform:translateX(-50%) translateY(0)}50%{transform:translateX(-50%) translateY(-5px)}}

/* ════════════════════════════════════════════════════════════
   DISCARD ZONES
   ════════════════════════════════════════════════════════════ */
/* transform-origin:left top set here (not just on .dzt/.dzl/.dzr below,
   which redundantly restate it) so .dzb — which has no rotation, and so no
   origin override of its own — also anchors there instead of defaulting to
   center. Matters for fitDiscardZone (script.js): it scales a deep pile
   down toward its own anchor corner rather than letting it slide when it
   would otherwise overlap that seat's nameplate — scaling from a center
   origin would shift the pile's visible anchor point instead of shrinking
   it in place. */
.dz{position:absolute;display:flex;flex-direction:column;;pointer-events:none;transform-origin:left top;}
.dz-row{display:flex;flex-direction:row;}

/* H = min(13vmin,105px) = panel half-size */

/* H = min(13vmin,105px) — panel half-size. */

/* BOTTOM: top-left of zone at panel bottom-left. No rotation. */
.dzb{top:calc(50% + calc(min(26vmin,26%) / 2));left:calc(50% - calc(min(26vmin,26%) / 2));}

/* TOP: their visual top-left (= DOM bottom-right after 180°) at panel top-left.
   Panel top-left = (50%-H, 50%-H). 
   right=50%+H → element right edge at 50%-H.
   bottom=50%+H → element bottom at 50%-H.
   transform-origin: right bottom. 
   Rows: DOM row-reverse so tiles go their left→right after 180° flip.
   Columns: column-reverse so first row is at visual top (nearest panel). */
.dzt{
  top:calc(50% - calc(min(26vmin,26%) / 2));
  left:calc(50% + calc(min(26vmin,26%) / 2));
  transform:rotate(180deg);
  transform-origin:left top;}

/* LEFT: their visual top-left (= DOM bottom-left after 90°CW) at panel bottom-left.
   Panel bottom-left = (50%-H, 50%+H).
   left=50%-H → element left edge at 50%-H.
   bottom=50%-H → element bottom at 50%+H.
   transform-origin: left bottom. */
.dzl{
  left:calc(50% - calc(min(26vmin,26%) / 2));
  top:calc(50% - calc(min(26vmin,26%) / 2));
  transform:rotate(90deg);
  transform-origin:left top;}

/* RIGHT: their visual top-left (= DOM top-left after -90°) at panel bottom-right.
   Panel bottom-right = (50%+H, 50%+H).
   left=50%+H, top=50%+H. transform-origin: left top. */
.dzr{
  top:calc(50% + calc(min(26vmin,26%) / 2));
  left:calc(50% + calc(min(26vmin,26%) / 2));
  transform:rotate(-90deg);
  transform-origin:left top;}

/* Riichi tile: rotated 90° within its zone row (from each player's perspective) */
.dz-riichi{display:inline-flex;transform:rotate(-90deg);transform-origin:center center;
  margin:calc((var(--tmd-w,40px) - var(--tmd-h,30px))/2) calc(var(--tmd-h,40px)/2 - var(--tmd-w,30px)/2);}

/* ════════════════════════════════════════════════════════════
   PLAYER AREAS (hand + melds)
   ════════════════════════════════════════════════════════════ */
/* Bottom (me): flush to bottom edge, column layout (nameplate above tiles) */
.pa-b{position:absolute;bottom:1vmin;left:10vmin;}
/* Top: flush to top edge, column layout (tiles first, nameplate below) */
.pa-t{position:absolute;top:1vmin;right:10vmin;transform-origin:center center;transform:rotate(180deg);}
/* Left: centre at left edge, vertically centred, rotate 90° CW */
/* Left: pivot at tile row left edge, centred on felt, rotate 90° CW */
.pa-l{position:absolute;top:calc(10vmin - var(--tsm-h,22px));left:1vmin;transform-origin:left bottom;transform:rotate(90deg);}
/* Right: pivot at tile row left edge, centred on felt, rotate -90° CCW */
.pa-r{position:absolute;bottom:calc(10vmin - var(--tsm-h,22px));right:1vmin;transform-origin:right top;transform:rotate(90deg);}

/* Nameplates */
/* cursor:pointer applies even to bot seats (openPlayerCardInGame no-ops for
   them rather than the cursor silently lying) — CSS has no way to know
   which seat is a bot at load time, only inGameSeatInfo (script.js) does,
   and gating the cursor per-seat via JS for a purely cosmetic hint isn't
   worth the extra DOM churn on every game start. */
.np{background:#1a1a1a;border-radius:1px;cursor:pointer;
  /* table-size-scaled, same reasoning as .wb/.pname/etc below — was raw
     vmin, which let this padding stay near its floor on short/wide windows
     while the felt (sized off --table-size) kept shrinking, oversizing the
     plate relative to the felt. */
  /* Floors lowered from 6px/14px (padding) and a flat 9px (gap) — those
     were still the OLD vmin-clamp floors, tuned only so text stayed
     legible, not with the icon/padding/gap "chrome" around it in mind. On
     an actual phone (not just a short/wide desktop window, which is what
     the table-size switch above targeted) those floors were the binding
     constraint — the plate stopped shrinking well before the felt did,
     reading as oversized. Padding/gap have no legibility floor of their
     own the way font-size does, so they can shrink further. */
  /* Right padding larger than the other 3 sides (~10-32px vs 4-13px) — was
     equalized to match briefly, then restored: with the plate's contents
     now bigger (13px name, 36px icon), the extra breathing room on the
     right reads as intentional again rather than an unexplained gap. */
  /* Whole plate — this padding/gap plus every wb/pname/ptitle size below —
     scaled to 60% of its prior value in one pass, keeping every floor,
     coefficient and ceiling in the same proportion to each other so the
     nameplate just gets uniformly smaller rather than any one part (icon
     vs text vs breathing room) changing shape relative to the rest. */
  padding:clamp(2px,calc(var(--table-size) * 0.004364),8px) clamp(6px,calc(var(--table-size) * 0.010909),19px) clamp(2px,calc(var(--table-size) * 0.004364),8px) clamp(2px,calc(var(--table-size) * 0.004364),8px);display:flex;align-items:center;gap:clamp(3px,calc(var(--table-size) * 0.00873),7px);
  position:relative;
  z-index:20;transition:all .25s;flex-shrink:0;white-space:nowrap;
  box-shadow:0 0 0 1px #222,inset 0 1px 0 rgba(255,255,255,.04);}
.np.active{background:#222;border-color:#ffdd44;
  box-shadow:0 0 0 2px rgba(255,221,68,.3),0 0 16px rgba(255,221,68,.5);animation:glow 1.6s ease-in-out infinite;}
@keyframes glow{0%,100%{box-shadow:0 0 0 2px rgba(255,221,68,.3),0 0 14px rgba(255,221,68,.4);}50%{box-shadow:0 0 0 2px rgba(255,221,68,.55),0 0 28px rgba(255,221,68,.7);}}
/* Nameplate skins live further down, right after .np-preview (see that
   class's own comment for why: it and this block's selectors are both
   single classes, so they have to stay in that relative order for the
   skins to actually win the cascade against .np-preview's own defaults). */
/* Chat "is typing…" indicator (see onTypingStatus in script.js, driven by
   room.js's typing()) — 3 real dot elements (.typing-dots span), not CSS
   `content`, specifically so each one can bounce on its own staggered
   delay (see @keyframes typingBounce below) — a single text/pseudo-element
   node can't animate its characters independently. onTypingStatus
   creates/removes this whole span on demand rather than it living
   permanently in index.html's markup, same "no extra DOM per seat unless
   actually needed" reasoning .status-dot's own comment gives for why only
   bottom's markup has one. No background (bare dots directly over the
   felt) rather than squeezed into the plate's own already-tight layout
   (see .pname/.ptitle's own max-width comments). Anchored above the seat
   wind icon specifically, not centered over the whole plate — `left` here
   replicates .wb's own left-edge offset (.np's left padding) plus half its
   width, i.e. .wb's horizontal centre. Positioned relative to .np (set
   position:relative above), which is what actually holds this element
   (appended as its child) — so it tracks the plate at any seat's
   position/rotation without its own per-seat placement math. A cool blue,
   not .active's gold — this isn't a problem state, just activity. */
.typing-dots{position:absolute;top:calc(var(--table-size) * -0.02);
  left:calc(clamp(2px,calc(var(--table-size) * 0.004364),8px) + clamp(22px,calc(var(--table-size) * 0.03571),63px)/2);
  transform:translateX(-50%);display:flex;gap:clamp(2px,calc(var(--table-size) * 0.00219),4px);
  z-index:26;pointer-events:none;}
.typing-dots span{width:clamp(3px,calc(var(--table-size) * 0.00328),6px);
  height:clamp(3px,calc(var(--table-size) * 0.00328),6px);border-radius:50%;background:#8fd6ff;
  animation:typingBounce .9s ease-in-out infinite;}
.typing-dots span:nth-child(2){animation-delay:.15s;}
.typing-dots span:nth-child(3){animation-delay:.3s;}
/* translateY in %, not a fixed/clamped px — relative to the dot's OWN
   height, so the bounce distance scales automatically with the dot size
   above instead of needing its own separate clamp() kept in sync. */
@keyframes typingBounce{0%,60%,100%{transform:translateY(0);opacity:.5;}
  30%{transform:translateY(-120%);opacity:1;}}
/* wb/pname/ptitle/btag below: floor+ceiling clamps
   added on every font-size/width/height that was plain unclamped `vmin` —
   at a 375px phone (vmin≈3.75px) that math put .ba's 1.6vmin label text at
   ~6px and this row's 1.2vmin badges at ~4.5px, well under anything
   legible; at 4K (vmin≈21.6px) the same numbers put .wb's wind badge at
   ~86x86px, far larger than any dialog/panel elsewhere in the app (all of
   which are capped via --ui-vmin — see .about-modal/#settings-panel).
   Floor/ceiling chosen from the vmin range ordinary desktop windows
   actually produce (~7px at 1366-wide, ~16px at 2560-wide) so nothing
   changes at any typical desktop size — only the phone/4K extremes are
   caught, same "protect the edges, leave the middle alone" approach as
   #cw-wind/.ci/.cwl's existing clamp()s just above in this file. */
/* wb/pname/ptitle/btag sizing below is expressed as a
   fraction of --table-size (same value the felt/tiles derive from — see
   --felt-size and updateTileSizes()'s feltSize read), not raw vmin. Raw vmin
   reads the viewport's min(width,height) directly, which ignores the top
   bar's fixed height and the 110%/86% factors baked into --table-size/
   --felt-size; on a short, wide window that mismatch let the nameplate stay
   near its px floor while the felt (sized off --table-size) kept shrinking
   faster, so the plate ended up oversized relative to the felt and covered
   the discard row. Each coefficient here is the old vmin value divided by
   110 (--table-size ≈ 110vmin on typical desktop aspect ratios), so normal
   windows render identically to before; only extreme aspect ratios differ,
   and now they differ in step with the felt instead of against it. */
/* Icon floor lowered 28px→22px — same "chrome, not text" reasoning as
   .np's padding/gap floors above; a circular seat-wind icon stays perfectly
   readable well below the old floor, unlike a floor on actual text. */
.wb{font-family:'Noto Serif JP',serif;font-weight:900;display:flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(201,168,76,.18);color:var(--gl);border:1px solid rgba(201,168,76,.45);flex-shrink:0;
  /* Floor/coefficient/ceiling scaled up again (22→25→36, font 17→19→27) —
     the plate's height is driven by the name+2-line-title text column next
     to it (see .ptitle below), not by this icon, so there was still room
     to grow the icon well past 25px without the plate growing any taller;
     36px (icon width/height) is that column's actual measured height on a
     412px phone (see fitNameplateText/textColHeight math this was checked
     against) — any bigger and the icon itself would start forcing the
     plate taller instead of just filling it. */
  font-size:clamp(16px,calc(var(--table-size) * 0.02599),46px);width:clamp(22px,calc(var(--table-size) * 0.03571),63px);height:clamp(22px,calc(var(--table-size) * 0.03571),63px);}
/* max-width used to be `none` — the nameplate grew freely with the
   player's name (see the kita-strip's own comment on .kita-b, which
   anchors at the felt's horizontal center specifically BECAUSE "no
   realistic name" was assumed to reach halfway). That held for names alone,
   but an unlocked achievement TITLE (.ptitle below) can run much longer —
   e.g. "YAKUMAN COLLECTOR" — and on a narrow phone-portrait felt the
   nameplate's un-capped width reached far enough right to sit underneath
   the bottom seat's own kita-strip tiles instead of stopping short of them.
   Capped to a fraction of --felt-size (with white-space:nowrap so
   overflow/ellipsis actually has something to truncate against, rather than
   just wrapping to a second line) restores that safety margin for every
   screen size, not just the ones a human happened to test with a short
   name. */
/* Font floor 13px→11px→9px→13px — the name is the identity people
   actually recognize a seat by at a glance; the title below is secondary
   flavour text, so once both had room to grow (see .wb's comment above on
   where that room came from) the name got the larger share and .ptitle's
   own floor came back down to compensate (see its own comment) rather than
   both growing in lockstep. */
.pname{font-size:clamp(8px,calc(var(--table-size) * 0.01163),21px);font-weight:700;color:#fff;
  max-width:calc(var(--felt-size) * 0.132);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
/* Title under the in-game nameplate's name (see startGame in script.js,
   title in room.js's _seatInfo) — empty/collapses to nothing for
   guests/bots, which have no profile to hold one. table-size-scaled like
   .pname above rather than a fixed px, so it stays proportional at any
   window size. Same max-width cap as .pname above and for the same reason
   — this is the field that actually triggered the overlap (see comment
   there), since titles routinely run longer than names. */
/* Wraps to up to 2 lines instead of clipping to 1 — even at
   fitNameplateText's (script.js) 7px shrink floor, the longest title
   ("Disciplined Safe Player") still measured wider than this max-width on
   an actual phone (412px-wide viewport, checked directly against
   scrollWidth/clientWidth), so a single-line layout could never show it in
   full no matter how far the font shrank. Two lines gives it roughly
   double the effective width to lay out in, which is enough for every
   title in server/achievements.js/profiles.js at a size still readable —
   -webkit-line-clamp:2 (supported unprefixed as `line-clamp` in newer
   engines too, but the -webkit- form has the broadest support and doesn't
   need a fallback here) caps it at 2 visual lines and adds its own
   trailing ellipsis if some future title is still too long even wrapped,
   so fitNameplateText's shrink-then-clip fallback still applies past that
   point. word-break — a title is a curated string from achievements.js,
   not user input, but this keeps a single overlong word from overflowing
   the max-width sideways instead of wrapping. */
/* Most titles (server/achievements.js) are a single short word/phrase —
   only a handful ("Disciplined/Elite/Legendary Safe Player",
   "Thirteen-Sided Master") are long enough to actually need the 2-line
   wrap below. fitTitleText (script.js) picks between .one-line/.two-line
   per-title at render time: it first tries .one-line's larger size, and
   only falls back to .two-line's smaller wrapped size if that overflows
   the max-width — so a short title reads bigger instead of always paying
   the small size reserved for the longest one. */
.ptitle{color:var(--gold);opacity:.8;font-family:'Cinzel Decorative',serif;
  max-width:calc(var(--felt-size) * 0.132);overflow:hidden;line-height:1.15;
  word-break:break-word;text-align:left;
  /* Separates it from .pname above — with no gap the name and the first
     title line read as one crowded block. table-size-scaled like every
     other measurement here (checked at 3px on a 412px phone) rather than a
     flat px, so it stays proportional at any window size. */
  margin-top:clamp(1px,calc(var(--table-size) * 0.003974),4px);}
/* Matches .pname's own scale (see its comment above) — a short title at
   this size sits comfortably next to a same-tier name instead of looking
   like an afterthought. */
.ptitle.one-line{font-size:clamp(6px,calc(var(--table-size) * 0.007272),13px);
  white-space:nowrap;text-overflow:ellipsis;}
/* Floor 8px — compensates for .one-line above being bigger than the old
   single flat size (name is still the primary identity, title secondary),
   and stays readable specifically because it wraps to 2 lines instead of
   needing to fit "Disciplined Safe Player" on one. Re-checked at 8px on a
   412px phone: still 2 lines, well under the max-width cap. */
.ptitle.two-line{font-size:clamp(5px,calc(var(--table-size) * 0.00582),10px);
  white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;}
/* table-size-scaled like .pname/.ptitle above (was a fixed 10px) — this sits
   directly beside both in a bot's nameplate, so a flat size here stood out
   more and more as the window shrank instead of shrinking with them. */
.btag{font-size:clamp(9px,calc(var(--table-size) * 0.010909),19px);color:rgba(201,168,76,.5);}
/* Tenpai/furiten indicator — replaces the old .tenpai/.furiten-badge
   floating pills (see git history if reviving them). Those were positioned
   independently on the felt (bottom+left offsets tuned to approximate the
   nameplate's own position), which repeatedly drifted out of alignment on
   non-desktop aspect ratios — this is anchored directly INSIDE .np instead
   (see #statusdot-b in index.html, a child of #npb), so it inherits the
   nameplate's own already-correct position/size and structurally can't
   drift away from it on any screen shape.
   Only one dot, not two — furiten in this ruleset only ever happens WHILE
   tenpai (a winning tile sitting in your own discards, or passed on one),
   so "tenpai but not furiten" and "tenpai AND furiten" are the only two
   real states; there's no standalone "furiten but not tenpai". Colour
   alone carries that distinction (gold vs red below) rather than needing
   two separate badges that were never actually independent of each other.
   display:none by default; script.js's renderState toggles between the
   plain 'status-dot', '.status-dot tenpai', and '.status-dot furiten'
   classNames — .tenpai/.furiten below switch display back on, the base
   rule's display:none is what actually hides it the rest of the time. */
.status-dot{display:none;position:absolute;top:calc(var(--table-size) * -0.007);right:calc(var(--table-size) * -0.007);
  width:clamp(8px,calc(var(--table-size) * 0.018),16px);height:clamp(8px,calc(var(--table-size) * 0.018),16px);
  border-radius:50%;box-shadow:0 0 0 2px #1a1a1a;z-index:25;}
.status-dot.tenpai{display:block;background:#ffdd44;}
.status-dot.furiten{display:block;background:#e24b4a;}

/* ════════════════════════════════════════════════════════════
   TILES
   ══════════════════════════════════════════════════════
/* ── TILES ──
   All sizes driven by CSS vars set dynamically by JS on resize.
   This makes everything scale to fit any screen. */
.tile{position:relative;display:inline-flex;align-items:center;justify-content:center;cursor:default;flex-shrink:0;overflow:hidden;pointer-events:none;}

.tlg{
  width:var(--tlg-w,40px);height:var(--tlg-h,56px);border-radius:0.4vmin;
  background:linear-gradient(170deg,#fffdf4,#f8edd8);
  border:0.1vmin solid #c8a84a;border-bottom:0.2vmin solid #7a5c10;
  transition:transform .12s ease,box-shadow .12s ease;}
.tlg.play{cursor:pointer;pointer-events:auto;}
.tlg.play:hover,.tlg.play:active{transform:translateY(-1vmin);box-shadow:2px 12px 18px rgba(0,0,0,.7),inset 0 2px 0 rgba(255,255,255,.95);z-index:20;}
.tlg.sel{transform:translateY(-1vmin);box-shadow:0 0 0 0.2vmin #00ffaa,2px 12px 18px rgba(0,0,0,.75);z-index:25;}

/* Draw tile — far right, taller glow, shimmer animation */
.tlg.drw{
  border-color:#ffe066;
  z-index:5;}

.tmd{
  width:var(--tmd-w,26px);height:var(--tmd-h,36px);border-radius:0.3vmin;
  background:linear-gradient(170deg,#fffdf4,#f8edd8);
  border:0.1vmin solid #c8a84a;border-bottom:0.2vmin solid #7a5c10;
  display:inline-flex;align-items:center;justify-content:center;}
/* Opponent concealed hand — a tile seen from above: the pale top face fills
   most of it, and the blue back face shows as a band along the near edge
   (toward table centre). Same 0.2vmin thickness edge as the player's own
   tiles (.tlg). #hdl / #hdt are counter-rotated 180deg (below) so that near
   edge lands toward centre for every seat. (Previously a flat teal back.) */
.tsm{
  display:inline-flex;
  width:var(--tsm-w,16px);height:var(--tsm-h,22px);
  border-radius:0.2vmin;
  background:linear-gradient(180deg,
    #fdf7e6 0%, #ecdcb6 68%,
    #fff8e6 70%, #fff8e6 71%,
    #37589f 72%, #223f7d 87%, #1a2c56 100%);
  border:0.1vmin solid #c8a84a;
  border-bottom:0.2vmin solid #12203c;
  flex-shrink:0;position:relative;
  box-shadow:0 0.15vmin 0.35vmin rgba(0,0,0,.4);}
/* Inset frame on the blue back-face band. */
.tsm::before{content:'';position:absolute;left:0.16vmin;right:0.16vmin;bottom:0.16vmin;top:76%;
  border:0.1vmin solid rgba(255,255,255,.18);border-radius:0.1vmin;}
.tsm.rot{width:var(--tsm-h,22px);height:var(--tsm-w,16px);}
/* Left & top opponents share the same container rotation as their neighbour
   opposite them, which would land the blue near-edge on the far side — spin
   each row 180deg so the top face points outward toward its owner. */
#hdl,#hdt{transform:rotate(180deg);}
.tdora{width:var(--tlg-w,46px);height:var(--tlg-h,64px);border-radius:0.4vmin;
  background:linear-gradient(170deg,#fffdf4,#f8edd8);border:0.1vmin solid #c8a84a;border-bottom:0.3vmin solid #7a5c10;
  display:inline-flex;align-items:center;justify-content:center;}

/* Hand layout */
.hr{display:flex;align-items:flex-end;flex-wrap:nowrap;}
/* Gap between main hand and draw tile */
.draw-sep{width:clamp(5px,1.5vmin,10px);flex-shrink:0;}
.mg{display:flex;margin-right:0;margin-left:0;align-items:flex-end;}
/* Called tile (chi/pon) rotated 90° to show it was taken from another player */
.mg .called{transform:rotate(-90deg) translateX(calc((var(--tmd-w,40px) - var(--tmd-h,26px))/2 + 1px));z-index:1;
  margin-right:calc(var(--tmd-h,40px)/2 - var(--tmd-w,26px)/2);z-index:2;
  margin-left:calc(var(--tmd-h,40px)/2 - var(--tmd-w,26px)/2);z-index:2;}
/* Kakan stack */
.mg .called-stack{position:relative;display:inline-flex;align-items:flex-end;justify-content:center;
  width:var(--tmd-h,40px);
  height:var(--tmd-h,35px);
  overflow:visible;
  margin:0;}
.mg .called-stack .kakan-base{position:absolute;bottom:calc((var(--tmd-w,26px) - var(--tmd-h,35px)) /2);left:50%;transform:translateX(-50%) rotate(-90deg);flex-shrink:0;z-index:4;}
.mg .called-stack .kakan-add{position:absolute;bottom:calc(var(--tmd-w,26px) - 1vmin);left:50%;transform:translateX(-50%) rotate(-90deg);z-index:3;}
.mr{display:flex;gap:0;align-items:flex-end;flex-shrink:0;}

/* ════════════════════════════════════════════════════════════
   ACTION BAR & BUTTONS
   ════════════════════════════════════════════════════════════ */
.actbar{display:flex;align-items:center;}
/* Floating action overlay — appears above the hand. flex-wrap:wrap (was
   nowrap) — up to 5 buttons can appear at once (RON+PON+KAN+CHI+SKIP, see
   drawActions in script.js), and on a narrow phone that row can run wider
   than the space actually left beside the nameplate; nowrap+flex-shrink:0
   just let it run off the felt/viewport edge instead, pushing the last
   button(s) out of reach. Now it wraps onto a second row instead.
   No width/max-width set here on purpose — drawActions (script.js) sets an
   explicit inline width every render, capped at whatever room is actually
   left beside the nameplate (which isn't fixed: see .pname/.ptitle's own
   max-width comments — a longer name or title widens the plate up to ITS
   cap). That has to happen in JS, not CSS: max-width alone left the actual
   used width up to the browser's ambiguous sizing pass for a wrapping flex
   item inside a shrink-to-fit ancestor (.np-wrap-b is position:absolute
   with no explicit width), wrapping buttons that fit comfortably under the
   intended budget. drawActions also caps that inline width at whatever the
   buttons ACTUALLY need (not always the full budget) — .act-overlay.visible
   below makes the whole box pointer-events:auto, so forcing it wider than
   its content for a 1-2 button turn would leave an invisible tap-blocking
   dead zone over the felt/hand tiles beneath it. row-gap slightly tighter
   than the button gap itself so two stacked rows read as one group, not
   two separate ones. */
.act-overlay{display:flex;gap:1vmin;row-gap:0.6vmin;align-items:center;
  z-index:40;pointer-events:none;flex-wrap:wrap;}
.act-overlay.visible{pointer-events:auto;}
.chi-picker{position:fixed;left:50%;bottom:18vh;transform:translateX(-50%);
  display:flex;gap:1.4vmin;align-items:center;z-index:60;
  background:rgba(8,18,12,.92);border:1px solid rgba(201,168,76,.35);
  border-radius:clamp(7px,0.8vmin,12px);padding:clamp(8px,1vmin,14px) clamp(10px,1.4vmin,18px);box-shadow:0 8px 28px rgba(0,0,0,.7);}
.chi-picker-opt{display:flex;gap:2px;align-items:flex-end;background:none;
  border:1px solid transparent;border-radius:8px;padding:4px;cursor:pointer;
  transition:all .15s;}
.chi-picker-opt:hover{background:rgba(201,168,76,.15);border-color:rgba(201,168,76,.5);
  transform:translateY(-2px);}
.chi-picker-disc{box-shadow:0 0 0 2px #00ffaa;border-radius:3px;}
.chi-picker-cancel{height:auto;align-self:stretch;}
/* Chonbo declare-confirm popup — reuses .chi-picker's overlay positioning
   but stacks vertically (message, then confirm, then cancel) instead of a
   row of tile options. */
.declare-picker{flex-direction:column;align-items:stretch;gap:10px;
  max-width:min(90vw,360px);border-color:rgba(255,128,64,.5);}
.declare-picker-msg{font-size:clamp(11px,1.4vmin,15px);line-height:1.5;color:rgba(245,237,216,.85);text-align:center;}
.declare-picker-confirm{width:100%;}
/* table-size-scaled (not raw vmin) with lowered floors — same fix, same
   reasoning, as .wb/.pname/.np's own comments above: raw vmin ignores the
   top bar/--table-size's 110%/86% factors, so on a short/wide window (or
   just a small phone, where these floors bind more often than intended)
   the buttons stayed near their old floor while the felt kept shrinking
   around them, reading as oversized. Floors lowered the same amount as
   .wb's icon (28px→22px) for height, proportionally for padding/font — a
   button label is short, all-caps text (TSUMO/RIICHI/PON/...), which stays
   legible well below the old floor, same as .wb's icon did. */
.ba{padding:0 clamp(7px,calc(var(--table-size) * 0.009091),20px);height:clamp(22px,calc(var(--table-size) * 0.03636),64px);border:none;border-radius:clamp(4px,calc(var(--table-size) * 0.005455),10px);
  font-family:'Cinzel Decorative',serif;font-size:clamp(9px,calc(var(--table-size) * 0.014545),26px);
  letter-spacing:0.1vmin;cursor:pointer;transition:all .15s;font-weight:700;white-space:nowrap;
  flex-shrink:0;box-shadow:0 0.4vmin 1.4vmin rgba(0,0,0,.6);}
.ba:hover{filter:brightness(1.2);transform:translateY(--0.3vmin);box-shadow:0 0.6vmin 2vmin rgba(0,0,0,.7);}
.ba:active{transform:translateY(0);}
.ba:disabled{opacity:.3;cursor:default;transform:none;filter:none;}
.hint{display:none;}
.bdis{background:#8a1818;color:#ffd8d8;border:0.2vmin solid #d44;}
.btsu{background:#8a6500;color:#fff8d0;border:0.2vmin solid #f0d060;font-weight:900;}
.brii{background:#1e1e8a;color:#d0d0ff;border:0.2vmin solid #77f;}
.bron{background:#aa1010;color:#fff;border:0.2vmin solid #f66;box-shadow:0 0 1vmin rgba(220,50,50,.6);font-weight:900;}
.bchi{background:#145a14;color:#c0ffc0;border:0.2vmin solid #4d4;}
.bpon{background:#6a3800;color:#ffe0b0;border:0.2vmin solid #ca0;}
.bkan{background:linear-gradient(135deg,#3a0a5a,#6a1a9a);color:#e8d0ff;border:0.2vmin solid #a060d0;}
/* Matches .call-banner[data-type="kita"]'s accent colours, same pairing
   convention as .bpon/.bchi/.bkan/.brii all matching their own banners. */
.bkita{background:linear-gradient(135deg,#0a3a45,#0a6a80);color:#b0f0ff;border:0.2vmin solid #20b0c0;}
.bpas{background:rgba(40,40,40,.95);color:rgba(245,237,216,.7);border:0.2vmin solid rgba(255,255,255,.2);}
/* Chonbo (rules.chonbo) "attempt" declare — deliberately muted/dashed
   rather than a bold solid colour like the other action buttons, so it
   visually recedes instead of inviting a tap; the real confirmation is
   the declare-picker popup this opens (.declare-picker-confirm below,
   which DOES use the full warning colour, since that tap is the actual
   deliberate commit). */
.bchonbo{background:rgba(90,30,10,.55);color:#ffb080;border:0.2vmin dashed rgba(255,128,64,.6);opacity:.8;}
.bchonbo:hover{opacity:1;}
.declare-picker-confirm{background:linear-gradient(135deg,#5a1a0a,#9a3a1a);color:#ffd0b0;border:0.2vmin solid #ff8040;}

/* ════════════════════════════════════════════════════════════
   CHAT
   ════════════════════════════════════════════════════════════ */
/* Chat bar: fixed to bottom-right, sits above action bar */
/* --ui-vmin: same clamped-scale-unit approach as #s-start/#s-lobby/
   #dora-panel — everything below multiplies this one variable so it
   scales together instead of drifting apart across resolutions. */
.chat-bar{--ui-vmin:clamp(6px,1vmin,10px);
  position:fixed;bottom:20px;right:20px;
  display:none;flex-direction:row;align-items:center;gap:5px;
  background:rgba(3,12,6,.95);border:1px solid rgba(201,168,76,.25);
  border-radius:10px;padding:calc(var(--ui-vmin) * .694) calc(var(--ui-vmin) * 1.111);z-index:65;
  box-shadow:0 4px 16px rgba(0,0,0,.6);}
/* Beginner-assist row — a child of .chat-bar (which is itself position:
   fixed, so it's already a containing block for this), absolutely anchored
   to sit directly above the bar's own left edge — left:0 here lines it up
   exactly with .chat-color-btn/.chat-emoji-btn, the first two children of
   .chat-bar right below it, rather than the input/send/history side.
   Being a child means it shows/hides automatically with the bar itself, no
   separate visibility toggle needed in script.js. Bare icons (no
   border/background "button" chrome, unlike .chat-color-btn/
   .chat-emoji-btn) — a drop-shadow instead keeps them legible over the
   felt table without needing a frame. */
.assist-bar{position:absolute;left:0;bottom:calc(100% + 8px);
  display:flex;flex-direction:row;gap:calc(var(--ui-vmin) * 1.111);}
.assist-btn{background:none;border:none;padding:0;color:rgba(245,237,216,.75);cursor:pointer;
  display:flex;align-items:center;justify-content:center;transition:color .2s,transform .15s;}
.assist-btn:hover{color:var(--gold);transform:scale(1.1);}
.assist-btn svg{width:calc(var(--ui-vmin) * 2.778);height:calc(var(--ui-vmin) * 2.778);display:block;
  filter:drop-shadow(0 1px 3px rgba(0,0,0,.8));}
.chat-input{width:calc(var(--ui-vmin) * 22.22);padding:calc(var(--ui-vmin) * .694) calc(var(--ui-vmin) * 1.111);background:rgba(0,0,0,.45);
  border:1px solid rgba(201,168,76,.2);border-radius:7px;
  color:var(--cream);font-size:calc(var(--ui-vmin) * 1.667);outline:none;font-family:'Noto Sans JP',sans-serif;}
.chat-input:focus{border-color:var(--jl);}
/* Message colour swatch + popup palette */
.chat-color-btn{width:calc(var(--ui-vmin) * 3.611);height:calc(var(--ui-vmin) * 3.611);
  border-radius:50%;border:2px solid rgba(201,168,76,.4);background:#fff;
  cursor:pointer;flex-shrink:0;padding:0;transition:border-color .2s,transform .15s;}
.chat-color-btn:hover{border-color:var(--gl);transform:scale(1.08);}
.chat-color-btn.open{border-color:var(--gl);}
.chat-color-picker{position:fixed;bottom:70px;right:20px;z-index:66;display:none;
  grid-template-columns:repeat(4,1fr);gap:8px;padding:10px;
  background:rgba(3,12,6,.97);border:1px solid rgba(201,168,76,.3);border-radius:10px;
  box-shadow:0 8px 24px rgba(0,0,0,.7);}
.chat-color-picker.open{display:grid;}
.chat-color-swatch{width:24px;height:24px;border-radius:50%;border:2px solid transparent;
  cursor:pointer;padding:0;transition:transform .15s,border-color .15s;}
.chat-color-swatch:hover{transform:scale(1.12);}
.chat-color-swatch.selected{border-color:var(--gl);}
/* Seat-emoji reaction swatch + popup — same shape/size as the colour
   swatch button right next to it, just showing an emoji face instead of a
   flat colour. .chat-emoji-picker reuses .chat-color-picker's grid/popup
   styling wholesale (see the two classes stacked on the div in
   index.html) rather than redefining the popup shell from scratch. */
.chat-emoji-btn{width:calc(var(--ui-vmin) * 3.611);height:calc(var(--ui-vmin) * 3.611);
  border-radius:50%;border:2px solid rgba(201,168,76,.4);background:rgba(0,0,0,.35);
  cursor:pointer;flex-shrink:0;padding:0;font-size:calc(var(--ui-vmin) * 2);line-height:1;
  transition:border-color .2s,transform .15s;}
.chat-emoji-btn:hover{border-color:var(--gl);transform:scale(1.08);}
.chat-emoji-btn.open{border-color:var(--gl);}
.chat-emoji-picker .emoji-swatch{width:32px;height:32px;border-radius:50%;border:2px solid transparent;
  background:none;font-size:1.4rem;line-height:1;cursor:pointer;padding:0;
  transition:transform .15s,border-color .15s,background .15s;}
.chat-emoji-picker .emoji-swatch:hover{transform:scale(1.15);background:rgba(201,168,76,.15);}
.chat-send{padding:0 calc(var(--ui-vmin) * 1.389);height:calc(var(--ui-vmin) * 3.611);background:rgba(26,107,69,.8);
  border:1px solid var(--jl);border-radius:7px;color:var(--cream);
  font-size:calc(var(--ui-vmin) * 1.528);cursor:pointer;font-family:'Noto Sans JP',sans-serif;white-space:nowrap;flex-shrink:0;}
.chat-send:hover{background:rgba(42,158,103,.9);}
/* History button */
.chat-hist-btn{position:relative;padding:0 calc(var(--ui-vmin) * 1.389);height:calc(var(--ui-vmin) * 3.611);
  background:none;border:none;border-left:1px solid rgba(201,168,76,.2);
  color:rgba(201,168,76,.6);font-size:calc(var(--ui-vmin) * 1.528);letter-spacing:calc(var(--ui-vmin) * .139);cursor:pointer;
  font-family:'Noto Sans JP',sans-serif;white-space:nowrap;flex-shrink:0;
  transition:color .2s;}
.chat-hist-btn:hover{color:var(--gl);}
.chat-hist-btn.open{color:var(--gl);}
.chat-hist-btn .badge{position:absolute;top:-5px;right:2px;
  background:#c02020;color:#fff;font-size:calc(var(--ui-vmin) * 1.111);font-weight:700;
  min-width:calc(var(--ui-vmin) * 1.944);height:calc(var(--ui-vmin) * 1.944);border-radius:7px;display:none;
  align-items:center;justify-content:center;padding:0 calc(var(--ui-vmin) * .278);}
.chat-hist-btn .badge.show{display:flex;}
/* History popup — opens upward from chat bar. A SIBLING of .chat-bar in
   index.html, not a descendant (both sit directly under #s-game), so it
   never inherited .chat-bar's own --ui-vmin — its text was flat px this
   whole time while everything else in the game screen scaled with the
   window. --ui-vmin here (same clamp as .chat-bar/#s-lobby/etc.) plus
   converting those flat sizes to the calc() pattern brings it back in
   line, especially on small screens where the mismatch was worst. */
.chat-panel{--ui-vmin:clamp(6px,1vmin,10px);position:fixed;bottom:106px;right:12px;
  width:clamp(220px,35vw,300px);max-height:260px;
  background:rgba(5,14,8,.97);border:1px solid rgba(201,168,76,.3);
  border-radius:12px;z-index:74;display:none;flex-direction:column;
  overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.8);
  animation:slideUpChat .2s ease;}
@keyframes slideUpChat{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.chat-panel.open{display:flex;}
.chat-panel-header{padding:calc(var(--ui-vmin) * .972) calc(var(--ui-vmin) * 1.667);background:rgba(26,107,69,.25);
  border-bottom:1px solid rgba(201,168,76,.2);font-size:calc(var(--ui-vmin) * 1.389);letter-spacing:2px;
  color:var(--gold);font-family:'Cinzel Decorative',serif;
  display:flex;justify-content:space-between;align-items:center;}
.chat-close{background:none;border:none;color:rgba(245,237,216,.45);
  font-size:calc(var(--ui-vmin) * 2.083);cursor:pointer;line-height:1;padding:0;}
.chat-close:hover{color:var(--cream);}
.chat-history{flex:1;overflow-y:auto;padding:calc(var(--ui-vmin) * 1.111);display:flex;flex-direction:column;gap:5px;}
.chat-history::-webkit-scrollbar{width:4px;}
.chat-history::-webkit-scrollbar-thumb{background:rgba(201,168,76,.3);border-radius:2px;}
.chat-entry{display:flex;flex-direction:column;gap:1px;}
.chat-entry-who{font-size:calc(var(--ui-vmin) * 1.25);letter-spacing:1px;font-weight:700;}
/* Text bubble + its reaction pills share one row so the pills read as
   attached to the end of THIS message, not a separate block for the
   whole entry — wraps onto its own line only if it doesn't fit. */
.chat-entry-msg-row{display:flex;align-items:center;flex-wrap:wrap;gap:5px;}
.chat-entry-text{font-size:calc(var(--ui-vmin) * 1.667);color:var(--cream);background:rgba(255,255,255,.05);
  border-radius:6px;padding:calc(var(--ui-vmin) * .556) calc(var(--ui-vmin) * 1.111);line-height:1.4;word-break:break-word;}
.chat-entry-time{font-size:calc(var(--ui-vmin) * 1.111);color:rgba(245,237,216,.3);margin-top:1px;}
/* Long-press feedback (see attachLongPress in script.js) — a brief
   highlight while the hold timer is counting down, so touch/mouse users
   get some indication something's about to happen before the picker
   actually appears. */
.chat-entry.pressing .chat-entry-text{background:rgba(201,168,76,.18);}
/* Per-message reaction pills — one per DISTINCT emoji used on that
   message (two people picking the same emoji still collapse to one pill,
   per request — see renderMessageReactions). .mine marks a pill that
   includes MY OWN reaction, so re-long-pressing the MESSAGE (opening the
   picker again) can show which one I already picked (see
   openReactionPicker) — long-pressing the PILL itself instead shows who's
   behind it (see attachPillLongPress/showReactionNames), hence the
   cursor:pointer/select:none affordance here. */
.chat-entry-reactions{display:flex;flex-wrap:wrap;gap:4px;}
.chat-entry-reactions:empty{display:none;}
.reaction-pill{display:inline-flex;align-items:center;
  font-size:calc(var(--ui-vmin) * 1.5);line-height:1.4;cursor:pointer;user-select:none;
  background:rgba(201,168,76,.12);border:1px solid rgba(201,168,76,.25);
  border-radius:10px;padding:0 calc(var(--ui-vmin) * .694);}
.reaction-pill.mine{background:rgba(201,168,76,.3);border-color:var(--gl);}
/* Long-press reaction picker — same dark/gold popup shell as
   .chat-color-picker/.chat-panel, positioned dynamically (JS, via the
   pressed message's own getBoundingClientRect) rather than pinned to a
   fixed corner like those two, since it needs to appear next to whichever
   message was pressed. */
.msg-react-picker{position:fixed;z-index:400;display:flex;gap:6px;
  padding:calc(var(--ui-vmin) * .833) calc(var(--ui-vmin) * 1.111);
  background:rgba(5,14,8,.97);border:1px solid rgba(201,168,76,.3);border-radius:12px;
  box-shadow:0 8px 24px rgba(0,0,0,.7);animation:slideUpChat .15s ease;}
/* Names-behind-a-pill popup (see showReactionNames in script.js) — same
   shell as .msg-react-picker again, just a text label instead of buttons. */
.reaction-names-popup{position:fixed;z-index:400;
  padding:calc(var(--ui-vmin) * .556) calc(var(--ui-vmin) * .972);
  font-size:calc(var(--ui-vmin) * 1.389);color:var(--cream);white-space:nowrap;
  background:rgba(5,14,8,.97);border:1px solid rgba(201,168,76,.3);border-radius:10px;
  box-shadow:0 8px 24px rgba(0,0,0,.7);animation:slideUpChat .15s ease;}
.msg-react-picker button{font-size:1.3rem;line-height:1;background:none;border:2px solid transparent;
  border-radius:50%;width:34px;height:34px;padding:0;cursor:pointer;
  transition:transform .15s,border-color .15s,background .15s;}
.msg-react-picker button:hover{transform:scale(1.15);background:rgba(201,168,76,.15);}
.msg-react-picker button.selected{border-color:var(--gl);}

/* Floating chat messages — niconico-style danmaku: each message spawns at
   a random height and slides right-to-left across the full screen at a
   constant opacity, disappearing (clipped by .chat-float's overflow, no
   fade) once it crosses the left edge. .chat-float is a clipped
   full-viewport layer; each .chat-msg carries its own random `top` (set
   inline in JS, see showChatMsg) and animates independently rather than
   the old centred fade-in-place stack. Default text colour is white;
   see .chat-color-picker for the per-player colour override. */
.chat-float{position:fixed;inset:0;z-index:300;pointer-events:none;overflow:hidden;}
.chat-msg{position:absolute;left:100%;white-space:nowrap;color:#fff;
  font-family:'MS Gothic','ＭＳ ゴシック',monospace;font-weight:700;
  font-size:clamp(20px,3.2vmin,34px);letter-spacing:1px;
  text-shadow:0 0 6px rgba(0,0,0,.9),0 0 16px rgba(0,0,0,.6),1px 1px 3px rgba(0,0,0,.95);
  animation:chatSlide 7s linear forwards;}
.chat-msg .chat-who{font-size:.7em;color:var(--gl);display:inline;letter-spacing:1px;}
.chat-msg .chat-who::after{content:": ";color:rgba(240,208,128,.6);}
/* Travels its own width plus the full viewport width, so it always fully
   exits past the left edge regardless of message length. No opacity
   animation — .chat-float's overflow:hidden is what makes it disappear. */
@keyframes chatSlide{
  0%{transform:translateX(0);}
  100%{transform:translateX(calc(-100vw - 100%));}
}

/* Seat-emoji popups (see showEmojiPopup in script.js) — a separate
   unrotated fixed layer, positioned per-popup via inline left/top (JS
   computes each from the target seat's on-screen rect, since .pa-t/.pa-l/
   .pa-r are themselves CSS-rotated — see the layer's own comment in
   index.html). 4s total lifetime matches the emojiPop animation length
   exactly, so JS's setTimeout(...,4000) removal lands right as it
   finishes fading rather than cutting it off mid-animation. */
/* z-index:150 — above every table/game element (nameplates/tiles/action
   bar top out around 60) but below the call banner (.cb-layer:205, pon/
   chii/kan/riichi), round-start card (.rs-layer:210), game-start roll
   call (.gs-layer:210), toast (.tc:200), loading overlay (#game-loading:
   200), and the end-game panel (#s-end:250) — a reaction shouldn't ever
   cover any of those. */
.emoji-layer{position:fixed;inset:0;z-index:150;pointer-events:none;overflow:hidden;}
/* Plain anchor point — left/top set inline (JS) to the seat's on-screen
   centre, no transform of its own. See showEmojiPopup's own comment in
   script.js for what each of the three nested elements below is for. */
.emoji-pop{position:fixed;left:0;top:0;}
/* STATIC centre+rotate (set once, inline, by JS — not animated). Rotating
   HERE rather than on .emoji-pop-motion means the motion keyframe's own
   translateY below runs inside this already-rotated frame, so "float up"
   comes out as the correct on-screen direction for whichever seat this
   is, not always screen-up regardless of seat. */
.emoji-pop-rotate{display:inline-block;}
/* Max raised well past mobile's actual reach (28-48px, still clamp's
   floor there) so the glyph keeps growing on genuinely large/4K
   viewports instead of capping out at a size that reads as tiny relative
   to how big the rest of the table renders there. */
/* line-height:1 (not the default `normal`, ~1.15-1.2x font-size) — without
   it, this box is taller than the glyph itself by a platform/font-
   dependent margin, and .emoji-pop-rotate's translate(-50%,-50%) centres
   on THIS box, not the visible ink inside it. Different platforms' native
   emoji fonts (Segoe UI Emoji on Windows/desktop vs. Apple/Noto Color
   Emoji on phones) pad that extra space differently, so the same
   centering math could still look visibly off-centre on one platform and
   fine on another — this removes that variable rather than trying to
   compensate for a specific font's metrics. */
.emoji-pop-motion{display:inline-block;line-height:1;font-size:clamp(28px,6vmin,100px);
  text-shadow:0 0 10px rgba(0,0,0,.6);
  animation:emojiPop 4s ease forwards;}
/* Distances in vmin (viewport-relative), not px or %-of-self — a fixed px
   float distance cleared the tile row fine on a phone-sized viewport but
   nowhere near enough on a 4K screen, where the same vmin-based table
   layout renders far larger in actual pixels. Floats away almost
   immediately (by 18%, ~0.7s in) rather than lingering at the anchor
   centre for most of the 4s lifetime, so it's not still sitting on top of
   the tiles for the bulk of the animation. */
@keyframes emojiPop{
  0%{opacity:0;transform:scale(.4);}
  10%{opacity:1;transform:scale(1.15);}
  18%{opacity:1;transform:translateY(-7vmin) scale(1);}
  85%{opacity:1;transform:translateY(-8vmin) scale(1);}
  100%{opacity:0;transform:translateY(-9.5vmin) scale(.9);}
}

/* ── Toast notifications ── */
.tc{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:200;pointer-events:none;}
.toast{background:rgba(0,0,0,.9);border:2px solid var(--gold);border-radius:12px;padding:12px 28px;font-family:'Cinzel Decorative',serif;font-size:clamp(14px,4vw,20px);letter-spacing:3px;color:var(--gl);text-align:center;animation:ta 2.8s ease forwards;box-shadow:0 0 50px rgba(201,168,76,.4);margin-bottom:8px;}
@keyframes ta{0%{opacity:0;transform:scale(.8) translateY(18px)}15%{opacity:1;transform:scale(1)}75%{opacity:1}100%{opacity:0;transform:scale(1.05) translateY(-8px)}}

/* ── Call banner (pon/kan/chi/riichi) — slides in from the bottom-left and settles across the felt ── */
.cb-layer{position:fixed;inset:0;z-index:205;pointer-events:none;
  display:flex;align-items:center;justify-content:center;overflow:hidden;}
.call-banner{
  --accent:var(--gl);--accent-strong:var(--gold);--accent-glow:rgba(201,168,76,.4);
  /* A rotated rectangle can only ever land ONE diagonal pair of corners on a
     fixed-width bounding box — the other pair is inset by H*sin(angle) no
     matter the width. To get all four corners flush with the table, the
     banner is a parallelogram instead: left/right edges stay vertical (so
     both left corners and both right corners sit exactly on the table's
     edges by construction) and the tilt comes from slanted top/bottom edges,
     offset by W*tan(4deg) so the slant reads as an equivalent ~4deg lean. */
  /* --table-size (110vmin) is deliberately allowed to overflow the
     viewport by up to 10% — fine for .wood-table, a background element
     that's meant to bleed off-screen, but not for a banner that needs to
     stay fully on-screen and readable. Cap it to the table size or 100%
     of the viewport WIDTH, whichever is smaller — not viewport height
     too: --table-size is also used as .wood-table's height (a square),
     but here it only ever drives the banner's width, so a vh-based cap
     would bind on perfectly ordinary landscape screens where the
     banner's actual (much shorter) height was never at risk of
     overflowing. On a narrow/portrait screen (e.g. a phone, where width
     is the smaller dimension) --table-size itself already exceeds the
     viewport width — that's fine for .wood-table, which is allowed to
     bleed off both edges, but the banner should use the full width
     right up to the screen edge rather than leaving extra margin, so
     it reads closer to the table's own (bleeding) size instead of
     looking small next to it. Every other measurement (slant, font
     sizes, min-height) derives from this SAME variable as a fixed ratio
     of it, so whichever constraint ends up binding, everything scales
     down together instead of one dimension getting capped while others
     keep scaling off the uncapped --table-size. */
  --cb-size:min(var(--table-size), 100vw);
  --cb-slant:calc(var(--cb-size) * tan(4deg));
  --cb-poly:polygon(0% var(--cb-slant), 100% 0, 100% calc(100% - var(--cb-slant)), 0% 100%);
  /* .cb-layer is a flex container, and flex items shrink to fit it by
     default (min-width:auto) — flex-shrink:0 makes sure the banner always
     renders at exactly --cb-size rather than being squeezed further. */
  position:relative;width:var(--cb-size);flex-shrink:0;
  /* Same ratio to --cb-size as the label font below (18/110), so height
     and label size are always locked together regardless of which
     constraint --cb-size is currently hitting. */
  min-height:calc(var(--cb-size) * 0.16364);
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;
  /* A CSS `border` draws a rectangle, which then gets chopped by clip-path —
     along the slanted edges it shows up as a jagged rectangular fragment
     instead of a line that follows the diagonal. Instead, the accent colour
     fills the whole outer polygon (acting as the border) and an inset
     pseudo-element, clipped to the same polygon shape, sits on top with the
     real background — leaving a clean accent-coloured ring that hugs the
     slant on every edge. */
  background:var(--accent-strong);border-radius:0;
  /* Vertical padding must be >= the slant, otherwise the diagonal corners
     cut into the label/name near the left and right edges instead of just
     trimming blank padding. */
  padding:calc(var(--cb-slant) + 10px) 22px;
  box-shadow:0 12px 34px rgba(0,0,0,.65),0 0 40px var(--accent-glow);
  clip-path:var(--cb-poly);
  animation:cb-slide 2.24s cubic-bezier(.22,.9,.3,1) forwards;
}
.call-banner::before{content:'';position:absolute;inset:2px;
  background:linear-gradient(155deg,rgba(18,18,18,.96),rgba(4,4,4,.98));
  clip-path:var(--cb-poly);z-index:0;}
.call-banner .cb-content{position:relative;z-index:1;
  display:flex;flex-direction:column;align-items:center;gap:2px;
  /* Same angle as --cb-slant (W*tan(4deg)) so the text leans with the
     banner's slanted top/bottom edges instead of sitting dead level. */
  transform:rotate(-4deg);}
.call-banner .cb-label{font-family:'Noto Serif JP',serif;font-weight:900;
  /* calc() off --cb-size (not plain vmin) so the label shrinks in lockstep
     whenever --cb-size hits its 92vw/92vh cap, instead of continuing to
     scale with vmin past the point the box itself has stopped growing. */
  font-size:calc(var(--cb-size) * 0.16364);letter-spacing:.08em;color:var(--accent);
  /* Fixed (not 1em) on purpose — .cb-label-long below shrinks font-size but
     deliberately does NOT touch line-height, so its line box (and therefore
     the whole banner's flex-sized height, since .call-banner has no fixed
     height of its own) stays identical to every other call type. Without
     this, a 1em line-height would shrink right along with the smaller font
     and the double-riichi banner would end up visibly shorter than a pon/
     kan/chi/riichi banner. */
  line-height:calc(var(--cb-size) * 0.16364);}
/* ダブルリーチ is twice the character count of every other call-banner
   label — same scaling basis as .cb-label above, just half the ratio so it
   fits the same banner width on one line instead of wrapping (see the
   cb-label-long toggle in callBanner, script.js). Only font-size shrinks;
   line-height is inherited unchanged from .cb-label above so the banner's
   height matches every other call type. */
.call-banner .cb-label.cb-label-long{font-size:calc(var(--cb-size) * 0.082);}
.call-banner .cb-name{font-family:'Cinzel Decorative',serif;
  font-size:calc(var(--cb-size) * 0.01455);letter-spacing:2px;color:rgba(245,237,216,.7);}
.call-banner .cb-name b{color:var(--accent-strong);}
.call-banner[data-type="pon"]{--accent:#ffe0b0;--accent-strong:#ca0;--accent-glow:rgba(202,170,0,.4);}
.call-banner[data-type="kan"]{--accent:#e8d0ff;--accent-strong:#a060d0;--accent-glow:rgba(160,96,208,.4);}
.call-banner[data-type="chi"]{--accent:#c0ffc0;--accent-strong:#4d4;--accent-glow:rgba(68,221,68,.35);}
.call-banner[data-type="riichi"]{--accent:#d0d0ff;--accent-strong:#77f;--accent-glow:rgba(119,119,255,.4);}
.call-banner[data-type="kita"]{--accent:#b0f0ff;--accent-strong:#20b0c0;--accent-glow:rgba(32,176,192,.4);}
/* Tsumo/Ron — the hand-ending win itself, shown via the same callBanner()
   used for pon/kan/chi/riichi (see onEnd in script.js) rather than a
   separate banner, so it gets the identical full-width slide-in treatment.
   Warm gold for Tsumo (a self-draw, no "victim") vs a hotter red for Ron
   (a direct hit off another player's discard) — same warm/aggressive split
   as most riichi games use for these two. */
.call-banner[data-type="tsumo"]{--accent:#ffe9b0;--accent-strong:#d4a017;--accent-glow:rgba(212,160,23,.5);}
.call-banner[data-type="ron"]{--accent:#ffb8b0;--accent-strong:#c62828;--accent-glow:rgba(198,40,40,.5);}
/* Double/triple ron (see ronBanners in script.js) plays one full-size
   .call-banner per winner in this exact same spot, one after another —
   no separate stacking/scaling styles needed here, since exactly one is
   ever in the DOM at a time. */
@keyframes cb-slide{
  0%{opacity:0;transform:translate(-58vw,42vh) scale(.55);}
  20%{opacity:.90;}
  32%{transform:translate(0,0) scale(1);}
  84%{opacity:.90;transform:translate(0,0) scale(1);}
  100%{opacity:0;transform:translate(0,-1.2vh) scale(.97);}
}
@media (prefers-reduced-motion:reduce){
  .call-banner{animation:none;opacity:.85;transform:none;}
}

/* ── Round-start title card — ink brush-in, sits above #game-loading (z-index
   200) since it's meant to be read together with the "dealing tiles" scrim
   rather than racing it. ── */
.rs-layer{position:fixed;inset:0;z-index:210;pointer-events:none;
  display:flex;align-items:center;justify-content:center;}
/* While the round-start card is actually showing, block clicks on the
   board underneath — #game-loading (round 1's "dealing tiles" overlay)
   already does this naturally since it has no pointer-events:none, but it
   hides as soon as the next game_state arrives (often well under a
   second), while this card keeps animating for its full 3.4s. Without
   this, round 2+ becomes clickable/discardable underneath a still-visible
   "東2局" announcement. Toggled by roundStartBanner() in script.js,
   on for exactly as long as the scrim/plate are in the DOM. */
.rs-layer.blocking{pointer-events:auto;}
.rs-scrim{position:absolute;inset:0;z-index:0;background:rgba(0,0,0,.85);
  opacity:0;animation:rs-scrim-hold 3.2s ease forwards;}
.rs-plate{position:relative;z-index:1;display:flex;flex-direction:column;
  align-items:center;gap:6px;
  /* Plain vmin (no clamp min/max) — same fix as the call banner: a px
     cap on padding/font-size would stop scaling before the others do at
     large or small resolutions and throw them out of proportion with
     each other. */
  padding:6vmin 9vmin;
  animation:rs-hold 3.2s ease forwards;}
.rs-wind{font-family:'Noto Serif JP',serif;font-weight:900;color:var(--gl);
  font-size:16vmin;line-height:1;
  text-shadow:0 0 30px rgba(201,168,76,.55),0 0 60px rgba(201,168,76,.25);
  opacity:0;animation:rs-wind-in 1s cubic-bezier(.2,.8,.3,1) forwards;}
.rs-sub{font-family:'Cinzel Decorative',serif;letter-spacing:.12em;color:var(--cream);
  font-size:2.4vmin;display:flex;align-items:center;gap:10px;
  opacity:0;animation:rs-fade-up .5s ease forwards;animation-delay:.55s;}
.rs-sub i{color:var(--gold);font-style:normal;opacity:.7;}
.rs-start{font-family:'Cinzel Decorative',serif;letter-spacing:.4em;text-transform:uppercase;
  font-size:1.6vmin;color:rgba(245,237,216,.5);
  /* Starts once .rs-sub's own .55s-delay + .5s fade has finished, so the
     round info reads first and 開始 follows as a distinct beat rather than
     fading in alongside it. */
  opacity:0;animation:rs-fade-up .5s ease forwards;animation-delay:1.15s;}
@keyframes rs-wind-in{
  0%{opacity:0;filter:blur(10px);transform:scale(1.3);}
  60%{opacity:1;filter:blur(0);}
  100%{opacity:1;transform:scale(1);}
}
@keyframes rs-fade-up{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:translateY(0);}}
@keyframes rs-hold{0%,78%{opacity:1;}100%{opacity:0;}}
@keyframes rs-scrim-hold{0%{opacity:0;}12%,78%{opacity:1;}100%{opacity:0;}}
@media (prefers-reduced-motion:reduce){
  .rs-plate,.rs-wind,.rs-sub,.rs-start,.rs-scrim{animation:none;opacity:1;filter:none;transform:none;}
}

/* ── Game-start roll call — shown once per game, right after the FIRST real
   game_state (i.e. once the board underneath is already fully dealt, not
   during the "配牌中/DEALING TILES" placeholder — see gameStartIntro() and
   the game_state branch of onMsg in script.js for why it waits). Same
   z-index/gating pattern as .rs-layer, reusing roundStartBanner's own
   roundStartBannerActive/pendingBannerGameState gate rather than a
   parallel one — the two never actually overlap, since this only ever
   plays once per game (right after its first state) and .rs-layer only
   from round 2 onward. Rows clone .up-nameplate/.pnp-* — the SAME
   icon+name+title plate used in the User Profile hub and in another
   player's card (see index.html's #userprofile-modal and renderPlayerCard
   in script.js) — rather than the in-game .np nameplate, so this reads as
   each player's actual profile identity, not their at-the-table plate.
   --ui-vmin here matches .about-modal's own value exactly (both scoped
   locally, not on :root) so a cloned plate renders at the identical scale
   it would inside that modal. */
.gs-layer{--ui-vmin:clamp(5px,min(1vw,1.4vh),12px);position:fixed;inset:0;z-index:210;
  pointer-events:none;display:flex;align-items:center;justify-content:center;}
.gs-layer.blocking{pointer-events:auto;}
.gs-scrim{position:absolute;inset:0;z-index:0;background:rgba(0,0,0,.85);
  opacity:0;animation:rs-scrim-hold 5s ease forwards;}
.gs-plate{position:relative;z-index:1;display:flex;flex-direction:column;
  align-items:center;gap:calc(var(--ui-vmin) * 2);
  padding:calc(var(--ui-vmin) * 5) calc(var(--ui-vmin) * 8);
  max-height:calc(100vh - 48px);overflow-y:auto;
  opacity:0;animation:rs-hold 5s ease forwards;}
.gs-title{font-family:'Cinzel Decorative',serif;letter-spacing:.35em;text-transform:uppercase;
  font-size:calc(var(--ui-vmin) * 2);color:var(--gold);
  opacity:0;animation:rs-fade-up .5s ease forwards;animation-delay:.1s;}
.gs-rows{display:flex;flex-direction:column;align-items:center;
  gap:calc(var(--ui-vmin) * 1.6);margin-top:calc(var(--ui-vmin) * 1.5);}
/* Wind badge sits OUTSIDE the cloned .up-nameplate, not inside it — the
   seat wind is this game's seating, not part of a player's profile
   identity, so it doesn't belong baked into their profile plate the way
   the in-game .np nameplate bakes it in. */
.gs-row{display:flex;align-items:center;gap:calc(var(--ui-vmin) * 1.6);
  opacity:0;animation:rs-fade-up .5s ease forwards;}
.gs-row .gs-wind{font-family:'Noto Serif JP',serif;font-weight:900;color:var(--gl);
  font-size:calc(var(--ui-vmin) * 2.6);width:calc(var(--ui-vmin) * 3.2);
  text-align:center;flex:none;
  text-shadow:0 0 12px rgba(201,168,76,.5);}
/* Fixed width (not just the plate's own natural content width) so all 3/4
   seats line up as an even column regardless of name length — .pnp-name/
   .pnp-title already come with white-space:nowrap+ellipsis from their
   shared base rule, so a name too long for this just truncates instead of
   growing the plate. Scoped to .gs-rows rather than touching .up-nameplate
   itself, which also renders un-widthed (sized to its own content) in the
   User Profile hub header and the Rankings player-card modal. */
.gs-rows .up-nameplate{width:calc(var(--ui-vmin) * 26);}
@media (prefers-reduced-motion:reduce){
  .gs-plate,.gs-title,.gs-row,.gs-scrim{animation:none;opacity:1;}
}

/* ════════════════════════════════════════════════════════════
   ROUND END SCREEN
   ════════════════════════════════════════════════════════════ */
/* Darkened overlay, not a solid screen — #s-game is deliberately left
   visible underneath (see show() in script.js) so the final board state
   reads as a dimmed backdrop instead of a blank black screen. Matches the
   existing modal-backdrop darkness (.about-backdrop) for consistency. */
/* --ui-vmin: same clamped-scale-unit approach as #s-start/#s-lobby (see
   their own comments) — without this, everything below that references
   var(--ui-vmin) (the reused .ready-btn/.bp buttons in .reb, plus every
   calc(var(--ui-vmin)*N) rule added below) had no ancestor defining it and
   silently fell back to invalid/zero sizing. The panel used flat px/vw
   values before, which don't share the 6-10px clamp everything else here
   plateaus at — this brings it in line with the rest of the app instead of
   scaling independently (or not at all) on very small/large screens. */
#s-end{--ui-vmin:clamp(6px,1vmin,10px);background:rgba(0,0,0,.6);z-index:250;flex-direction:column;}
/* Width trimmed from 77.78 to 70 units — at the old width, the game-over
   action row's two buttons (fixed-width .reb-left + auto-width Leave
   Room) left a large dead gap between them under justify-content:
   space-between, since .rec was far wider than the row's actual content
   needed. Still comfortably fits the widest realistic content (a 17-tile
   hand + a meld — see fitEndTileSize in script.js, which sizes those
   tiles to whatever width .rec actually gives it, so this doesn't risk
   re-breaking that). */
/* Horizontal padding uses clamp(...,vw,...) rather than pure --ui-vmin —
   --ui-vmin bottoms out at a fixed 6px floor (see its own comment above),
   so past that floor this padding used to stay frozen at ~38px/side even
   as the panel itself kept shrinking with the viewport. That fixed cost
   was exactly what starved .reb-split's game-over action row of room to
   stay on one line on real phone widths (~360-414px) — see .reb-split's
   own comment. The vw term lets padding keep shrinking below the vmin
   floor on narrow screens instead of eating into content width forever. */
.rec{background:linear-gradient(155deg,rgba(18,52,30,.98),rgba(6,16,9,.99));border:2px solid var(--gold);border-radius:16px;
  padding:calc(var(--ui-vmin, 7.2px) * 4.722) clamp(14px, 4.5vw, calc(var(--ui-vmin, 7.2px) * 6.389));text-align:center;
  box-shadow:0 0 100px rgba(201,168,76,.35);width:min(calc(var(--ui-vmin, 7.2px) * 70), 96vw);position:relative;}
.rec::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--gold),transparent);}
.ret{font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin, 7.2px) * 3.125);color:var(--gl);filter:drop-shadow(0 0 14px rgba(201,168,76,.6));margin-bottom:5px;}
/* Double/triple ron prev/next nav (see renderWinnerNav in script.js) —
   display:none until .visible is added; there's nothing to navigate for
   every other outcome (a single winner or none). */
.re-winner-nav{display:none;align-items:center;justify-content:center;
  gap:calc(var(--ui-vmin, 7.2px) * 1.111);}
.re-winner-nav.visible{display:flex;margin-bottom:calc(var(--ui-vmin, 7.2px) * 1.111);}
.re-winner-arrow{background:rgba(0,0,0,.4);border:1px solid rgba(201,168,76,.3);border-radius:50%;
  width:calc(var(--ui-vmin, 7.2px) * 2.5);height:calc(var(--ui-vmin, 7.2px) * 2.5);flex-shrink:0;
  color:var(--gold);font-size:calc(var(--ui-vmin, 7.2px) * 1.1);line-height:1;cursor:pointer;
  display:flex;align-items:center;justify-content:center;transition:all .2s;}
.re-winner-arrow:hover{border-color:var(--gold);background:rgba(201,168,76,.15);}
/* Just a position count ("1 / 2"), not the winner's name — that already
   shows in #rehn right below, so repeating it here would be redundant. */
.re-winner-indicator{font-family:'Cinzel Decorative',serif;color:rgba(245,237,216,.75);
  font-size:calc(var(--ui-vmin, 7.2px) * 1.25);letter-spacing:1px;
  min-width:calc(var(--ui-vmin, 7.2px) * 6);text-align:center;}
/* Wraps #rehn/#reyl/#rewt only (see index.html) — switching winner tabs
   restarts this animation (toggle animation to 'none' then back, forcing a
   reflow in between — the standard way to replay a CSS animation that's
   already applied) so the newly-selected winner's info visibly slides in
   rather than just snapping to the new content. Plays on the very first
   render too (nothing suppresses it there), which is deliberate — every
   winner reveal reads consistently, not just tab switches. */
.re-win-content{animation:re-win-slide-in .35s ease;}
@keyframes re-win-slide-in{from{opacity:0;transform:translateX(24px);}to{opacity:1;transform:translateX(0);}}
@media (prefers-reduced-motion:reduce){
  .re-win-content{animation:none;}
}
.rehn{font-size:calc(var(--ui-vmin, 7.2px) * 1.5625);color:var(--jl);letter-spacing:2px;margin-bottom:12px;}
.re-hand{color:rgba(245,237,216,.6);font-size:0.9em;}
/* Chonbo's offender name reuses .re-winner's markup (see onEnd in
   script.js) but must NOT read as a win — .rehn's own green (--jl)
   otherwise applies here by inheritance, same "success" colour used for
   an actual winner. */
.re-offender{color:#e07070;}
.re-dora-row{display:flex;gap:8px;align-items:center;justify-content:center;flex-wrap:wrap;margin-bottom:6px;}
.re-dora-lbl{font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:var(--gold);letter-spacing:1px;}
.re-ura-lbl{color:#f88;margin-left:6px;}
.re-tile-row{display:flex;gap:3px;justify-content:center;flex-wrap:wrap;align-items:flex-end;}
.re-meld-sep{width:8px;flex-shrink:0;}
.re-kan-back{width:var(--tmd-w,28px);height:var(--tmd-h,38px);background:rgba(30,60,40,.8);border:1px solid rgba(201,168,76,.3);border-radius:3px;}
.re-td-delta{text-align:right;width:calc(var(--ui-vmin, 7.2px) * 7.5);}
.re-delta{font-size:calc(var(--ui-vmin, 7.2px) * 1.375);font-weight:700;letter-spacing:.5px;}
.re-badge-pos{color:#7ec87e;}.re-badge-neg{color:#e07070;}
.re-delta-pos{color:#7ec87e;}.re-delta-neg{color:#e07070;}
.re-delta-nil{color:rgba(245,237,216,.25);font-weight:400;}
@keyframes re-badge-float{0%{opacity:0;transform:translateY(0)}15%{opacity:1;transform:translateY(-4px)}70%{opacity:1;transform:translateY(-12px)}100%{opacity:0;transform:translateY(-20px)}}
.re-limit{display:inline-block;margin-left:8px;padding:calc(var(--ui-vmin, 7.2px) * .25) calc(var(--ui-vmin, 7.2px) * 1.25);border-radius:20px;font-size:calc(var(--ui-vmin, 7.2px) * 1.375);font-weight:700;letter-spacing:1.5px;text-transform:uppercase;vertical-align:middle;}
.lbl-mangan  {background:rgba(80,160,80,.18);border:1px solid rgba(100,200,100,.4);color:#90e090;}
.lbl-haneman {background:rgba(80,140,200,.18);border:1px solid rgba(100,180,255,.4);color:#90c8ff;}
.lbl-baiman  {background:rgba(160,80,200,.18);border:1px solid rgba(200,120,255,.4);color:#d090ff;}
.lbl-sanbaiman{background:rgba(200,100,60,.18);border:1px solid rgba(255,160,80,.4);color:#ffb070;}
.lbl-yakuman {background:rgba(201,168,76,.18);border:1px solid rgba(201,168,76,.6);color:var(--gold);box-shadow:0 0 10px rgba(201,168,76,.3);}
.lbl-double-yakuman{background:rgba(201,168,76,.28);border:1px solid rgba(201,168,76,.9);color:var(--gold);box-shadow:0 0 16px rgba(201,168,76,.55),0 0 32px rgba(201,168,76,.2);font-size:calc(var(--ui-vmin, 7.2px) * 1.5);letter-spacing:2px;}

/* ── Yakuman/double-yakuman flash (see playYakumanFx in script.js) — a
   bigger, more dramatic moment than an ordinary win, playing BEFORE the
   normal call-banner announcement rather than replacing it. Plain vmin
   (no --ui-vmin clamp) for the same reason .call-banner/.rs-plate use it:
   this is meant to read as one specific size relative to the whole
   screen, not scale with a panel's own local UI-density variable. ── */
/* Palette is per-yakuman: playYakumanFx sets --ym-ink / --ym-glow /
   --ym-glow2 / --ym-accent (the last three are "r,g,b" triples used inside
   rgba()) / --ym-scrim-a / --ym-scrim-b on .yakuman-layer from the matched
   YAKUMAN_THEMES entry. The fallbacks here reproduce the original gold
   flash for any code path that appends layer content without setting them. */
.yakuman-layer{position:fixed;inset:0;z-index:216;pointer-events:none;
  display:flex;align-items:center;justify-content:center;
  --ym-ink:var(--gl,#ffe9a8);--ym-glow:255,215,100;--ym-glow2:255,180,40;
  --ym-accent:255,140,0;--ym-scrim-a:rgba(70,45,0,.6);--ym-scrim-b:rgba(0,0,0,.93);}
.yakuman-layer.blocking{pointer-events:auto;}
.yakuman-scrim{position:absolute;inset:0;z-index:0;
  background:radial-gradient(circle,var(--ym-scrim-a),var(--ym-scrim-b) 70%);
  opacity:0;animation:yakuman-scrim-hold 4.4s ease forwards;}
.yakuman-burst{position:relative;z-index:2;display:flex;flex-direction:column;
  align-items:center;gap:1.5vmin;}
.yakuman-eyebrow{font-family:'Noto Serif JP',serif;font-weight:700;
  font-size:3.4vmin;line-height:1;letter-spacing:.35em;
  color:rgba(var(--ym-glow),.9);text-shadow:0 0 20px rgba(var(--ym-glow),.6);
  opacity:0;animation:rs-fade-up .5s ease .3s forwards;}
.yakuman-eyebrow.double{color:rgba(var(--ym-accent),.95);letter-spacing:.15em;}
.yakuman-text{font-family:'Noto Serif JP',serif;font-weight:900;
  font-size:16vmin;line-height:1;color:var(--ym-ink);
  text-shadow:0 0 40px rgba(var(--ym-glow),.9),0 0 90px rgba(var(--ym-glow2),.6),
    0 0 140px rgba(var(--ym-accent),.35);
  opacity:0;
  animation:yakuman-burst-in 1s cubic-bezier(.2,.9,.25,1.4) forwards,
    yakuman-pulse 1.4s ease-in-out 1s infinite;}
/* Longer headlines (the yaku's own kanji, or ダブル役満) shrink to keep the
   burst reading as the same overall size/impact rather than growing wider
   — same reasoning as .cb-label-long elsewhere. */
.yakuman-text.long{font-size:11vmin;letter-spacing:.03em;}
.yakuman-text.xlong{font-size:7.6vmin;letter-spacing:.02em;}
.yakuman-text.double{font-size:11vmin;letter-spacing:.05em;
  text-shadow:0 0 40px rgba(var(--ym-glow),.95),0 0 100px rgba(var(--ym-glow2),.7),
    0 0 160px rgba(var(--ym-accent),.45);}
.yakuman-sub{font-family:'Cinzel Decorative',serif;letter-spacing:.4em;
  text-transform:uppercase;color:rgba(var(--ym-glow),.85);font-size:1.8vmin;
  text-align:center;opacity:0;animation:rs-fade-up .6s ease .55s forwards;}
@keyframes yakuman-burst-in{
  0%{opacity:0;transform:scale(.3) rotate(-4deg);filter:blur(14px);}
  55%{opacity:1;transform:scale(1.12) rotate(1deg);filter:blur(0);}
  75%{transform:scale(.97) rotate(-.5deg);}
  100%{opacity:1;transform:scale(1) rotate(0);}
}
@keyframes yakuman-pulse{
  0%,100%{filter:drop-shadow(0 0 0 transparent);}
  50%{filter:drop-shadow(0 0 25px rgba(var(--ym-glow),.6));}
}
@keyframes yakuman-scrim-hold{0%{opacity:0;}10%,82%{opacity:1;}100%{opacity:0;}}

/* ── Per-yakuman particle motif (see spawnYakumanMotif). One <span> per
   particle, absolutely placed inside .yakuman-motif; JS seeds --sz/--dur/
   --delay/--rot and the per-particle vectors (--dx/--dy/--tilt), CSS runs
   the chosen keyframe via --motion. --tint is the particle's fill. ── */
.yakuman-motif{position:absolute;inset:0;z-index:1;overflow:hidden;}
.ym-particle{position:absolute;width:var(--sz,2vmin);height:var(--sz,2vmin);
  opacity:0;will-change:transform,opacity;
  animation:var(--motion,ym-fall) var(--dur,2.2s) ease-out var(--delay,0s) forwards;}
.ym-s-star{background:var(--tint,#ffd75a);
  clip-path:polygon(50% 0,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
  filter:drop-shadow(0 0 6px var(--tint,#ffd75a));}
.ym-s-shard{background:linear-gradient(135deg,#fff,var(--tint,#b9c4ff));
  clip-path:polygon(50% 0,100% 42%,58% 100%,0 58%);
  filter:drop-shadow(0 0 8px var(--tint,#b9c4ff));}
.ym-s-crystal{background:linear-gradient(180deg,#fff,var(--tint,#cfd6f0));
  clip-path:polygon(50% 0,100% 50%,50% 100%,0 50%);
  filter:drop-shadow(0 0 10px var(--tint,#cfd6f0));}
.ym-s-leaf{background:var(--tint,#5ce65c);border-radius:0 100% 0 100%;
  filter:drop-shadow(0 0 5px var(--tint,#5ce65c));}
.ym-s-ember{border-radius:50%;
  background:radial-gradient(circle at 40% 40%,rgba(255,255,255,.5),var(--tint,#ff5a46));
  filter:drop-shadow(0 0 8px var(--tint,#ff5a46));}
.ym-s-orb{border-radius:50%;
  background:radial-gradient(circle at 40% 40%,#fff,var(--tint,#ffc86e) 70%,transparent);
  filter:drop-shadow(0 0 12px var(--tint,#ffc86e));}
.ym-s-coin{border-radius:50%;border:.3vmin solid rgba(255,255,255,.5);
  background:radial-gradient(circle at 35% 35%,#fff8d0,var(--tint,#ffd75a));
  filter:drop-shadow(0 0 8px var(--tint,#ffd75a));}
.ym-s-bolt{background:linear-gradient(180deg,#fff,var(--tint,#fff));
  clip-path:polygon(42% 0,76% 0,55% 40%,82% 40%,30% 100%,46% 54%,20% 54%);
  filter:drop-shadow(0 0 10px var(--tint,#9ff)) drop-shadow(0 0 4px #fff);}
.ym-s-streak{height:calc(var(--sz,14vmin) * .16);width:var(--sz,14vmin);border-radius:40%;
  background:linear-gradient(90deg,transparent,var(--tint,#66e6ff),transparent);
  filter:blur(.25vmin) drop-shadow(0 0 6px var(--tint,#66e6ff));}
@keyframes ym-fall{
  0%{opacity:0;transform:translate3d(0,0,0) rotate(0);}
  12%{opacity:1;}85%{opacity:1;}
  100%{opacity:0;transform:translate3d(var(--dx,0),115vh,0) rotate(var(--rot,360deg));}
}
@keyframes ym-rise{
  0%{opacity:0;transform:translate3d(0,0,0) rotate(0);}
  12%{opacity:1;}85%{opacity:1;}
  100%{opacity:0;transform:translate3d(var(--dx,0),-120vh,0) rotate(var(--rot,-360deg));}
}
@keyframes ym-radiate{
  0%{opacity:0;transform:translate3d(0,0,0) scale(.2) rotate(0);}
  16%{opacity:1;}78%{opacity:.9;}
  100%{opacity:0;transform:translate3d(var(--dx,0),var(--dy,0),0) scale(1) rotate(var(--rot,180deg));}
}
@keyframes ym-gale{
  0%{opacity:0;transform:translate3d(0,0,0) rotate(var(--tilt,-8deg));}
  14%{opacity:1;}85%{opacity:.85;}
  100%{opacity:0;transform:translate3d(128vw,var(--dy,0),0) rotate(var(--tilt,-8deg));}
}
@media (prefers-reduced-motion:reduce){
  .yakuman-text,.yakuman-sub,.yakuman-eyebrow,.yakuman-scrim{animation:none;opacity:1;filter:none;transform:none;}
  .yakuman-motif{display:none;}
}
.yl{display:flex;flex-wrap:wrap;gap:5px;justify-content:center;margin-bottom:12px;}
.yc{background:rgba(201,168,76,.1);border:1px solid rgba(201,168,76,.3);border-radius:20px;padding:calc(var(--ui-vmin, 7.2px) * .375) calc(var(--ui-vmin, 7.2px) * 1.25);font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:var(--cream);}
.yh{color:var(--gl);font-weight:700;margin-left:4px;}.yj{color:rgba(245,237,216,.4);font-size:calc(var(--ui-vmin, 7.2px) * 1);margin-left:3px;}
.st{width:100%;border-collapse:collapse;margin:10px 0;}.st td{padding:calc(var(--ui-vmin, 7.2px) * .75) calc(var(--ui-vmin, 7.2px) * 1.5);font-size:calc(var(--ui-vmin, 7.2px) * 1.5);border-bottom:1px solid rgba(201,168,76,.1);text-align:left;}
.st td:last-child{text-align:right;font-weight:700;color:var(--gl);}.st tr.w td{color:var(--gl);font-size:calc(var(--ui-vmin, 7.2px) * 1.625);}
.st tr.l td{color:#e07070;font-size:calc(var(--ui-vmin, 7.2px) * 1.625);}
/* Game-over/bankruptcy final standings (see onGameOver in script.js) —
   each placement cell pairs a medal-coloured ordinal with a cloned
   .up-nameplate/.pnp-* plate instead of the plain "1. Name" text .st
   normally renders. td.re-final-cell (not just .re-final-cell) matches
   .st td's own specificity so this reliably overrides its padding/
   text-align rather than losing the tie-break to source order. The
   nameplate's own pnp-name/pnp-title colors are explicit, so .st tr.w/.l's
   row-wide recolor (still applying to the plain-text score cell beside it)
   doesn't bleed into them. */
td.re-final-cell{display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * 1.2);
  padding-top:calc(var(--ui-vmin, 7.2px) * .5);padding-bottom:calc(var(--ui-vmin, 7.2px) * .5);}
.re-final-ordinal{font-family:'Cinzel Decorative',serif;font-weight:700;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.5);flex:none;width:calc(var(--ui-vmin, 7.2px) * 3);}
.re-final-cell .up-nameplate{width:calc(var(--ui-vmin, 7.2px) * 24);}
.wt{display:flex;flex-direction:column;align-items:center;gap:4px;margin:10px 0;}
.re-tenpai{display:flex;flex-direction:column;gap:8px;margin:10px 0;}
.re-tenpai-row{display:flex;flex-direction:column;align-items:center;gap:4px;}
.re-tenpai-name{font-size:calc(var(--ui-vmin, 7.2px) * 1.375);color:var(--gold);letter-spacing:1px;}
/* Riichi-stick/honba pot carrying into next round — hidden (no text, no
   margin/border) when the round ended the game or nothing's on the table,
   set in onEnd() (script.js). */
.re-carryover{font-size:calc(var(--ui-vmin, 7.2px) * 1.375);color:var(--gold);letter-spacing:.5px;
  padding-top:8px;margin-top:4px;border-top:1px solid rgba(201,168,76,.15);}
.re-carryover:empty{display:none;padding:0;margin:0;border:none;}
/* ── Settings panel ── */
/* --ui-vmin: same clamped-scale-unit approach as #s-start/#s-lobby/#s-end
   (see their own comments) — this panel is reachable from every screen
   (see toggleSettings in script.js) so it's never a descendant of any of
   those, and everything below already reads var(--ui-vmin, 7.2px) but had
   nothing actually defining it, so it was permanently stuck at the 7.2px
   fallback (i.e. never actually scaling) regardless of screen size —
   which read as "too small" specifically on very large/4K displays. */
#settings-panel{--ui-vmin:clamp(6px,1vmin,10px);--pg:calc(var(--ui-vmin) * 6);position:fixed;top:0;right:0;bottom:0;
  width:calc(var(--ui-vmin) * 33.33);max-width:calc(100vw - 24px);
  /* Diagonal-stagger, drawer edition — the panel is flush against three
     screen edges, so only the left edge is treated: a staggered double
     gold rule (bold outer at x=0 cut top+bottom, finer inner a few px in
     cut through the middle). Drawn as background layers, not ::before, so
     it stays pinned while the settings sections scroll. Matches the
     .about-modal / #bstart frame language. */
  background:
    linear-gradient(180deg,transparent 0 var(--pg),#e8c25c var(--pg) calc(100% - var(--pg)),transparent) 0 0/2px 100% no-repeat,
    linear-gradient(180deg,#c9a84c 0 38%,transparent 38% 62%,#c9a84c 62%) calc(var(--ui-vmin) * .7) 0/1px 100% no-repeat,
    rgba(6,12,7,.97);
  border-left:0;
  z-index:200;transform:translateX(100%);transition:transform .25s ease;
  display:flex;flex-direction:column;padding:calc(var(--ui-vmin) * 2.778) calc(var(--ui-vmin) * 2.222);
  gap:calc(var(--ui-vmin) * 2.778);overflow-y:auto;}
#settings-backdrop{display:none;position:fixed;inset:0;z-index:199;}
.sp-title{font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin, 7.2px) * 1.528);letter-spacing:3px;
  color:var(--gold);border-bottom:1px solid rgba(201,168,76,.2);padding-bottom:calc(var(--ui-vmin, 7.2px) * 1.389);}
.sp-section{display:flex;flex-direction:column;gap:12px;}
.sp-header{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);letter-spacing:2px;color:rgba(245,237,216,.45);}
.sp-row{display:flex;align-items:center;justify-content:space-between;}
/* .sp-row's rows normally get their vertical spacing from the settings
   panel's .sp-section{gap:12px} wrapper — the Rules modal's .about-body
   uses bare .sp-row divs with no such wrapper, so give them their own
   breathing room here instead. */
.about-body .sp-row{padding:calc(var(--ui-vmin, 7.2px) * .556) 0;}
/* Highlights the current player's own row in the Rankings list. */
.sp-row-mine .sp-label{color:var(--gold);font-weight:700;}
/* Rankings name → player card modal (see openPlayerCard in script.js). No
   underline — cursor+hover colour alone is the clickability affordance. */
.sp-label-clickable{cursor:pointer;transition:color .15s;}
.sp-label-clickable:hover{color:var(--gold);}
.player-card-loading{text-align:center;color:rgba(245,237,216,.5);padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;}
/* Reuses .up-nameplate's card look wholesale (same border/background/tier
   shine as the User Profile hub's own nameplate) inside #player-card-modal
   — just cancels .about-title's fixed modal-header spacing/centring, which
   would otherwise fight the nameplate's own flex layout and margins. */
.player-card-nameplate{margin:0 0 calc(var(--ui-vmin, 7.2px) * 1.111) 0;text-align:left;}

/* In-game nameplate click popover (see openPlayerCardInGame/
   renderPlayerPopover in script.js) — a small anchored card, NOT a modal:
   no backdrop, no dimming, positioned via inline left/top next to whichever
   nameplate was clicked so the rest of the board stays visible/interactive
   mid-hand. Own --ui-vmin (same clamp() pattern as #settings-panel/
   .about-modal above) so .up-nameplate/.player-card-nameplate/.sp-row
   inside it — all of which read var(--ui-vmin, 7.2px) — scale down to fit a
   card this small rather than inheriting some ancestor's larger value. */
/* Glassy but restrained: a translucent tinted background + blur (the actual
   "glass" read) plus one soft inset highlight along the top edge only (a
   frosted pane catching light from above, not an all-over shine). No
   diagonal specular sweep like .up-nameplate::before uses elsewhere in this
   file — that's the "too much gloss" look this was asked to avoid, fine for
   a static profile-hub header but too busy for a small popover that pops
   in/out constantly mid-game. backdrop-filter falls back to the flat
   background alone on browsers without support (still fully readable). */
.player-popover{--ui-vmin:clamp(6px,1vmin,10px);position:fixed;
  width:calc(var(--ui-vmin) * 30.5);max-width:calc(100vw - 16px);
  background:linear-gradient(180deg,rgba(20,32,22,.72),rgba(10,22,12,.72));
  -webkit-backdrop-filter:blur(14px) saturate(140%);backdrop-filter:blur(14px) saturate(140%);
  border:1px solid rgba(201,168,76,.32);border-radius:2px;
  padding:calc(var(--ui-vmin) * 2) calc(var(--ui-vmin) * 1.8);
  box-shadow:0 8px 28px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.08);
  z-index:180;display:flex;flex-direction:column;gap:calc(var(--ui-vmin) * 1.4);}
.player-popover-stats{display:flex;flex-direction:column;gap:calc(var(--ui-vmin) * 0.8);
  font-size:calc(var(--ui-vmin) * 1.25);}
.player-popover-stats .sp-label{color:rgba(245,237,216,.6);}
/* Rankings' sort-stat picker (Points/Wins/Avg Place/etc. — see
   setLeaderboardSort in script.js) — a real tab strip (shared bottom
   border, active tab picked out by an underline) rather than a row of
   pill buttons, icon-only (title attr carries the label as a hover
   tooltip, same as the corner nav icons like .card-icon-btn) so all 7 fit
   in one row without wrapping even at the modal's narrowest width. */
.rank-sort-tabs{display:flex;border-bottom:1px solid rgba(201,168,76,.2);margin-bottom:calc(var(--ui-vmin, 7.2px) * .556);}
.rank-sort-tab{flex:1;display:flex;align-items:center;justify-content:center;
  padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;margin-bottom:-1px;
  background:none;border:none;border-bottom:2px solid transparent;
  color:rgba(245,237,216,.45);cursor:pointer;transition:all .2s;}
.rank-sort-tab svg{width:calc(var(--ui-vmin, 7.2px) * 2.222);height:calc(var(--ui-vmin, 7.2px) * 2.222);display:block;}
.rank-sort-tab:hover{color:var(--gl);}
.rank-sort-tab.active{color:var(--gold);border-bottom-color:var(--gold);}
/* Rankings' period picker (All Time/This Week/This Month — see
   setLeaderboardPeriod in script.js) — same real-tab-strip language as
   .rank-sort-tabs right above (shared bottom border, active tab picked out
   by an underline), just with the label always visible as plain text
   instead of an icon+tooltip, since only 3 tabs need to fit rather than 7. */
.rank-period-tabs{display:flex;border-bottom:1px solid rgba(201,168,76,.2);margin-bottom:calc(var(--ui-vmin, 7.2px) * 1.111);}
.rank-period-tab{flex:1;padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;margin-bottom:-1px;
  background:none;border:none;border-bottom:2px solid transparent;
  font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin, 7.2px) * 1.389);
  color:rgba(245,237,216,.45);cursor:pointer;transition:all .2s;}
.rank-period-tab:hover{color:var(--gl);}
.rank-period-tab.active{color:var(--gold);border-bottom-color:var(--gold);}
/* Persistent label for whichever tab above is active (see
   updateRankSortHeader in script.js) — icon-only tabs otherwise have no
   always-visible indicator of what's actually selected, especially on
   touch devices where the tabs' own hover title never fires at all. */
.rank-sort-header{text-align:center;font-family:'Cinzel Decorative',serif;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.528);letter-spacing:calc(var(--ui-vmin, 7.2px) * .208);
  color:var(--gold);margin-bottom:calc(var(--ui-vmin, 7.2px) * 1.389);}
.sp-label{font-size:calc(var(--ui-vmin, 7.2px) * 1.667);color:var(--cream);}
/* Placements mini bar-chart (see renderPlacementBar in script.js) —
   replaces the old "1st 1 · 2nd 0 · 3rd 0 · 4th 0" text line, which was
   hard to compare at a glance. One thin bar per rank, width = that rank's
   % of games played; brightness fades rank-over-rank via .place-bar-1..4
   (1st brightest gold, last dimmest) so "better placement" reads as
   "brighter bar" without a legend. */
.place-bars{margin:calc(var(--ui-vmin, 7.2px) * .556) 0;}
.place-bar-row{display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * 1.111);
  padding:calc(var(--ui-vmin, 7.2px) * .278) 0;}
.place-bar-label{flex:0 0 calc(var(--ui-vmin, 7.2px) * 3.5);font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:rgba(245,237,216,.6);}
.place-bar-track{flex:1;height:calc(var(--ui-vmin, 7.2px) * 1.111);border-radius:999px;
  background:rgba(0,0,0,.35);overflow:hidden;}
.place-bar-fill{height:100%;border-radius:999px;transition:width .4s ease;}
.place-bar-1{background:rgba(201,168,76,1);}
.place-bar-2{background:rgba(201,168,76,.75);}
.place-bar-3{background:rgba(201,168,76,.5);}
.place-bar-4{background:rgba(201,168,76,.3);}
.place-bar-pct{flex:0 0 calc(var(--ui-vmin, 7.2px) * 8);text-align:right;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:rgba(245,237,216,.7);}
/* Achievements panel (see renderAchievements in script.js) — one item per
   achievement, reusing .place-bar-track/.place-bar-fill above for its
   progress bar. Not-yet-earned ones are just dimmed via .achv-locked
   (opacity, no lock icon) — that treatment is deliberately reserved for
   the Title/Voice Theme pickers, which hide/disable instead. */
.achv-item{padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;
  border-bottom:1px solid rgba(201,168,76,.12);}
.achv-item:last-child{border-bottom:none;}
.achv-item.achv-locked{opacity:.45;}
.achv-label{font-size:calc(var(--ui-vmin, 7.2px) * 1.667);color:var(--cream);}
.achv-reward{font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:var(--gold);opacity:.8;
  margin:calc(var(--ui-vmin, 7.2px) * .278) 0 calc(var(--ui-vmin, 7.2px) * .417);}
/* Achievement-unlock toast (see index.html/showAchievementUnlock in
   script.js) — a corner banner, not a blocking modal: no backdrop, and
   pointer-events is off while hidden/off-screen so it never intercepts
   clicks to whatever's underneath (the round-end screen's View
   Results/Main Menu/Ready buttons, specifically — a full about-modal here
   used to sit right on top of them). Slides in from off-screen via
   transform rather than display toggling, so it can auto-dismiss with a
   visible animation instead of just vanishing. */
/* --ui-vmin: same clamped-scale-unit approach as #s-end/#settings-panel
   (see their own comments) — this toast is a standalone body-level element
   (see index.html), never a descendant of #s-lobby/#s-start/etc., so
   everything below was permanently stuck at the calc()'s 7.2px fallback
   regardless of screen size until this was added. */
.achv-toast{--ui-vmin:clamp(6px,1vmin,10px);position:fixed;top:calc(var(--ui-vmin) * 2);right:calc(var(--ui-vmin) * 2);
  z-index:350;width:min(320px,calc(100vw - 24px));
  background:#0a160c;border:1px solid rgba(201,168,76,.4);border-radius:8px;
  padding:calc(var(--ui-vmin, 7.2px) * 2.222);box-shadow:0 12px 32px rgba(0,0,0,.6);
  transform:translateX(calc(100% + 24px));transition:transform .4s ease;
  pointer-events:none;max-height:calc(100vh - 32px);overflow-y:auto;}
.achv-toast.show{transform:translateX(0);pointer-events:auto;}
/* Stack the corner toasts (achievement / room invite / daily bonus) when
   more than one is on screen at once, so a later one drops below the
   earlier instead of landing on top of it. Sibling order in index.html is
   achv-toast, invite-toast, coin-toast; `~` skips any that aren't `.show`,
   so a 2nd visible toast is nudged down one step and a 3rd two steps,
   regardless of which of the three they are. Approximate step height —
   these are all short (title + one or two lines). */
.achv-toast.show ~ .achv-toast.show{transform:translateX(0) translateY(calc(var(--ui-vmin) * 15));}
.achv-toast.show ~ .achv-toast.show ~ .achv-toast.show{transform:translateX(0) translateY(calc(var(--ui-vmin) * 30));}
/* Room Invite toast's Accept button (#invite-toast-accept) — the
   achievement toast has no equivalent, it's purely informational, so this
   only applies to the invite variant. */
#invite-toast-accept{margin-top:calc(var(--ui-vmin, 7.2px) * 1.389);}
.achv-toast-title{font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin, 7.2px) * 1.806);
  color:var(--gold);margin-bottom:calc(var(--ui-vmin, 7.2px) * 1.389);padding-right:calc(var(--ui-vmin, 7.2px) * 2.778);}
/* Wordless on/off switch (replaces the old "ON"/"OFF" text pill) — a knob
   (::after) slides right when on, left when the .off class is added (see
   toggleMusic/toggleFx/toggleDiscardSfx/applyRuleButton in script.js,
   which now just toggle that class instead of writing textContent + two
   inline styles). Track stays the same green-tinted "on"/red-tinted "off"
   coding the old text buttons used, just via .off instead of inline style,
   so this needs no localization and reads at a glance without reading
   words. Buttons are now empty elements (see index.html) — the knob is the
   entire visual, nothing else to lay out. */
/* Recolored to reuse the same "selected/unselected" language as
   .mode-btn.active/.mode-btn (jade tint + jade border when on, dim
   near-black + faint gold border when off) instead of a stoplight red/
   green pair — matches the rest of the Janshin theme and reads as a
   quieter on/off cue than the old saturated colors. */
.sp-toggle{position:relative;width:calc(var(--ui-vmin, 7.2px) * 4.2);height:calc(var(--ui-vmin, 7.2px) * 2.15);
  padding:0;border-radius:999px;cursor:pointer;flex-shrink:0;
  background:rgba(26,107,69,.4);border:1px solid var(--jl);
  transition:background .2s,border-color .2s;}
.sp-toggle::after{content:'';position:absolute;top:calc(var(--ui-vmin, 7.2px) * .225);
  left:calc(var(--ui-vmin, 7.2px) * 2.3);width:calc(var(--ui-vmin, 7.2px) * 1.7);height:calc(var(--ui-vmin, 7.2px) * 1.7);
  border-radius:50%;background:rgba(245,237,216,.85);box-shadow:0 1px 3px rgba(0,0,0,.35);
  transition:left .2s,background .2s;}
.sp-toggle.off{background:rgba(0,0,0,.3);border-color:rgba(201,168,76,.25);}
.sp-toggle.off::after{left:calc(var(--ui-vmin, 7.2px) * .225);background:rgba(245,237,216,.55);}
.sp-vol{display:flex;flex-direction:column;gap:calc(var(--ui-vmin, 7.2px) * .833);}
.sp-vol-row{display:flex;justify-content:space-between;align-items:center;}
.sp-vol-label{font-size:calc(var(--ui-vmin, 7.2px) * 1.528);color:var(--gold);}
.sp-slider{width:100%;accent-color:var(--gold);cursor:pointer;}
.sp-theme-list{display:flex;flex-direction:column;gap:calc(var(--ui-vmin, 7.2px) * .833);}
/* Voice theme option buttons (see renderVoiceThemes in script.js) — base
   look lives here now instead of an inline style.cssText, so sizing scales
   with --ui-vmin like everything else in the modal; JS only ever toggles
   .locked (via a class, not inline px) plus the per-theme active/hover
   background swap already handled separately in applyVoiceTheme. */
.theme-btn{padding:calc(var(--ui-vmin, 7.2px) * .972) calc(var(--ui-vmin, 7.2px) * 1.667);border-radius:2px;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.528);letter-spacing:calc(var(--ui-vmin, 7.2px) * .139);
  transition:all .2s;text-align:left;width:100%;
  background:rgba(42,58,42,.3);border:1px solid rgba(201,168,76,.2);
  color:var(--cream);font-family:'Cinzel Decorative',serif;cursor:pointer;}
.theme-btn.locked{opacity:.45;cursor:default;}
/* Game Music picker (see renderGameMusicList) reuses .theme-btn but is
   multi-select rather than pick-one, so it needs its own persistent
   "selected" look instead of applyVoiceTheme's single-active inline style. */
.theme-btn.selected{background:rgba(201,168,76,.25);border-color:var(--gold);}
.theme-btn.selected::before{content:'✓ ';color:var(--gold);}
/* Player Icon grid — User Profile modal only (see renderPlayerIcons in
   script.js). Each icon is a small hand-coded SVG mark, not an uploaded
   image — the app has no image-upload capability. */
.icon-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:calc(var(--ui-vmin, 7.2px) * 1.111);}
/* Choose an Existing Profile — its own window now (#choose-profile-modal
   in index.html), so this just fills that window's .about-body, which
   already scrolls on its own (see .about-body's comment) — no height cap
   needed here the way the old inline version needed one to avoid pushing
   the Log In card's own fields around underneath it. Still a compact
   variant of .profile-card though: icon and name laid out horizontally
   (like a plain list row) instead of icon-on-top/name-below, 2 columns
   (not .icon-grid's usual 3) so a horizontal card has enough width for
   the name. */
#lobby-profile-grid{grid-template-columns:repeat(2,1fr);}
#lobby-profile-grid .profile-card{flex-direction:row;justify-content:flex-start;
  padding:calc(var(--ui-vmin, 7.2px) * .833) calc(var(--ui-vmin, 7.2px) * 1.111);
  gap:calc(var(--ui-vmin, 7.2px) * .833);}
#lobby-profile-grid .profile-card svg{width:calc(var(--ui-vmin, 7.2px) * 2.778);height:calc(var(--ui-vmin, 7.2px) * 2.778);flex-shrink:0;}
#lobby-profile-grid .profile-card-name{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);}
.icon-btn{background:rgba(42,58,42,.3);border:1px solid rgba(201,168,76,.2);border-radius:2px;
  padding:calc(var(--ui-vmin, 7.2px) * .833);cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;}
.icon-btn svg{width:calc(var(--ui-vmin, 7.2px) * 5);height:calc(var(--ui-vmin, 7.2px) * 5);display:block;}
.icon-btn:hover{border-color:rgba(201,168,76,.5);}
.icon-btn.active{background:rgba(201,168,76,.2);border-color:var(--gold);}

/* ── Start screen button ── */
/* Raw vmin works for the logo (large coefficients stay legible even at
   tiny vmin), but these are small text — 1.4vmin on a 320px phone is a
   4.5px caption. Route them through one clamped scale unit instead: it
   still tracks vmin (and everything below stays proportional to
   everything else, since they all multiply the SAME variable — no
   independent caps to decouple), but never drops below a readable floor
   or balloons past a sensible ceiling on very large screens. Scoped to
   #s-start (inherited by its descendants) rather than :root, so it
   doesn't affect anything outside the start screen. */
#s-start{--ui-vmin:clamp(6px,1vmin,10px);}
/* START — the "diagonal stagger" frame from the start-button exploration
   (the Brushed Silver geometry from the start-frames artifact), static,
   and recoloured to gold to match the 雀心 logo and the Janshin palette.
   The button is two nested broken rectangles: the outer rule (::before)
   is cut at its top-left and bottom-right corners, the inner (::after) at
   the other two, so neither ever closes. Each is a gold gradient masked
   to that stroke shape — a gentle static highlight, no sheen animation,
   biased bright so no part of either frame drops out on the green ground.
   No fill, no border, no radius — the frame is the button. The 18px
   corner gap and 1px rule are the artifact's own fixed values. */
#bstart{position:relative;isolation:isolate;
  padding:1.15rem 2.9rem;
  font-family:'Cinzel Decorative',serif;font-weight:700;
  font-size:1.02rem;letter-spacing:.46em;
  background:none;border:0;color:var(--gl);cursor:pointer;
  transition:color .25s ease,letter-spacing .25s ease,transform .18s ease;}
#bstart>span{position:relative;z-index:1;padding-left:.46em;}
/* "Hanko" motif (see silver-motifs exploration) — a small tilted vermilion
   seal, 始 "begin", pressed onto the frame's top-right corner; hover
   straightens it. Red against the gold, the way a chop sits on a document. */
#bstart>i{position:absolute;z-index:3;top:-7px;right:-7px;
  width:19px;height:19px;border-radius:2px;
  display:flex;align-items:center;justify-content:center;
  font-family:'Noto Serif JP',serif;font-weight:700;font-size:.66rem;font-style:normal;
  background:#b23a2e;color:#f8ece4;
  transform:rotate(-4deg);transition:transform .25s ease;
  box-shadow:0 1px 3px rgba(0,0,0,.45);pointer-events:none;}
#bstart:hover{color:#ffe680;}
#bstart:hover>i{transform:rotate(0deg);}
#bstart.pressed{transform:scale(.9);}
#bstart:focus-visible{outline:2px solid var(--gl);outline-offset:8px;}
#bstart::before,#bstart::after{content:"";position:absolute;pointer-events:none;
  background-repeat:no-repeat;transition:filter .25s ease;}
#bstart::before{inset:0;
  background-image:linear-gradient(115deg,#b5903f 0%,#ffe9b0 48%,#cfa74d 100%);
  -webkit-mask:
    linear-gradient(90deg,transparent 0 18px,#000 18px) top/100% 1px no-repeat,
    linear-gradient(90deg,#000 0 calc(100% - 18px),transparent calc(100% - 18px)) bottom/100% 1px no-repeat,
    linear-gradient(180deg,transparent 0 18px,#000 18px) left/1px 100% no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - 18px),transparent calc(100% - 18px)) right/1px 100% no-repeat;
  mask:
    linear-gradient(90deg,transparent 0 18px,#000 18px) top/100% 1px no-repeat,
    linear-gradient(90deg,#000 0 calc(100% - 18px),transparent calc(100% - 18px)) bottom/100% 1px no-repeat,
    linear-gradient(180deg,transparent 0 18px,#000 18px) left/1px 100% no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - 18px),transparent calc(100% - 18px)) right/1px 100% no-repeat;}
#bstart::after{inset:5px;opacity:.9;
  background-image:linear-gradient(115deg,#a37f35 0%,#f4e0ac 48%,#bd9740 100%);
  -webkit-mask:
    linear-gradient(90deg,#000 0 calc(100% - 18px),transparent calc(100% - 18px)) top/100% 1px no-repeat,
    linear-gradient(90deg,transparent 0 18px,#000 18px) bottom/100% 1px no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - 18px),transparent calc(100% - 18px)) left/1px 100% no-repeat,
    linear-gradient(180deg,transparent 0 18px,#000 18px) right/1px 100% no-repeat;
  mask:
    linear-gradient(90deg,#000 0 calc(100% - 18px),transparent calc(100% - 18px)) top/100% 1px no-repeat,
    linear-gradient(90deg,transparent 0 18px,#000 18px) bottom/100% 1px no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - 18px),transparent calc(100% - 18px)) left/1px 100% no-repeat,
    linear-gradient(180deg,transparent 0 18px,#000 18px) right/1px 100% no-repeat;}
#bstart:hover::before,#bstart:hover::after{filter:brightness(1.12);}

/* Start -> lobby transition: press-pulse the button (via .pressed above),
   fade+scale the start screen out, then fade+scale the lobby in once
   show() has swapped which screen has display:none — see enterLobby(). */
#s-start.screen-out{animation:screenOut .27s ease forwards;}
#s-lobby.screen-in{animation:screenIn .315s ease .045s backwards;}
@keyframes screenOut{to{opacity:0;transform:scale(.96);}}
@keyframes screenIn{from{opacity:0;transform:scale(1.03);}to{opacity:1;transform:scale(1);}}
.start-copy{font-size:calc(var(--ui-vmin) * 1.4);color:rgba(245,237,216,.3);
  letter-spacing:calc(var(--ui-vmin) * .28);}
.about-link{font-size:calc(var(--ui-vmin) * 1.4);color:rgba(245,237,216,.4);
  letter-spacing:calc(var(--ui-vmin) * .14);
  text-decoration:underline;margin-top:6px;cursor:pointer;transition:color .2s;}
.about-link:hover{color:var(--gold);}
/* Rankings/Match Settings — icon-only buttons, a flex item within
   .card-header-row (see index.html) alongside the back button and header,
   rather than absolutely anchored to .card's own corner — that's what
   actually keeps them on the header's baseline instead of just sharing a
   "top" offset with .back-icon-btn (a text glyph box and an svg box don't
   naturally come out to the same height at the same font-size). Sits flush
   against .card's own (now-reduced) padding edge rather than clawing past
   it with a negative margin — see .card's padding comment. Rankings sits
   first in DOM order so it renders left of Match Settings. */
.card-icon-btns{flex-shrink:0;display:flex;gap:calc(var(--ui-vmin, 7.2px) * 1.111);}
.card-icon-btn{background:none;border:none;padding:0;color:#888;cursor:pointer;
  display:flex;align-items:center;justify-content:center;transition:color .2s;}
.card-icon-btn:hover{color:var(--gold);}
.card-icon-btn svg{width:calc(var(--ui-vmin, 7.2px) * 1.667);height:calc(var(--ui-vmin, 7.2px) * 1.667);display:block;}
/* Readout of the current Match Settings (game mode + player count) on the
   lobby card itself — those pickers now live inside the settings modal
   (see #rules-modal), so without this there'd be no way to tell what
   you're about to queue for without opening it. */
/* Profile nameplate — icon left, name right, with a reserved line under
   the name for a future title/grade (see #pnp-title, currently always
   empty). Click anywhere on it to open the profile picker, same action
   the old #iname text field had — see refreshProfileDisplay in script.js
   for what keeps its icon/name in sync. */
/* Plain display now, not a button — logging out lives in its own Logout
   link below (see .lobby-auth-link on #lobby-inputs' Logout <a>), so this
   is just the icon+name+title readout, no click affordance of its own. */
.profile-nameplate{display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * 1.389);width:100%;
  background:rgba(0,0,0,.5);border:1px solid rgba(201,168,76,.2);border-radius:2px;
  padding:calc(var(--ui-vmin, 7.2px) * 1.111) calc(var(--ui-vmin, 7.2px) * 1.667);
  text-align:left;margin-bottom:0;}
.pnp-icon{flex-shrink:0;width:calc(var(--ui-vmin, 7.2px) * 4.444);height:calc(var(--ui-vmin, 7.2px) * 4.444);}
.pnp-icon svg{width:100%;height:100%;display:block;}
.pnp-info{display:flex;flex-direction:column;flex:1;min-width:0;}
.pnp-name{font-size:calc(var(--ui-vmin, 7.2px) * 1.944);color:var(--cream);font-family:'Cinzel Decorative',serif;
  letter-spacing:calc(var(--ui-vmin, 7.2px) * .069);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.pnp-title{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:var(--gold);opacity:.9;letter-spacing:calc(var(--ui-vmin, 7.2px) * .139);min-height:calc(var(--ui-vmin, 7.2px) * 1.667);}
/* User Profile hub's header — same icon+name pairing as .profile-nameplate
   above, boxed the same way (rounded corners, gold border) but centered and
   non-interactive in place of .about-title's plain text (see #up-icon/
   #up-name in index.html), with a brushed-gold/glossy finish to read as a
   little metal nameplate rather than a flat panel: a dark gold-to-black
   gradient for the "brushed metal" base, a diagonal light streak (::before)
   for the glossy highlight, and inset+outer shadows for plate depth. */
/* margin-left:0 lines the plate's left edge up with the hub links below
   (.hub-link has no left inset either, both start flush at .about-body's
   edge); margin-right alone pulls the right edge in enough to clear the
   ✕ button's footprint (its right offset + icon size, see .about-close)
   so the plate's own edge never sits underneath it. */
.up-nameplate{display:flex;align-items:center;justify-content:left;gap:calc(var(--ui-vmin, 7.2px) * 1.389);
  margin-left:0;margin-right:calc(var(--ui-vmin, 7.2px) * 1.2);
  position:relative;overflow:hidden;padding:calc(var(--ui-vmin, 7.2px) * 1.389) calc(var(--ui-vmin, 7.2px) * 2.222);border-radius:2px;
  transition:background .3s,border-color .3s;
  /* Bronze — the default tier (under 10 combined wins, see
     updateNameplateTier in script.js). .tier-silver/.tier-gold/
     .tier-platinum below override both border and background together;
     kept as plain modifier classes (not CSS vars) since each tier needs a
     genuinely different multi-stop gradient, not just one swapped color. */
  border:1px solid rgba(180,110,60,.6);
  background:linear-gradient(145deg,#8a5a2e,#3a2410 40%,#1a0f06 55%,#3a2410 70%,#8a5a2e);
  box-shadow:inset 0 1px 0 rgba(255,210,170,.2),inset 0 -1px 2px rgba(0,0,0,.7),0 3px 8px rgba(0,0,0,.5);}
.up-nameplate.tier-silver{border-color:rgba(210,212,218,.6);
  background:linear-gradient(145deg,#d4d6dc,#6c6e76 40%,#2c2d31 55%,#6c6e76 70%,#d4d6dc);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.3),inset 0 -1px 2px rgba(0,0,0,.7),0 3px 8px rgba(0,0,0,.5);}
.up-nameplate.tier-gold{border-color:rgba(255,210,90,.7);
  background:linear-gradient(145deg,#f5d67c,#b8892a 40%,#5a3d0a 55%,#b8892a 70%,#f5d67c);
  box-shadow:inset 0 1px 0 rgba(255,245,200,.4),inset 0 -1px 2px rgba(0,0,0,.6),0 3px 8px rgba(0,0,0,.5);}
.up-nameplate.tier-platinum{border-color:rgba(200,232,240,.65);
  background:linear-gradient(145deg,#eaf6fa,#a9c9d2 40%,#54727a 55%,#a9c9d2 70%,#eaf6fa);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.45),inset 0 -1px 2px rgba(0,20,30,.6),0 3px 8px rgba(0,0,0,.5);}
/* Diamond — 200+ wins, the top tier. Brighter/more saturated blue-white
   than Platinum (pure white highlights vs Platinum's softer gray-teal),
   plus a SECOND glossy streak (::after, mirrored direction from the base
   ::before one every tier shares) so it visibly "sparkles" rather than
   just being Platinum-but-bluer. */
.up-nameplate.tier-diamond{border-color:rgba(190,235,255,.85);
  background:linear-gradient(145deg,#ffffff,#bfe8fb 25%,#7ec8e8 45%,#4a90c2 55%,#7ec8e8 70%,#bfe8fb 85%,#ffffff);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.6),inset 0 -1px 2px rgba(0,20,40,.55),0 0 12px rgba(140,210,240,.35),0 3px 8px rgba(0,0,0,.5);}
.up-nameplate.tier-diamond::after{content:'';position:absolute;top:0;right:-25%;width:18%;height:100%;
  background:linear-gradient(100deg,transparent,rgba(255,255,255,.5),transparent);
  transform:skewX(-25deg);}
.up-nameplate::before{content:'';position:absolute;top:0;left:-30%;width:35%;height:100%;
  background:linear-gradient(100deg,transparent,rgba(255,255,255,.22),transparent);
  transform:skewX(-25deg);}
.up-nameplate .pnp-icon{width:calc(var(--ui-vmin, 7.2px) * 6.5);height:calc(var(--ui-vmin, 7.2px) * 6.5);}
/* Click affordance for #up-icon (see index.html) — it opens Player Icon
   directly now instead of a separate hub link, so it needs to visibly read
   as clickable. The SVG's outer <rect stroke="var(--gold)" stroke-width="2">
   every playerIcons entry in script.js draws is thinned to 1.2 here
   (scoped to just this one usage, not every icon-btn/profile-card
   elsewhere that reuses the same SVGs) so the frame reads as delicate at
   rest; hovering doesn't thicken it back up — instead the whole icon gets
   a soft glow (drop-shadow, transitioned via blur radius 0 → 6px so it
   animates smoothly rather than popping in) plus the frame brightening to
   the lighter --gl gold, so the emphasis reads as "lit up" rather than
   "outlined harder". `svg rect` only ever matches that one frame rect —
   every other mark in playerIcons is a circle/line/text, never a second
   rect — so this can't accidentally restyle anything else. */
.up-icon-btn{cursor:pointer;}
.up-icon-btn svg{transition:filter .25s;filter:drop-shadow(0 0 0 var(--gl));}
.up-icon-btn svg rect{stroke-width:0.5;transition:stroke .25s;}
.up-icon-btn:hover svg{filter:drop-shadow(0 0 6px var(--gl));}
.up-icon-btn:hover svg rect{stroke:var(--gl);}
.up-nameplate .pnp-name{position:relative;text-shadow:0 1px 1px rgba(0,0,0,.6);}
/* #up-title (see index.html) — same text-shadow as .pnp-name above so it
   stays legible against the metallic background. */
.up-nameplate .pnp-title{position:relative;text-shadow:0 1px 1px rgba(0,0,0,.6);}
/* Click affordance for #up-title — opens Title. Same "brighten, don't
   frame" language as .up-icon-btn above: fades from --gold to the lighter
   --gl on hover rather than adding a ring/shadow around the text. */
.up-title-btn{cursor:pointer;transition:color .2s;}
.up-title-btn:hover{color:var(--gl);}
.about-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:300;}
/* Same clamped scale-unit approach as #s-lobby/#s-start/.topbar (see their
   own --ui-vmin comments) — defining it here means every descendant below
   (.about-title/.about-body/.about-section-title/.hub-link/.icon-grid/
   .profile-nameplate/etc, plus .ig/.bp/.mode-btn which already read this
   var with a static 7.2px fallback for when they're used with no such
   ancestor) scales together with viewport size instead of every dialog
   rendering at one fixed pixel size regardless of a 320px phone or a 4K
   display. width is clamp()-driven the same way (50 * the 6–12px clamp
   range = 300–600px) instead of a flat 360px, still bounded by max-width
   so it can never overflow a narrow phone screen.
   Driven by 1vw, not 1vmin — vmin resolves off whichever dimension is
   SMALLER, which on any ordinary landscape desktop window is the height,
   so a vmin-based panel completely ignores how wide the window actually
   is: a 1870×950 window and a 950×950 window produced the exact same
   (small) panel, which read as "a small box floating in a lot of empty
   background" on a normal wide monitor even though there was plenty of
   width to use. vw fixes that directly (a wider window now genuinely
   grows the panel), and behaves IDENTICALLY to the old vmin value on a
   portrait phone, where width already IS the smaller dimension (vw==vmin
   there) — so this only changes landscape/desktop behavior. A short
   LANDSCAPE window (a phone rotated sideways) can in principle push the
   vw-driven height past what's visible, but max-height/overflow-y below
   already exist to catch exactly that (scrolls, doesn't overflow).
   Content inside scales by the exact same factor as the panel (font-size/
   padding are all driven by this same --ui-vmin), so growing the panel
   reads as "the same design, bigger" rather than the box outgrowing its
   own contents. Border/border-radius/box-shadow stay plain px on purpose
   — hairline chrome like that doesn't need to track viewport size, only
   content/spacing does. */
/* height is fixed (not content-driven) so every panel in this family is
   the same box, whether it's a short hub list or a long stats readout —
   without this, switching between e.g. Voice Theme (short) and My Stats
   (tall) or the User Profile hub and any of its children snaps the box to
   a different size on every navigation, which reads as a jump rather than
   a transition. 48 units matches the tallest common panel's natural
   content height at the base 7.2px scale, so most panels just gain a
   little breathing room at the bottom rather than looking empty; anything
   that still overflows it (About's text, a long My Stats/Rankings list)
   scrolls via overflow-y, same as before. max-height still wins over this
   on short viewports, so it can't push the box off-screen. .leave-modal
   (the in-game "Leave Game?" confirm) opts back out to height:auto below —
   it's a standalone 2-button confirm, not a panel in this hub/child
   navigation, so forcing it to the same tall box would just look broken. */
/* Ceiling lowered from 18px to 12px — the old ceiling was reached by any
   window ≥1800px wide, which includes ordinary 1920×1080 (FHD) displays,
   not just 4K/ultrawide ones. At 18px the box hit 900×864px — on a FHD
   screen that's ~80% of the viewport height, reading as "the panel takes
   over the whole screen" instead of a centered dialog. 12px caps it at a
   more normal 600×576px on every desktop-class window from ~1200px wide
   up, while narrower/portrait viewports are unaffected (still driven down
   to the same 6px floor as before). */
.about-modal{--ui-vmin:clamp(6px,1vw,12px);
  --pg:calc(var(--ui-vmin) * 4.5);--pw:calc(var(--ui-vmin) * .15);
  display:none;flex-direction:column;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
  width:calc(var(--ui-vmin) * 50);height:calc(var(--ui-vmin) * 48);
  max-width:calc(100vw - 24px);max-height:calc(100vh - 48px);overflow:visible;
  background:#0a160c;border:1px solid rgba(201,168,76,.12);
  border-radius:0;padding:calc(var(--ui-vmin) * 3.333);z-index:301;box-shadow:0 12px 48px rgba(0,0,0,.8);}
/* Diagonal-stagger gold frame — the same treatment as the START button
   (#bstart), at panel scale. The modal is a flex column with .about-body
   scrolling inside it (see below) so this ::before/::after stays pinned to
   the panel edge rather than scrolling away with the content. Outer rule
   (::before) is cut at top-left + bottom-right — the ✕ sits in that
   top-right gap — inner rule (::after) at the other two. --pg is the corner
   gap (scales with the panel via --ui-vmin), --pw the rule weight. */
.about-modal::before,.about-modal::after{content:"";position:absolute;pointer-events:none;z-index:2;
  background-repeat:no-repeat;}
.about-modal::before{inset:0;
  background-image:linear-gradient(115deg,#b5903f 0%,#ffe9b0 48%,#cfa74d 100%);
  -webkit-mask:
    linear-gradient(90deg,transparent 0 var(--pg),#000 var(--pg)) top/100% var(--pw) no-repeat,
    linear-gradient(90deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) bottom/100% var(--pw) no-repeat,
    linear-gradient(180deg,transparent 0 var(--pg),#000 var(--pg)) left/var(--pw) 100% no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) right/var(--pw) 100% no-repeat;
  mask:
    linear-gradient(90deg,transparent 0 var(--pg),#000 var(--pg)) top/100% var(--pw) no-repeat,
    linear-gradient(90deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) bottom/100% var(--pw) no-repeat,
    linear-gradient(180deg,transparent 0 var(--pg),#000 var(--pg)) left/var(--pw) 100% no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) right/var(--pw) 100% no-repeat;}
.about-modal::after{inset:calc(var(--ui-vmin) * .55);opacity:.7;
  background-image:linear-gradient(115deg,#a37f35 0%,#f4e0ac 48%,#bd9740 100%);
  -webkit-mask:
    linear-gradient(90deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) top/100% var(--pw) no-repeat,
    linear-gradient(90deg,transparent 0 var(--pg),#000 var(--pg)) bottom/100% var(--pw) no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) left/var(--pw) 100% no-repeat,
    linear-gradient(180deg,transparent 0 var(--pg),#000 var(--pg)) right/var(--pw) 100% no-repeat;
  mask:
    linear-gradient(90deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) top/100% var(--pw) no-repeat,
    linear-gradient(90deg,transparent 0 var(--pg),#000 var(--pg)) bottom/100% var(--pw) no-repeat,
    linear-gradient(180deg,#000 0 calc(100% - var(--pg)),transparent calc(100% - var(--pg))) left/var(--pw) 100% no-repeat,
    linear-gradient(180deg,transparent 0 var(--pg),#000 var(--pg)) right/var(--pw) 100% no-repeat;}
.about-backdrop.visible{display:block;}
.about-modal.visible{display:flex;}
.leave-modal{height:auto;}
/* Smaller corner gap on the compact confirm dialogs (Leave Game? / the
   generic yes/no) — at their short auto height the default gap would eat
   almost the whole vertical rule. */
.leave-modal{--pg:calc(var(--ui-vmin) * 2.5);}
/* Portrait phones (and phone-sized/portrait tablets) have far more spare
   vertical room than --ui-vmin's width-driven (1vw) value assumes — the
   height formula above never sees how tall the viewport actually is, only
   how wide. Add real vh-based room on top of it here so tall content (My
   Stats, Rankings, a long Play History) needs less internal scrolling — content
   sizing (font/padding) is untouched, still driven by the same --ui-vmin
   as everywhere else, so this only gives the BOX more room, it doesn't
   make anything inside it bigger (per the "keep content proportions"
   requirement this was built to). min(...) with the existing max-height
   still wins on a short/squat viewport, same safety net as before. */
@media (orientation:portrait) and (max-width:820px){
  .about-modal{height:min(calc(var(--ui-vmin) * 48 + 26vh),calc(100vh - 48px));}
  /* Re-assert .leave-modal's own height:auto opt-out (see its rule above) —
     without this, the same-specificity .about-modal rule right above wins
     on a portrait phone purely because it comes later in the file, quietly
     un-opting-out the one panel that deliberately isn't part of this tall
     fixed-box family. */
  .leave-modal{height:auto;}
}
.about-close{position:absolute;top:calc(var(--ui-vmin, 7.2px) * 1.389);right:calc(var(--ui-vmin, 7.2px) * 1.667);background:none;border:none;
  color:#888;font-size:calc(var(--ui-vmin, 7.2px) * 2.222);cursor:pointer;}
/* Mirrors .about-close exactly (same top offset AND font-size, so the two
   icons sit at the same y position) on the opposite corner — returns to
   the Developer Settings hub instead of exiting it (see backToDevPanel in
   script.js). Icon-only, same as .about-close's ✕. */
.about-back{position:absolute;top:calc(var(--ui-vmin, 7.2px) * 1.389);left:calc(var(--ui-vmin, 7.2px) * 1.667);background:none;border:none;
  color:#888;font-size:calc(var(--ui-vmin, 7.2px) * 2.222);cursor:pointer;}
.about-back:hover{color:var(--gold);}
.about-title{font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin, 7.2px) * 1.944);letter-spacing:calc(var(--ui-vmin, 7.2px) * .278);
  color:var(--gold);margin-bottom:calc(var(--ui-vmin, 7.2px) * 1.944);text-align:center;}
/* flex:1 + own overflow so the body scrolls INSIDE the flex-column modal
   (see .about-modal) — this is what keeps the title, the ✕/← and the gold
   frame pinned while a long list scrolls. flex-basis:auto (not 0) so the
   short confirm dialogs, which are height:auto, still size to their text. */
.about-body{font-size:calc(var(--ui-vmin, 7.2px) * 1.667);line-height:1.6;color:rgba(245,237,216,.75);
  flex:1 1 auto;min-height:0;overflow-y:auto;}
.about-body p{margin:0 0 calc(var(--ui-vmin, 7.2px) * 1.389);}
.about-body p:last-child{margin-bottom:0;}
/* Inline links inside modal body text (Legal section, sign-up's Terms/
   Privacy checkbox) — otherwise unstyled <a> tags would fall back to the
   browser default blue, which nothing else in this gold/jade palette uses. */
.about-body a,.profile-remember-row a{color:var(--gold);}
.about-body a:hover,.profile-remember-row a:hover{text-decoration:none;}
.about-section{margin-top:calc(var(--ui-vmin, 7.2px) * 1.944);padding-top:calc(var(--ui-vmin, 7.2px) * 1.944);border-top:1px solid rgba(201,168,76,.15);}
.about-section-title{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);letter-spacing:calc(var(--ui-vmin, 7.2px) * .278);color:rgba(201,168,76,.95);
  font-family:'Cinzel Decorative',serif;margin-bottom:calc(var(--ui-vmin, 7.2px) * .833);}
/* Yaku Achieved section header (see renderStatsBlock/toggleYakuSection in
   script.js) — clickable to expand/collapse the achieved+not-yet-achieved
   list below it. .yaku-caret flips ▸/▾ in JS; transition here just
   smooths the color change on hover, same hover language as .hub-link. */
.yaku-toggle{cursor:pointer;display:flex;align-items:center;gap:4px;transition:color .2s;}
.yaku-toggle:hover{color:var(--gold);}
/* Rows for yaku never achieved yet (count always 0) — dimmed so the
   achieved ones above them still read as the "real" data at a glance. */
.sp-row.yaku-not-achieved{opacity:.4;}
/* Hub-panel links (Developer Settings, User Profile) — same golden look
   .about-section-title had back when these were collapsible section
   headers, kept even though they're plain links now (each opens its own
   window instead of expanding inline). */
.hub-link{display:block;font-size:calc(var(--ui-vmin, 7.2px) * 1.528);letter-spacing:calc(var(--ui-vmin, 7.2px) * .278);color:rgba(201,168,76,.95);
  font-family:'Cinzel Decorative',serif;text-align:left;text-decoration:none;padding:calc(var(--ui-vmin, 7.2px) * 1.389) 0;
  cursor:pointer;}
.hub-link:hover{color:var(--gold);}
/* Log In card's Create an Account/Forgot Password/Continue as Guest links
   (see #lobby-login-fields in index.html), plus the game card's own
   Logout link below the nameplate — sized and set in the same font as
   "Remember me" (.profile-remember-row), same dim color as the start
   screen's About link (.about-link) with no hover color change (unlike
   .hub-link, used for actual navigation links elsewhere — About > Legal,
   Developer Settings' hub). inline-block rather than .hub-link's
   display:block so the clickable area is only ever the text itself —
   each link sits in its own wrapper div in index.html for the line break
   that display:block used to give for free, since two inline-block
   elements next to each other in markup would otherwise sit side by
   side. */
.lobby-auth-link{display:inline-block;font-size:calc(var(--ui-vmin, 7.2px) * 1.389);font-family:'Noto Sans JP',sans-serif;
  color:rgba(245,237,216,.4);text-decoration:none;cursor:pointer;padding:calc(var(--ui-vmin, 7.2px) * .694) 0;}
/* Logout's own top padding overridden to near-zero so it sits snug under
   the nameplate above it (see #profile-nameplate in index.html) — the
   bottom padding stays, for breathing room before Quick Match/Room List
   below it. */
.nameplate-logout{padding-top:calc(var(--ui-vmin, 7.2px) * .139);}
/* Continue as Guest sits above the identifier field, right-aligned (see
   #lobby-login-fields in index.html) — .lobby-auth-link is already
   inline-block, so text-align on this row is enough with no extra
   override needed on the link itself. */
.lobby-guest-row{text-align:left;}
/* Floating label — "Username"/"Password"/etc. live INSIDE the
   box like a placeholder until the field is focused or has a value, then
   shrink and move to sit at the box's own top edge (see .ig-float in
   index.html for the input+label markup this pairs with, and its comment
   on why each input needs a real, if blank, placeholder attribute).
   Scoped to .ig.ig-float specifically — plain .ig (Developer Settings,
   Change Username/Code, etc.) keeps its ordinary label-above-input
   layout unchanged. */
.ig.ig-float{position:relative;}
/* Two extra classes (.ig.ig-float, not just .ig-float) so this reliably
   outranks plain .ig input's own padding shorthand regardless of which
   rule happens to come first in the file — same specificity trick as
   .ig .pw-field input below. */
.ig.ig-float input{padding-top:calc(var(--ui-vmin, 7.2px) * 2.6);padding-bottom:calc(var(--ui-vmin, 7.2px) * .9);}
.ig-float label{position:absolute;left:calc(var(--ui-vmin, 7.2px) * 1.944);top:50%;transform:translateY(-50%);
  font-size:calc(var(--ui-vmin, 7.2px) * 2.222);color:rgba(245,237,216,.55);
  text-transform:none;letter-spacing:normal;margin-bottom:0;pointer-events:none;
  transition:top .15s ease,font-size .15s ease,color .15s ease;}
.ig-float input:focus+label,
.ig-float input:not(:placeholder-shown)+label{
  top:calc(var(--ui-vmin, 7.2px) * 1.15);transform:translateY(0);
  font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:var(--gold);
  text-transform:uppercase;letter-spacing:calc(var(--ui-vmin, 7.2px) * .417);}
/* Show/hide toggle inside a password field (see .pw-field/.pw-eye in
   index.html's Log In/Sign Up forms) — the eye button sits absolutely
   inside the same wrapper as the input, so .ig input's own 100%-width
   padding rule needs a right-side callout wide enough for the icon
   without the typed text running under it; higher specificity than plain
   ".ig input" (two classes instead of one) is what lets this win without
   !important. */
.pw-field{position:relative;}
.ig .pw-field input{padding-right:calc(var(--ui-vmin, 7.2px) * 5);}
.pw-eye{position:absolute;top:50%;right:calc(var(--ui-vmin, 7.2px) * 1.111);transform:translateY(-50%);
  background:none;border:none;padding:0;margin:0;color:rgba(245,237,216,.5);cursor:pointer;
  display:flex;align-items:center;line-height:0;}
.pw-eye:hover{color:var(--gold);}
.pw-eye svg{width:calc(var(--ui-vmin, 7.2px) * 2.222);height:calc(var(--ui-vmin, 7.2px) * 2.222);}
/* Profile picker cards — icon on top, name below, one per profile (see
   renderProfileList/selectProfileCard in script.js). Reuses .icon-grid's
   3-column layout but each cell needs a taller, name-labeled card instead
   of .icon-btn's icon-only square. */
.profile-card{position:relative;background:rgba(42,58,42,.3);border:1px solid rgba(201,168,76,.2);border-radius:2px;
  padding:calc(var(--ui-vmin, 7.2px) * 1.111) calc(var(--ui-vmin, 7.2px) * .556);cursor:pointer;transition:all .2s;display:flex;flex-direction:column;
  align-items:center;gap:calc(var(--ui-vmin, 7.2px) * .556);overflow:hidden;}
.profile-card svg{width:calc(var(--ui-vmin, 7.2px) * 5);height:calc(var(--ui-vmin, 7.2px) * 5);display:block;}
.profile-card-name{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:var(--cream);font-family:'Cinzel Decorative',serif;
  letter-spacing:calc(var(--ui-vmin, 7.2px) * .069);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;}
.profile-card:hover{border-color:rgba(201,168,76,.5);}
.profile-card.active{background:rgba(201,168,76,.2);border-color:var(--gold);}
/* Online-status dot (see the Invite Players picker, renderInvitePlayers in
   script.js) — a small glowing green mark, top-right of the card, only
   present on profiles the server reported as online right now. Pulses
   gently rather than sitting static, so "online" reads as a live signal
   rather than just another icon. */
.profile-card-dot{position:absolute;top:calc(var(--ui-vmin, 7.2px) * .556);right:calc(var(--ui-vmin, 7.2px) * .556);
  width:calc(var(--ui-vmin, 7.2px) * .972);height:calc(var(--ui-vmin, 7.2px) * .972);border-radius:50%;
  background:#4eff9a;box-shadow:0 0 6px 2px rgba(78,255,154,.8);animation:profile-dot-pulse 1.8s ease-in-out infinite;}
@keyframes profile-dot-pulse{0%,100%{opacity:1;}50%{opacity:.5;}}
/* Offline (or just currently unreachable — see server/index.js's
   'get_online_profiles') profiles are still listed, just dimmed and
   non-interactive (renderInvitePlayers never attaches an onclick to
   these), rather than missing from the picker entirely. */
.profile-card-offline{opacity:.4;cursor:default;}
.profile-card-offline:hover{border-color:rgba(201,168,76,.2);}
/* Invite Players' staged picks (see #rules-invited-list in index.html,
   renderInvitedChips in script.js) — shown above "+ Create Room" once the
   picker window closes. */
.invite-chip-list{display:flex;flex-wrap:wrap;gap:calc(var(--ui-vmin, 7.2px) * .556);margin-top:calc(var(--ui-vmin, 7.2px) * .833);}
.invite-chip{display:inline-flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * .417);
  background:rgba(201,168,76,.15);border:1px solid rgba(201,168,76,.35);border-radius:999px;
  padding:calc(var(--ui-vmin, 7.2px) * .278) calc(var(--ui-vmin, 7.2px) * .278) calc(var(--ui-vmin, 7.2px) * .278) calc(var(--ui-vmin, 7.2px) * .833);
  font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:var(--cream);}
.invite-chip-x{background:none;border:none;color:rgba(245,237,216,.6);cursor:pointer;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.111);line-height:1;padding:calc(var(--ui-vmin, 7.2px) * .417);border-radius:50%;}
.invite-chip-x:hover{color:#ff8080;background:rgba(255,128,128,.15);}
#profile-code-section{margin-top:calc(var(--ui-vmin, 7.2px) * 2.222);}
.leave-modal .about-body{text-align:center;}
.leave-actions{display:flex;gap:calc(var(--ui-vmin, 7.2px) * 1.389);margin-top:calc(var(--ui-vmin, 7.2px) * 2.5);}
.leave-actions button{flex:1;padding:calc(var(--ui-vmin, 7.2px) * 1.528);border-radius:2px;font-size:calc(var(--ui-vmin, 7.2px) * 1.667);
  font-family:'Cinzel Decorative',serif;letter-spacing:calc(var(--ui-vmin, 7.2px) * .278);cursor:pointer;transition:all .25s;}
.leave-btn-stay{background:linear-gradient(135deg,var(--jade),var(--jd));border:1px solid var(--jl);color:var(--cream);}
.leave-btn-stay:hover{transform:translateY(-2px);box-shadow:0 8px 20px rgba(26,107,69,.5);}
.leave-btn-confirm{background:rgba(120,20,20,.85);border:1px solid rgba(200,60,60,.5);color:rgba(255,200,200,.9);}
.leave-btn-confirm:hover{background:rgba(160,30,30,.95);transform:translateY(-2px);}
/* font-size here relies on --ui-vmin from whichever ancestor this button
   is currently in — #s-start, #s-lobby, or .topbar (via .tb-right) — all
   three declare the same clamp(6px,1vmin,10px), so the icon reads as one
   consistent scale wherever it's shown. */
.settings-icon-btn{position:absolute;top:calc(var(--ui-vmin, 7.2px) * 1.667);right:calc(var(--ui-vmin, 7.2px) * 1.944);background:none;border:none;
  color:#888;font-size:calc(var(--ui-vmin, 7.2px) * 2.778);cursor:pointer;z-index:10;letter-spacing:-1px;}
/* Holds an inline SVG (the profile's chosen player icon, see
   renderUserIconButton) instead of a text glyph like its .settings-icon-btn
   sibling — sized to roughly the same footprint the ☰ glyph occupies.
   calc(var(--ui-vmin))-based like everything else here (not raw vmin) so it
   shares the same clamp(6px,1vmin,10px) floor/ceiling — a bare vmin value
   would shrink with no floor on a short/narrow viewport and grow with no
   ceiling on a huge one. */
.user-icon-btn{display:flex;align-items:center;justify-content:center;padding:0;}
.user-icon-btn svg{width:calc(var(--ui-vmin, 7.2px) * 6.111);height:calc(var(--ui-vmin, 7.2px) * 6.111);display:block;}

/* Unread-notification dot on the 人 user icon — a small red disc at its
   top-right corner, toggled by the .has-notif class (see updateNotifBadge
   in script.js). A dot, not a count: it just says "there's something in
   Notifications" (inside the User Profile hub). ::after so it survives
   refreshProfileDisplay()'s innerHTML rewrite of the button. */
.user-icon-btn.has-notif::after{content:"";position:absolute;
  top:calc(var(--ui-vmin, 7.2px) * .3);right:calc(var(--ui-vmin, 7.2px) * -.4);
  width:calc(var(--ui-vmin, 7.2px) * 1.6);height:calc(var(--ui-vmin, 7.2px) * 1.6);
  border-radius:50%;background:#d02323;box-shadow:0 0 calc(var(--ui-vmin, 7.2px) * .7) rgba(208,35,35,.8);}
/* Same dot, inline after the "Notifications" hub-link text, so once the hub
   is open you can see which row has news. */
.hub-badge{display:none;width:calc(var(--ui-vmin, 7.2px) * 1.1);height:calc(var(--ui-vmin, 7.2px) * 1.1);
  border-radius:50%;background:#d02323;vertical-align:middle;margin-left:calc(var(--ui-vmin, 7.2px) * .833);}
.hub-badge.show{display:inline-block;}

/* Lobby status cluster — the "Online Friends" button + coin balance, as one
   flex row anchored where the coin balance alone used to sit (see
   #coin-count's old `right`) and growing LEFTWARD, so a wide coin count
   pushes the button further from the 人 icon rather than overlapping it.
   Same `top` as .settings-icon-btn so button, numerals, 人 and ☰ all sit
   the same distance from the top edge. */
.lobby-status{position:absolute;top:calc(var(--ui-vmin, 7.2px) * 1.667);right:calc(var(--ui-vmin, 7.2px) * 12.7);
  display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * 1.4);z-index:10;}
.lobby-status-btn{background:none;border:0;padding:0;cursor:pointer;color:#888;
  display:flex;align-items:center;justify-content:center;position:relative;transition:color .18s;}
.lobby-status-btn:hover{color:var(--gold, #c9a84c);}
.lobby-status-btn svg{width:calc(var(--ui-vmin, 7.2px) * 2.9);height:calc(var(--ui-vmin, 7.2px) * 2.9);display:block;}

/* Coin balance — pale-gold serif numerals + leaning coin-stack icon over a
   gradient-fade underline (no box; see coinUnderlineSvg / updateCoins in
   script.js). Sits inside .lobby-status now (was absolutely positioned
   itself). */
.coin-count{position:relative;
  display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * .6);
  padding:0 calc(var(--ui-vmin, 7.2px) * .5) calc(var(--ui-vmin, 7.2px) * .55) 0;
  color:#e6dcc2;font-family:'Noto Serif JP',serif;font-weight:700;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.85);line-height:1;white-space:nowrap;
  letter-spacing:calc(var(--ui-vmin, 7.2px) * .06);font-variant-numeric:tabular-nums;
  text-shadow:0 1px calc(var(--ui-vmin, 7.2px) * .28) rgba(0,0,0,.6);
  cursor:pointer;background:none;border:0;outline-offset:calc(var(--ui-vmin, 7.2px) * .5);
  transition:filter .18s;}
.coin-count:hover{filter:brightness(1.12);}
.coin-count:active{filter:brightness(.95);}
.coin-count>span{position:relative;z-index:1;}
.coin-count .coin-icon{display:flex;align-items:center;}
/* Gradient-fade underline — a mark, not a container. Starts just left of
   the coin icon and fades out past the numerals; preserveAspectRatio="none"
   stretches it to width. */
.coin-underline{position:absolute;left:calc(var(--ui-vmin, 7.2px) * -.4);
  width:calc(100% + var(--ui-vmin, 7.2px) * .7);
  bottom:calc(var(--ui-vmin, 7.2px) * -.55);height:calc(var(--ui-vmin, 7.2px) * 2.2);
  z-index:0;pointer-events:none;}
/* The SVG coin-stack — sized to the surrounding text, used in both the
   lobby count and the #s-end reward line. */
.coin-svg{width:1.65em;height:1.65em;display:inline-block;vertical-align:-.42em;flex-shrink:0;
  filter:drop-shadow(0 1px 1px rgba(0,0,0,.4));}

/* "Coins earned +N" line on the game-over results panel (#s-end) — same
   pale gold as the lobby count, no underline (one-off line, not a HUD). */
.coin-reward{text-align:center;color:#e6dcc2;font-family:'Noto Serif JP',serif;font-weight:700;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.9);letter-spacing:calc(var(--ui-vmin, 7.2px) * .08);
  margin:calc(var(--ui-vmin, 7.2px) * 1.1) 0 calc(var(--ui-vmin, 7.2px) * .4);}
.coin-reward:empty{display:none;}
.coin-reward .coin-cap{opacity:.65;font-size:.82em;}

/* Store modal (opened from #coin-count) — see renderShopIcons/
   renderShopNameplates in script.js. */
/* Balance readout — top-left. top AND left both match .about-modal's own
   padding (see that rule) — the same inset .about-title's text box starts
   from vertically, and the same left inset .shop-section-label ("PLAYER
   ICONS") starts from horizontally, since neither .about-title nor
   .about-body carry any extra margin/padding of their own beyond the
   modal's. Lines up with both rather than .about-close's higher, further-in
   corner-notch position (the ✕ deliberately sits above/right of that, in
   the gold frame's corner cutout — this shouldn't copy it). Visible right
   on the Store itself rather than only in the topbar corner behind this
   modal. Same icon/number pairing as #coin-count (populated by the same
   updateCoins() call, see script.js). */
.shop-balance{position:absolute;top:calc(var(--ui-vmin, 7.2px) * 3.333);left:calc(var(--ui-vmin, 7.2px) * 3.333);
  display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * .5);
  color:#e6dcc2;font-family:'Noto Serif JP',serif;font-weight:700;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.5);}
.shop-balance .coin-icon{display:flex;align-items:center;}
.shop-section-label{color:var(--gold);font-family:'Cinzel Decorative',serif;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.4);letter-spacing:calc(var(--ui-vmin, 7.2px) * .14);
  text-transform:uppercase;margin:calc(var(--ui-vmin, 7.2px) * 2) 0 calc(var(--ui-vmin, 7.2px) * .833);}
.shop-section-label:first-child{margin-top:0;}
.shop-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:calc(var(--ui-vmin, 7.2px) * 1.111);}
.shop-item{display:flex;flex-direction:column;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * .417);
  background:rgba(42,58,42,.3);border:1px solid rgba(201,168,76,.2);border-radius:2px;
  padding:calc(var(--ui-vmin, 7.2px) * .833);cursor:pointer;transition:all .2s;text-align:center;}
.shop-item:hover{border-color:rgba(201,168,76,.5);}
.shop-item svg{width:calc(var(--ui-vmin, 7.2px) * 4.5);height:calc(var(--ui-vmin, 7.2px) * 4.5);display:block;}
.shop-item .np-preview{transform:scale(.85);}
.shop-item-label{font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:var(--cream);}
.shop-item-tier{font-size:calc(var(--ui-vmin, 7.2px) * .972);text-transform:uppercase;letter-spacing:.05em;}
.shop-item-tier.common{color:rgba(245,237,216,.55);}
.shop-item-tier.uncommon{color:var(--jl);}
.shop-item-tier.rare{color:var(--gl);}
.shop-item-tier.legendary{color:#ffe066;text-shadow:0 0 4px rgba(255,224,102,.6);}
.shop-item-price{font-size:calc(var(--ui-vmin, 7.2px) * 1.111);color:var(--gold);}
/* .shop-equipped/.shop-owned: already yours — border reads "selected",
   price line shows state instead of a cost. .shop-buy: for sale, shown
   identically regardless of balance — the actual funds check only ever
   happens server-side (cosmetics.purchase) when the player commits from
   the preview's Buy button (see openShopPreview), so there's nothing
   trustworthy to grey out here even if we wanted to. */
.shop-item.shop-equipped{border-color:var(--gold);background:rgba(201,168,76,.2);}
.shop-item.shop-owned:hover{border-color:var(--gl);}

/* Nameplate skin picker (#nameplate-list) and the Shop's nameplate cards
   both preview a skin via .np-preview — a small, self-contained stand-in
   for the real in-game .np, since .np's own sizing is calibrated entirely
   off --table-size (only meaningful inside the game table, see that
   class's comment) and would render degenerate here. Only the .np-skin-*
   background/border/box-shadow rules are shared with the real thing (see
   those classes below) — every size/spacing value here is its own, fixed,
   rem-based scale. */
.nameplate-grid{display:flex;flex-direction:column;gap:calc(var(--ui-vmin, 7.2px) * .833);}
.nameplate-btn{background:rgba(42,58,42,.3);border:1px solid rgba(201,168,76,.2);border-radius:2px;
  padding:calc(var(--ui-vmin, 7.2px) * .556);cursor:pointer;transition:all .2s;display:flex;justify-content:center;}
.nameplate-btn:hover{border-color:rgba(201,168,76,.5);}
.nameplate-btn.active{border-color:var(--gold);}
.np-preview{display:inline-flex;align-items:center;gap:.5rem;padding:.5rem .9rem;
  border-radius:1px;background:#1a1a1a;box-shadow:0 0 0 1px rgba(255,255,255,.08);}
.np-preview .npv-wind{width:1.6rem;height:1.6rem;border-radius:50%;flex-shrink:0;
  display:flex;align-items:center;justify-content:center;font-family:'Noto Serif JP',serif;
  font-weight:900;font-size:.85rem;background:rgba(201,168,76,.18);color:var(--gl);
  border:1px solid rgba(201,168,76,.45);}
.np-preview .npv-text{display:flex;flex-direction:column;line-height:1.2;}
.np-preview .npv-name{font-size:.85rem;font-weight:700;color:#fff;}
/* Title line — only present in the big Shop preview (see
   renderShopPreview), not the compact picker/grid thumbnails, which stay
   wind+name only. Mirrors .ptitle's own gold/Cinzel look. */
.np-preview .npv-title{font-size:.7rem;color:var(--gold);opacity:.8;font-family:'Cinzel Decorative',serif;}

/* Nameplate skins — purchasable reskins of .np (see server/cosmetics.js's
   CATALOG.nameplate and NAMEPLATE_CATALOG in script.js). Each one only ever
   overrides background/box-shadow/color-type declarations, mirroring
   .up-nameplate's own tier classes elsewhere in this file — never padding/
   gap/font-size/width/height, so .np's own calibrated sizing (see its own
   comment) stays untouched regardless of skin. box-shadow (not a real
   `border`) for the same reason .np's own default chrome uses it: a real
   border adds to the box's rendered size, box-shadow doesn't. Each rule
   also targets .npv-wind/.npv-name so the exact same class works for both
   the real in-game plate and the Shop/picker's .np-preview above —
   deliberately placed AFTER .np-preview's own base rule: both are single-
   class selectors, so with equal specificity the later one in the
   stylesheet wins: a skin declared any earlier would lose its background/
   box-shadow to .np-preview's plain #1a1a1a default the moment both
   classes land on the same element. .np.np-skin-*.active re-asserts just
   the background — .np.active's own background:#222 would otherwise stomp
   every skin at the one moment (a seat's turn) it matters most to still
   look like the skin; the glow itself (box-shadow/animation) is left to
   the shared .active/@glow rule (well above, near .np's own definition),
   unskinned on purpose so "it's this seat's turn" reads identically
   regardless of cosmetic. */
.np-skin-hai{background:linear-gradient(170deg,#fffdf4,#f0e8d0);box-shadow:0 0 0 1px var(--gold),0 0 0 3px #7a5c10,inset 0 1px 0 rgba(255,255,255,.5);}
.np-skin-hai .pname,.np-skin-hai .npv-name{color:#1a1a1a;}
.np-skin-hai .ptitle,.np-skin-hai .npv-title{color:#7a5c10;}
.np-skin-hai .wb,.np-skin-hai .npv-wind{background:rgba(0,0,0,.06);color:#7a5c10;border-color:rgba(122,92,16,.4);}
.np.np-skin-hai.active{background:linear-gradient(170deg,#fffdf4,#f0e8d0);}

.np-skin-andon{background:radial-gradient(ellipse at 50% 40%,#fff3d0,#f0c878 70%,#c99a4a);box-shadow:0 0 0 1px #8a6b20,0 0 10px rgba(240,200,120,.5);}
.np-skin-andon .pname,.np-skin-andon .npv-name{color:#3a2a10;}
.np-skin-andon .ptitle,.np-skin-andon .npv-title{color:#6b5220;}
.np-skin-andon .wb,.np-skin-andon .npv-wind{background:rgba(255,255,255,.3);color:#8a6b20;border-color:rgba(138,107,32,.4);}
.np.np-skin-andon.active{background:radial-gradient(ellipse at 50% 40%,#fff3d0,#f0c878 70%,#c99a4a);}

.np-skin-kinpaku{background:radial-gradient(ellipse at 30% 30%,#f5dc90,#d4af37 40%,#a8842a 70%,#f5dc90 100%);box-shadow:0 0 0 1px #6b5218;}
.np-skin-kinpaku .pname,.np-skin-kinpaku .npv-name{color:#3a2a08;}
/* Darker than the other skins' title colour, and opacity re-asserted to 1
   — the base .ptitle/.npv-title rules dim titles to 80% opacity, which on
   every other (darker) skin still reads fine but on this pale-gold
   background washed the title out to near-illegible: the 80% fade blends
   an already gold-toned color further into the gold behind it. */
.np-skin-kinpaku .ptitle,.np-skin-kinpaku .npv-title{color:#4a3208;opacity:1;}
.np-skin-kinpaku .wb,.np-skin-kinpaku .npv-wind{background:rgba(0,0,0,.12);color:#5c4712;border-color:rgba(92,71,18,.4);}
.np.np-skin-kinpaku.active{background:radial-gradient(ellipse at 30% 30%,#f5dc90,#d4af37 40%,#a8842a 70%,#f5dc90 100%);}

.np-skin-hyosatsu{background:linear-gradient(170deg,#5a1414,#2c0a0a);box-shadow:0 0 0 1px var(--gold),inset 0 0 8px rgba(0,0,0,.5);}
.np-skin-hyosatsu .pname,.np-skin-hyosatsu .npv-name{color:#f5edd8;}
.np-skin-hyosatsu .ptitle,.np-skin-hyosatsu .npv-title{color:#e0b8b8;}
.np-skin-hyosatsu .wb,.np-skin-hyosatsu .npv-wind{background:rgba(201,168,76,.2);color:var(--gl);border-color:var(--gold);}
.np.np-skin-hyosatsu.active{background:linear-gradient(170deg,#5a1414,#2c0a0a);}

.np-skin-tatamiberi{background:linear-gradient(170deg,#d4c896,#b8a870);box-shadow:0 0 0 1px #6b5220,0 0 0 3px #2a6b45;}
.np-skin-tatamiberi .pname,.np-skin-tatamiberi .npv-name{color:#2a2408;}
.np-skin-tatamiberi .ptitle,.np-skin-tatamiberi .npv-title{color:#4a4020;}
.np-skin-tatamiberi .wb,.np-skin-tatamiberi .npv-wind{background:rgba(0,0,0,.08);color:#2a6b45;border-color:rgba(42,107,69,.4);}
.np.np-skin-tatamiberi.active{background:linear-gradient(170deg,#d4c896,#b8a870);}

/* Shop item preview (opened by clicking any Shop card, see openShopPreview
   in script.js) — a bigger, standalone look at whichever item was
   clicked, shown the way it's actually seen in play: an icon preview
   reuses the real .up-nameplate/.pnp-* profile-plate markup (see that
   class elsewhere in this file), a nameplate-skin preview reuses
   .np-preview/.np-skin-* (scaled up here) with no icon. See
   renderShopPreview's own comment for why the two differ. */
.shop-preview-stage{display:flex;justify-content:center;padding:calc(var(--ui-vmin, 7.2px) * 3.5) 0 calc(var(--ui-vmin, 7.2px) * 2);}
.shop-preview-stage .np-preview{transform:scale(1.6);}
.shop-preview-meta{text-align:center;margin-bottom:calc(var(--ui-vmin, 7.2px) * 1.5);}
.shop-preview-meta .shop-item-tier{display:block;margin-top:calc(var(--ui-vmin, 7.2px) * .417);}
.shop-preview-meta .shop-item-price{display:block;margin-top:calc(var(--ui-vmin, 7.2px) * .417);
  font-size:calc(var(--ui-vmin, 7.2px) * 1.25);}
.shop-preview-status{text-align:center;color:var(--gl);font-size:calc(var(--ui-vmin, 7.2px) * 1.389);
  padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;}
/* Shown in place of the Buy button when the price is more than the
   player's current balance — see renderShopPreview. A plain statement, not
   an error: nothing was attempted, so nothing failed. */
.shop-preview-insufficient{text-align:center;color:rgba(224,128,128,.9);font-size:calc(var(--ui-vmin, 7.2px) * 1.25);
  padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;}

/* ── Friends panel & Notifications panel ── */
.friends-my-id{display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * 1.111);flex-wrap:wrap;
  margin-bottom:calc(var(--ui-vmin, 7.2px) * 2);}
.friends-my-id-label{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);letter-spacing:calc(var(--ui-vmin, 7.2px) * .417);
  color:var(--gold);text-transform:uppercase;opacity:.9;}
#friends-my-id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:calc(var(--ui-vmin, 7.2px) * 1.944);
  color:var(--cream);background:rgba(0,0,0,.25);border:1px solid rgba(212,175,110,.3);border-radius:2px;
  padding:calc(var(--ui-vmin, 7.2px) * .556) calc(var(--ui-vmin, 7.2px) * 1.111);user-select:all;letter-spacing:calc(var(--ui-vmin, 7.2px) * .139);}
#friends-copy-id{background:none;border:1px solid rgba(212,175,110,.4);border-radius:2px;
  color:var(--gold);font-size:calc(var(--ui-vmin, 7.2px) * 1.25);cursor:pointer;
  padding:calc(var(--ui-vmin, 7.2px) * .556) calc(var(--ui-vmin, 7.2px) * 1.111);font-family:'Noto Sans JP',sans-serif;}
/* Friends modal's two-tab strip — friends list vs. add-by-ID search.
   Same underline-tab language as Rankings' .rank-sort-tabs/.rank-period-tabs
   (shared bottom border, active tab picked out by a gold underline). The
   add tab (.friends-tab-icon) is a magnifier only; its label is in
   title/aria-label. Both flex:1 so "Friends" and the icon split the width
   evenly. */
.friends-tabs{display:flex;border-bottom:1px solid rgba(201,168,76,.2);
  margin-bottom:calc(var(--ui-vmin, 7.2px) * 1.111);}
.friends-tab{flex:1;display:flex;align-items:center;justify-content:center;
  padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;margin-bottom:-1px;
  background:none;border:none;border-bottom:2px solid transparent;
  font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin, 7.2px) * 1.389);
  color:rgba(245,237,216,.45);cursor:pointer;transition:all .2s;}
.friends-tab:hover{color:var(--gl);}
.friends-tab.active{color:var(--gold);border-bottom-color:var(--gold);}
/* The magnifier tab is label-less — let it shrink to its icon so the two
   text tabs ("Friends", "Recently Played") get the width instead of a
   three-way even split. */
.friends-tab-icon{flex:0 0 auto;padding-left:calc(var(--ui-vmin, 7.2px) * 2.4);padding-right:calc(var(--ui-vmin, 7.2px) * 2.4);}
.friends-tab-icon svg{width:calc(var(--ui-vmin, 7.2px) * 2.222);height:calc(var(--ui-vmin, 7.2px) * 2.222);display:block;}
/* Recently Played tab — the intro line, plus a muted "3d ago" after each
   name. .recent-when sits inside .friend-nm (a flex row) so it shares that
   row's baseline with the name. */
.friends-recent-hint{color:rgba(245,237,216,.5);font-size:calc(var(--ui-vmin, 7.2px) * 1.35);
  line-height:1.5;margin:calc(var(--ui-vmin, 7.2px) * .5) 0 calc(var(--ui-vmin, 7.2px) * 1.5);}
.recent-when{flex-shrink:0;color:rgba(245,237,216,.4);font-size:calc(var(--ui-vmin, 7.2px) * 1.15);
  font-family:'Noto Sans JP',sans-serif;font-weight:400;}
.friends-section{margin-top:calc(var(--ui-vmin, 7.2px) * 1.5);}
.friends-section-title,.friends-list-title{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);letter-spacing:calc(var(--ui-vmin, 7.2px) * .417);
  color:var(--gold);text-transform:uppercase;opacity:.9;margin:calc(var(--ui-vmin, 7.2px) * 1.5) 0 calc(var(--ui-vmin, 7.2px) * .833);}
.friends-list-title{margin-top:calc(var(--ui-vmin, 7.2px) * 2);}
.friends-empty{color:rgba(245,237,216,.5);font-size:calc(var(--ui-vmin, 7.2px) * 1.528);}
.friend-row{display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * 1.111);
  padding:calc(var(--ui-vmin, 7.2px) * .833) 0;border-bottom:1px solid rgba(212,175,110,.15);}
.friend-row-pending{opacity:.7;}
.friend-ic{flex-shrink:0;width:calc(var(--ui-vmin, 7.2px) * 3.333);height:calc(var(--ui-vmin, 7.2px) * 3.333);display:flex;align-items:center;justify-content:center;}
.friend-ic svg{width:100%;height:100%;display:block;}
.friend-nm{flex:1;min-width:0;color:var(--cream);font-size:calc(var(--ui-vmin, 7.2px) * 1.667);
  display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * .833);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.friend-dot{width:calc(var(--ui-vmin, 7.2px) * 1.111);height:calc(var(--ui-vmin, 7.2px) * 1.111);border-radius:50%;
  background:#4caf50;box-shadow:0 0 calc(var(--ui-vmin, 7.2px) * .833) #4caf50;flex-shrink:0;}
.friend-dot-busy{background:#d4af6e;box-shadow:none;}
.friend-actions{display:flex;gap:calc(var(--ui-vmin, 7.2px) * .694);flex-shrink:0;}
.friend-actions button{background:none;border:1px solid rgba(212,175,110,.4);border-radius:2px;
  color:var(--gold);font-size:calc(var(--ui-vmin, 7.2px) * 1.25);cursor:pointer;font-family:'Noto Sans JP',sans-serif;
  padding:calc(var(--ui-vmin, 7.2px) * .417) calc(var(--ui-vmin, 7.2px) * 1);white-space:nowrap;}
.friend-actions .friend-remove{border-color:rgba(224,96,96,.4);color:#e06060;}
.friend-pending{font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:rgba(245,237,216,.5);align-self:center;}

/* Online Friends modal — each row is a multi-select toggle (no checkbox
   control, the whole row is the hit target, same as the Invite Players
   picker's cards). .online-pick is the little hollow ring on the right that
   fills in when the row is selected. */
.online-row-pick{cursor:pointer;transition:background .15s;}
.online-row-pick:hover{background:rgba(212,175,110,.06);}
.friend-row.selected{background:rgba(201,168,76,.12);}
.online-pick{width:calc(var(--ui-vmin, 7.2px) * 1.8);height:calc(var(--ui-vmin, 7.2px) * 1.8);border-radius:50%;
  border:1.5px solid rgba(212,175,110,.5);box-sizing:border-box;display:block;align-self:center;transition:all .15s;}
.friend-row.selected .online-pick{background:var(--gold, #c9a84c);border-color:var(--gold, #c9a84c);
  box-shadow:0 0 calc(var(--ui-vmin, 7.2px) * .7) rgba(201,168,76,.6);}
#online-invite-btn{margin-top:calc(var(--ui-vmin, 7.2px) * 2);}
#online-invite-btn:disabled{opacity:.45;cursor:default;}
.notif-row{padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;border-bottom:1px solid rgba(212,175,110,.15);
  display:flex;flex-direction:column;gap:calc(var(--ui-vmin, 7.2px) * .833);}
.notif-row-unread{position:relative;}
.notif-row-unread::before{content:"";position:absolute;left:calc(var(--ui-vmin, 7.2px) * -1.111);top:calc(var(--ui-vmin, 7.2px) * 1.667);
  width:calc(var(--ui-vmin, 7.2px) * .833);height:calc(var(--ui-vmin, 7.2px) * .833);border-radius:50%;background:#c02020;}
.notif-text{color:var(--cream);font-size:calc(var(--ui-vmin, 7.2px) * 1.667);}
.notif-actions{display:flex;gap:calc(var(--ui-vmin, 7.2px) * .833);}
.notif-actions button{background:none;border:1px solid rgba(212,175,110,.4);border-radius:2px;
  color:var(--gold);font-size:calc(var(--ui-vmin, 7.2px) * 1.389);cursor:pointer;font-family:'Noto Sans JP',sans-serif;
  padding:calc(var(--ui-vmin, 7.2px) * .556) calc(var(--ui-vmin, 7.2px) * 1.389);}
.notif-actions .notif-decline{border-color:rgba(224,96,96,.4);color:#e06060;}

/* ＋ Add Friend inside the in-game nameplate popover */
.pp-addfriend{margin-top:calc(var(--ui-vmin, 7.2px) * 1.111);width:100%;
  background:none;border:1px solid rgba(212,175,110,.45);border-radius:2px;
  color:var(--gold);font-size:calc(var(--ui-vmin, 7.2px) * 1.528);cursor:pointer;font-family:'Noto Sans JP',sans-serif;
  padding:calc(var(--ui-vmin, 7.2px) * .833) 0;}
/* A flex item within .card-header-row (see index.html), alongside the
   header and Rankings/Match Settings, rather than absolutely anchored to
   .card's own top-left corner — .card-header-row's align-items:center is
   what lines up the ← glyph's vertical centre with the header text/icon
   svgs, not a shared "top" offset. Sits flush against .card's own
   (now-reduced) padding edge rather than clawing past it with a negative
   margin — see .card's padding comment. Only shown on #s-lobby (there's
   nowhere "back" from #s-start itself). */
.back-icon-btn{flex-shrink:0;display:flex;align-items:center;justify-content:center;
  background:none;border:none;color:#888;font-size:calc(var(--ui-vmin, 7.2px) * 2.778);cursor:pointer;}

/* ── Hand Advice ("Help") panel ── */
/* Transparent click-catcher, not a dimming overlay — clicking anywhere
   outside the panel closes it (same onclick wiring the .about-modal family
   uses) without darkening the board behind it, since this panel doesn't
   block the game the way a true modal does. */
.advice-backdrop-clear{display:none;position:fixed;inset:0;background:transparent;z-index:300;}
.advice-backdrop-clear.visible{display:block;}
/* Anchored so its bottom-left corner sits just above the ❓ help icon that
   opened it (see positionAdvicePanel in script.js, which sets left/top
   inline from the button's own getBoundingClientRect — the left/bottom
   here are only the pre-JS/fallback position) rather than screen-centered
   like .about-modal — a glassy floating panel popping up from the button
   that triggered it, same "frosted pane" language as .player-popover (the
   nameplate-click profile card): translucent tinted gradient + blur for
   the actual glass read, one soft inset highlight along the top edge only
   (not an all-over shine), backdrop-filter degrades to the flat background
   alone on browsers without support (still fully readable). Sized smaller
   than .about-modal on purpose (36 units, capped further by vw/vh below)
   — this is a compact popover next to the board, not a full panel. */
.advice-panel{--ui-vmin:clamp(6px,1vw,9px);
  display:none;position:fixed;left:20px;bottom:20px;
  width:calc(var(--ui-vmin) * 36);max-width:min(340px,calc(100vw - 24px));
  max-height:min(70vh,480px);overflow-y:auto;
  background:linear-gradient(180deg,rgba(20,32,22,.72),rgba(10,22,12,.72));
  -webkit-backdrop-filter:blur(14px) saturate(140%);backdrop-filter:blur(14px) saturate(140%);
  border:1px solid rgba(201,168,76,.32);border-radius:8px;
  padding:calc(var(--ui-vmin) * 2.778);
  box-shadow:0 8px 28px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.08);
  z-index:301;}
.advice-panel.visible{display:block;}
/* Rows/cards inside #advice-body — same --ui-vmin scale as the rest of the
   .about-modal family (inherited from that ancestor), not raw px. */
.advice-list{display:flex;flex-direction:column;gap:calc(var(--ui-vmin, 7.2px) * .556);}
.advice-row{background:rgba(201,168,76,.08);border:1px solid rgba(201,168,76,.2);border-radius:6px;
  padding:calc(var(--ui-vmin, 7.2px) * .833) calc(var(--ui-vmin, 7.2px) * 1.111);
  font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:rgba(245,237,216,.85);}
.advice-row-best{border-color:rgba(78,255,154,.5);background:rgba(78,255,154,.08);}
.advice-row-bad{border-color:rgba(255,120,120,.35);background:rgba(255,120,120,.06);opacity:.85;}
/* Sub-lines inside a "Should You Call?" row (see renderAdvice in
   script.js) — what taking that call would actually lead to, beneath the
   shanten-based recommend/reason line above it. */
.advice-call-yaku{display:flex;flex-wrap:wrap;align-items:center;gap:2px;
  margin-top:calc(var(--ui-vmin, 7.2px) * .417);font-size:calc(var(--ui-vmin, 7.2px) * 1.25);}
.advice-call-warning{margin-top:calc(var(--ui-vmin, 7.2px) * .417);color:#ff9a9a;
  font-size:calc(var(--ui-vmin, 7.2px) * 1.25);}
.advice-call-potential{margin-top:calc(var(--ui-vmin, 7.2px) * .417);display:flex;flex-direction:column;gap:2px;}
.advice-call-potential-item{font-size:calc(var(--ui-vmin, 7.2px) * 1.2);color:rgba(245,237,216,.65);}
/* "Gives up potential: X" line under a discard candidate row (see
   renderAdvice in script.js) — amber, same "be careful" language as
   .advice-row-warn, just scoped to inside a single row instead of its own. */
.advice-lost-yaku{margin-top:calc(var(--ui-vmin, 7.2px) * .417);font-size:calc(var(--ui-vmin, 7.2px) * 1.2);color:#ffcf80;}
.advice-wait{background:rgba(201,168,76,.08);border:1px solid rgba(201,168,76,.2);border-radius:6px;
  padding:calc(var(--ui-vmin, 7.2px) * .833) calc(var(--ui-vmin, 7.2px) * 1.111);margin-bottom:calc(var(--ui-vmin, 7.2px) * .556);}
.advice-wait-tile{display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * .556);
  font-family:'Cinzel Decorative',serif;color:var(--gold);font-size:calc(var(--ui-vmin, 7.2px) * 1.528);}
.advice-wait-score{font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:rgba(245,237,216,.7);margin-top:2px;}
.advice-wait-yaku{display:flex;flex-wrap:wrap;gap:2px;margin-top:calc(var(--ui-vmin, 7.2px) * .417);}
/* Mini tile icon inline within a text row — used by Yaku Reference's
   tile-art cards (renderYakuRefCard/miniTileHTML in script.js). Vertical-
   align keeps it level with the surrounding text baseline instead of
   sitting high/low like a bare inline SVG normally does. */
.advice-mini-tile{display:inline-flex;vertical-align:middle;margin-right:calc(var(--ui-vmin, 7.2px) * .556);}

/* ── Jargon glossary popups ──
   Beginner-facing mahjong terms inside the Help panel (renderAdvice in
   script.js) are wrapped as .jargon-term spans (see jt()/jtYaku()/
   jargonizeText() there) — a dotted underline marks them clickable without
   shouting over the surrounding advice text. Clicking one opens
   #jargon-popup, positioned next to it (positionJargonPopup) the same
   anchor-below/flip-above/clamp-to-viewport way #player-popover anchors to
   a clicked nameplate. */
.jargon-term{cursor:pointer;border-bottom:1px dotted rgba(201,168,76,.75);
  -webkit-tap-highlight-color:transparent;}
.jargon-term:hover,.jargon-term:focus-visible{color:var(--gl);border-bottom-color:var(--gl);outline:none;}
/* Yaku pills (.yc) already read as a distinct, clickable-looking chip on
   their own — swap the dotted underline for a filled hover instead of
   layering both treatments on top of each other. */
.yc.jargon-term{border-bottom:none;}
.yc.jargon-term:hover,.yc.jargon-term:focus-visible{background:rgba(201,168,76,.22);color:var(--cream);}
/* Same frosted-glass recipe as .advice-panel/.player-popover, just sized
   for one short definition. z-index sits above the advice panel (301) that
   spawns it. */
/* No display:none here — same convention as .player-popover: this class's
   own default is visible (flex), and the element starts (and gets
   re-hidden) via the plain inline style="display:none" in index.html /
   closeJargonPopup, not a .visible modifier class. */
.jargon-popup{--ui-vmin:clamp(6px,1vw,9px);
  position:fixed;display:flex;flex-direction:column;
  width:calc(var(--ui-vmin) * 28);max-width:min(300px,calc(100vw - 24px));
  background:linear-gradient(180deg,rgba(20,32,22,.8),rgba(10,22,12,.8));
  -webkit-backdrop-filter:blur(16px) saturate(150%);backdrop-filter:blur(16px) saturate(150%);
  border:1px solid rgba(201,168,76,.4);border-radius:8px;
  padding:calc(var(--ui-vmin) * 1.8) calc(var(--ui-vmin) * 2);
  box-shadow:0 10px 32px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.1);
  z-index:400;}
.jargon-popup-head{display:flex;align-items:baseline;gap:calc(var(--ui-vmin) * .8);
  margin-bottom:calc(var(--ui-vmin) * 1);}
#jargon-popup-term{font-weight:700;color:var(--gl);font-size:calc(var(--ui-vmin) * 1.7);flex:1;}
.jargon-popup-close{background:none;border:none;color:rgba(245,237,216,.6);
  font-size:calc(var(--ui-vmin) * 1.6);cursor:pointer;line-height:1;padding:0;}
.jargon-popup-close:hover{color:var(--cream);}
.jargon-popup-desc{font-size:calc(var(--ui-vmin) * 1.3);line-height:1.5;color:rgba(245,237,216,.88);}
/* Beginner traps/opportunities row (furiten/kita/riichi/danger — see
   renderAdvice in script.js), amber for "be careful" — distinct from
   .advice-row-best's green "do this" and .advice-row-bad's red "don't". */
.advice-row-warn{border-color:rgba(255,200,100,.4);background:rgba(255,200,100,.08);}

/* ── Magnifier (see openMagnify/renderMagnifyHand in script.js) ── */
/* A genuinely DIMMING full-screen overlay (unlike .advice-panel's
   transparent click-catcher) — this one's meant to draw full attention
   onto the enlarged hand, not sit alongside the board like a popover.
   Centers its content both ways so the hand reads as the sole focus
   regardless of how many tiles it has to lay out. */
.magnify-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);
  z-index:400;align-items:center;justify-content:center;cursor:pointer;}
.magnify-overlay.visible{display:flex;}
/* Own cursor:default (overriding the overlay's pointer-shows-it's-clickable
   cursor above) since this specific area either does nothing on click
   (stopPropagation, see index.html) or discards a tile — neither reads as
   "click to dismiss" the way the dimmed background around it does.
   max-width here is a pre-JS/no-JS fallback only — renderMagnifyHand sets
   it inline to the table's actual pixel width every render, which is the
   real constraint ("only the width of the table", not just the viewport).
   transform-origin:center (not the box default) so the pan-out/zoom-in
   open/close animation (openMagnify/closeMagnify in script.js) scales
   from the content's own center, matching how the translate offset in
   that animation is computed (center-to-center against #hdb). */
.magnify-content{display:flex;flex-direction:column;align-items:center;
  max-width:96vw;cursor:default;transform-origin:center;}
/* 壱/弐 row-count toggle — pinned next to the ✕ close button, top-right
   (a sibling of #magnify-content, not a child — see index.html's comment)
   rather than flowing right below the tiles, specifically so it stays put
   when switching modes: #magnify-content is vertically centered by
   .magnify-overlay, and its height changes between 1 row and 2 rows of
   tiles, which used to visibly shift the toggle along with it every time
   it was part of that flow. Boxed in its own small panel now that it's
   floating independently over the dimmed backdrop rather than sitting
   directly under the tile group — a bare row of text there would've read
   as a stray floating label with nothing to anchor it. right:70px lines
   its right edge up just left of .magnify-close's own right:20px (20px
   button + ~30px clearance). Each button still picks itself out with its
   own underline when active, same tab-strip language as elsewhere. 壱/弐
   (the formal/"daiji" forms of 一/二, read "ichi"/"ni") fit the game's
   existing Japanese-first labeling (see .about-title, tile glyphs, etc). */
.magnify-mode-toggle{position:fixed;top:16px;right:70px;
  z-index:401;display:flex;gap:2px;cursor:default;
  background:rgba(20,32,22,.85);border:1px solid rgba(201,168,76,.3);
  border-radius:6px;padding:3px;box-shadow:0 4px 16px rgba(0,0,0,.5);}
.magnify-mode-btn{padding:4px 14px;background:none;border:none;border-radius:4px;
  border-bottom:2px solid transparent;font-family:'Cinzel Decorative',serif;
  font-size:15px;letter-spacing:1px;color:rgba(245,237,216,.45);cursor:pointer;transition:all .2s;}
.magnify-mode-btn:hover{color:var(--gl);}
.magnify-mode-btn.active{color:var(--gold);border-bottom-color:var(--gold);background:rgba(201,168,76,.12);}
/* Rows stack vertically in .magnify-hand (1 child row in 壱 mode, 2 in
   弐); each .magnify-row is its own horizontal strip — `tile SIZE is
   computed in script.js from the table's actual width divided across
   however many tiles the widest row holds, so both modes fill the table
   edge-to-edge rather than shrinking to fit an arbitrary flex-wrap. Both
   the row and column gap below are only a pre-JS fallback — script.js
   sets the real gap inline (as an exact px value that its own tile-width
   budget math already accounts for), since a mismatch between the two was
   exactly what pushed the outermost tiles past the table's edge before. */
/* padding-top reserves room for a selected tile's own lift (.tlg.sel's
   translateY(-1vmin)) — transforms don't affect layout/box size, so
   without this the lifted tile pokes above .magnify-hand's own top edge
   and overflow-y:auto (needed as a safety net for tall content) clips it,
   since a scrollport's static bounds sit exactly at the container's own
   padding-box, not wherever a transformed child visually ends up. */
/* overflow-x:hidden is NOT decorative — leaving it unset (default
   'visible') while overflow-y is 'auto' hits a CSS spec quirk where the
   'visible' axis silently computes to 'auto' too whenever the other axis
   isn't 'visible', which was producing an unwanted HORIZONTAL scrollbar
   the instant tile sizing rounded even 1px over. Tile width is already
   computed in script.js to fit with no overflow at all, so this is a hard
   guarantee, not a fallback. */
.magnify-hand{display:flex;flex-direction:column;gap:30px;padding-top:16px;
  max-width:96vw;max-height:78vh;overflow-y:auto;overflow-x:hidden;}
.magnify-row{display:flex;gap:3px;justify-content:center;align-items:flex-end;}
.magnify-close{position:fixed;top:20px;right:20px;background:none;border:none;
  color:rgba(245,237,216,.85);font-size:28px;line-height:1;cursor:pointer;z-index:401;
  transition:color .2s;}
.magnify-close:hover{color:var(--gold);}

/* ── Yaku Reference cards (see renderYakuRefCard in script.js) ── */
/* Each yaku gets its own card with real tile art showing the shape, not
   just a name/han pill — a beginner can see what the pattern actually
   looks like instead of having to picture it from the Japanese term. */
.yakuref-list{display:flex;flex-direction:column;gap:calc(var(--ui-vmin, 7.2px) * .833);}
.yakuref-card{background:rgba(201,168,76,.08);border:1px solid rgba(201,168,76,.2);border-radius:8px;
  padding:calc(var(--ui-vmin, 7.2px) * .972) calc(var(--ui-vmin, 7.2px) * 1.111);}
.yakuref-card-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:calc(var(--ui-vmin, 7.2px) * .417);
  font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:var(--cream);margin-bottom:calc(var(--ui-vmin, 7.2px) * .694);}
/* nowrap (not wrap) — script.js's fitYakuRefTiles sizes every tile to
   actually fit the card's rendered width in one line, so wrapping should
   never be needed; nowrap+hidden here is a hard guarantee of that (a
   backstop against a stray rounding pixel), not a fallback layout. */
.yakuref-tiles{display:flex;flex-wrap:nowrap;overflow:hidden;gap:2px;margin-bottom:calc(var(--ui-vmin, 7.2px) * .694);}
/* The tiles that specifically MAKE the yaku (see `highlight` in
   YAKU_REFERENCE) render at full brightness; everything else dims/
   desaturates instead — that contrast alone reads as "this subset is the
   yaku, everything else is just filling out a legal 14-tile hand around
   it", with no extra outline/frame needed on the highlighted tiles
   themselves. Whole-hand yaku highlight every tile, so nothing dims. */
.yakuref-tile-dim{opacity:.4;filter:saturate(.5);}
/* Menzen (closed-hand) requirement badge — pushed to the row's right edge
   via margin-left:auto so it doesn't crowd the name/han/jp on the left.
   Three colors matching how much the open/closed distinction actually
   matters for that yaku (see YAKU_MENZEN_BADGE in script.js): red for a
   hard requirement, amber for "scores less open", green for "no penalty
   either way". */
.yakuref-menzen-badge{margin-left:auto;padding:calc(var(--ui-vmin, 7.2px) * .278) calc(var(--ui-vmin, 7.2px) * .694);
  border-radius:999px;font-size:calc(var(--ui-vmin, 7.2px) * 1.111);white-space:nowrap;}
.yakuref-menzen-required{background:rgba(255,120,120,.15);border:1px solid rgba(255,120,120,.4);color:#ff9a9a;}
.yakuref-menzen-bonus{background:rgba(255,200,100,.15);border:1px solid rgba(255,200,100,.4);color:#ffcf80;}
.yakuref-menzen-any{background:rgba(120,255,154,.15);border:1px solid rgba(120,255,154,.4);color:#8fffb0;}
/* Situational yaku (Riichi, Ippatsu, Tenhou, ...) have no tile shape at
   all — the description below stands in for the tile row entirely, so the
   card still tells you why there's nothing to draw rather than looking
   broken/empty. */
.yakuref-desc{font-size:calc(var(--ui-vmin, 7.2px) * 1.25);color:rgba(245,237,216,.65);line-height:1.5;}

/* ── Topbar inline elements ── */
.tb-right .settings-icon-btn{position:static;z-index:auto;}
.tb-mode-label{font-size:calc(var(--ui-vmin) * 1.389);opacity:.8;margin-left:6px;}
.tb-version{font-size:calc(var(--ui-vmin) * 1.25);opacity:.65;margin-left:6px;letter-spacing:calc(var(--ui-vmin) * .139);font-family:var(--font-sans);}
.about-version{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);opacity:.95;margin-left:calc(var(--ui-vmin, 7.2px) * .833);letter-spacing:calc(var(--ui-vmin, 7.2px) * .139);font-family:var(--font-sans);}
.tb-right{display:flex;align-items:center;gap:6px;}

/* ── Game loading overlay ── */
#game-loading{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.85);
  display:none;flex-direction:column;align-items:center;justify-content:center;gap:18px;}
.gl-kanji{font-family:'Noto Serif JP',serif;font-size:clamp(28px,6vmin,48px);
  font-weight:900;color:var(--gold);letter-spacing:4px;animation:pulse 1.2s ease-in-out infinite;}
.gl-dots{display:flex;gap:10px;}
/* clamp(px,vmin,px) like .gl-kanji above, not a flat size — otherwise this
   stops shrinking with the "配牌中" kanji above it once the window gets
   small, throwing the two out of proportion with each other. */
.gl-label{font-size:clamp(9px,2vmin,13px);letter-spacing:3px;color:rgba(245,237,216,.45);font-family:'Cinzel Decorative',serif;}

/* ── Centre score positions ── */
.ctr-score-top{position:absolute;top:0;transform:rotate(180deg);z-index:1;}
.ctr-score-left{position:absolute;left:-40%;transform:rotate(90deg);z-index:4;clip-path:inset(0 0 0 20%);}
.ctr-score-right{position:absolute;right:-40%;transform:rotate(-90deg);z-index:2;}
.ctr-score-bottom{position:absolute;bottom:0;z-index:3;}
.ctr-mid-rel{position:absolute;}

/* ── Riichi stick positions ── */
/* top:50% + translateY(-50%) instead of a bottom:0.3vmin anchor — the old
   anchor left the stick offset from its row's centre (.ctr-score, the
   same box for all four sides; rotate(90/-90deg) on .ctr-score-left/
   -right just remaps which screen axis "vertical" ends up on, so the
   same bottom-anchor offset showed up as a Y gap for top/bottom seats
   and an X gap for left/right seats). Percentage-based positioning is
   resolution-independent by construction — no vmin/px value to fall out
   of proportion at another screen size. (Pixel-matching this to
   .ctr-score-val's own padding-shifted centre made it sit noticeably
   high in practice, so this centers on the row's own box instead.) */
.rs-bottom{display:none;position:absolute;top:50%;right:1.45vmin;transform:translateY(-50%);z-index:16;}
.rs-top{display:none;position:absolute;top:50%;right:1.45vmin;transform:translateY(-50%);z-index:16;}
.rs-left{display:none;position:absolute;top:50%;right:1.45vmin;transform:translateY(-50%);z-index:16;}
.rs-right{display:none;position:absolute;top:50%;right:1.45vmin;transform:translateY(-50%);z-index:16;}

/* ── Riichi stick size ── */
.rs-bottom svg{width:6vmin;height:auto;display:block;}
.rs-top svg{width:6vmin;height:auto;display:block;}
.rs-left svg{width:6vmin;height:auto;display:block;}
.rs-right svg{width:6vmin;height:auto;display:block;}

/* ── Meld containers ── */
.ml-b{position:absolute;bottom:0;right:0;display:flex;flex-direction:row;align-items:flex-end;z-index:15;}
.ml-t{position:absolute;top:0;left:0;display:flex;flex-direction:row;align-items:flex-end;transform:rotate(180deg);transform-origin:center center;z-index:15;}
.ml-l{position:absolute;bottom:0;left:0;display:flex;flex-direction:row;align-items:flex-end;transform:rotate(90deg) translateX(-100%);transform-origin:bottom left;z-index:15;}
.ml-r{position:absolute;top:0;right:0;display:flex;flex-direction:row;align-items:flex-end;transform:rotate(-90deg) translateY(-100%);transform-origin:top right;z-index:15;}

/* ── Nameplates positions ── */
.np-wrap-b{position:absolute;bottom:calc(var(--tlg-h) + 3vmin);left:15%;display:flex;gap:1vmin;align-items:flex-end;}
.np-inner{display:flex;align-items:center;position:relative;}
.actbar{transform:translateY(-50%);display:flex;align-items:center;gap:1vmin;white-space:nowrap;}
.np-wrap-t{position:absolute;top:calc(var(--tsm-h) + 3vmin);right:15%;transform:rotate(180deg);z-index:20;}
.np-wrap-l{position:absolute;top:15%;left:calc(var(--tsm-h) + 3vmin);transform:translateX(-50%) rotate(90deg);transform-origin:center bottom;z-index:20;}
.np-wrap-r{position:absolute;bottom:15%;right:calc(var(--tsm-h) + 3vmin);transform:translateX(50%) rotate(90deg);transform-origin:center top;z-index:20;}
.np-flip{transform:rotate(180deg); transform-origin:center center;}

/* ── Kita strip (sanma only) — banked North (pei-nuki) tiles. Each seat's
   strip is rotated to FACE that seat, like its discard pile / hand / melds
   (left +90°, right -90°), and every strip starts from the felt's CENTRE
   line and grows outward toward that seat's far corner — exactly like the
   bottom seat (which starts at left:50% and grows right). Starting at the
   centre means the strip can never reach the nameplate no matter how long
   the player's name is.
   It also FILLS the way that seat's discard pile does: tiles are appended
   oldest-first (see renderState) and the OLDEST tile is the fixed anchor —
   new tiles extend away from it and nothing already placed moves.
     - bottom seat: oldest at centre, grows right;
     - left seat: oldest at centre, grows down (its discards run top→bottom);
     - right seat: oldest at centre, grows up, so the newest is on top
       (its discards run bottom→top — the mirror of the left).
   The earlier version pinned the NEWEST tile and let every older tile jump
   to make room on each new kita, which read as the strip filling backwards
   ("right to left") for both opponents. Plain `flex-direction:row` with the
   near edge pinned (via `left`, and a `transform-origin` on that same edge,
   NOT the width-scaled translate the old rule used) keeps the oldest tile
   still. ── */
/* z-index below the nameplates (.np-wrap-*, z-index:20): starting at the
   centre keeps the strip well clear of the nameplate in normal play, but
   should a corner case ever bring them together the nameplate must stay
   readable on top. Still above the melds (.ml-*, z-index:15) — a side
   strip grown to 4 tiles can just reach a full 4-meld row. */
.kita-strip{position:absolute;display:flex;flex-direction:row;gap:0;align-items:flex-end;z-index:16;}
.kita-strip:empty{display:none;}
/* Bottom seat: anchored at the felt's horizontal CENTRE (left:50%) rather
   than tucked close to the nameplate — .pname has no max-width of its own
   (grows with the player's name up to the 20-char profile limit), so a
   closer anchor risks the two overlapping on a narrow screen with a long
   name. 50% still leaves the whole right half of the row for tiles to grow
   into. max-width hard-caps growth before it reaches the right player's
   nameplate corner regardless of tile count or screen size (4 tiles is the
   realistic case; this also covers a rarer 5th+); overflow-x auto (not
   hidden) keeps an actual overflow reachable by scrolling rather than
   silently clipped. */
.kita-b{bottom:calc(var(--tlg-h) + 3vmin);left:50%;max-width:38%;overflow-x:auto;overflow-y:hidden;}
/* kita-t: the top seat never exists in sanma (.table.sanma .kita-t is
   display:none) and kita only ever renders in sanma, so this is dead —
   kept only so the element still has a sane position if that changes. */
.kita-t{top:calc(var(--tsm-h) + 3vmin);left:15%;transform:rotate(180deg);}
/* Left seat: oldest tile's top edge on the felt's vertical centre, strip
   grows straight DOWN toward the bottom-left corner (nameplate is up at
   top:15%, so it grows away from it). Rotate about the bottom-left corner;
   the -var(--tmd-h) in `top` cancels this rule's own one-tile render
   offset so the oldest tile lands exactly on 50%. */
.kita-l{top:calc(50% - var(--tmd-h));left:calc(var(--tsm-h) + 3vmin);transform:rotate(90deg);transform-origin:bottom left;}
/* Right seat: oldest tile's bottom edge on the felt's vertical centre,
   strip grows straight UP toward the top-right corner (nameplate is down
   at bottom:15%). Rotate about the top-left corner so it grows up from a
   fixed oldest tile; `left` (not `right`) so that oldest tile — the first
   flex child — is the pinned one, and -var(--tmd-h) keeps the rotated
   strip's far edge on the felt. */
.kita-r{top:50%;left:calc(100% - (var(--tsm-h) + 3vmin) - var(--tmd-h));transform:rotate(-90deg);transform-origin:top left;}
.kita-strip .mg{outline:1.5px solid var(--gold, #c9a84c);outline-offset:-1px;border-radius:2px;}

/* ── Dora panel ── */
/* --ui-vmin: same clamped-scale-unit approach as #s-start/#s-lobby, so
   padding/font-size/margins stay proportional to each other at any
   resolution instead of drifting apart. .dora-slot's own tile size isn't
   touched here — it already comes from --tlg-w/h, computed in JS as a
   fraction of the felt's own (already-consistent) size, not px/clamp. */
/* flex-direction:column lives here (not set inline from JS) specifically
   so the panel has the same shape the instant it's shown — several call
   sites toggle `style.display='flex'` (game_start's loading state,
   round_start, ...) and only renderState() used to also set flexDirection,
   so the panel briefly rendered its two rows (slots/sticks) SIDE BY SIDE
   during the loading overlay before snapping to the stacked layout once
   the first game_state arrived. */
#dora-panel{--ui-vmin:clamp(6px,1vmin,10px);
  position:fixed;top:44px;left:8px;z-index:80;pointer-events:none;
  background:rgba(8,25,12,.97);border:2px solid rgba(201,168,76,.5);border-radius:12px;
  padding:calc(var(--ui-vmin) * 1.944) calc(var(--ui-vmin) * 2.222) calc(var(--ui-vmin) * 2.222);
  display:none;flex-direction:column;
  max-height:calc(100vh - 54px);overflow-y:auto;
  box-shadow:0 4px 24px rgba(0,0,0,.8),inset 0 1px 0 rgba(201,168,76,.2);}
.dora-slots{display:flex;gap:6px;align-items:flex-end;}
/* Live riichi-stick/honba pot, shown alongside the dora indicators for the
   duration of the round (unlike .re-carryover on the round-end screen,
   which reports what carries into the NEXT round) — populated in
   renderState() from the same game_state fields. Always visible (counts
   start from 0), unlike .re-carryover which hides when there's nothing to
   report — this is a persistent live counter, not a one-off callout. */
.dora-sticks{display:flex;gap:calc(var(--ui-vmin) * 2.5);align-items:center;justify-content:center;
  margin-top:calc(var(--ui-vmin) * 1.111);padding-top:calc(var(--ui-vmin) * 1.111);
  border-top:1px solid rgba(201,168,76,.25);}
.stick-item{display:flex;align-items:center;gap:6px;}
/* A stick's own shape/gradient mirrors .tlg's tile styling (cream face,
   gold border) for visual consistency, kept nearly rectangular (a small
   corner radius, not a pill) to read as a physical wooden stick. The
   riichi stick's red centre dot mirrors the aka-dora red marker already
   drawn on tiles (see svgMan's `marker` in script.js) — same "red mark =
   special/valuable" convention. Real 100-point sticks have no red mark at
   all (see the honba::after 4x2 black-dot grid below), which is exactly
   why it's the visually "odd one out" of a real riichi stick set. */
.stick{position:relative;flex-shrink:0;width:44px;height:9px;border-radius:1.5px;
  background:linear-gradient(170deg,#fffdf4,#f0e8d0);border:1px solid #c8a84a;
  box-shadow:0 1px 2px rgba(0,0,0,.4);}
.stick-riichi::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
  width:4px;height:4px;border-radius:50%;background:#c82020;}
/* 4 columns x 2 rows of small black dots — one real dot (top-left of the
   grid) plus 7 box-shadow copies at column/row offsets, a standard
   single-element "dot grid" trick (each shadow inherits the base dot's
   border-radius, so every copy renders as a circle too). Positioned
   relative to the stick's PADDING box (border excluded from an absolutely
   positioned child's containing block) — with box-sizing:border-box
   (global reset) .stick's 44x9px already includes its 1px border, so the
   real interior to fit dots in is 42x7px, not 44x9. Real 100-point sticks
   cluster the dots tightly in the middle third of the stick rather than
   spreading them across the full length — columns at 16/19/22/25 (pitch 3,
   centered in the 42px interior) and rows at 1.5/4.5 (pitch 3, centered
   in the 7px interior) reproduce that tight, centered grid. */
.stick-honba::after{content:'';position:absolute;top:1.5px;left:16px;width:1.5px;height:1.5px;border-radius:50%;
  background:#1a1a1a;
  box-shadow:3px 0 0 #1a1a1a,6px 0 0 #1a1a1a,9px 0 0 #1a1a1a,
             0 3px 0 #1a1a1a,3px 3px 0 #1a1a1a,6px 3px 0 #1a1a1a,9px 3px 0 #1a1a1a;}
.stick-count{font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin) * 1.4);color:var(--gold);font-weight:700;}

/* ── Round end screen ── */
.reb-btn{max-width:calc(var(--ui-vmin, 7.2px) * 27.78);}
.ready-status{margin-top:8px;font-size:calc(var(--ui-vmin, 7.2px) * 1.375);color:rgba(245,237,216,.5);letter-spacing:1px;text-align:center;}
/* Game-over action row: Play Again (+ host-only Start New Game) on the
   left, Leave Room on the right — a plain .reb (no flex, see its own
   lack-of-rule comment further down) is fine for the single-button Ready/
   View Results states, but a left/right split needs an explicit flex row.
   Scoped to its own wrapper (.reb-split) rather than making .reb itself
   flex, so those single-button states keep their existing centered look
   instead of collapsing to the flex-row start edge. */
/* flex-wrap:wrap stays as a last-resort safety net, but .reb-left and
   Leave Room are no longer hard-pinned to a fixed size below it (see their
   own rules) — they were both flex-shrink:0 at a fixed --ui-vmin-based
   width, which stopped shrinking the moment --ui-vmin hit its 6px floor
   (see #s-end's own comment) even though .rec kept shrinking past that
   point via its 96vw cap. That mismatch — fixed content demand vs.
   shrinking available width — was exactly what forced Leave Room onto its
   own wrapped row on ordinary phone widths (~360-414px), not just on
   genuinely tiny screens. Both items now shrink together to stay on one
   row as long as there's any reasonable room at all; wrap only kicks in
   as the true last resort on extreme widths. */
.reb-split{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:clamp(6px, 3vw, 12px);width:100%;}
/* clamp() floor (165px) is "▶ Start New Game" 's real single-line minimum
   at this row's font-size (the longest label here) — below that,
   shrinking further wraps it onto 2 lines instead, which reads worse than
   the row-wrap this whole fix is meant to avoid. Above the floor,
   flex-shrink:1 lets this column compress alongside .reb-split's
   available width; flex-basis (the clamp's preferred value) still targets
   the original 30-unit width whenever there's room for it, and
   flex-grow:0 keeps it from ballooning past that on wide screens (a plain
   flex:1 here fought .reb-split's justify-content:space-between by eating
   the gap itself instead of leaving it as empty space). min-width matches
   the floor so flexbox's own content-based auto-minimum can't override
   the clamp. Only below ~340px content width (an already-legacy phone
   size, e.g. the 320px iPhone SE) does honoring this floor leave no room
   for Leave Room beside it — .reb-split's flex-wrap then takes over as
   the last-resort fallback exactly as it already did before this fix. */
.reb-left{display:flex;flex-direction:column;gap:10px;flex:0 1 clamp(176px, 38vw, calc(var(--ui-vmin, 7.2px) * 30));min-width:176px;}
#bplay-again-start{max-width:100%;}
/* Distinct colour per action so Play Again/Start New Game/Leave Room read
   apart at a glance instead of all sharing .bp's same jade pill. */
.reb-btn-green{background:linear-gradient(135deg,var(--jade),var(--jd));border:1px solid var(--jl);color:var(--cream);}
.reb-btn-green:hover{box-shadow:0 10px 28px rgba(26,107,69,.55);border-color:var(--gold);}
.reb-btn-gold{background:linear-gradient(135deg,var(--gold),var(--gd));border:1px solid var(--gl);color:#241a04;}
.reb-btn-gold:hover{box-shadow:0 10px 28px rgba(201,168,76,.5);border-color:var(--gl);}
/* width/flex overrides (not just colour): unlike the left-side buttons
   above, which fill .reb-left's column via .bp's own width:100%, this one
   sits alone as a direct .reb-split flex item and should size to its own
   label instead of stretching to match that column's width. Horizontal
   padding uses the same clamp(...,vw,...) shrink as .rec's own padding
   (see its comment) instead of a pure --ui-vmin value, so it keeps giving
   up room on narrow phone widths rather than freezing at the vmin floor —
   same fix as .reb-left, for the same "everything stayed fixed-size past
   the floor" reason. flex-shrink:1 (not 0) lets it compress alongside
   .reb-left rather than being the one item forcing the wrap. */
.reb-btn-red{width:auto;flex:0 1 auto;min-width:0;white-space:nowrap;
  padding-left:clamp(10px, 2.5vw, calc(var(--ui-vmin, 7.2px) * 2.5));padding-right:clamp(10px, 2.5vw, calc(var(--ui-vmin, 7.2px) * 2.5));
  background:linear-gradient(135deg,rgba(120,20,20,.9),rgba(60,10,10,.95));border:1px solid rgba(200,60,60,.6);color:rgba(255,214,214,.95);}
.reb-btn-red:hover{box-shadow:0 10px 28px rgba(160,30,30,.45);border-color:rgba(255,120,120,.8);}

/* ── Mode button sub-label ── */
.mode-sub{font-size:calc(var(--ui-vmin, 7.2px) * 1.25);letter-spacing:calc(var(--ui-vmin, 7.2px) * .139);opacity:.9;}

/* ── Lobby mode row ── */
.mode-row{display:flex;gap:8px;margin-top:4px;}

/* ── Play History (User Profile hub) ────────────────────────────────── */
/* Wider than the default .about-modal (round-detail cards need room for a
   full hand's worth of tiles on one line — see buildHistoryRoundCard's own
   fit-sizing pass in script.js, which caps tile size to whatever width
   actually ends up available here). */
#playhistory-modal{width:calc(var(--ui-vmin) * 70);}
.ph-game-row{border-bottom:1px solid rgba(201,168,76,.12);}
.ph-game-row:last-child{border-bottom:none;}
.ph-game-head{display:flex;align-items:center;gap:calc(var(--ui-vmin, 7.2px) * 1.111);
  padding:calc(var(--ui-vmin, 7.2px) * 1.111) 0;cursor:pointer;}
/* Field order top-to-bottom: game rule + date/time sharing one line, then
   every player's placement+username+points (own row highlighted) — see
   renderPlayHistory in script.js. */
.ph-game-info{flex:1;display:flex;flex-direction:column;gap:calc(var(--ui-vmin, 7.2px) * .417);min-width:0;}
.ph-game-topline{display:flex;justify-content:space-between;align-items:baseline;gap:calc(var(--ui-vmin, 7.2px) * .833);}
.ph-game-rule{flex:1;min-width:0;font-size:calc(var(--ui-vmin, 7.2px) * 1.25);letter-spacing:calc(var(--ui-vmin, 7.2px) * .139);
  color:rgba(201,168,76,.85);text-transform:uppercase;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* 2 columns × N rows (2 rows for yonma's 4 seats, 2+1 for sanma's 3) rather
   than one player per line — a 4-seat game standing on 4 stacked lines was
   the single biggest driver of this modal's scroll height. */
.ph-game-standings{display:grid;grid-template-columns:1fr 1fr;
  column-gap:calc(var(--ui-vmin, 7.2px) * .833);row-gap:calc(var(--ui-vmin, 7.2px) * .278);}
.ph-standing-row{display:flex;align-items:baseline;gap:calc(var(--ui-vmin, 7.2px) * .556);min-width:0;
  padding:calc(var(--ui-vmin, 7.2px) * .139) calc(var(--ui-vmin, 7.2px) * .556);border-radius:calc(var(--ui-vmin, 7.2px) * .417);}
/* The viewing profile's own seat in the standings — same gold-tinted
   highlight language as .sp-row-mine in Rankings, so "which row is me"
   reads the same way it does elsewhere in the app. */
.ph-standing-mine{background:rgba(201,168,76,.12);border:1px solid rgba(201,168,76,.3);}
.ph-standing-mine .ph-game-name{color:var(--gold);font-weight:700;}
.ph-game-place{font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin, 7.2px) * 1.25);
  font-weight:700;color:rgba(245,237,216,.5);flex:0 0 calc(var(--ui-vmin, 7.2px) * 2.5);}
.ph-game-place.place-1{color:#f0d080;}
.ph-game-place.place-2{color:#c0c0c0;}
.ph-game-place.place-3{color:#cd7f32;}
.ph-game-name{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:var(--cream);flex:1;min-width:0;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ph-game-score{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);font-weight:700;color:var(--gl);}
.ph-game-date{font-size:calc(var(--ui-vmin, 7.2px) * 1.111);color:rgba(245,237,216,.4);flex:0 0 auto;white-space:nowrap;}
.ph-game-chevron{flex:0 0 auto;background:none;border:none;padding:calc(var(--ui-vmin, 7.2px) * .556);
  font-size:calc(var(--ui-vmin, 7.2px) * 1.667);color:var(--gold);cursor:pointer;transition:transform .2s;line-height:1;}
.ph-game-row.open .ph-game-chevron{transform:rotate(180deg);}
.ph-game-detail{display:none;padding:0 0 calc(var(--ui-vmin, 7.2px) * 1.389);}
.ph-game-row.open .ph-game-detail{display:block;}
.ph-loading{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:rgba(245,237,216,.5);}
.ph-round-card{background:rgba(0,0,0,.2);border:1px solid rgba(201,168,76,.15);border-radius:calc(var(--ui-vmin, 7.2px) * .694);
  padding:calc(var(--ui-vmin, 7.2px) * 1.111);margin-bottom:calc(var(--ui-vmin, 7.2px) * .833);}
.ph-round-card:last-child{margin-bottom:0;}
.ph-round-head{display:flex;justify-content:space-between;align-items:baseline;gap:calc(var(--ui-vmin, 7.2px) * .833);
  margin-bottom:calc(var(--ui-vmin, 7.2px) * .694);}
.ph-round-kyoku{font-family:'Cinzel Decorative',serif;font-size:calc(var(--ui-vmin, 7.2px) * 1.389);color:var(--gold);}
.ph-round-reason{font-size:calc(var(--ui-vmin, 7.2px) * 1.111);color:rgba(245,237,216,.5);}
.ph-round-info{font-size:calc(var(--ui-vmin, 7.2px) * 1.389);margin-bottom:calc(var(--ui-vmin, 7.2px) * .694);}
.ph-yaku-row{display:flex;flex-wrap:wrap;gap:calc(var(--ui-vmin, 7.2px) * .556);margin-bottom:calc(var(--ui-vmin, 7.2px) * .694);}
.ph-tenpai{display:flex;flex-direction:column;gap:calc(var(--ui-vmin, 7.2px) * .556);margin-bottom:calc(var(--ui-vmin, 7.2px) * .694);}
.ph-score-table{width:100%;border-collapse:collapse;margin-top:calc(var(--ui-vmin, 7.2px) * .694);}
.ph-score-table td{padding:calc(var(--ui-vmin, 7.2px) * .417) 0;font-size:calc(var(--ui-vmin, 7.2px) * 1.25);
  border-top:1px solid rgba(201,168,76,.08);}
.ph-score-table td:nth-child(2){text-align:right;font-weight:700;color:var(--cream);}
.ph-score-table td:nth-child(3){text-align:right;}
/* Dealer marker (see buildHistoryRoundCard in script.js) — a plain gold
   dot, superscript after the name (a real <sup>, so it rides the raised
   baseline for free instead of a manual vertical-align offset), same
   sizing convention as .profile-card-dot, just static/no glow since "who
   was dealer" is a fixed historical fact, not a live status worth calling
   extra attention to. */
.ph-dealer-dot{display:inline-block;width:calc(var(--ui-vmin, 7.2px) * .556);height:calc(var(--ui-vmin, 7.2px) * .556);
  border-radius:50%;background:var(--gold);margin-left:calc(var(--ui-vmin, 7.2px) * .417);}
.ph-score-table tr.w td:first-child{color:var(--gl);}
.ph-score-table tr.l td:first-child{color:#e07070;}
