/* =====================================================================================
 *  Panel v2 - one token set, both themes. PLAN_StatusSiteV2.md §8.
 *
 *  Palette is the v1 page's dark scheme lifted into custom properties, then extended with the
 *  semantic colours the new views need (rung colours, ping series, ok/warn/fail).
 *
 *  ⚠ Light is defined on bare :root and dark overrides it, so a browser with no preference and
 *  no stored choice still gets a complete palette. Every colour is defined in the light block -
 *  a colour whose ONLY definition is inside a media query is a colour that disappears.
 *  ⚠ text_color_paleblue is unreadable on white - the v1 Launcher paint code already falls back
 *  to a darker blue there, and so does this.
 * ===================================================================================== */
:root {
  --bg:#f5f6f8; --bg2:#ffffff; --bg3:#eceef2; --line:#d8dce4;
  --fg:#1b1f27; --fg2:#5a6474; --fg3:#8892a4;
  --accent:#0b6bcb; --accent2:#0a5aa8;
  --vlum:42%; --vbg:14%; --vbd:55%;   /* version-chip colour, light theme */
  --subsel:#6d4bd6;                   /* the selected SUB-tab: a quiet violet, not the tab-bar blue */
  --ok:#1a7f37; --warn:#9a6700; --fail:#b42318; --info:#0a5aa8;
  --rung-direct:#1a7f37; --rung-nat:#9a6700; --rung-mona:#7048c6;
  --flash:#fff6c2;
  --mono:ui-monospace,"Cascadia Mono",Consolas,"Liberation Mono",monospace;
  --r:8px; --row:30px;
}
:root[data-theme="dark"], :root:not([data-theme="light"]) {
  --bg:#0f1216; --bg2:#161a21; --bg3:#1d2229; --line:#2a313b;
  --fg:#e6e9ef; --fg2:#a3adbb; --fg3:#6f7987;
  --accent:#4ea1ff; --accent2:#77b8ff;
  --vlum:72%; --vbg:18%; --vbd:52%;   /* version-chip colour, dark theme */
  --subsel:#a78bfa;
  --ok:#3fb950; --warn:#d29922; --fail:#f85149; --info:#58a6ff;
  --rung-direct:#3fb950; --rung-nat:#d29922; --rung-mona:#a371f7;
  --flash:#3a3413;
}
@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) {
  --bg:#f5f6f8; --bg2:#ffffff; --bg3:#eceef2; --line:#d8dce4;
  --fg:#1b1f27; --fg2:#5a6474; --fg3:#8892a4;
  --accent:#0b6bcb; --accent2:#0a5aa8;
  --vlum:42%; --vbg:14%; --vbd:55%; --subsel:#6d4bd6;
  --ok:#1a7f37; --warn:#9a6700; --fail:#b42318; --info:#0a5aa8;
  --rung-direct:#1a7f37; --rung-nat:#9a6700; --rung-mona:#7048c6;
  --flash:#fff6c2;
} }

*{box-sizing:border-box}
/* ⚠ THE PANEL HAD NO [hidden] RULE. `[hidden]{display:none}` is only a UA-stylesheet default, so
   any class that sets `display` silently outranks it - which is how the tab hint stayed on screen
   while marked hidden. `.hidden` is toggled on ten elements here (the map tooltip and its Reset
   button, folded table rows, a whole view in Compare Logs), so this is stated once, for all of
   them, rather than patched per component. */
[hidden]{display:none!important}
body{margin:0;background:var(--bg);color:var(--fg);
     font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
a{color:var(--accent);text-decoration:none} a:hover{text-decoration:underline}

/* ---- shell: TOP TAB BAR, same idiom and wording as the classic page (user 2026-08-25:
   "I liked the old layout with buttons on top"). No sidebar. ---- */
.top{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--bg2);
     border-bottom:1px solid var(--line);position:sticky;top:0;z-index:36;flex-wrap:wrap}
/* ⚠ z-index 36, ABOVE the floating chat window (35, chat.css): the bar is a stacking context, so its account menu can
   never rise above the bar itself - at 20 the menu opened UNDER the chat (2026-09-15). The phone chat sheet (38) still
   covers the bar, and the window never overlaps the bar itself (its top is clamped below it). */
/* The title opens the first tab (the World Map) without a reload (2026-09-15). Nobody selects it as text, but everybody drags across it while
   reaching for a tab - so the selection is pure noise and the click may as well do the thing
   you already wanted (user, 2026-08-26). */
.brand{font-weight:700;font-size:17px;letter-spacing:.2px;cursor:pointer;
       user-select:none;-webkit-user-select:none;-moz-user-select:none}
.brand:active{opacity:.65}
.brand span{color:var(--accent);font-weight:600;margin-left:7px}
/* nicknames stay on ONE line - the Games host column wrapped to two and looked broken */
td.nick{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:300px}
.brand small{color:var(--fg3);font-weight:400;margin-left:6px}
/* ---- the logo (markup: mmb_brand_html in lib/boot.php, user 2026-09-14) ----------------------------
   Desktop: the letters of "Modern MP" light up one after another under the mouse - a glow, no
   background. Phone: "EE2.eu" and "Modern MP" take turns in ONE slot, the new word wiping over the
   old behind a bright edge, so the bar keeps the room the two words used to take. */
.brand{position:relative}
.brand .brand-a{color:inherit;font-weight:inherit;margin-left:0}
.brand .brand-b{color:var(--accent);font-weight:600;margin-left:7px;white-space:pre}
.brand .brand-b i{font-style:normal;display:inline-block}
@media (hover:hover) and (min-width:701px) and (prefers-reduced-motion:no-preference){
  .brand:hover .brand-b i{animation:brand-glow 1s cubic-bezier(.3,.6,.3,1) calc(var(--i) * 55ms) both}
}
@keyframes brand-glow{
  0%{transform:none;text-shadow:none}
  35%{transform:translateY(-2px);color:color-mix(in srgb,var(--accent) 55%,#fff);
      text-shadow:0 0 6px color-mix(in srgb,var(--accent) 90%,transparent),0 0 16px color-mix(in srgb,var(--accent) 55%,transparent)}
  100%{transform:none;text-shadow:0 0 7px color-mix(in srgb,var(--accent) 45%,transparent)}
}
@media (max-width:700px){
  .brand{display:inline-grid;grid-template-areas:"w";align-items:center;overflow:hidden}
  .brand .brand-a,.brand .brand-b{grid-area:w;margin-left:0;white-space:nowrap}
  .brand .brand-a{animation:brand-a 9s cubic-bezier(.65,0,.35,1) infinite}
  .brand .brand-b{animation:brand-b 9s cubic-bezier(.65,0,.35,1) infinite}
  .brand::after{content:"";position:absolute;top:10%;bottom:10%;left:0;width:2px;border-radius:2px;pointer-events:none;
    background:var(--accent);box-shadow:0 0 8px var(--accent),0 0 18px color-mix(in srgb,var(--accent) 60%,transparent);
    opacity:0;animation:brand-edge 9s cubic-bezier(.65,0,.35,1) infinite}
}
@media (max-width:700px) and (prefers-reduced-motion:reduce){
  .brand .brand-a,.brand .brand-b,.brand::after{animation:none}
  .brand .brand-b,.brand::after{display:none}
}
/* A: shown, wiped away from the left, parked hidden on the other side, wiped back in. B: the mirror image. */
@keyframes brand-a{0%,42%{clip-path:inset(0 0 0 0)} 50%{clip-path:inset(0 0 0 100%)} 50.01%,92%{clip-path:inset(0 100% 0 0)} 100%{clip-path:inset(0 0 0 0)}}
@keyframes brand-b{0%,42%{clip-path:inset(0 100% 0 0)} 50%,92%{clip-path:inset(0 0 0 0)} 99.99%{clip-path:inset(0 0 0 100%)} 100%{clip-path:inset(0 100% 0 0)}}
@keyframes brand-edge{0%,41.9%{left:0;opacity:0} 42%{left:0;opacity:1} 50%{left:100%;opacity:1} 50.1%,91.9%{left:0;opacity:0} 92%{left:0;opacity:1} 99.9%{left:100%;opacity:1} 100%{left:100%;opacity:0}}
.spacer{flex:1}
.tabs{display:flex;gap:4px;flex-wrap:wrap;padding:6px 14px;background:var(--bg2);
      border-bottom:1px solid var(--line);position:sticky;top:45px;z-index:19}
.tab{padding:7px 13px;border-radius:9px;cursor:pointer;color:var(--fg2);font-weight:550;
     border:1px solid transparent;white-space:nowrap;font-size:14px}
.tab:hover{background:var(--bg3);color:var(--fg)}
.tab.on{background:var(--accent);color:#fff}
.tabs{display:block;padding:0}
.tabrow{display:flex;gap:4px;flex-wrap:wrap;padding:7px 16px}
/* hoisted into the title bar - no padding of its own, it inherits the bar's */
.tabrow.sectiontabs{padding:0;margin-left:18px}
.tabrow.subrow{background:var(--bg3);border-top:1px solid var(--line);padding:6px 16px}
.tab.sec{font-size:14px;font-weight:650}
.tab.sec.on{background:var(--accent);color:#fff}
.subrow .tab{font-size:13.5px}
/* The sub-tabs, boxed. On a desktop this is exactly the row they were before - they wrap inside it
   and the hint takes the slack beside them. On a phone it becomes the scroller. */
.tabstrip{display:flex;gap:4px;flex-wrap:wrap;min-width:0;flex:0 1 auto}
/* ---- the tab hint: what this tab is ---------------------------------------------------------
   It sits after the last sub-tab and takes the leftover width.
   ⚠ NO FRAME. It was --fg3 at 11.5px, which read as fine print, so it was given a tinted border
   and background - and that swung too far the other way (user, 2026-09-05: "too noticeable ... it
   takes so much attention in this blue frame. Maybe remove that frame and just keep text bigger").
   What made it readable was the TYPOGRAPHY, not the box: the tab's name in full ink and the
   caption at 12.5px. The ⓘ alone marks it as a note and separates it from the last tab. */
/* ⚠ flex-basis 0, NOT auto. With `auto` the basis is the caption's own width, so a long one
   demanded more room than the row had left and flex-wrap moved the whole chip BELOW the tabs
   (user, 2026-09-05). Basis 0 asks for nothing and takes what is spare, so it stays beside them
   and the TEXT wraps instead of the element. */
.tabtools { flex:0 0 auto; align-self:center; margin-left:auto; padding-left:10px }
.tabhint { flex:1 1 0; min-width:0; align-self:center;
           display:flex; align-items:baseline; gap:6px;
           margin-left:10px; padding-left:13px;
           border-left:1px solid var(--line);
           color:var(--fg2); font-size:12.5px; line-height:1.35; overflow:hidden }
.tabhint::before { content:'ⓘ'; flex:0 0 auto; color:var(--info); line-height:1.2 }
/* The tab's name carries full-strength ink; the explanation stays one step back from it. */
/* .tabhintname is gone with the repeated tab title - the sub-tab row already says which one. */
/* Up to TWO lines, then ellipsis. One line truncated most captions; unlimited lines would let a
   caption push the tab row taller than the content it labels. */
/* The optional link sits after the caption and never shrinks - a truncated link is useless. */
.tabhintlink { flex:0 0 auto; color:var(--accent); white-space:nowrap }
.tabhintlink:hover { text-decoration:underline }
.tabhinttext { min-width:0; overflow:hidden; white-space:normal;
               display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
               line-clamp:2 }
/* On a narrow screen it takes its own line rather than squeezing the tabs to nothing. */
/* ⚠ 900px, THE SAME BREAKPOINT AS THE HEADER. At 820 the hint kept its beside-the-tabs styling
   for 80px after the tabs had already become a full-width scroller, and drew a left border against
   nothing. */
@media (max-width:900px) {
  /* Full width now, so the rule moves to the top - a left border there would divide nothing. */
  .tabhint { flex:1 0 100%; margin:7px 0 0; padding:7px 0 0;
             border-left:0; border-top:1px solid var(--line) }
  .tabhinttext { white-space:normal }
}
/* ---- sub-tabs -------------------------------------------------------------------------------
   ⚠ THE SELECTED SUB-TAB WAS NOT RECOGNISABLE. It was one shade of dark grey against another, so
   a reader could not tell which of them they were on and clicked around looking for it (user,
   2026-09-06, watching somebody else use it). Two changes, and both matter:

     - EVERY sub-tab now reads as a button: its own surface, its own border, a hairline of depth.
       Against the row's darker ground they look pressable rather than like text.
     - THE SELECTED ONE IS ORANGE, not the accent blue. Blue is what the MAIN tab row already
       uses for its selection, so repeating it here would be one colour meaning "selected" at two
       levels; a second hue says "you are here, inside that". A tint plus a solid border plus an
       underline - not a saturated fill, which would shout louder than the main tab above it. */
.subrow .tab{
  background:color-mix(in srgb, var(--bg2) 60%, transparent);
  border:1px solid color-mix(in srgb, var(--line) 80%, transparent);
  box-shadow:0 1px 0 color-mix(in srgb, #000 10%, transparent);
}
.subrow .tab:hover{ background:var(--bg2); color:var(--fg);
                    border-color:color-mix(in srgb, var(--fg3) 70%, transparent) }
.subrow .tab.on{
  /* ⚠ QUIET. The first version used the "on multiplayer" orange with a 2px underline and a glow,
     which read louder than the MAIN tab above it and inverted the hierarchy (user, 2026-09-06:
     "too strong, especially that frame-bottom orange line"). A soft violet says "you are here"
     without competing: distinct from the tab-bar blue, and nowhere near as hot as the orange. */
  background:color-mix(in srgb, var(--subsel) 11%, var(--bg2));
  border-color:color-mix(in srgb, var(--subsel) 45%, transparent);
  color:var(--fg);
  font-weight:650;
  /* One hairline, not a bar - enough to find, not enough to shout. */
  box-shadow:inset 0 -1px 0 color-mix(in srgb, var(--subsel) 55%, transparent);
}
.subrow .tab.on:hover{ background:color-mix(in srgb, var(--subsel) 16%, var(--bg2)) }
/* A locked tab keeps its dashed invitation - the selection styling must not out-shout it. */
.subrow .tab.locked{ background:transparent; box-shadow:none }

/* ---- the MAIN (section) tabs: the same glass, without losing the blue --------------------
 * The sub-tabs got their translucent surfaces and violet selection on 2026-09-06; the row above
 * them stayed a flat --bg3 hover and a flat blue fill, which is what made it look like the one
 * unmodernised control on the page (user, 2026-09-07: "The main tab still uses fixed blue color.
 * Would it be possible to improve that fixed blue to also be semi-transparent and look modern
 * with mouse hover events ... Selected tab should STILL be blue and noticeable just maybe a bit
 * different").
 *
 * ⚠ IT IS THE SUB-TAB RECIPE IN BLUE, not a lit solid fill. The first attempt kept the saturated
 * --accent background and added a pale border and a bloom, which only made a strong thing
 * stronger (user, 2026-09-07: "still bad, and even worse with even lighter frame. This light blue
 * is too strong for these, I thought it would be different semi-transparent blue, similar to what
 * we have in sub-tabs").
 *
 * So: a TINT over the bar, an accent border, an underline hairline - exactly `.subrow .tab.on`
 * one section up, swapping the violet for the tab-bar blue. Hierarchy is kept by degree rather
 * than by hue: a slightly heavier tint (15% vs 11%) and a 2px underline against the sub-tab's
 * 1px, so the main row still reads as the outer level.
 *
 * ⚠ AND THE LABEL BECOMES --fg, NOT #fff. That is what makes the transparency safe: white text
 * on a translucent accent measured about 3.9:1 in the light theme, under the 4.5:1 this text size
 * needs. Foreground text on a 15% tint of the bar is far clearer than the old solid ever was.
 */
.sectiontabs .tab.sec {
  border:1px solid transparent;
  transition:background-color .13s ease, border-color .13s ease,
             color .13s ease, box-shadow .13s ease;
}
/* Unselected: nothing until you approach it, then a real glass surface. */
.sectiontabs .tab.sec:not(.on):not(.locked):hover {
  color:var(--fg);
  background:color-mix(in srgb, var(--bg3) 70%, transparent);
  border-color:color-mix(in srgb, var(--fg3) 45%, transparent);
  -webkit-backdrop-filter:blur(10px) saturate(140%);
  backdrop-filter:blur(10px) saturate(140%);
}
.sectiontabs .tab.sec.on {
  background:color-mix(in srgb, var(--accent) 15%, var(--bg2));
  border-color:color-mix(in srgb, var(--accent) 55%, transparent);
  color:var(--fg);
  box-shadow:inset 0 -2px 0 color-mix(in srgb, var(--accent) 70%, transparent);
}
.sectiontabs .tab.sec.on:hover {
  background:color-mix(in srgb, var(--accent) 21%, var(--bg2));
}
.sectiontabs .tab.sec:active { transform:translateY(1px) }
.sectiontabs .tab.sec:focus-visible { outline:2px solid var(--accent); outline-offset:2px }
@media (prefers-reduced-motion:reduce) {
  .sectiontabs .tab.sec { transition:none }
  .sectiontabs .tab.sec:active { transform:none }
}
/* A tab a guest can SEE but not open. Deliberately inviting rather than disabled-looking: the
   point is to show what an account is worth, so it keeps the normal hover and gains a padlock.
   The data behind it is still refused server-side - this is advertising, not a permission. */
.tab.locked{color:var(--fg3);border-style:dashed;border-color:var(--line)}
.tab.locked:hover{color:var(--accent);border-color:var(--accent);border-style:solid}
.tab.locked .lockicon{margin-left:6px;font-size:11px;opacity:.85;vertical-align:middle}
/* ---- the account menu + profile chooser ------------------------------------------------------
   ⚠ The bar shows the chosen PROFILE NICKNAME; the email is inside the menu, one click deeper. */
.acctbtn.needpick{ border-color:color-mix(in srgb, var(--warn) 60%, transparent);
                   color:var(--warn) }
/* ---- the account menu, REDESIGNED 2026-09-15 ----------------------------------------------------------------------
   User: nicer, colours, columns for sections, some transparency, animations under the pointer, wider, no "Playing as".
   Mica panel; the profile on top the way the chats show it (its colour in --me, its game avatar); two columns of sections;
   every row brings its own accent in --ic, and its icon tile lifts and tints when pointed at. */
.acctwho{ display:flex; align-items:center; gap:12px; padding:10px 12px; margin-bottom:6px; border-radius:13px;
  background:linear-gradient(120deg, color-mix(in srgb, var(--me, var(--accent)) 17%, transparent), color-mix(in srgb, var(--me, var(--accent)) 3%, transparent) 72%);
  box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--me, var(--accent)) 22%, transparent) }
.acctav{ flex:0 0 56px; width:56px; height:44px; border-radius:11px; overflow:hidden; display:grid; place-items:center;
  background:linear-gradient(145deg, var(--me, var(--accent)), color-mix(in srgb, var(--me, var(--accent)) 55%, #000));
  box-shadow:0 8px 18px -10px var(--me, var(--accent)), inset 0 0 0 1px rgba(255,255,255,.2) }
.acctav img{ display:block; width:100%; height:100%; object-fit:cover }
.acctav span{ font:700 19px/1 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.4) }
.acctwhot{ flex:1; min-width:0; display:flex; flex-direction:column; gap:3px }
.acctwho b{ display:block; font:650 16px/1.25 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; letter-spacing:-.1px; color:var(--me, var(--fg));
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.acctmailsm{ display:block; font-size:11.5px; color:var(--fg3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.acctenh{ flex:0 0 auto; padding:4px 9px; border-radius:999px; font:700 11px/1 system-ui,-apple-system,"Segoe UI",sans-serif; color:var(--fg); white-space:nowrap;
  background:linear-gradient(90deg, color-mix(in srgb, #febc34 26%, transparent), color-mix(in srgb, #39d3b6 22%, transparent));
  box-shadow:inset 0 0 0 1px color-mix(in srgb, #febc34 42%, transparent) }
.acctcols{ display:grid; grid-template-columns:1fr 1fr; gap:2px 8px }
.acctcol{ min-width:0; display:flex; flex-direction:column; gap:2px }
.acctsec{ padding:7px 10px 4px; font:700 10px/1 system-ui,-apple-system,"Segoe UI",sans-serif; letter-spacing:1.4px; text-transform:uppercase; color:var(--fg3) }
/* under the pointer (user 2026-09-15: the tilting tiles looked unprofessional): the row tints and only the emoji zooms a little */
.acctmenu .acctitem{ --ic:var(--accent); display:flex; align-items:center; gap:10px; width:100%; padding:7px 9px; border:0; border-radius:11px;
  background:none; color:var(--fg); font:inherit; text-align:left; text-decoration:none; cursor:pointer; transition:background .15s ease }
.acctmenu .acctitem:hover{ background:color-mix(in srgb, var(--ic) 11%, transparent); text-decoration:none }
.acctmenu .acctitem:focus-visible{ outline:2px solid color-mix(in srgb, var(--ic) 70%, transparent); outline-offset:-2px }
.acctemo{ flex:0 0 32px; width:32px; height:32px; display:grid; place-items:center; border-radius:10px; font-size:16px; line-height:1;
  background:color-mix(in srgb, var(--ic, var(--accent)) 14%, transparent); box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--ic, var(--accent)) 22%, transparent);
  transition:background .15s ease, box-shadow .15s ease }
.acctemo i{ display:block; font-style:normal; transition:transform .18s ease-out }
.acctmenu .acctitem:hover .acctemo{ background:color-mix(in srgb, var(--ic) 22%, transparent); box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--ic) 36%, transparent) }
.acctmenu .acctitem:hover .acctemo i{ transform:scale(1.2) }
.acctlbl{ min-width:0; display:flex; flex-direction:column; gap:1px }
.acctlbl b{ font-size:13px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.acctlbl small{ font-size:11px; color:var(--fg3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
/* the red cross on Reset settings: only the icon is red, Sign out stays the loudest row */
.acctemo.danger{ color:var(--fail) }
.acctsep{ height:1px; margin:6px 8px; background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--fg) 16%, transparent), transparent) }
.acctmenu .acctitem.acctout b{ color:var(--fail) }
.acctmenu .acctitem.acctout:hover{ background:color-mix(in srgb, var(--fail) 13%, transparent) }

/* The chooser. A GRID because forty profiles on one account is normal here - a dropdown would be
   forty look-alike lines - and it is modal because until one is picked there is nothing to do. */
/* The suggestion is a question, not the unavoidable first-run chooser: it is narrower and it can
   always be dismissed. */
.sgcard{ max-width:430px }
.sgback .pfrow{ justify-content:flex-end }
.pfback{ position:fixed; inset:0; z-index:60; display:flex; align-items:center;
         justify-content:center; padding:16px;
         background:color-mix(in srgb, #000 55%, transparent);
         -webkit-backdrop-filter:blur(3px); backdrop-filter:blur(3px) }
.pfcard-wrap{ width:min(620px, 100%); max-height:min(80vh, 720px); display:flex;
              flex-direction:column; gap:9px; padding:16px;
              background:var(--bg2); border:1px solid var(--line); border-radius:14px;
              box-shadow:0 18px 60px rgba(0,0,0,.5) }
.pfcard-wrap h3{ margin:0; font-size:16px; color:var(--fg) }
.pfsub{ margin:0; font-size:12px; color:var(--fg2); line-height:1.45 }
/* A nickname inside the switch dialog. Blue and bold so the two names stand out of the sentence
   without quotes around them (user, 2026-09-08). --accent is theme-aware, so this stays readable
   in dark mode where the plain-text colour is not. */
.pfnick{ color:var(--accent); font-weight:700 }
/* ---- the profile chooser, REDESIGNED 2026-09-15 (user: more modern, default scrollbar, show avatars) ------------------
   Mica card like Profile Style; search with an icon; cards = avatar (game avatar or initial) + nickname in its colour + id and
   last played; "Current" tag; the pick gets an accent ring and a check. Only .pfpick: the other .pfcard-wrap dialogs keep theirs. */
.pfpback{ background:rgba(8,12,18,.45); -webkit-backdrop-filter:blur(5px); backdrop-filter:blur(5px) }
.pfcard-wrap.pfpick{ width:min(760px, 100%); max-height:min(84vh, 780px); gap:0; padding:0; overflow:hidden; border-radius:18px;
  border:1px solid color-mix(in srgb, var(--fg) 12%, transparent); background:color-mix(in srgb, var(--bg2) 95%, transparent);
  -webkit-backdrop-filter:blur(24px) saturate(160%); backdrop-filter:blur(24px) saturate(160%);
  box-shadow:0 40px 90px -30px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07); animation:pfpin .18s ease-out }
@keyframes pfpin { from { opacity:0; transform:translateY(8px) scale(.985) } }
.pfphead{ display:flex; align-items:flex-start; gap:12px; padding:16px 18px 10px }
.pfpht{ flex:1; min-width:0 }
.pfpick h3{ margin:0 0 3px; font-size:18px; font-weight:650; letter-spacing:-.15px }
.pfpx{ width:32px; height:32px; margin:-4px -6px 0 0; border:0; border-radius:9px; background:none; color:var(--fg3); font-size:22px; line-height:1; cursor:pointer }
.pfpx:hover{ color:var(--fg); background:color-mix(in srgb, var(--fg) 8%, transparent) }
.pfpsearch{ position:relative; display:block; margin:0 18px 12px }
.pfpsearch svg{ position:absolute; left:11px; top:50%; width:16px; height:16px; transform:translateY(-50%); color:var(--fg3); pointer-events:none }
.pfpsearch input{ width:100%; box-sizing:border-box; height:38px; padding:0 12px 0 34px; border-radius:11px; font:inherit; font-size:13.5px; color:var(--fg);
  border:1px solid color-mix(in srgb, var(--fg) 12%, transparent); background:color-mix(in srgb, var(--fg) 5%, transparent); outline:none;
  transition:border-color .15s ease, box-shadow .15s ease }
.pfpsearch input:focus{ border-color:color-mix(in srgb, var(--accent) 60%, transparent); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent) }
.pfpgrid{ flex:1; min-height:120px; overflow:auto; overscroll-behavior:contain; display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));
  align-content:start; gap:8px; padding:8px 18px 16px; scrollbar-gutter:stable }
.pfpgrid::-webkit-scrollbar{ width:10px }
.pfpgrid::-webkit-scrollbar-track{ background:transparent }
.pfpgrid::-webkit-scrollbar-button{ display:none; height:0 }
.pfpgrid::-webkit-scrollbar-thumb{ background-color:color-mix(in srgb, var(--fg) 15%, transparent); border-radius:10px; border:3px solid transparent; background-clip:content-box; min-height:40px }
.pfpgrid:hover::-webkit-scrollbar-thumb{ background-color:color-mix(in srgb, var(--fg) 26%, transparent) }
@supports not selector(::-webkit-scrollbar) { .pfpgrid{ scrollbar-width:thin; scrollbar-color:color-mix(in srgb, var(--fg) 22%, transparent) transparent } }
.pfp{ position:relative; display:flex; align-items:center; gap:11px; min-width:0; padding:8px 12px 8px 8px; border-radius:13px; cursor:pointer; text-align:left;
  font:inherit; color:var(--fg); border:1px solid color-mix(in srgb, var(--fg) 9%, transparent); background:color-mix(in srgb, var(--fg) 3%, transparent);
  transition:background .15s ease, border-color .15s ease, box-shadow .15s ease }
.pfp:hover{ background:color-mix(in srgb, var(--fg) 7%, transparent); border-color:color-mix(in srgb, var(--fg) 18%, transparent) }
.pfp:focus-visible{ outline:2px solid color-mix(in srgb, var(--accent) 70%, transparent); outline-offset:1px }
.pfp.on{ border-color:color-mix(in srgb, var(--accent) 70%, transparent); background:color-mix(in srgb, var(--accent) 13%, transparent);
  box-shadow:0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), 0 10px 24px -16px var(--accent) }
.pfpav{ flex:0 0 52px; width:52px; height:41px; display:grid; place-items:center; border-radius:9px; overflow:hidden;
  font:700 18px/1 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.35);
  background:linear-gradient(145deg, hsl(var(--h, 210) 55% 52%), hsl(var(--h, 210) 50% 34%)); box-shadow:inset 0 0 0 1px rgba(255,255,255,.14) }
.pfpav.pfpimg{ background:#10151d; box-shadow:inset 0 0 0 1px rgba(255,255,255,.1) }
.pfpav img{ width:100%; height:100%; object-fit:contain }
.pfptxt{ flex:1; min-width:0; display:flex; flex-direction:column; gap:2px }
.pfptxt b{ font-size:13.5px; font-weight:650; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.pfptxt small{ font-size:11.5px; color:var(--fg3); font-variant-numeric:tabular-nums; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.pfpnow{ margin-right:5px; font-weight:700; color:var(--accent) }
/* a chosen nickname colour, kept readable on each theme like the chats do (lightness clamped) */
.pfpick{ --pfp-lmin:58; --pfp-lmax:80 }
:root[data-theme="light"] .pfpick{ --pfp-lmin:24; --pfp-lmax:44 }
@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .pfpick{ --pfp-lmin:24; --pfp-lmax:44 } }
.pfptxt b.pfpnc{ color:var(--nc) }
@supports (color: hsl(from red h s l)) { .pfptxt b.pfpnc{ color:hsl(from var(--nc) h s clamp(var(--pfp-lmin), l, var(--pfp-lmax))) } }
.pfpcheck{ position:absolute; top:-6px; right:-6px; width:20px; height:20px; display:grid; place-items:center; border-radius:50%; font:800 11px/1 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
  font-style:normal; color:#fff; background:var(--accent); box-shadow:0 0 0 2px var(--bg2); opacity:0; transform:scale(.6); transition:opacity .15s ease, transform .18s ease }
.pfp.on .pfpcheck{ opacity:1; transform:none }
.pfpnone{ grid-column:1/-1; padding:26px 14px; text-align:center; color:var(--fg3); font-size:13px }
.pfpfoot{ display:flex; align-items:center; gap:8px; padding:12px 18px; border-top:1px solid color-mix(in srgb, var(--fg) 9%, transparent) }
.pfpcount{ flex:1; font-size:12px; color:var(--fg3) }
.pfpick .pfok, .pfpick .pfcancel{ height:36px; padding:0 18px; border-radius:10px; font-size:13.5px }
.pfpick .pfok:disabled{ opacity:.45 }
@media (max-width: 560px) {
  .pfback.pfpback{ padding:0; align-items:stretch }
  .pfcard-wrap.pfpick{ width:100%; max-height:none; height:100%; height:100dvh; border-radius:0; border:0 }
  .pfpgrid{ grid-template-columns:minmax(0, 1fr); padding:8px 14px 14px }
  .pfphead{ padding:14px 14px 8px } .pfpsearch{ margin:0 14px 10px } .pfpfoot{ padding:10px 14px calc(10px + env(safe-area-inset-bottom)) }
}
@media (prefers-reduced-motion: reduce) { .pfcard-wrap.pfpick, .pfp, .pfpcheck { animation:none !important; transition:none !important } }
/* ⚠ NARROWER THAN THE PROFILE PICKER. That one lists names and wants the room; this one is three
   sentences and read as a banner at 620px (user, 2026-09-08: "gets too wide ... it can be wide but
   not extreme"). 460 keeps each description to two or three comfortable lines. */
.locwrap{ width:min(460px, 100%) }
/* A radio with a title and an explanation under it. The description is what makes three location
   choices comprehensible without a help page, so it is part of the control, not a tooltip - which
   is why it is nearly the size of the title rather than fine print (user, 2026-09-08). */
.locopt{ display:flex; align-items:flex-start; gap:10px; padding:11px 12px; margin-top:8px;
         border:1px solid var(--line); border-radius:10px; cursor:pointer;
         background:var(--bg3); transition:border-color .15s ease, background .15s ease }
.locopt:hover{ border-color:var(--fg3); background:var(--bg2) }
.locopt input{ margin:3px 0 0; flex:none; accent-color:var(--accent); width:16px; height:16px }
.locopt:has(input:checked){ border-color:var(--accent);
                            background:color-mix(in srgb, var(--accent) 14%, var(--bg2));
                            box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent) }
.locoptt{ display:flex; flex-direction:column; gap:3px; min-width:0 }
/* The name carries the accent, so the eye lands on the three CHOICES and reads the explanations
   only when it needs them. The selected one goes to full strength. */
.locoptname{ font-size:13.5px; font-weight:650; color:var(--accent); letter-spacing:.1px }
.locopt:has(input:checked) .locoptname{ color:var(--accent2) }
.locoptd{ font-size:12.5px; color:var(--fg2); line-height:1.5 }
/* The reader's own flag standing in for an emoji in the option name. Slightly taller than
   the text, like every other flag on the panel, and nudged onto the text baseline. */
.locoptname .locoptflag{ height:1.05em; vertical-align:-.12em; display:inline-block }
/* Why the location could not be read. Amber rather than red: nothing broke, the browser or the
   operating system simply declined - and the reader can act on both. */
.locnote{ margin-top:9px; padding:8px 10px; border-radius:8px; font-size:11.5px; line-height:1.45;
          color:var(--fg); border:1px solid color-mix(in srgb, var(--warn) 45%, var(--line));
          background:color-mix(in srgb, var(--warn) 12%, var(--bg2)) }
.pfrow{ display:flex; justify-content:flex-end; gap:8px }
.pfok,.pfcancel{ font:inherit; font-size:13px; padding:7px 16px; border-radius:8px; cursor:pointer;
                 border:1px solid var(--line); background:var(--bg3); color:var(--fg2) }
.pfok{ background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600 }
.pfok:disabled{ opacity:.7; cursor:default }
.pfcancel:hover{ color:var(--fg); border-color:var(--fg3) }
.main{padding:14px;max-width:1900px;margin:0 auto}
@media (max-width:900px){.tabs{top:0;position:static}}

/* ---- THE HEADER ON A PHONE -------------------------------------------------------------------
   ⚠ THE BAR USED TO WRAP INTO FIVE OR SIX LINES OF DIFFERENT HEIGHTS, each starting at a different
   x because the section row carries an 18px indent meant for sitting BESIDE the brand (user,
   2026-09-06: "Entire top looks weird ... Big breaks, unaligned lines"). Every strip becomes ONE
   sideways-scrolling line instead: three tidy rows, flush left, short enough to stay sticky. */
@media (max-width:900px) {
  .top{gap:8px;row-gap:6px;padding:7px 12px}
  /* ⚠ THE IDENTITY LINE HAS TO FIT IN ONE ROW, or the header is back to breaking. Signed in as an
     admin it carries brand + View as + theme + Sign out; at these sizes that is ~350px of a 360px
     bar, and the brand truncates rather than pushing anything to a second line. */
  .brand{font-size:15px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .top button,.top .tab{font-size:12.5px;padding:4px 9px}

  /* ⚠ THE ADDRESS GOES, or the identity line is two lines. At 360px the signed-in address is
     wider than the brand itself, and brand + address + theme + Sign out is ~450px of content in
     a 360px bar - so it wrapped, which is half of the "big breaks". It tells a signed-in reader
     nothing the Sign-out button does not, and it survives as that button's title. */
  .top .note{display:none}
  /* The nickname stays, cut with an ellipsis: the account button takes the room left between the brand and the theme button. */
  .top:has(.acctwrap) .spacer{display:none}
  .acctwrap{flex:1 1 0;min-width:0;display:flex;justify-content:flex-end}
  .acctbtn{display:flex;align-items:center;max-width:100%;min-width:0;padding:4px 6px}
  .acctmail{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .caretdn{flex:none}
  /* order:9 drops the section row BELOW the identity line rather than between brand and buttons. */
  .tabrow.sectiontabs{order:9;flex:1 0 100%;margin-left:0;padding:2px 0 1px}
  .tabstrip,.tabrow.sectiontabs{flex-wrap:nowrap;overflow-x:auto;overscroll-behavior-x:contain;
                                -webkit-overflow-scrolling:touch;scrollbar-width:none}
  .tabstrip::-webkit-scrollbar,.tabrow.sectiontabs::-webkit-scrollbar{display:none}
  /* The strip is the whole line; the hint sits under it, not beside it. */
  .tabrow.subrow{display:block;padding:6px 12px}
  .tabstrip{width:100%}
  .tab{padding:6px 11px;font-size:13.5px}
  .tab.sec{font-size:13.5px}
}
/* Only while there IS more to the right - measured, not assumed, so a desktop never fades and the
   end of a scroll does not pretend to continue. */
.tabstrip.hasmore,.sectiontabs.hasmore{
  -webkit-mask-image:linear-gradient(to right,#000 calc(100% - 28px),transparent);
  mask-image:linear-gradient(to right,#000 calc(100% - 28px),transparent)}

/* ---- the signed-in account menu ------------------------------------------------------------
   The address used to be a dead label; it is the handle for account actions now. */
.acctwrap{position:relative}
.acctbtn{background:transparent;border:1px solid transparent;color:var(--fg2);
         font-size:13px;padding:4px 8px;border-radius:8px;cursor:pointer;
         max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.acctbtn:hover{background:var(--bg3);border-color:var(--line);color:var(--fg)}
.accticon{display:none;font-size:14px}
.caretdn{color:var(--fg3);margin-left:4px}
.acctmenu{ position:absolute; right:0; top:calc(100% + 8px); z-index:40; width:min(540px, calc(100vw - 20px)); padding:8px; border-radius:18px;
  border:1px solid color-mix(in srgb, var(--fg) 12%, transparent);
  background:linear-gradient(160deg, color-mix(in srgb, var(--me, var(--accent)) 9%, transparent), transparent 40%), color-mix(in srgb, var(--bg2) 84%, transparent);
  -webkit-backdrop-filter:blur(24px) saturate(165%); backdrop-filter:blur(24px) saturate(165%);
  box-shadow:0 26px 60px -22px rgba(0,0,0,.55), 0 2px 10px rgba(0,0,0,.12), inset 0 1px 0 rgba(255,255,255,.1);
  animation:acctin .16s ease-out }
@keyframes acctin { from { opacity:0; transform:translateY(-4px) } }
/* a phone: one column, pinned to the screen edges rather than to the button */
@media (max-width: 560px) {
  .acctmenu{ position:fixed; left:10px; right:10px; top:58px; width:auto; max-height:calc(100vh - 70px); overflow:auto }
  .acctcols{ grid-template-columns:1fr }
}
@media (prefers-reduced-motion: reduce) { .acctmenu, .acctemo, .acctemo i { animation:none !important; transition:none !important } }

/* ---- the verification wall -------------------------------------------------------------------
   Shown INSTEAD of the app when a signed-in address has not been activated. It is the only thing
   on the page, so it is allowed to be large and to say the whole thing rather than hint at it. */
.verifywall{max-width:640px;margin:36px auto;padding:26px 28px;text-align:center;
            background:var(--bg2);border:1px solid var(--line);border-radius:14px}
.vwicon{font-size:40px;line-height:1;margin-bottom:6px}
.verifywall h2{margin:6px 0 12px;font-size:21px}
.verifywall p{margin:0 0 12px;line-height:1.6;color:var(--fg2);text-align:left}
.vwlead{color:var(--fg)!important;font-size:15px}
.vwacts{display:flex;gap:12px;align-items:center;justify-content:center;
        flex-wrap:wrap;margin-top:18px;text-align:center!important}
.vwgo{display:inline-block;padding:10px 18px;border-radius:9px;font-weight:600;
      background:var(--accent);color:#fff;border:1px solid var(--accent)}
.vwgo:hover{text-decoration:none;filter:brightness(1.08)}
.vwalt{color:var(--fg2);font-size:13.5px}
.vwfoot{text-align:center!important;font-size:12.5px}

/* ---- controls ---- */
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px}
input,select,button{font:inherit;color:var(--fg);background:var(--bg2);
     border:1px solid var(--line);border-radius:var(--r);padding:5px 9px}
input:focus,select:focus{outline:2px solid var(--accent);outline-offset:-1px}
button{cursor:pointer} button:hover{background:var(--bg3)}
/* Destructive actions read as destructive - the classic page had .btn.danger and the kick
   buttons looked like ordinary buttons without it (user, 2026-08-26). Red text + red border
   rather than a solid fill: same as the old page, and it stays legible in both themes. */
button.dflt{color:var(--fg3);border-style:dashed;padding:2px 8px;font-size:12.5px}
button.dflt:hover{color:var(--fg);border-style:solid}
.modalback{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;
  align-items:flex-start;justify-content:center;padding:60px 16px;z-index:50;overflow:auto}
button.warn{color:var(--warn,#d08a2a);border-color:color-mix(in srgb,var(--warn,#d08a2a) 45%,transparent)}
button.danger{color:var(--fail);border-color:color-mix(in srgb,var(--fail) 45%,transparent)}
button.danger:hover{background:color-mix(in srgb,var(--fail) 12%,transparent);
  border-color:var(--fail)}
button.on{background:var(--accent);border-color:var(--accent);color:#fff}
.grow{flex:1;min-width:160px}
.note{color:var(--fg3);font-size:13.5px}
.warnbar{background:color-mix(in srgb,var(--warn) 18%,var(--bg2));border:1px solid var(--warn);
     border-radius:var(--r);padding:6px 10px;margin-bottom:8px}

/* ---- tables ---- */
.tw{overflow:auto;border:1px solid var(--line);border-radius:var(--r);background:var(--bg2)}
table{border-collapse:collapse;width:100%;font-size:14px}
th{position:sticky;top:0;background:var(--bg3);text-align:left;font-weight:600;color:var(--fg2);
   padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;z-index:1}
td{padding:7px 11px;border-bottom:1px solid var(--line);vertical-align:top}
tr.r:hover{background:var(--bg3);cursor:pointer}
/* MonaNAT ladder drag handle. Only the grip is draggable - the row is full of inputs. */
td.drag{width:22px;padding-right:0}
.grip{cursor:grab;color:var(--fg3);user-select:none;font-size:15px;line-height:1}
.grip:active{cursor:grabbing}
.grip:hover{color:var(--fg)}
tr.dragging{opacity:.45}
/* MonaNAT node health, left-edge stripe + a wash so it reads on any row background. */
tr.rowgood td:first-child{box-shadow:inset 3px 0 0 #2ea043}
tr.rowwarn td:first-child{box-shadow:inset 3px 0 0 #d29922}
tr.rowbad  td:first-child{box-shadow:inset 3px 0 0 #f85149}
tr.rowoff  td:first-child{box-shadow:inset 3px 0 0 var(--fg3)}
tr.rowgood td{background:rgba(46,160,67,.07)}
tr.rowwarn td{background:rgba(210,153,34,.09)}
tr.rowbad  td{background:rgba(248,81,73,.09)}
tr.rowoff  td{opacity:.55}
.compact td{padding:5px 11px}
td.mono,.mono{font-family:var(--mono);font-size:13px}
/* nowrap: "4.7 MB" was breaking after the number and the unit dropped to a second line
   (user, 2026-09-04: "Total column should be wider, because B moves to next line"). A number
   and its unit are one token; the trailing expander gives the table the room. */
td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
td.time{color:var(--fg2);white-space:nowrap;font-variant-numeric:tabular-nums}
.trunc{max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}

/* new-row flash: respects reduced motion (§4c) */
@keyframes flashrow{from{background:var(--flash)}to{background:transparent}}
tr.new{animation:flashrow var(--flashms,1400ms) ease-out 1}
/* ---- the per-cell flash on state tables -------------------------------------------------------
   ⚠ PER CELL, NOT PER ROW. On Players Online the last-seen timestamp advances on every keepalive,
   so a row-level flash would light the whole table every poll for ever. This way the heartbeat
   blinks one cell and a real change is impossible to miss. */
@keyframes flashcell {
  from { background:color-mix(in srgb, var(--accent) 34%, transparent) }
  to   { background:transparent }
}
td.cellflash{animation:flashcell var(--flashms,1400ms) ease-out 1}
@media (prefers-reduced-motion:reduce){
  td.cellflash{animation:none;box-shadow:inset 0 -2px 0 var(--accent)}
}

/* In a game right now. A frame, deliberately quiet - it marks a handful of rows in a live list,
   it is not a warning (user, 2026-09-06: "orange frame or shadow (not too noticeable)"). */
tr.rowmp > td{background:color-mix(in srgb, var(--sr2) 7%, transparent)}
tr.rowmp > td:first-child{box-shadow:inset 3px 0 0 var(--sr2)}

/* Status chips. Only MP/Observer carry colour: those are the two that change what a row MEANS. */
.pill.st-mp{border-color:color-mix(in srgb,var(--sr2) 55%,var(--line));
            background:color-mix(in srgb,var(--sr2) 14%,transparent)}
.pill.st-obs{border-color:color-mix(in srgb,var(--info) 55%,var(--line));
             background:color-mix(in srgb,var(--info) 12%,transparent)}
.pill.st-sp,.pill.st-menu{color:var(--fg2)}

/* The country is now the column, not a footnote to an address - so it reads at full strength
   (user, 2026-09-06: "country should have better visible font, and emoji/flag near it"). */
.flag{line-height:1;margin-right:7px;display:inline-flex;align-items:center}
/* ⚠ THE FLAG IS A VENDORED SVG, NOT AN EMOJI - Windows has no flag glyphs (see mmbFlag()).
   Sized in `em` so it scales with whatever text it sits beside, and deliberately a little TALLER
   than the line: that is how a flag reads on Android, and a flag matched exactly to the cap height
   looks like a smudge (user, 2026-09-08). 4x3 aspect is the file's own, kept here so nothing
   squashes. The 2px radius and the hairline border are what stop a white flag (Japan, Poland's
   lower half) from bleeding into a light background - without a border they simply vanish. */
.flagimg{
  height:1.15em; width:auto; aspect-ratio:4/3; display:block;
  border-radius:2px; vertical-align:-.15em;
  box-shadow:0 0 0 1px rgba(0,0,0,.18);
  background:var(--bg3);
}
:root:not([data-theme="light"]) .flagimg{ box-shadow:0 0 0 1px rgba(255,255,255,.22) }
@media (prefers-color-scheme: light){ :root:not([data-theme="dark"]) .flagimg{ box-shadow:0 0 0 1px rgba(0,0,0,.18) } }
:root[data-theme="dark"] .flagimg{ box-shadow:0 0 0 1px rgba(255,255,255,.22) }
.cname{font-size:13.5px;color:var(--fg)}
/* Four status chips must not wrap onto a second line; the column sizes to them instead. */
td.statuscell{white-space:nowrap;width:1%}
/* Content-sized columns. width:1% is the CSS idiom for "as narrow as the content allows"; the
   trailing .expander cell is width:100% and takes everything left over, so the row still fills
   the table without any column being stretched. */
table.fitcols th,table.fitcols td{width:1%;white-space:nowrap}
table.fitcols th.expander,table.fitcols td.expander{width:100%}

/* ---- the raw column dump (expand) -------------------------------------------------------------
   47 columns one-per-line is a screenful of scrolling; wrapped pairs fit on a few rows.
   ⚠ The NAME is the dim monospaced half and the VALUE is full-strength ink - at this density the
   two must never be mistakable for each other. */
.dbgrid{display:flex;flex-wrap:wrap;gap:5px 8px}
.dbkv{display:flex;align-items:baseline;gap:6px;padding:2px 9px;border-radius:6px;
      border:1px solid var(--line);background:color-mix(in srgb,var(--fg) 4%,transparent);
      max-width:100%;min-width:0}
.dbk{font-family:var(--mono);font-size:11.5px;color:var(--fg3);white-space:nowrap}
.dbk::after{content:':'}
.dbv{font-size:12.5px;color:var(--fg);word-break:break-all;min-width:0}
.dbv.dbempty{color:var(--fg3)}
@media (prefers-reduced-motion:reduce){tr.new{animation:none;box-shadow:inset 3px 0 0 var(--accent)}}

.pill{display:inline-block;padding:1px 7px;border-radius:999px;background:var(--bg3);
      border:1px solid var(--line);font-size:12.5px;white-space:nowrap}
.perm-tree{margin-top:10px;display:flex;flex-direction:column;gap:2px}
.perm-sec{border:1px solid var(--bd,#3a3a3a);border-radius:6px;padding:7px 10px}
.perm-head{display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:600}
.perm-name{flex:0 0 auto}
.perm-count{color:var(--fg3);font-weight:400;font-size:12px}
.perm-kids{display:flex;flex-wrap:wrap;gap:4px 16px;margin:8px 0 2px 22px}
.perm-kids label{display:flex;align-items:center;gap:6px;font-weight:400;cursor:pointer}
.perm-admin{display:flex;align-items:center;gap:6px;margin:12px 0 4px;cursor:pointer}
/* Admin -> Chat Access (assets/chataccess.js) */
.ca-check{display:flex;align-items:baseline;gap:2px;margin:6px 0;cursor:pointer;flex-wrap:wrap}
.ca-check .hint{color:var(--fg3);font-size:12.5px}
.ca-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin:12px 0}
.ca-chans{display:flex;flex-direction:column;margin:4px 0 10px}
tr.ca-off td{opacity:.55}
.card .pill.ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}
.card .pill.warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,transparent)}
.seen{font-size:11px;line-height:1.6;white-space:nowrap;opacity:.9}
.ok{color:var(--ok)} .warn{color:var(--warn)} .fail{color:var(--fail)}

/* ---- ladder bar (Joins) ---- */
/* ⚠ WIDER ON A WIDE SCREEN, UNCHANGED ON A PHONE, and no breakpoint to maintain: clamp() grows
   the bar with the viewport from its old 120px and stops at 250px, so it roughly doubles without
   turning into a stripe across the table (user, 2026-09-05: "could be twice the size, maybe not to
   go to extreme"). */
.ladder{display:flex;height:14px;border-radius:3px;overflow:hidden;background:var(--bg3);
        min-width:clamp(120px,17vw,250px)}
.ladder i{display:block;height:100%;position:relative;overflow:hidden}
/* The timings, written on the segments. White with a shadow rather than a theme ink: it has to
   hold up on the light amber and the bright green alike, and it sits ON the colour, not beside it.
   Hidden below 900px, where the bar is at its minimum and there is no room to be honest with. */
.ladder b{display:none}
@media (min-width:900px){
  .ladder{height:16px}
  .ladder i.lab b{display:block;text-align:center;white-space:nowrap;
                  font:600 10px/16px system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
                  color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.6)}
}
.ladder i.d{background:var(--rung-direct)} .ladder i.n{background:var(--rung-nat)}
.ladder i.m{background:var(--rung-mona)}
.lad-l{font-size:11px;color:var(--fg3);white-space:nowrap}

/* ---- detail ---- */
tr.det td{background:var(--bg3);padding:10px 14px}
.kv{display:grid;grid-template-columns:190px 1fr;gap:4px 14px;font-size:13.5px}
.kv dt{color:var(--fg3)} .kv dd{margin:0;font-family:var(--mono);word-break:break-word;white-space:pre-wrap}

/* ---- cards / pulse ---- */
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px;margin-bottom:12px}
.card{background:var(--bg2);border:1px solid var(--line);border-radius:var(--r);padding:10px 12px}
/* ⚠⚠ STAT CARDS ARE ONE FIXED SKELETON: number, label, optional description - always in that
   order and always in the same place.
   The first attempt at this centred the whole block (justify-content:center) and made it WORSE:
   a card with a description has three rows and one without has two, so centring floated every
   card's number to a different height and the row stopped reading as a set (user, 2026-09-08:
   "numbers are in quite random positions compared to the ones next to them").
   The underlying cause is that the value has FOUR font sizes - statValue() picks 34/24/18/15px
   from the string length - so even top-aligned, a short number and a long one never shared a
   baseline. Fixing it needs three things, not one:
     1. the value gets ONE line box (36px) whatever font bucket it lands in, so every number in a
        row sits at the same height;
     2. content is TOP-packed, never centred, so the label is always directly under the number;
     3. a min-height covering all three rows, so a card WITHOUT a description is the same shape as
        one with it instead of being the odd short box in the row - which is what made "ENet sleep"
        look wrong in the first place.
   Scoped to `.cards > .card`: `.card` is also a plain container in panels, modals and the profile
   chooser, where none of this applies. */
.cards > .card{display:flex;flex-direction:column;text-align:center;min-height:88px}
/* One line box for every bucket. `display:block` is kept deliberately - `.card b` relies on it
   for `text-overflow:ellipsis`, which a flex container would silently stop honouring. */
.cards > .card > b{line-height:36px;min-height:36px}
.card b{display:block;font-size:22px;font-weight:650;font-variant-numeric:tabular-nums}
/* ⚠ `>` IS LEAD-LINED. This styles a stat card's LABEL, which is a DIRECT child; without the
   combinator it also hit every span nested anywhere inside a card - tables, tooltips, panels -
   and upper-cased player nicknames three separate times (2026-09-05). A stat card's own label is
   unaffected: it is a direct child. */
.card > span{color:var(--fg3);font-size:12px;text-transform:uppercase;letter-spacing:.6px}

/* ⚠ A NICKNAME IS REPRODUCED EXACTLY. Upper-casing one invents a name nobody has, so this is a
   correctness rule, not a preference - stated outright so no future container can undo it. */
.nick, .tname, .wtipnick, td.nick, .card .nick, .card .tname {
  text-transform:none; letter-spacing:0 }

/* ---- charts ---- */
.chart{background:var(--bg2);border:1px solid var(--line);border-radius:var(--r);padding:8px}
.chart svg{display:block;width:100%;height:auto}
.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:13px;color:var(--fg2);margin-top:6px}
.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px}

.cats{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:13.5px}
.cats label{display:inline-flex;align-items:center;gap:4px;cursor:pointer}
.logview{font-family:var(--mono);font-size:12.5px;white-space:pre-wrap;max-height:70vh;overflow:auto;
     background:var(--bg2);border:1px solid var(--line);border-radius:var(--r);padding:8px}

/* Two short tables side by side; they stack only when there is genuinely no room. */
.fld{display:flex;flex-direction:column;gap:3px}
.fld>label{color:var(--fg3);font-size:11.5px;text-transform:uppercase;letter-spacing:.5px}
.fld input,.fld select{width:100%}
.fld .hint{font-size:11.5px;line-height:1.35}
.bar.top{align-items:flex-start}
.cols2{display:grid;grid-template-columns:repeat(auto-fit,minmax(330px,1fr));gap:14px;align-items:start}
th.num{text-align:right}

/* ---- Compare logs -------------------------------------------------------------------------
 * One merged timeline, one colour per player. The colour lives on a CSS variable set inline on
 * the row, so the same value paints the tag, the left rule and the picker chip. */
.cmpgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:8px;
         max-height:230px;overflow:auto;margin:8px 0}
.cmppick{display:flex;gap:8px;align-items:center;border:1px solid var(--line);border-radius:var(--r);
         padding:6px 9px;background:var(--bg2);cursor:pointer}
.cmppick.sel{border-color:var(--accent)}
.cmppick i{width:10px;height:10px;border-radius:3px;flex:0 0 auto;background:var(--line)}
.cmppick .who{font-weight:600}
.cmppick .sub{color:var(--fg2);font-size:11px}
.cmpline{display:flex;gap:8px;padding:1px 6px;border-left:3px solid var(--c);white-space:pre-wrap;
         word-break:break-word}
.cmpline:hover{background:var(--bg3)}
.cmpline .t{color:var(--fg2);flex:0 0 auto}
.cmpline .w{color:var(--c);font-weight:600;flex:0 0 auto}
.cmpline.hit{background:color-mix(in srgb,var(--accent) 14%,transparent)}
.cmpwrap{border:1px solid var(--line);border-radius:var(--r);background:var(--bg2);
         font-family:var(--mono);font-size:12px;line-height:1.5;overflow:auto;max-height:64vh;padding:6px 0}
.threads{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0}
.thread{border:1px solid var(--line);border-radius:999px;padding:3px 10px;cursor:pointer;
        background:var(--bg2);font-family:var(--mono);font-size:11px}
.thread.on{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 16%,transparent)}
.thread b{font-weight:600}
.thread span{color:var(--fg2)}

/* Join kind. Amber for a host-quit migration: it is not a failure, but it is the case worth
   spotting in a list, so it must not look like every other row. */
.tag-new{background:color-mix(in srgb,var(--accent) 18%,transparent);
         border:1px solid color-mix(in srgb,var(--accent) 45%,transparent)}
.tag-hq{background:color-mix(in srgb,#d29922 22%,transparent);
        border:1px solid color-mix(in srgb,#d29922 55%,transparent)}
/* RECONNECT had no entry at all, so it fell through to .tag-other and read as grey lower-case
   prose beside two proper labels. It is its own kind of join and gets its own colour. */
.tag-rc{background:color-mix(in srgb,var(--rung-mona) 20%,transparent);
        border:1px solid color-mix(in srgb,var(--rung-mona) 55%,transparent)}
.tag-other{background:var(--bg3);border:1px solid var(--line)}

/* =============================================================================================
 *  ONE VISUAL LANGUAGE - added 2026-09-04.
 *
 *  ⚠ WHY THIS BLOCK EXISTS. Seven class names were in daily use in the JS and defined NOWHERE:
 *  `primary` (six Save buttons in monanat.js/users.js), `sect` (six section headings), and
 *  `tablewrap` / `empty` / `k` / `v` / `panel` (all in views2.js). They were not styled badly -
 *  they were not styled at all, which is why the Traffic overview tab had stat cards with no
 *  big number, a table with no frame and a chart with no panel. The aliases are gone from the
 *  JS now; the two that named a real idea (`sect`, `empty`) are defined here properly.
 *
 *  THE RULES, so the next view does not drift again:
 *    1. Vertical space comes from .stack, never an inline marginTop. There were ~35 of those
 *       across six files, using six different values.
 *    2. Every hand-written view opens with .pagehead - a title and one line saying what the tab
 *       answers. Half of them used to open with a bare grey paragraph and half with nothing.
 *    3. A block of content is a .card. A heading inside one is .sect.
 *    4. Nothing / working / failed are .empty, .empty.busy and .warnbar. One shape each.
 * ============================================================================================= */

/* -- spacing scale. One dial, so "a bit more room" is a token, not a number in a JS file. ---- */
:root { --s1:6px; --s2:10px; --s3:14px; --s4:22px; }

/* Rule 1. Everything a view appends goes in a .stack and the gaps take care of themselves. */
.stack > * + * { margin-top: var(--s3); }
.stack.tight > * + * { margin-top: var(--s2); }

/* Rule 2. The tab says what it is; the sub-line says which question it answers. The rule under
   it does the separating, so a page header costs almost no vertical space of its own. */
.pagehead { border-bottom:1px solid var(--line); padding-bottom:10px; margin-bottom:var(--s3) }
/* With the blurb moved to the tab row, a view whose title merely repeats its tab leaves this box
   empty - and an empty box would still draw its rule and its margin. */
.pagehead:empty { display:none }
.pagehead h2 { margin:0 0 3px; font-size:19px; font-weight:650; letter-spacing:-.15px }
/* ONE LINE. A dashboard that opens with a paragraph is not a dashboard (user, 2026-09-04:
   "It goes to two lines instead of one. This text looks unprofessional."). */
.pagehead p  { margin:0; color:var(--fg2); font-size:13.5px; max-width:none;
               white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
@media (max-width:700px) { .pagehead p { white-space:normal } }

/* -- log viewer: auto-detected module filters + configured topic filters --------------------- */
.filtergroup { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-bottom:6px }
.flabel { font-size:11px; font-weight:700; letter-spacing:.7px; text-transform:uppercase;
          color:var(--fg3); flex:0 0 auto; min-width:150px }
.cats label.off { opacity:.45 }
.fcount { font-variant-numeric:tabular-nums; font-size:10.5px; color:var(--fg3);
          background:var(--bg3); border-radius:999px; padding:0 5px; margin-left:1px }
.pagehead .bar { margin:10px 0 0 }

/* Rule 3. A section heading inside a card or a column. Small spaced caps separate it from body
   text without needing a box or a large font. */
.sect { font-size:11.5px; font-weight:700; letter-spacing:.9px; text-transform:uppercase;
        color:var(--fg3); margin:0 0 var(--s2) }
.card > .sect:not(:first-child), .stack > .sect { margin-top:var(--s4) }
.card h3 { margin:0 0 4px; font-size:15.5px; font-weight:650 }
.card > .note:first-of-type { margin-bottom:var(--s2) }
.card > .tw { border-radius:6px }

/* Rule 4. The three states every asynchronous view has, now the same three shapes everywhere. */
.empty { color:var(--fg3); font-size:13.5px; padding:18px 14px; text-align:center;
         border:1px dashed var(--line); border-radius:var(--r); background:var(--bg2) }
.empty.busy::after { content:""; display:inline-block; width:9px; height:9px; margin-left:8px;
         border-radius:50%; background:var(--accent); animation:pulse 1.1s ease-in-out infinite;
         vertical-align:middle }
@keyframes pulse { 0%,100% { opacity:.25; transform:scale(.8) } 50% { opacity:1; transform:scale(1) } }
@media (prefers-reduced-motion:reduce) { .empty.busy::after { animation:none; opacity:.7 } }

/* A VALUE BEING MEASURED (mmbPending). Sits where the digits will, sized in ch, so the tile does
   not jump when the real number lands. The sweep is a moving highlight over a flat bar - the
   standard "this is coming" idiom, and it cannot be mistaken for content. */
/* Inside a stat card the value is 34px, so the default .72em bar would be a slab and its ch-based
   width would fill the whole tile. Scaled down to read as a placeholder for the digits, and capped
   so it can never be wider than the card. */
.cards > .card > b > .pending{ height:.5em; max-width:100% }
.pending{ display:inline-block; height:.72em; vertical-align:-.02em; border-radius:4px;
          background:linear-gradient(90deg,
              color-mix(in srgb, var(--fg3) 22%, transparent) 0%,
              color-mix(in srgb, var(--fg3) 22%, transparent) 35%,
              color-mix(in srgb, var(--accent) 34%, transparent) 50%,
              color-mix(in srgb, var(--fg3) 22%, transparent) 65%,
              color-mix(in srgb, var(--fg3) 22%, transparent) 100%);
          background-size:260% 100%; animation:shimmer 1.5s linear infinite }
@keyframes shimmer { from { background-position:130% 0 } to { background-position:-130% 0 } }

/* THE CHART'S OWN WAITING STATE - a different idiom on purpose: the frame it is going to fill,
   with a line sweeping along the baseline, rather than a dashed "nothing here" box. Heights match
   the real chart (130px plot) so the tab does not reflow when the first two samples arrive. */
.chartskel .chartskelplot{ position:relative; height:130px; border-radius:var(--r);
          border:1px solid var(--line); background:var(--bg2); overflow:hidden }
.chartskel .chartskelbar{ position:absolute; left:0; right:0; bottom:0; height:3px;
          background:linear-gradient(90deg, transparent 0%,
              color-mix(in srgb, var(--accent) 70%, transparent) 50%, transparent 100%);
          background-size:45% 100%; background-repeat:no-repeat;
          animation:sweep 1.6s ease-in-out infinite }
@keyframes sweep { from { background-position:-45% 0 } to { background-position:145% 0 } }
.chartskel .chartskelnote{ color:var(--fg3); font-size:12px; padding:7px 2px 0; text-align:center }
@media (prefers-reduced-motion:reduce) {
  .pending{ animation:none }
  .chartskel .chartskelbar{ animation:none; background-position:50% 0; opacity:.6 }
}

/* -- a modal that is a card, not a floating div ---------------------------------------------- */
.modalback .card { box-shadow:0 18px 48px rgba(0,0,0,.32); width:100% }

/* -- the sign-in card: the whole page when there is nothing else, an overlay when there is ----
 * Same markup both ways (mmb_signin_card), so the captcha and the csrf field cannot go missing
 * from one of them. Over the panel it sits on the standard semi-transparent backdrop. */
.signincard { max-width:390px; margin:6vh auto }
.signinback { align-items:flex-start; padding-top:12vh; backdrop-filter:blur(2px) }
.signinwho { margin:0 0 14px }
.signinrow { margin:0 0 9px }
.signinrow input { width:100% }
.signincaptcha { margin:0 0 12px; display:flex; justify-content:center }
.signinbtn { width:100% }
.signinfoot { margin:12px 0 0; text-align:center }
/* The recovery hint after repeated failures. Sits under the error bar and reads as help, not as
   a second warning - so no colour of its own beyond the link. */
.signinhelp { margin:-4px 0 12px; line-height:1.45 }

/* ---- THE SIGN-IN CALL TO ACTION IN THE BAR ---------------------------------------------------
   ⚠ IT WAS A .tab AMONG .tabs AND READ AS ONE (user, 2026-09-07). A signed-out visitor is exactly
   who this panel most needs to reach - they are one click from an account - so this is the one
   control in the bar allowed to ask for attention: a real frame, and a slow ring that expands
   once every few seconds. NOT a blink; most of the cycle is the button sitting still.
   ⚠ REDUCED MOTION TURNS THE PULSE OFF, and the frame alone still carries it. */
.tab.signincta { border:1px solid var(--accent); color:var(--accent); font-weight:650;
                 animation:mmbctapulse 4.5s ease-out infinite }
.tab.signincta:hover { background:var(--accent); color:var(--bg2); border-color:var(--accent) }
@keyframes mmbctapulse {
  0%   { box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent) }
  12%  { box-shadow:0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent) }
  100% { box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent) }
}
@media (prefers-reduced-motion: reduce) { .tab.signincta { animation:none } }
.modalhead { display:flex; align-items:baseline; gap:10px; margin-bottom:var(--s2) }
.modalhead h3 { margin:0; flex:1 }
.modalhead .x { cursor:pointer; color:var(--fg3); font-size:19px; line-height:1; padding:0 4px }
.modalhead .x:hover { color:var(--fg) }

/* -- THE JOIN TRANSCRIPT: what one player actually saw ---------------------------------------
 * Opened from the player's NAME in the Joins tab rather than by unfolding the row (user,
 * 2026-09-04: "Just in nicer form than expanding everything"). One line per message, the clock
 * in the gutter and the event type as its own column, so it scans like the chat it was. */
td.nick .talk { margin-left:6px; cursor:pointer; color:var(--fg3); font-size:11px;
                border:1px solid var(--line); border-radius:999px; padding:0 6px; white-space:nowrap }
td.nick .talk:hover { color:var(--accent); border-color:var(--accent) }
.talkwrap { border:1px solid var(--line); border-radius:var(--r); background:var(--bg);
            max-height:56vh; overflow:auto; padding:4px 0 }
.talkline { display:grid; grid-template-columns:74px 128px 1fr; gap:10px; align-items:baseline;
            padding:4px 12px; font-size:13px; border-left:3px solid transparent }
.talkline + .talkline { border-top:1px solid color-mix(in srgb,var(--line) 55%,transparent) }
.talkline .t { color:var(--fg3); font-family:var(--mono); font-size:11.5px }
.talkline .k { font-size:11px; letter-spacing:.4px; text-transform:uppercase; color:var(--fg2) }
.talkline .x { word-break:break-word }
/* The moments worth spotting at a glance in a join that went wrong. */
.talkline.k-connected { border-left-color:var(--ok) }
.talkline.k-connected .k { color:var(--ok) }
.talkline.k-joining { border-left-color:var(--accent) }
.talkline.k-joining .k { color:var(--accent) }
.talkline.k-disconnected_self, .talkline.k-disconnected { border-left-color:var(--warn) }
.talkline.k-disconnected_self .k, .talkline.k-disconnected .k { color:var(--warn) }
.talkline.k-failed, .talkline.k-timeout { border-left-color:var(--fail) }
.talkline.k-failed .k, .talkline.k-timeout .k { color:var(--fail) }
@media (max-width:640px) { .talkline { grid-template-columns:64px 1fr }
                           .talkline .k { grid-column:2 } .talkline .x { grid-column:1/-1 } }

/* -- NAT negotiation: one cell per player, both families in it ------------------------------
 * The natneg table has 39 columns, 16 of them one endpoint each. Chips instead: the family is
 * the label, the protocol rides along, and a TCP-only endpoint says so rather than passing for
 * the UDP one the game would actually have used. */
.eps { display:flex; flex-direction:column; gap:3px }
.epswho { font-weight:600; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.epsrow { display:flex; flex-wrap:wrap; gap:4px }
.ep { display:inline-flex; align-items:center; gap:5px; font-family:var(--mono); font-size:11.5px;
      border:1px solid var(--line); border-radius:999px; padding:1px 8px; background:var(--bg3);
      white-space:nowrap }
.ep b { font-family:inherit; font-weight:700; color:var(--fg3); font-size:10px; letter-spacing:.4px }
.ep i { font-style:normal; font-size:9.5px; letter-spacing:.5px; color:var(--ok);
        border:1px solid color-mix(in srgb,var(--ok) 45%,transparent); border-radius:3px; padding:0 3px }
/* TCP where UDP is missing is a fact worth noticing, not decoration. */
.ep i.tcp { color:var(--warn); border-color:color-mix(in srgb,var(--warn) 55%,transparent) }

/* Game pills: EE2 and AOS in their own colours, so a mixed list is not two identical greys. */
.pill.game-ee2 { background:color-mix(in srgb,var(--accent) 18%,transparent);
                 border-color:color-mix(in srgb,var(--accent) 45%,transparent) }
.pill.game-aos { background:color-mix(in srgb,var(--rung-mona) 20%,transparent);
                 border-color:color-mix(in srgb,var(--rung-mona) 50%,transparent) }

/* -- MTU: the lower value is the one that binds -----------------------------------------------
 * The host takes the joiner's advertised MTU outright and the joiner applies min() against its
 * own 1400, so the pair converges on the smaller of the two. Marking it saves the reader doing
 * that comparison on every row. */
/* ⚠ ONE LINE. The " / " separator gave the cell two break points, so each number dropped onto
   its own line and every row in the table got twice as tall (user, 2026-09-05). Nothing else in
   this cell can wrap, so pinning the whole box is the whole fix. */
.mtubox { white-space:nowrap }
.mtueff { color:var(--ok); font-weight:650 }
.mtusep { color:var(--fg3) }
.mtunone { color:var(--fg3) }

/* -- the row expander ------------------------------------------------------------------------
 * Nothing on screen used to say a row could be opened at all. */
/* ⭐ AND IT IS THE SLACK-EATER. The table is width:100%, so with a handful of columns the
 * browser shared the leftover width out among them and every column sat miles from its
 * neighbour (user, 2026-09-04: "Columns should be as wide as data, you recently made it 100%
 * width which is bad ... should all be close to the left"). Giving the LAST cell width:100%
 * makes it absorb all the slack, so the real columns collapse to their content and pack left.
 * The descriptor's `w` values still act as minimums. */
th.expander { width:100% }
td.expander, th.expander { text-align:center; padding-left:0; padding-right:8px }
th { white-space:nowrap }
td.expander .caret { display:inline-block; color:var(--fg3); font-size:15px; line-height:1;
                     transition:transform .12s ease }
tr.r:hover td.expander .caret { color:var(--accent) }
tr.r.open td.expander .caret { transform:rotate(180deg); color:var(--accent) }
@media (prefers-reduced-motion:reduce) { td.expander .caret { transition:none } }
/* A hand-built detail is a layout, not a field dump - let it breathe. */
tr.det .cols2 { align-items:stretch }
tr.det details > summary { cursor:pointer; list-style:revert }
tr.det details[open] > summary { margin-bottom:6px }

/* A Result that carries a reason gets a marker beside it, NOT an underline on the pill - an
   underlined pill reads as a hyperlink. */
.whyi { margin-left:5px; color:var(--fg3); cursor:help; font-size:12px; vertical-align:middle }
.whyi:hover { color:var(--accent) }

/* An endpoint chip whose TCP address differs from its UDP one - the interesting case. */
.ep.alt { border-color:color-mix(in srgb,var(--warn) 55%,transparent);
          background:color-mix(in srgb,var(--warn) 12%,transparent) }

/* -- lockout countdown ------------------------------------------------------------------------ */
.lockon { color:var(--fail); font-weight:650 }
.lockoff { color:var(--fg3) }
tr.rowlocked td:first-child { box-shadow:inset 3px 0 0 var(--fail) }
tr.rowlocked td { background:rgba(248,81,73,.07) }

/* -- "View as": one control in the title bar, next to the signed-in address -------------------
 * It was a full-width strip above the tabs and it was in the way (user, 2026-09-04: "The 'View
 * as' tab is annoying. This should eventually display near the admin account mail in the
 * top-right"). Idle it is an ordinary button; ACTIVE it is a loud amber pill, because a preview
 * you have forgotten about is a bug report about missing tabs. */
/* ⚠ IT IS AN ACCOUNT-MENU ROW NOW, not a title-bar button (moved 2026-09-07). `.acctitem` gives
   it the row layout; the only thing left here is the ACTIVE state, which still has to shout - a
   preview that is running changes what every tab shows. The width clamps are gone with the bar. */
.viewasbtn.on-preview { background:color-mix(in srgb,var(--warn) 22%,var(--bg2));
                        color:var(--fg); font-weight:600 }
.viewasbtn.on-preview:hover { background:color-mix(in srgb,var(--warn) 32%,var(--bg2)) }
/* The one mark left outside the menu while a preview runs - see viewAsControl(). */
.acctbtn.previewing { border-color:var(--warn);
                      box-shadow:0 0 0 1px color-mix(in srgb,var(--warn) 45%,transparent) }

/* -- STATUS PANELS: sized to their content, wrapping like a dashboard ------------------------
 * The old .cols2 grid gave every panel half the page whatever it contained, so a five-row table
 * sat in a very wide box (user, 2026-09-05: "too wide for the content it has ... not modern
 * enough"). Flex with `flex:0 1 auto` sizes each card to its table and wraps the rest, so a
 * third panel moves to the right when there is room and drops below when there is not. */
.panels { display:flex; flex-wrap:wrap; gap:var(--s3); align-items:flex-start }
.panels > .panel { flex:0 1 auto; min-width:230px; max-width:100%; padding:12px 14px }
/* ⚠ The global rule is table{width:100%}; inside a panel that is the whole problem. */
.panels .tw { border:0; border-radius:0; background:transparent; overflow:visible }
.panels table { width:auto }
/* ⚠ NOT 11px. These are the first tables on the front page and they were being read as
   fine print (user, 2026-09-05: "You made text in ladders still small"). */
.panels th { background:transparent; padding:0 16px 6px 0; font-size:12px; letter-spacing:.4px;
             text-transform:uppercase; border-bottom:1px solid var(--line) }
.panels td { padding:6px 16px; padding-left:0; font-size:14px; border-bottom:1px solid
             color-mix(in srgb,var(--line) 45%,transparent) }
/* Colour is the message in these tables: green OK, red Failed. A zero stays neutral - nothing
   having failed is not a red fact. */
/* ⚠ ONE STYLE PER COLUMN ROLE, and the builder is the only thing that applies it. The panels
   had drifted apart - one table's labels dimmer than the next one's, bold numbers in some and
   plain in others (user, 2026-09-05: "Why can't you use the same style in all?"). Both roles now
   name their colour outright rather than inheriting whatever the cell happened to sit in. A
   semantic span inside a number cell still wins, but only on colour. */
.panels td.plabel { color:var(--fg); font-weight:500 }
.panels td.pnum { color:var(--fg); font-weight:650; text-align:right;
                  font-variant-numeric:tabular-nums; white-space:nowrap }
.numstrong { font-weight:650 }
.numstrong.ok { color:var(--ok) }
.numstrong.warn { color:var(--warn) }
.numstrong.fail { color:var(--fail) }
.numzero { color:var(--fg3) }
/* Belt and braces: a number cell names its own colour, so scope the semantic ones INSIDE it.
   These are the green OK / red Failed columns the user asked for twice - they are not allowed to
   depend on which rule happens to be written last. */
.panels td.pnum .ok { color:var(--ok) }
.panels td.pnum .warn { color:var(--warn) }
.panels td.pnum .fail { color:var(--fail) }
.panels td.pnum .numzero { color:var(--fg3) }

/* ⚠ A BAND, NOT A FIT. `width:max-content` alone would re-measure every poll and the box would
   twitch as the text changed, which is what looks unprofessional; a floor and a ceiling mean it
   only moves when the content genuinely leaves the band (user, 2026-09-05). */
.pingrow .ingame, .pingrow .feedbox { width:max-content; min-width:28ch; max-width:56ch }
/* The feed's facts and the host's own words are two different things, so they sit next to each
   other rather than stacked - stacked, the little grid looked like a stray header above the
   message (user, 2026-09-05: "displays above ... and looks bad. Should be next to it"). */
.pingside { display:flex; flex-wrap:wrap; gap:var(--s3); align-items:flex-start }
.pingrow.stackside { flex-direction:column }
.pingrow.stackside > * { width:100% }
/* The feed's own facts, kept apart from the host's words. */
.feedbox { display:grid; grid-template-columns:auto 1fr; gap:2px 14px; font-size:12.5px;
           padding:8px 12px; border:1px solid var(--line); border-radius:9px;
           background:color-mix(in srgb, var(--bg2) 60%, transparent) }
/* The game picker gets the whole toolbar row - its options name every player in the game. */
.gamepick { flex:1 1 100%; min-width:0 }

/* Table and the host's own status text, side by side when the window allows it. The table asks
   for its content width; the status takes what is left and drops below when there is too little. */
.pingrow { display:flex; flex-wrap:wrap; gap:var(--s3); align-items:flex-start }
.pingrow > :first-child { flex:0 1 auto; min-width:min(100%, 420px) }
.pingrow > :last-child { flex:1 1 260px; min-width:0 }

/* Top Crashes: sized to its content and no further. ⚠ width:auto, NOT 100% - the page is allowed
   to have room left over; stretching a table across it is what made Exception look enormous
   (user, 2026-09-05). */
.crashtbl { width:auto }
.crashtbl td, .crashtbl th { padding-left:14px; padding-right:14px; white-space:nowrap }
/* The one column that could be long is capped instead of flexible; the full text is the title. */
.crashtbl td.exccell { max-width:46ch; overflow:hidden; text-overflow:ellipsis }
.crashtbl td.time { font-size:12.5px }
/* The patch a date belongs to, in a quiet box beside it - not a column of its own, which could
   only have carried one of the two versions. */
.vchip { display:inline-block; margin-left:8px; padding:0 6px; border-radius:5px;
         font-size:11px; font-variant-numeric:tabular-nums; color:var(--fg2);
         border:1px solid var(--line);
         background:color-mix(in srgb, var(--fg) 6%, transparent) }

/* The ping table: content-sized like Hosted Games, but with more room - it is the thing people
   come to this tab to read, so it gets a larger type size and wider cells. */
.pingtbl { width:100% }
.pingtbl th.fit, .pingtbl td.fit { width:1%; white-space:nowrap }
.pingtbl td, .pingtbl th { padding-left:12px; padding-right:12px }
.pingtbl td { font-size:14px }
.pingtbl td.nick { font-size:14px; font-weight:600 }
/* ⚠ A chart is a block. Left inline it sat on the line of text above it (user, 2026-09-05:
   "Jitter per player table/chart seems to be overlapping on text"). */
.chart svg { display:block }
.chart + .chart { margin-top:16px }

/* ⚠ width:1% IS THE IDIOM FOR "as wide as its content". A cell can never be narrower than what
   it holds, so an absurdly small request makes the browser fall back to the content width; the
   columns without `.fit` then absorb all the remaining space (user, 2026-09-05: "don't make
   columns width respecting total width"). */
.hg { width:100% }
.hg th.fit, .hg td.fit { width:1%; white-space:nowrap }

/* ---- hosted games: the status says what happened, in one glance ---------------------------
   Colour carries it (user, 2026-09-05: openstaging green, closedstaging yellow, closedplaying
   red) - and the SERVER picks the tone, because the same raw status means "playing now" in the
   live frame and "it did start" in history. */
.gpill { display:inline-block; padding:1px 9px; border-radius:999px; white-space:nowrap;
         font-size:11.5px; font-weight:650; border:1px solid transparent }
.gpill.good { color:var(--ok);   background:color-mix(in srgb,var(--ok) 14%,transparent);
              border-color:color-mix(in srgb,var(--ok) 40%,transparent) }
.gpill.warn { color:var(--warn); background:color-mix(in srgb,var(--warn) 14%,transparent);
              border-color:color-mix(in srgb,var(--warn) 40%,transparent) }
.gpill.bad  { color:var(--fail); background:color-mix(in srgb,var(--fail) 14%,transparent);
              border-color:color-mix(in srgb,var(--fail) 40%,transparent) }
/* The Join button. Deliberately the loudest thing in the row - it is the one control on this
   page that DOES something rather than reporting something (user, 2026-09-06: "some very
   noticeable button called Join"). */
/* ⚠ AN <a>, NOT A <button>. The browser has to follow this natively for the protocol to fire, so
   it must be a real link that only LOOKS like a button. */
.joinbtn { display:inline-block; background:var(--accent); border:1px solid var(--accent);
           color:#fff !important; font-weight:650; font-size:12.5px; padding:3px 12px;
           border-radius:8px; white-space:nowrap; cursor:pointer; text-decoration:none }
.joinbtn:hover { filter:brightness(1.1); text-decoration:none }
/* ⚠ ONE JOIN CONTROL, NOT TWO. There was briefly a second "Join on my PC" button beside the
   protocol link; one button that takes both routes and lets the Launcher discard the loser is
   what the reader actually wants (user, 2026-09-06). Nothing here needs a wrapper any more.
   The collapsed card keeps the SAME control, sized for a header row and pushed to the right. */
.joinmini { margin-left:auto; flex:none }
.wcloud .joinmini.joinbtn { font-size:9.5px; padding:1px 7px; border-radius:5px; line-height:1.5 }
.joinbtn.off { background:transparent; color:var(--fg3) !important; border-color:var(--line);
               font-weight:500; cursor:not-allowed; filter:none }

/* The "starting the game" notice: a framed card in the middle of the screen. The first version was
   a line of text beside the search box and was easy to miss (user, 2026-09-06). */
/* ⚠ TOP-ALIGNED, NOT CENTRED. The thing the reader actually has to click is the BROWSER's own
   "Open …?" dialog under the address bar - so the card sits near it and points up at it, instead
   of pulling the eye to the middle of the screen (user, 2026-09-06). */
/* ⚠ BELOW THE BROWSER'S OWN DIALOG, NOT UNDER IT. Chrome draws "Open EE2 ver. 1.6 - Launcher?"
   just under the address bar and it needs that top strip; a card sitting there competes with the
   one control that actually matters (user, 2026-09-06: "popup is too high, that message box from
   chrome would take that space"). */
.joinback { position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:60;
            display:flex; align-items:flex-start; justify-content:center;
            padding:190px 20px 20px }
@media (max-height:620px) { .joinback { padding-top:120px } }
.joincard { background:var(--bg2); border:1px solid var(--line); border-radius:14px;
            padding:22px 26px; max-width:430px; text-align:center;
            box-shadow:0 18px 50px rgba(0,0,0,.4) }
.joincard.ok  { border-color:color-mix(in srgb,var(--ok) 55%,var(--line)) }
.joincard.bad { border-color:color-mix(in srgb,var(--warn) 55%,var(--line)) }
.joincard h3 { margin:10px 0 8px; font-size:18px }
.joinmsg { margin:0 0 16px; color:var(--fg2); line-height:1.55; font-size:13.5px }
.joinspin { font-size:26px; color:var(--accent); line-height:1 }
/* The pointer to the browser's own permission dialog. */
.joinarrow { font-size:30px; line-height:1; color:var(--accent); margin-bottom:2px }
.joinask { font-weight:650; font-size:14px; color:var(--fg); margin-bottom:10px }
@media (prefers-reduced-motion:no-preference) {
  .joinspin { animation:joinpulse 1.1s ease-in-out infinite }
  .joinarrow { animation:joinpoint 1.2s ease-in-out infinite }
}
@keyframes joinpulse { 0%,100% { opacity:.35 } 50% { opacity:1 } }
@keyframes joinpoint { 0%,100% { transform:translateY(0); opacity:.55 }
                       50%    { transform:translateY(-5px); opacity:1 } }

/* A game in progress - its own state, neither success nor failure. */
.gpill.live { color:var(--info); background:color-mix(in srgb,var(--info) 14%,transparent);
              border-color:color-mix(in srgb,var(--info) 40%,var(--line)) }
.gpill.off  { color:var(--fg3);  background:color-mix(in srgb,var(--fg3) 12%,transparent);
              border-color:color-mix(in srgb,var(--fg3) 30%,transparent) }
/* The expert-only expand: a plain two-column reading of the game's own data. */
.kvgrid { display:grid; grid-template-columns:auto 1fr; gap:2px 14px; padding:6px 4px;
          font-size:12.5px }
.kvk { color:var(--fg3) }
.kvv { color:var(--fg); font-variant-numeric:tabular-nums }

/* ---- traffic history: one bar per day -------------------------------------------------------
   preserveAspectRatio="none" on purpose - the bars should fill whatever width the card has, and a
   bar chart has no aspect to protect. One hue: this is a single series and the magnitude is the
   height, so colour has no second job to do here. */
.daybars { display:block; width:100%; height:150px }
.daybars rect { fill:var(--accent) }
.daybars rect:hover { fill:var(--accent2) }

/* ---- THE LIVE PLAYER MAP --------------------------------------------------------------------
   Land, sea and graticule are all mixed from the theme's own ink, so the map re-tints itself in
   dark and light rather than carrying two hand-picked palettes. The points are ONE hue - this is
   a single series and magnitude is carried by size, not by colour. */
/* ⚠ SEA AND LAND WERE NINE POINTS OF THE SAME NEUTRAL APART (4% vs 13% of --fg), which is the
   whole reason the map read as a grey rectangle (user, 2026-09-07: "this gray color might not be
   so good"). The sea carries a cool water hue now and the land a stronger neutral, so they part by
   HUE as well as by lightness - and the water is deliberately NOT the accent blue the player dots
   are drawn in, which would have put the data and the background in the same colour.
   ⚠ TO REVERT, this is the whole change: --wsea back to `var(--fg) 4%` and --wland to 13%. */
.wmap { padding:0;
        --wsea:color-mix(in srgb, #1f6f9a 11%, var(--bg2));
        --wland:color-mix(in srgb, var(--fg) 17%, var(--bg2));
        --wlandline:color-mix(in srgb, var(--fg) 30%, transparent);
        --wgratline:color-mix(in srgb, var(--fg) 7%, transparent) }
.wmapbox { position:relative; margin-top:8px }

/* ---- THE WORLD MAP TAKES THE WHOLE WINDOW ----------------------------------------------------
   ⚠ IT IS NOT A PANEL ON A PAGE, IT IS THE PAGE (user, 2026-09-07). A rounded card, a 14px gutter
   and the 1900px reading cap are all correct for a table of rows and all wrong for the one thing
   on this tab - they spend screen on furniture around a map that could be using it.
   Scoped by the body attribute app.js stamps, so no other view is affected and nothing here has
   to know which view is on screen.
   ⚠ THE CORNER CONTROLS KEEP THEIR OWN INSET - .wtally and .wreset are positioned inside
   .wmapbox with their own 8-10px, so removing the page gutter does not push them to the edge.
   (The sound and satellite switches moved to the tab bar on 2026-09-07; see .wtools.)
   ⚠ applyBand() ALREADY FITS THE MAP TO THE VIEWPORT HEIGHT, so widening it does not make a
   taller map - it makes a wider band of real map. The exception is a screen wider than ~2.5:1,
   where the minimum band is taller than the window and the page scrolls a little. */
body[data-view="worldmap"] .main { padding:0; max-width:none }
body[data-view="worldmap"] .wmapbox { margin-top:0 }
body[data-view="worldmap"] .wmap svg:not(.wleads) { border-radius:0 }
/* ⚠ clip, NOT hidden. `hidden` on one axis forces the other to `auto`, which would make this a
   scroll container - and the card layout depends on the PAGE scrolling (it re-places itself on
   every scroll so the sticky bars never cover a card). `overflow-x:clip` leaves the y axis truly
   visible, so a card may still hang past the bottom edge while nothing can widen the page.
   The width cap in applyBand() is what actually prevents the overflow; this is the net. */
body[data-view="worldmap"] .wmap { overflow-x:clip }
/* ⚠ THE SEA FILLS THE BOX, THE MAP SITS IN THE MIDDLE OF IT. A world map has one aspect ratio per
   latitude band, so the last few pixels of height (and the sides on a very wide screen) cannot be
   filled with more map - and leaving them as page background reads as a black margin. applyBand()
   sets the box height and adds .wfilled; the sea colour does the rest, so there is no visible
   seam between "map" and "leftover". */
.wmap.wfilled .wmapbox { background:var(--wsea) }
.wmap.wfilled svg:not(.wleads) { margin:0 auto }
/* The error slot is the only other thing this view renders; it keeps a normal gutter. */
body[data-view="worldmap"] .main > .stack > *:not(.wmap) { padding:0 14px }
/* ⚠⚠ AN EMPTY BOX IS NOT WEIGHTLESS. The error slot is an empty <div> most of the time, and
   `.stack > * + *` still gave it a top margin - so once the map was sized to fill the window
   exactly, that margin made the PAGE about a dozen pixels taller than the viewport: a scrollbar
   that moves a little and reveals a black strip under the map (user, 2026-09-07: "feels like if
   there was some invisible element to be rendered"). It was. Empty means gone. */
body[data-view="worldmap"] .main > .stack > *:empty { display:none }
/* ⚠ touch-action:none ALWAYS, not just while zoomed. A pinch is a browser gesture by default
   (page zoom), so the map never saw one and there was no way to zoom on a tablet at all (user,
   2026-09-05). The cost is that dragging ON the map no longer scrolls the page - which is how
   every map widget behaves, and the rest of the page scrolls normally. */
.wmapbox svg { touch-action:none }
.wmapbox.wzoomed svg { cursor:grab }
.wmapbox.wgrabbing svg { cursor:grabbing }
/* ⚠ :not(.wleads) IS LOAD-BEARING. This rule used to be a bare `.wmap svg`, which matched EVERY
   svg inside the map - including the leader-line overlay added for the game cards. The overlay
   therefore inherited width:100%, height:auto AND the opaque sea background, and painted itself
   straight over the map: the map went invisible and only the game card was left (user,
   2026-09-06: "Map is invisible now, just the hosted game is shown"). The overlay is sized in
   pixels by layoutClouds() and must stay transparent. */
.wmap svg:not(.wleads) { display:block; width:100%; height:auto; border-radius:10px;
            background:var(--wsea) }
.wgrat line { stroke:var(--wgratline); stroke-width:1; vector-effect:non-scaling-stroke }
.wlandpath { fill:var(--wland); stroke:var(--wlandline); stroke-width:.7; fill-rule:evenodd;
             vector-effect:non-scaling-stroke }
.wpt { cursor:pointer; outline:none }
.whalo { fill:color-mix(in srgb, var(--accent) 22%, transparent) }
/* ⚠ non-scaling-stroke: zooming must not fatten the ring around a dot. */
.wdot { fill:var(--accent); stroke:var(--bg2); stroke-width:.8;
       vector-effect:non-scaling-stroke;
        vector-effect:non-scaling-stroke }
/* Invisible but hittable - transparent takes pointer events, `none` would not. */
.whit { fill:transparent }
/* ⚠ GREEN = SOMEBODY IS PLAYING HERE. Blue (the default .wdot) = a Launcher is open and that is
   all. Two states, one question - see the note in update(). */
/* ⭐ ORANGE = SIGNED IN ON THE WEBSITE AND NOWHERE ELSE. Not in the lobby, no Launcher running -
   somebody who is simply reading this page.
   ⚠ IT CANNOT COMPETE WITH THE PLAYER COLOURS: a place with any player never gets this class at
   all (see update()), so the rule is enforced by the data and these declarations never have to
   out-specify .wmp or .whost.
   ⚠⚠ NEVER `var(--sr5, <pink>)`. That was the first attempt and it rendered BLUE: --sr5 EXISTS in
   the series palette and is #035ede, so the fallback was never reached (user, 2026-09-08: "it's
   showing me as blue not pink"). A fallback only fires for an UNDEFINED token - it is not a
   preference. Name the token you actually want. */
.wdot.wweb { fill:var(--sr2) }
.whalo.wweb { fill:color-mix(in srgb, var(--sr2) 24%, transparent) }
.wpt:hover .wdot.wweb, .wpt:focus .wdot.wweb {
  fill:color-mix(in srgb, var(--sr2) 80%, white) }
.wdot.wmp { fill:var(--ok) }
.whalo.wmp { fill:color-mix(in srgb, var(--ok) 26%, transparent) }
/* ⚠ A JOINABLE HOST IS A PULSE, NOT A HUE. Colour was already spent saying "playing"; motion is
   a stronger signal anyway and does not have to be learned. Reduced-motion falls back to a plain
   brighter ring, which is still distinguishable. */
.whalo.whost { fill:color-mix(in srgb, var(--ok) 42%, transparent);
               transform-box:fill-box; transform-origin:center;
               animation:whostpulse 2.4s ease-in-out infinite }
@keyframes whostpulse {
  0%, 100% { transform:scale(1);   opacity:.85 }
  50%      { transform:scale(1.5); opacity:.35 } }
@media (prefers-reduced-motion: reduce) { .whalo.whost { animation:none } }
.wpt:hover .wdot, .wpt:focus .wdot { fill:var(--accent2) }
.wpt:hover .wdot.whost, .wpt:focus .wdot.whost { fill:color-mix(in srgb, var(--ok) 80%, white) }
.wpt:hover .whalo, .wpt:focus .whalo { fill:color-mix(in srgb, var(--accent) 40%, transparent) }
.wpt:focus .wdot { stroke:var(--fg) }
/* ⚠ scale(), not an animated r. Animating the r ATTRIBUTE from CSS is not reliable across
   browsers; a transform on the circle's own fill-box is. */
/* ⚠ NO vector-effect HERE, AND IT WAS DECLARED TWICE. `non-scaling-stroke` makes the browser
   recompute the stroke in DEVICE space on every frame of the scale animation, which throws away
   the one advantage of animating transform+opacity - and this element animates constantly (a
   ping a second). Letting the stroke scale with the ring also looks more like a ripple; the base
   width is lower to compensate for the 2.6x growth. */
.wflash { fill:none; stroke:var(--accent); stroke-width:.9; pointer-events:none;
          transform-box:fill-box; transform-origin:center;
          animation:wflash 1.7s cubic-bezier(.15,.7,.3,1) forwards }
@keyframes wflash {
  from { transform:scale(.25); opacity:.9 }
  to   { transform:scale(2.6); opacity:0 } }
@media (prefers-reduced-motion: reduce) { .wflash { display:none } }
/* ⭐ chat on the map: a speech bubble with typing dots pops out of a dot and drifts up (views2.js mapSay) */
.wsaypos { pointer-events:none }
.wsay { transform-box:fill-box; transform-origin:50% 100%; animation:wsay 2.1s cubic-bezier(.2,.8,.3,1) forwards }
.wsaybody { fill:color-mix(in srgb, var(--bg2) 88%, transparent); stroke:#febc34; stroke-width:1.3px; vector-effect:non-scaling-stroke;
            filter:drop-shadow(0 0 3px rgba(254,188,52,.55)) }
.wsaypm .wsaybody { stroke:#39d3b6; filter:drop-shadow(0 0 4px rgba(57,211,182,.7)) }
.wsaydot { fill:#febc34; animation:wsaydot .9s ease-in-out infinite }
.wsaypm .wsaydot { fill:#39d3b6 }
.wsaydot.wsd1 { animation-delay:.15s } .wsaydot.wsd2 { animation-delay:.3s }
@keyframes wsay { 0% { opacity:0; transform:translateY(0) scale(.2) } 14% { opacity:1; transform:translateY(-6%) scale(1.12) }
                  24% { transform:translateY(-10%) scale(1) } 72% { opacity:1; transform:translateY(-38%) scale(1) }
                  100% { opacity:0; transform:translateY(-70%) scale(.92) } }
@keyframes wsaydot { 0%, 100% { opacity:.3 } 50% { opacity:1 } }
/* a private message to you: a golden orb arcs from the sender's dot into the Chat button (views2.js mapPmComet) */
.wpmorb { position:fixed; left:0; top:0; z-index:90; width:24px; height:24px; margin:-12px 0 0 -12px; border-radius:50%; pointer-events:none;
          display:grid; place-items:center; font:12px/1 "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif;
          background:radial-gradient(circle at 35% 30%, #fff6da, #febc34 48%, #a86f00);
          box-shadow:0 0 14px 4px rgba(254,188,52,.55), 0 0 32px 10px rgba(57,211,182,.22) }
.wpmspark { position:fixed; left:0; top:0; z-index:89; width:6px; height:6px; margin:-3px 0 0 -3px; border-radius:50%; pointer-events:none;
            background:#febc34; box-shadow:0 0 8px 2px rgba(254,188,52,.6); animation:wpmspark .7s ease-out forwards }
@keyframes wpmspark { to { opacity:0; margin:-1px 0 0 -1px; width:2px; height:2px } }
@media (prefers-reduced-motion: reduce) { .wsay, .wsaydot { animation:none } .wsaypos { display:none } }
/* ---- the map tooltip: a small mica card with a real table in it -----------------------------
   ⚠ IT IS A TABLE, NOT A HEADLINE WITH FOOTNOTES. The place name used to be the only large bold
   thing and the facts under it were grey on grey, so "17 players" and "2 on Multiplayer" ran
   together and their numbers never lined up (user, 2026-09-05). Title bar, then a two-column grid
   with every number in one right-aligned column. Same glass and lit top edge as the bands. */
/* ⚠ NO transform HERE ANY MORE - show() writes exact left/top and flips the card below the dot
   when the north edge is close, so it always stays inside the map and can never be painted over
   by the page around it. z-index is raised anyway for the case where it overlaps a game card. */
.wtip { position:absolute; z-index:6; pointer-events:none;
        overflow:hidden; max-width:260px; border-radius:9px; line-height:1.5;
        border:1px solid var(--line);
        background:color-mix(in srgb, var(--bg2) 92%, transparent);
        box-shadow:0 6px 20px rgba(0,0,0,.3);
        -webkit-backdrop-filter:blur(12px) saturate(140%);
        backdrop-filter:blur(12px) saturate(140%) }
.wtip::before { content:''; position:absolute; left:0; right:0; top:0; height:1px;
                background:linear-gradient(90deg, transparent,
                           color-mix(in srgb, var(--fg) 22%, transparent), transparent) }
/* ⚠⚠ EVERY RULE BELOW IS QUALIFIED WITH .wtip ON PURPOSE. `.card span` (app.css:172) sets
   uppercase + letter-spacing + grey + 12px on EVERY span inside a card, and this map IS a card -
   at (0,1,1) it beats a bare `.wtipk`. That one rule is why the tooltip came out shouting and
   washed out (user, 2026-09-05: "player names shouldnt BE IN CAPS ... fonts too grayed out").
   ⚠ A NICKNAME MUST BE REPRODUCED EXACTLY. Upper-casing it invents a name nobody actually has. */
.wtip span { text-transform:none; letter-spacing:0; font-size:inherit; color:inherit }
/* ⚠ WRAPS NOW, and carries the flag. It was `white-space:nowrap` with a bare place name; a blob of
   three cities plus a country does not fit on one line and clipping it would hide exactly the
   names the grouping exists to show (user, 2026-09-08). The flag sits on the left the way it does
   everywhere else on the panel.
   ⚠ `center`, NOT `flex-start`. Pinned to the first line the flag looked stranded at the top of a
   two- or three-line title (user, 2026-09-08); centred it reads as belonging to the whole block.
   A single-line title is one line tall, so the two are identical there - nothing regresses. */
.wtiphead { padding:5px 10px 4px; font-size:11.5px; font-weight:650; color:var(--fg);
            display:flex; align-items:center; gap:6px;
            border-bottom:1px solid var(--line);
            background:color-mix(in srgb, var(--fg) 5%, transparent) }
/* A single place stays on ONE line and ellipsises, exactly as it always did. */
.wtipheadt { min-width:0; line-height:1.35; overflow:hidden; text-overflow:ellipsis }
/* ⚠ A GROUP WRAPS EARLIER, IT DOES NOT BECOME A COLUMN. `.wtip` allows 260px and a run of three
   city names used every pixel of it, so the card grew sideways (user, 2026-09-08). Capping just
   this line at 176px turns the extra length into height instead - two or three comfortable lines.
   One name per line was tried and is too far the other way. Three lines then ellipsis, so a very
   long set cannot walk off the map; the count in the JS bounds it too. */
.wtipheadt.wtipheadg { max-width:176px; white-space:normal;
                       display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:3 }
/* One place name, kept whole. The line may break in the space AFTER it, never inside it - which is
   what stopped "United States" being split down the middle. */
.wtipheadt .wtipnb { white-space:nowrap }
/* ⚠ LINES, NOT A TWO-COLUMN GRID. The grid pinned every label left and every number RIGHT, so
   the short "eye" row put its icon and its value at opposite ends of the card with an empty gulf
   between them (user, 2026-09-07). Each fact is one line that ends where its text ends. */
.wtiprows { display:block; padding:6px 10px; font-size:11.5px }
.wtiprow { display:flex; align-items:baseline; gap:5px; white-space:nowrap; line-height:1.55 }
/* ⚠ A FIXED SLOT, same rule as the account menu. The two glyphs are different widths - the pair
   of people is much wider than the single eye - so left to themselves each row's text started at
   its own indent and the rows read as misaligned (user, 2026-09-07). */
.wtip .wtipk { color:inherit; flex:0 0 15px; width:15px; text-align:center }
.wtip .wtiplbl { color:var(--fg2) }
.wtip .wtipv { font-weight:650; color:var(--fg); font-variant-numeric:tabular-nums }
/* "12s ago" is context, not a number worth shouting - bold white gave it the same weight as the
   player count it sits under (user, 2026-09-07). */
.wtip .wtipv.wtipdim { color:var(--fg2); font-weight:400 }
/* ⚠ --sr2 FOR THE GAME COUNT, NOT --warn: "a game is running here" is an identity, not a status,
   and it matches the orange dot on the map. The multiplayer share now rides INSIDE the players
   value ("1 (1 Multiplayer)"), so the colour is what separates the two numbers - the .wmp class
   is kept for the older markup path. */
/* Only the COUNT is orange, and it keeps the value's weight; the words around it stay quiet
   label ink, so the number is what the eye lands on. */
.wtip .wtipv.wmp, .wtip .wtipmp { color:var(--sr8) }   /* matches .wdot.wmp */
/* ...and the web count matches .wdot.wweb, for the same reason. */
.wtip .wtipweb { color:var(--sr2); font-weight:650 }
.wtip .wtipmplbl { color:var(--fg2); font-weight:400 }
/* Names in the accent, on the plain surface. They were in bordered chips, which put a coloured
   frame around a coloured word and left the two competing (user: "the blue frame makes nicknames
   unclear"). A rule above them separates them from the numbers; nothing else is needed. */
.wtipnicks { margin:0 10px; padding:5px 0 7px; font-size:11.5px;
             border-top:1px solid color-mix(in srgb, var(--line) 70%, transparent) }
.wtip .wtipnick { color:var(--accent); font-weight:600 }
/* ⭐ PINNED (a clicked dot, 2026-09-14): it takes the pointer so its names can be clicked, and a gold hairline says it stays. */
.wtip.wpinned { pointer-events:auto; border-color:color-mix(in srgb, #febc34 45%, var(--line));
                box-shadow:0 8px 26px rgba(0,0,0,.36), 0 0 0 1px color-mix(in srgb, #febc34 18%, transparent) }
.wcloud .wcloudpn { white-space:nowrap }
/* a name the reader can message is the button itself (user 2026-09-15): the tint is a box-shadow spread so the text never moves,
   and the chat badge fades onto the name's corner only under the pointer */
.wpmnick { position:relative; display:inline-block; vertical-align:baseline; margin:0; padding:0; border:0; border-radius:5px; background:none;
           font:inherit; line-height:inherit; color:inherit; white-space:nowrap; cursor:pointer; transition:background .15s ease, box-shadow .15s ease }
.wpmnick:hover, .wpmnick:focus-visible { outline:none; background:color-mix(in srgb, var(--accent) 15%, transparent);
           box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent) }
/* .wpmnick in front: the game cards reset every span and i inside them (colour included), which hid the glyph */
.wpmnick .wpmbadge { position:absolute; top:-7px; right:-7px; width:13px; height:13px; display:grid; place-items:center; border-radius:50%; pointer-events:none;
            color:#fff; background:var(--accent); font-size:0; box-shadow:0 0 0 2px var(--bg2), 0 3px 8px -2px rgba(0,0,0,.45);
            opacity:0; transform:scale(.4); transition:opacity .14s ease-out, transform .18s cubic-bezier(.2,.9,.3,1.3) }
.wpmnick .wpmbadge svg { width:8px; height:8px; display:block; color:#fff }
.wpmnick:hover .wpmbadge, .wpmnick:focus-visible .wpmbadge { opacity:1; transform:none }
.wtip:not(.wpinned) .wpmnick { pointer-events:none }
@media (prefers-reduced-motion: reduce) { .wpmnick, .wpmbadge { transition:none } }
.wtip .wtiphint { margin:0 10px; padding:0 0 7px; font-size:10.5px; color:var(--fg3) }
/* ---- open games floating over the map ------------------------------------------------------
   ⚠ THE CARD IS NOT INSIDE THE <svg>. It holds a real Join link, and anything inside the map
   would be scaled by the zoom. Both overlays below are plain box-pixel space and are re-placed
   from each dot's measured position - see layoutClouds() for why that is the simple half. */
.wleads { position:absolute; left:0; top:0; z-index:2; pointer-events:none;
          overflow:visible; background:none; border-radius:0 }
/* --leadw is set per layout from JS (LEAD_W x zoomBoost) so the lines thicken with the dots;
   the fallback keeps them sane if that ever fails to run. */
.wlead { fill:none; stroke:color-mix(in srgb, var(--ok) 60%, transparent);
         stroke-width:var(--leadw, 1.6);
         stroke-dasharray:3 4; stroke-linecap:round;
         animation:wleaddash 22s linear infinite }
/* ⚠ POSITIVE, AND THAT IS THE WHOLE POINT. The path is drawn box-edge -> dot, so a NEGATIVE
   offset marched the dashes along that direction and the map read "the game is reaching out to
   the players". A positive offset runs them the other way: players -> the game they are in,
   which is what is actually happening (user, 2026-09-07). Geometry unchanged. */
@keyframes wleaddash { to { stroke-dashoffset:140 } }
.wleaddot { fill:var(--ok); stroke:var(--bg2); stroke-width:1 }
/* ⚠ THE OTHER PLAYERS IN THAT GAME. Orange because that is already what "in a multiplayer game"
   means on this map (.wdot.wmp), so the line and the dot it reaches are the same colour. Thinner
   and dimmer than the host's line, which stays the one that names the game. */
/* ⚠ FOLLOWS .wdot.wmp. This line lands on a player who is in the game, so it wears that state's
   colour - pink since 2026-09-08. A lead whose colour disagrees with the dot it touches is the
   bug this pairing exists to prevent. */
.wlead.wlead2 { stroke:color-mix(in srgb, var(--sr8) 52%, transparent);
                stroke-width:calc(var(--leadw, 1.6) * 0.8);
                stroke-dasharray:2 5; animation-duration:30s }
.wleaddot.wlead2dot { fill:var(--sr8) }
/* Every game box lives here now - open and collapsed alike - so one placement search owns them
   all and two of them structurally cannot overlap. */
.wclouds { position:absolute; inset:0; z-index:3; pointer-events:none }
/* ⭐ CHAT BUBBLES on the map (views2.js showSay) - MICA: see-through frosted glass with a soft light edge, a coloured 💬
   badge (gold = lobby, teal = a private message to you), the nickname with its channel under it, then the lines (last 4,
   the newest brightest). Narrow on purpose. Click opens that chat. */
.wsaycard { --say:254,188,52;
            position:absolute; pointer-events:auto; cursor:pointer; width:max-content; min-width:136px; max-width:214px;
            border-radius:15px; overflow:hidden; line-height:1.38; font-size:12px; isolation:isolate;
            background:linear-gradient(155deg, rgba(255,255,255,.10), rgba(255,255,255,.02) 55%),
                       color-mix(in srgb, var(--bg2) 52%, transparent);
            border:1px solid rgba(255,255,255,.13);
            box-shadow:0 14px 34px -12px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.12),
                       0 0 0 1px rgba(var(--say),.14), 0 0 22px -10px rgba(var(--say),.6);
            -webkit-backdrop-filter:blur(22px) saturate(185%); backdrop-filter:blur(22px) saturate(185%);
            transition:left .28s cubic-bezier(.2,.7,.3,1), top .28s cubic-bezier(.2,.7,.3,1), transform .18s, box-shadow .25s;
            animation:wsayin .34s cubic-bezier(.2,.8,.3,1) }
.wsaycard.wsaypmcard { --say:57,211,182 }
/* the tint of its colour pooling in the corner behind the badge */
.wsaycard::before { content:''; position:absolute; inset:0; z-index:-1; pointer-events:none;
                    background:radial-gradient(120px 70px at 0 0, rgba(var(--say),.08), transparent 70%) }
.wsaycard:hover { transform:translateY(-2px);
                  box-shadow:0 18px 38px -12px rgba(0,0,0,.65), inset 0 1px 0 rgba(255,255,255,.16),
                             0 0 0 1px rgba(var(--say),.35), 0 0 28px -8px rgba(var(--say),.7) }
.wsaycard span { text-transform:none; letter-spacing:0; font-size:inherit; color:inherit }
.wsayhead { display:flex; align-items:center; gap:8px; padding:8px 11px 4px 8px }
/* no filled disc any more (it outshone the message): a hairline ring with a thin arc of the card's colour orbiting it */
.wsaycard .wsayicon { position:relative; flex:none; width:26px; height:26px; border-radius:50%; display:grid; place-items:center;
                      font:14px/1 "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif;
                      box-shadow:inset 0 0 0 1px rgba(var(--say),.16) }
.wsaycard .wsayicon::before { content:''; position:absolute; inset:0; border-radius:50%; padding:1.5px; pointer-events:none;
                      background:conic-gradient(rgba(var(--say),0) 0deg, rgba(var(--say),.9) 70deg, rgba(var(--say),0) 150deg, rgba(var(--say),0) 360deg);
                      -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite:xor;
                      mask:linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
                      animation:wsayorbit 2.6s linear infinite }
/* a private message: the same ring, teal, turning slower and breathing */
.wsaycard.wsaypmcard .wsayicon::before { animation:wsayorbit 4s linear infinite, wsaybreath 2s ease-in-out infinite }
@keyframes wsayorbit { to { transform:rotate(360deg) } }
@keyframes wsaybreath { 50% { opacity:.45 } }
.wsaycard .wsaywho { display:flex; flex-direction:column; min-width:0; line-height:1.2 }
.wsaycard .wsaynick { font-size:12.5px; font-weight:700; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
.wsaycard .wsaywhere { margin-top:1px; font-size:10.5px; color:rgb(var(--say)); opacity:.85; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
/* the lines start under the name, like a chat message under its author */
.wsaylines { padding:2px 12px 9px 6px }
.wsayline { display:grid; grid-template-columns:30px minmax(0, 1fr); column-gap:6px; align-items:baseline; color:var(--fg) }
.wsaycard .wsaytime { font:10px/1.5 var(--mono); color:var(--fg3); text-align:right; font-variant-numeric:tabular-nums }
.wsaycard .wsaytext { overflow-wrap:anywhere; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden }
.wsayline + .wsayline { margin-top:3px }
.wsayline:not(:last-child) .wsaytext { color:var(--fg2); opacity:.8 }
.wsayline:last-child { animation:wsayline .6s ease-out }
.wsaycard.wsaybump .wsayicon { animation:wsaybadge .5s cubic-bezier(.3,1.6,.5,1) }
/* a private message card stays until read - its ✕ closes it without reading: quiet at rest, an easy target on hover */
.wsaypmcard .wsayhead { padding-right:30px }
.wsayx { position:absolute; top:7px; right:7px; width:20px; height:20px; padding:0; border:0; border-radius:50%; cursor:pointer;
         display:grid; place-items:center; color:var(--fg2); background:rgba(255,255,255,.06); opacity:.5;
         transition:opacity .15s, background .15s, transform .15s, color .15s }
/* ⚠ `.wmap svg:not(.wleads)` gives every map svg the sea background and 100% width, and `.wmap.wsaton svg:not(.wleads)`
   (satellite, later in this file) repaints it #070b12 - so the icon's own sizing and background are !important */
.wmap .wsaycard .wsayx svg { display:block; width:9px !important; height:9px !important; background:none !important; border-radius:0 !important; margin:0 !important; pointer-events:none }
.wsaycard:hover .wsayx, .wsayx:focus-visible { opacity:.9 }
.wsayx:hover { opacity:1; color:var(--fg); background:rgba(var(--say),.24); transform:scale(1.1) }
.wsaycard.wsayout { opacity:0; transform:translateY(-6px) scale(.96); transition:opacity .26s, transform .26s }
@keyframes wsayin { from { opacity:0; transform:translateY(8px) scale(.92) } }
@keyframes wsayline { from { opacity:.2; transform:translateY(3px) } }
@keyframes wsaybadge { 45% { transform:scale(1.25) rotate(-10deg) } }
.wlead.wleadsay { stroke:rgba(254,188,52,.7) }
.wlead.wleadsay.wleadpm { stroke:rgba(57,211,182,.75) }
.wleaddot.wleadsaydot { fill:#febc34 }
.wleaddot.wleadsaydot.wleadpm { fill:#39d3b6 }
@media (prefers-reduced-motion: reduce) { .wsaycard, .wsayline:last-child, .wsaycard .wsayicon, .wsaycard .wsayicon::before { animation:none; transition:none } }

/* ---- Host a game -----------------------------------------------------------------------------
   A cloud in the same family as the game cards - mica, same radius, same type size - but VIOLET,
   because it is the one thing on this map you do rather than watch. Green already means "an open
   game is here" and orange means "someone is in a game"; a third meaning needed a third colour.
   Deliberately not expandable: it is a button, and there is nothing to unfold.
   ⚠ Positioned in BOX PIXELS by placeHost(), beside your own dot - except in `.corner`, where the
   lobby does not know where you are and it falls to the bottom RIGHT (bottom-left is the tally). */
/* ⚠ PLAIN INK ON THE WORDS, COLOUR ON THE FRAME AND THE FLAG. Violet lettering on violet-tinted
   mica was muddy (user, 2026-09-07); the text takes --fg (white in dark, near-black in light, so
   it stays readable in both) and the identity is carried by the border, the glow and the flag. */
/* ⚠ TWO HOMES (2026-09-07). Beside your own dot it is `.wfloat` and absolutely positioned by
   placeHost(), leader line and all - that box is the "this one is you" marker. With no dot to sit
   beside it goes into the tool row instead of the old restless bottom-right corner, and there
   `position:static` lets the row lay it out like every other control. */
.whostbtn { position:static; z-index:4; cursor:pointer; white-space:nowrap;
            display:flex; align-items:center; gap:6px;
            font:inherit; font-size:12px; font-weight:650; line-height:1;
            padding:7px 11px; border-radius:9px; text-decoration:none;
            color:var(--fg); border:1px solid var(--subsel);
            background:color-mix(in srgb, var(--subsel) 12%, var(--bg2));
            -webkit-backdrop-filter:blur(9px); backdrop-filter:blur(9px) }
/* ⚠ THE GLOW IS FOR FLOATING ONLY. Over the map it lifts the button off the imagery; in a tool
   row it is just a smear under a control that already sits on a flat surface (user, 2026-09-07). */
.whostbtn.wfloat { box-shadow:0 6px 20px color-mix(in srgb, var(--subsel) 38%, transparent) }
.whostbtn .whostemo { color:var(--subsel); font-size:13px; line-height:1 }
/* ---- Host Game, with nothing running: the words label the choice, the chips ARE the choice ----
   The Launcher starts either game, so this state has to ask which. The frame keeps the violet
   identity but stops behaving like a button - no pointer, no hover lift - and the two chips carry
   it instead, so there is never an ambiguous place to click. */
.whostbtn.whostsplit { cursor:default }
.whostbtn.whostsplit:hover { color:var(--fg); border-color:var(--subsel);
                             background:color-mix(in srgb, var(--subsel) 12%, var(--bg2)) }
.whostbtn.wfloat.whostsplit:hover { box-shadow:0 6px 20px color-mix(in srgb, var(--subsel) 38%, transparent) }
.whostpick { display:inline-flex; gap:4px; margin-left:2px }
.whostgame { display:inline-block; cursor:pointer; text-decoration:none;
             font-size:11px; font-weight:700; letter-spacing:.4px; line-height:1;
             padding:4px 7px; border-radius:6px;
             color:var(--fg); border:1px solid color-mix(in srgb, var(--subsel) 55%, transparent);
             background:color-mix(in srgb, var(--subsel) 22%, var(--bg2));
             transition:background-color .12s ease, border-color .12s ease }
.whostgame:hover { text-decoration:none;
                   background:color-mix(in srgb, var(--subsel) 46%, var(--bg2));
                   border-color:var(--subsel) }
.whostgame:active { transform:translateY(1px) }
.whostgame:focus-visible { outline:2px solid var(--subsel); outline-offset:2px }
/* The line from the Host button to your own dot - violet like the button, so it reads as "this
   one is you" rather than as another game's lead. */
.wleadhost { stroke:color-mix(in srgb, var(--subsel) 70%, transparent) }
.wleadhostdot { fill:var(--subsel) }

/* ---- YOUR OWN game on the map ----------------------------------------------------------------
   Same violet identity as the Host button, because it means the same thing: this one is you.
   A frame and the button, nothing louder - it is still a game card like the others. */
/* ⚠ QUIET. This marks a card as YOURS - it is not an alert. The first version put a bright violet
   outline on translucent mica and it pulled the eye off the whole map (user, 2026-09-07). A faint
   tint on the frame is enough; the Maximize button below says the rest.
   The HOST GAME button keeps its full violet - that one IS asking to be clicked. */
.wcloudmine { border-color:color-mix(in srgb, var(--subsel) 34%, var(--line)) }
/* ⚠ SOLID, LIKE .joinbtn - whose border is the same colour as its fill, so it reads frameless.
   Maximize is the SECONDARY action of the pair (Join is the one people came for), so it takes a
   desaturated slate-violet instead of the accent, and no contrasting outline at all. */
.joinbtn.minebtn { color:var(--fg) !important;
                   background:color-mix(in srgb, var(--subsel) 26%, var(--bg3));
                   border-color:color-mix(in srgb, var(--subsel) 26%, var(--bg3)) }
.joinbtn.minebtn:hover { filter:none;
                   background:color-mix(in srgb, var(--subsel) 42%, var(--bg3));
                   border-color:color-mix(in srgb, var(--subsel) 42%, var(--bg3)) }
.whostbtn:hover { color:var(--fg); border-color:var(--subsel);
                  background:color-mix(in srgb, var(--subsel) 26%, var(--bg2));
                  text-decoration:none }
.whostbtn.wfloat:hover { box-shadow:0 8px 26px color-mix(in srgb, var(--subsel) 52%, transparent) }
/* In a bar (the map's tool row, Hosted Games, under a narrow map) there is no frame (user 2026-09-15): the violet fades out at the
   edges - an inner feather plus a soft outer glow - so the button melts into the bar instead of being outlined. The floating one
   beside your own dot keeps its frame: that box is the "this one is you" marker. */
.whostbtn:not(.wfloat) { border-color:transparent; background:color-mix(in srgb, var(--subsel) 16%, transparent);
  box-shadow:inset 0 0 10px 1px color-mix(in srgb, var(--subsel) 34%, transparent), 0 0 18px -2px color-mix(in srgb, var(--subsel) 40%, transparent);
  transition:background-color .15s ease, box-shadow .15s ease }
.whostbtn:not(.wfloat):hover { border-color:transparent; background:color-mix(in srgb, var(--subsel) 25%, transparent);
  box-shadow:inset 0 0 12px 1px color-mix(in srgb, var(--subsel) 46%, transparent), 0 0 24px -2px color-mix(in srgb, var(--subsel) 55%, transparent) }
.whostbtn.whostsplit:not(.wfloat):hover { background:color-mix(in srgb, var(--subsel) 16%, transparent);
  box-shadow:inset 0 0 10px 1px color-mix(in srgb, var(--subsel) 34%, transparent), 0 0 18px -2px color-mix(in srgb, var(--subsel) 40%, transparent) }
.whostbtn:not(.wfloat) .whostgame { border-color:transparent; background:color-mix(in srgb, var(--subsel) 30%, transparent) }
.whostbtn:not(.wfloat) .whostgame:hover { border-color:transparent; background:color-mix(in srgb, var(--subsel) 50%, transparent) }
/* Anchored beside your own dot: placeHost() moves it onto the map overlay and sets left/top. */
.whostbtn.wfloat { position:absolute }
/* The old fixed corner. Dead unless something floats the button without giving it a dot. */
.whostbtn.wfloat.corner { right:8px; bottom:8px; left:auto !important; top:auto !important }
/* Narrow map: the tally on the left, Host Game on the right, in one row under the map (placeHost). */
.wnarrowbar { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:8px }
.wmap.wnarrow .wnarrowbar .wtally { margin-top:0 }
.whostbtn.wunder { flex:none; padding:8px 12px; font-size:13px; border-radius:10px }
/* Mica: the map shows through, but the text never fights it. Same lit top edge as the tooltip
   and the bands, so this reads as part of the same surface family.
   ⚠ A FIXED WIDTH, NOT max-content. The player list is the one value whose length nobody
   controls, and while the card sized itself to its content that list decided how wide the card
   got. Fixing the width makes names WRAP instead of stretching, and it makes several cards line
   up with each other (user, 2026-09-06). */
.wcloud { position:absolute; pointer-events:auto; width:232px; max-width:calc(100vw - 24px);
          border-radius:11px; overflow:hidden; line-height:1.45;
          border:1px solid color-mix(in srgb, var(--ok) 34%, var(--line));
          background:color-mix(in srgb, var(--bg2) 88%, transparent);
          box-shadow:0 10px 30px rgba(0,0,0,.38),
                     0 0 0 1px color-mix(in srgb, var(--ok) 10%, transparent);
          -webkit-backdrop-filter:blur(16px) saturate(150%);
          backdrop-filter:blur(16px) saturate(150%);
          transition:left .28s cubic-bezier(.2,.7,.3,1), top .28s cubic-bezier(.2,.7,.3,1) }
.wcloud::before { content:''; position:absolute; left:0; right:0; top:0; height:1px;
                  background:linear-gradient(90deg, transparent,
                             color-mix(in srgb, var(--ok) 55%, transparent), transparent) }
/* Same reason as .wtip: `.card span` shouts at every span inside a card, and this IS one. */
.wcloud span, .wcloud i { text-transform:none; letter-spacing:0; font-size:inherit;
                          color:inherit; font-style:normal }
/* Line one: the short facts. Line two: the name, full width and single-line. */
.wcloudhead { padding:6px 10px 6px; border-bottom:1px solid var(--line);
              background:color-mix(in srgb, var(--ok) 9%, transparent) }
.wcloudmeta1 { display:flex; align-items:center; gap:5px; flex-wrap:nowrap; min-width:0 }
.wcloud .wcloudflag { flex:none; font-size:12px; line-height:1; display:inline-flex; align-items:center }
.wcloud .wcloudname { display:block; margin-top:3px; font-size:12px; font-weight:680;
                      color:var(--fg); line-height:1.3;
                      white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
/* ⚠ THE VERSION GETS ITS OWN COLOUR, PICKED FROM ITS OWN TEXT. It briefly wore the status green
   (wrong - it is not a status) and then plain grey (worse - unreadable and dull, user
   2026-09-06). Each distinct version string now hashes to one of eight hues, so "EE2 1.6" and
   "AOS 1.6" are always told apart at a glance and are always the SAME colour from one visit to
   the next - a hash, not first-seen order, which would shuffle on every reload.
   ⚠ ONE HUE PER CHIP, LIGHTNESS FROM THE THEME. --vlum/--vbg/--vbd are set in the theme blocks,
   so the same hue stays readable on the light ground and on the dark one. */
.wcloud .wcloudtag { flex:none; font-size:10px; font-weight:650; padding:1px 6px; border-radius:5px;
                     white-space:nowrap;
                     color:hsl(var(--vh, 265) 72% var(--vlum));
                     border:1px solid hsl(var(--vh, 265) 62% var(--vlum) / var(--vbd));
                     background:hsl(var(--vh, 265) 72% var(--vlum) / var(--vbg)) }
.wcloud .wcloudtag.wv0 { --vh:265 }   /* violet  */
.wcloud .wcloudtag.wv1 { --vh:330 }   /* pink    */
.wcloud .wcloudtag.wv2 { --vh:28  }   /* orange  */
.wcloud .wcloudtag.wv3 { --vh:185 }   /* teal    */
.wcloud .wcloudtag.wv4 { --vh:212 }   /* blue    */
.wcloud .wcloudtag.wv5 { --vh:95  }   /* lime    */
.wcloud .wcloudtag.wv6 { --vh:48  }   /* amber   */
.wcloud .wcloudtag.wv7 { --vh:0   }   /* rose    */
.wcloud .wcloudago { display:flex; align-items:center; gap:3px; font-size:10.5px; color:var(--fg2);
                     white-space:nowrap; font-variant-numeric:tabular-nums; margin-left:auto }
/* ⚠ PAIRS, NOT ONE FACT PER LINE. Each of these is two or three characters of value next to a
   one-character label, so a full row each wasted half the card (user, 2026-09-06: "we could put
   in pairs two things because there is a space"). Map spans both columns - it is the long one. */
.wcloudmeta { display:grid; grid-template-columns:1fr 1fr; gap:3px 8px; padding:7px 10px 5px;
              font-size:11.5px }
.wcloud .wfact { display:flex; align-items:baseline; gap:5px; min-width:0 }
.wcloud .wfactwide { grid-column:1 / -1 }
/* The emoji is the label. Slightly dimmed so the VALUE is what the eye lands on.
   ⚠ FIXED WIDTH, GLYPH CENTRED IN IT. Without a width each icon took its own advance, and the
   advance differs per glyph: an emoji carries generous side bearings, a plain text arrow like ⇅
   almost none. That is why ⏳ looked pushed right of the ⚔ above it while ⇅ started to the LEFT of
   the 🏆 above it (user, 2026-09-08) - the grid columns were aligned all along, the ink inside
   them was not. One box for every icon lines up both the icons AND the values after them. */
.wcloud .wfi { flex:none; width:15px; text-align:center; font-size:11px; opacity:.85;
               line-height:1 }
.wcloud .wfv { color:var(--fg); font-weight:600; min-width:0;
               overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
/* ⚠ ONE FLOWING BLOCK, NOT TWO COLUMNS. As a grid the names wrapped INSIDE their own column, so
   the second line began under the roster and left the count column's width empty beside it (user,
   2026-09-06). Inline flow means the count is just the first thing on the first line and every
   wrapped line starts at the left edge, where there is room for another name. */
.wcloudteam { display:block; margin:0 10px; padding:6px 0 7px; font-size:11.5px; line-height:1.5;
              border-top:1px solid color-mix(in srgb, var(--line) 70%, transparent) }
.wcloud .wcloudcount { display:inline-flex; align-items:baseline; gap:5px; white-space:nowrap;
                       margin-right:7px; font-variant-numeric:tabular-nums }
.wcloud .wcloudcount .wfv { color:var(--ok); font-weight:700 }
/* Full is not an error, but it is worth seeing before you click Join rather than after. */
.wcloud .wcloudcount.wfull .wfv { color:var(--warn) }
.wcloud .wcloudnick { color:var(--accent); font-weight:600;
                      overflow-wrap:anywhere; word-break:break-word }
.wcloudfoot { padding:0 10px 9px }
.wcloudfoot .joinbtn { display:block; text-align:center }
/* ---- collapsed games ------------------------------------------------------------------------
   ⚠ ONE PLACEMENT SYSTEM FOR ALL OF THEM. Closed and Started used to dock in rows at the top and
   bottom of the map, which was a second layout that knew nothing about the first - so open cards
   were placed on top of chips. They are ordinary placed boxes now; the only difference is that
   they are COLLAPSED, so they need much less sky and the joinable games still get the good spots
   (user, 2026-09-06). */
/* wchip is the COLLAPSED size, whatever the game's state - an open game the reader collapsed by
   clicking it wears this too, and keeps its green frame. */
.wcloud { cursor:pointer }
/* ⚠ FIT THE CONTENT, WITHIN BOUNDS. A fixed width cut the running time off whenever the version
   name was long; a collapsed box is a header row, so let it be as wide as that row needs and cap
   it instead (user, 2026-09-06). The name still ellipsises, so the cap is never exceeded.
   ⚠ THE FLOOR WAS DOING THE DAMAGE, not the width. `width:max-content` was already right, but a
   186px `min-width` under it meant a chip whose content was narrower simply got padded out - and
   the narrowest chips are exactly the [Closed] ones, which carry no "started ago" at all, so a
   short game name left a visible empty strip on the right (user, 2026-09-08). 128px is now only a
   click-target floor: wide enough to stay comfortably clickable and to look like a card rather
   than a label, narrow enough that a short Closed chip actually shrinks.
   The neighbouring boxes follow for free - layoutClouds() measures each card with
   getBoundingClientRect(), so a chip that got narrower reserves less space and the others pack
   closer to it on the next layout. */
.wcloud.wchip { width:max-content; min-width:128px; max-width:270px;
                transition:left .28s cubic-bezier(.2,.7,.3,1), top .28s cubic-bezier(.2,.7,.3,1),
                           border-color .18s ease }
.wcloud.wchip .wcloudhead { border-bottom:0; background:none; padding:5px 9px 6px }
.wcloud.wchip:hover { border-color:var(--fg3) }
/* Expanded, it is a full card again - same width as an open game so the map reads evenly. */
.wcloud.wchip.wopened { width:232px }
/* An open game keeps the full width whether or not it is currently collapsed by hand. */
.wcloud.wopen.wchip { width:232px }
.wcloud.wchip.wopened .wcloudhead { border-bottom:1px solid var(--line); padding:6px 10px 5px }
/* The three states, on the frame and on the status word alike. */
.wcloud.ggood { border-color:color-mix(in srgb, var(--ok) 40%, var(--line)) }
.wcloud.gwarn { border-color:color-mix(in srgb, var(--warn) 46%, var(--line));
                box-shadow:0 8px 24px rgba(0,0,0,.34),
                           0 0 0 1px color-mix(in srgb, var(--warn) 12%, transparent) }
.wcloud.glive { border-color:color-mix(in srgb, var(--info) 50%, var(--line));
                box-shadow:0 8px 24px rgba(0,0,0,.34),
                           0 0 0 1px color-mix(in srgb, var(--info) 14%, transparent) }
.wcloud .wcloudst { font-size:9.5px; font-weight:700; letter-spacing:.3px; white-space:nowrap;
                    padding:1px 5px; border-radius:4px; text-transform:uppercase;
                    border:1px solid currentColor }
.wcloud .wcloudst.ggood { color:var(--ok);
                          background:color-mix(in srgb, var(--ok) 14%, transparent) }
.wcloud .wcloudst.gwarn { color:var(--warn);
                          background:color-mix(in srgb, var(--warn) 15%, transparent) }
.wcloud .wcloudst.glive { color:var(--info);
                          background:color-mix(in srgb, var(--info) 15%, transparent) }
/* A collapsed game's leader line is quieter and takes the game's own colour. */
.wlead.wleaddock { stroke-dasharray:2 6; opacity:.75 }
.wlead.wleaddock.gwarn { stroke:color-mix(in srgb, var(--warn) 60%, transparent) }
.wlead.wleaddock.glive { stroke:color-mix(in srgb, var(--info) 60%, transparent) }
.wleaddot.gwarn { fill:var(--warn) }
.wleaddot.glive { fill:var(--info) }

/* ⚠ THE PHONE LAYOUT. Not a media query: the map can be narrow inside a wide window, so the
   decision is made from the element's own measured width in layoutClouds() and lands here as a
   class. The cards stop being an overlay and become an ordinary list under the map. */
.wclouds.wstacked { position:static; inset:auto; pointer-events:auto; display:grid;
                    grid-template-columns:repeat(auto-fill, minmax(210px, 1fr));
                    gap:8px; margin-top:10px }
.wclouds.wstacked .wcloud { position:static; width:auto; max-width:none; transition:none }
.wmap.wnarrow .wtally { position:static; margin-top:8px; display:inline-block }
/* On a phone every game is in the one list under the map - there is no sky to dock into. */
.wclouds.wstacked .wcloud.wchip { max-width:none; width:auto }

/* A pop, then a minimise. Scale from the bottom edge, which is the side the leader line leaves
   from, so the card looks like it grew out of its own dot. */
.wcloud.wpop { animation:wpop .34s cubic-bezier(.2,1.5,.4,1) both }
.wcloud.wunpop { animation:wunpop .26s cubic-bezier(.4,0,.7,.2) both; pointer-events:none }
@keyframes wpop {
  from { opacity:0; transform:scale(.72) translateY(8px) }
  to   { opacity:1; transform:none } }
@keyframes wunpop {
  from { opacity:1; transform:none }
  to   { opacity:0; transform:scale(.66) translateY(10px) } }
@media (prefers-reduced-motion: reduce) {
  .wcloud { transition:none }
  .wcloud.wpop, .wcloud.wunpop { animation:none }
  .wcloud.wunpop { opacity:0 }
  .wlead { animation:none } }
/* ---- the corner tally --------------------------------------------------------------------- */
.wtally { position:absolute; left:10px; bottom:10px; z-index:5; pointer-events:none;
          padding:7px 11px 8px; border-radius:11px; line-height:1.25;
          border:1px solid var(--line);
          background:color-mix(in srgb, var(--bg2) 86%, transparent);
          box-shadow:0 8px 24px rgba(0,0,0,.32);
          -webkit-backdrop-filter:blur(14px) saturate(150%);
          backdrop-filter:blur(14px) saturate(150%) }
.wtally::before { content:''; position:absolute; left:0; right:0; top:0; height:1px;
                  background:linear-gradient(90deg, transparent,
                             color-mix(in srgb, var(--fg) 20%, transparent), transparent) }
.wtally span, .wtally i { text-transform:none; letter-spacing:0; color:inherit; font-style:normal }
.wtallyn { display:flex; align-items:baseline; gap:6px }
.wtally .wtallyv { font-size:19px; font-weight:700; color:var(--fg);
                   font-variant-numeric:tabular-nums; line-height:1 }
.wtally .wtallyl { font-size:11px; color:var(--fg2) }
/* ⚠ WRAPS TO A SECOND LINE, AND ONLY WHEN THERE IS ONE. Two chips sit on one row; the optional
   Hosting and Website chips push it to two, which is where "Hosting Game" lands under
   "Multiplayer" and "Website" on the right (user, 2026-09-08). The max-width is what forces the
   break at two per row rather than letting four spread across the map. */
.wtallysub { display:flex; flex-wrap:wrap; gap:3px 10px; margin-top:4px; max-width:210px }
.wtally .wtallychip { display:flex; align-items:center; gap:4px; font-size:11px }
.wtally .wtallydot { width:7px; height:7px; border-radius:50%; flex:none }
/* ⚠⚠ THIS IS THE MAP'S ONLY ON-SCREEN LEGEND - the corner tally names the colours in words
   ("Multiplayer", "Singleplayer") beside a dot of each. A dot colour changed in the rules but not
   here leaves the map explaining itself WRONGLY, which is worse than not explaining itself: it was
   still showing orange for Multiplayer after the dots went pink (user, 2026-09-08).
   ⚠ Anything added to the four-line colour table further down MUST get a chip here too. */
.wtally .mp .wtallydot { background:var(--sr8) }
.wtally .sp .wtallydot { background:var(--accent) }
.wtally .web .wtallydot { background:var(--sr2) }
.wtally .host .wtallydot { background:var(--ok) }
.wtally .wtallycn { font-weight:700; color:var(--fg); font-variant-numeric:tabular-nums }
.wtally .wtallycl { color:var(--fg2) }

/* ---- the map's switches, in the tab bar ------------------------------------------------------
   ⚠ THEY USED TO FLOAT OVER THE MAP, where they were small and sat on top of the thing they
   control (user, 2026-09-07). In the bar they can be a readable size and carry a WORD, which is
   what makes a three-state control legible at all - the colour says which state, the word says
   what it means, and the tooltip says what pressing it will do. */
.wtools { display:flex; align-items:center; gap:6px }
.maptool { cursor:pointer; font:inherit; font-size:12px; font-weight:600; line-height:1;
           padding:5px 9px; border-radius:7px; white-space:nowrap;
           color:var(--fg2); border:1px solid var(--line); background:var(--bg2) }
.maptool:hover { color:var(--fg); border-color:var(--fg3) }
/* Satellite: blue for night, orange for day, plain for off - so the button itself says which
   Earth you are looking at without reading the word. */
.maptool.mt-sat.night { color:var(--accent); border-color:var(--accent);
                        background:color-mix(in srgb, var(--accent) 12%, var(--bg2)) }
.maptool.mt-sat.day   { color:var(--warn); border-color:var(--warn);
                        background:color-mix(in srgb, var(--warn) 14%, var(--bg2)) }
/* Detail: Auto is the quiet default; HQ and LQ are deliberate choices, so they say so. */
.maptool.mt-qual.hq { color:var(--accent); border-color:var(--accent);
                      background:color-mix(in srgb, var(--accent) 12%, var(--bg2)) }
.maptool.mt-qual.lq { color:var(--fg3); border-style:dashed }
.maptool.mt-sound.off { color:var(--fg3) }
/* ---- satellite mode --------------------------------------------------------------------------
   The imagery IS the land now, so the filled grey shape has to get out of its way - what stays is
   a thin border line, because the whole point of the vector layer over the photo is that you can
   still see where one country ends and the next begins.
   ⚠⚠ THE INK IS FIXED, NOT --fg. The picture no longer follows the theme (Auto follows the clock),
   so theme-following ink made the Light theme's borders vanish: dark ink measured 1.07:1 median
   over the tiles, this light ink 3.4:1 over both day and night (2026-09-11). */
.wmap:not(.wsaton) .wsat { display:none }
.wmap.wsaton .wlandpath { fill:none; stroke:rgba(230,233,239,.42); stroke-width:.6 }
.wmap.wsaton .wgrat line { stroke:rgba(230,233,239,.09) }
/* Under the photo there is nothing to see, so the sea tint would only tint the letterbox strip. */
.wmap.wsaton svg:not(.wleads) { background:#070b12 }
.wmap.wsaton.wfilled .wmapbox { background:#070b12 }
.wreset { position:absolute; right:8px; top:8px; z-index:5; cursor:pointer;
          font:inherit; font-size:11px; padding:3px 9px; border-radius:6px;
          color:var(--fg2); border:1px solid var(--line);
          background:color-mix(in srgb, var(--bg2) 88%, transparent);
          -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px) }
.wreset:hover { color:var(--fg); border-color:var(--fg3) }
/* ⚠ PINK, and orange moved to the web dots (user, 2026-09-08). --sr8 is the palette's pink; do
   not inline a hex here, or the two themes stop agreeing with each other. */
.wdot.wmp { fill:var(--sr8) }
.whalo.wmp { fill:color-mix(in srgb, var(--sr8) 26%, transparent) }
.wpt:hover .wdot.wmp, .wpt:focus .wdot.wmp { fill:var(--sr8) }
.wpt:hover .whalo.wmp, .wpt:focus .whalo.wmp {
  fill:color-mix(in srgb, var(--sr8) 45%, transparent) }

/* ---- THE THREE DOT STATES, AND EVERYTHING AROUND A DOT MATCHES IT ---------------------------
   ⚠ THESE COME LAST ON PURPOSE. `.wdot.wmp` is orange here but green higher up, and `.whalo.wmp`
   likewise - equal specificity, so source order decides and anything defined earlier loses. That
   is exactly how the host state got lost: `.whalo.whost` (green, further up) was being beaten by
   `.whalo.wmp` (orange, just above), while `.wdot.whost` had no fill of its own at all - so a
   player hosting a joinable game showed as an ORANGE dot with a blue ripple and only a small
   green pip from the leader line on top (user, 2026-09-07).

     blue   --accent  a Launcher is open, nothing more
     pink   --sr8     in a multiplayer game
     green  --ok      hosting a game you can join   <- wins over pink, it is the stronger fact
     orange --sr2     signed in on the WEBSITE only - no lobby, no Launcher (.wweb)

   ⚠ THIS BLOCK IS THE MAP'S ONLY LEGEND. There is no on-screen key, so a colour changed in the
   rules without changing these four lines leaves nothing anywhere that says what it means.

   The ripple and the halo now BOTH take the dot's colour, because flash() copies the dot's own
   class list onto the ring - so a new state cannot be added here and forgotten there. */
.wdot.whost { fill:var(--ok) }
.whalo.whost { fill:color-mix(in srgb, var(--ok) 42%, transparent) }
.wpt:hover .wdot.whost, .wpt:focus .wdot.whost { fill:color-mix(in srgb, var(--ok) 80%, white) }
.wpt:hover .whalo.whost, .wpt:focus .whalo.whost {
  fill:color-mix(in srgb, var(--ok) 60%, transparent) }
/* The ping ripple, one rule per state, same order of precedence as the dots above. */
/* ⚠ THE RIPPLE FOLLOWS ITS DOT. flash() copies the dot's class list onto the ring, so every dot
   colour needs its twin here - and .wmp was left orange when the dot went pink, which is exactly
   the "pink dot, orange waves" that resulted (user, 2026-09-08). */
.wflash.wmp   { stroke:var(--sr8) }
.wflash.whost { stroke:var(--ok) }
.wflash.wweb  { stroke:var(--sr2) }

/* ---- TIMEFRAME BANDS: two panes of tinted glass, side by side ------------------------------
   The two timeframes are different KINDS of fact, and a caption over a shared row was not enough
   to say so (user, 2026-09-05: "Last 24h could be next to Right now, just in separate frame box
   ... more colored to themes, like frame with slight color, transparency, mica-a-like windows
   style"). Each band is its own translucent frame and the TINT carries the meaning: the live one
   leans into the accent and breathes, the history one settles into neutral grey.

   Every colour is mixed from the theme tokens, so both bands re-tint themselves in dark mode and
   in light mode without a second set of rules. */
.bands { display:flex; flex-wrap:wrap; gap:var(--s3); align-items:flex-start }
/* ⚠ THE TWO BANDS ARE ONE DESIGN, NOT TWO. They were given their own border colours, their own
   backgrounds and their own edge highlights, which made them read as two unrelated widgets that
   happened to be adjacent (user, 2026-09-05: "Right now box has totally different frame and style
   than 24h one. It looks just weird."). Frame, fill and edge are now shared and stated ONCE here;
   a band may only tint its corner glow and its heading. */
.band { flex:1 1 auto; min-width:min(100%, 320px); position:relative; overflow:hidden;
        padding:13px 18px 6px; border-radius:14px;
        border:1px solid var(--line);
        background:radial-gradient(130% 150% at 0% 0%, var(--bandglow), transparent 62%),
                   color-mix(in srgb, var(--bg2) 72%, transparent);
        -webkit-backdrop-filter:blur(14px) saturate(140%);
        backdrop-filter:blur(14px) saturate(140%);
        --bandglow:transparent; --bandink:var(--fg3) }
/* The lit top edge is what stops the frame reading as a flat box - it is the whole trick. */
.band::before { content:''; position:absolute; left:0; right:0; top:0; height:1px;
                background:linear-gradient(90deg, transparent,
                           color-mix(in srgb, var(--fg) 18%, transparent), transparent) }
/* ⚠ THE LIVE BAND TAKES ONLY WHAT IT NEEDS. Both bands were `flex:1 1 auto`, so the spare width
   was split evenly however little each held - and Right Now (two 2-column tables) ended up as
   wide as the 24-hour band (a 5-column ladder, transport and now the traffic ladder), which read
   as the small band being oversized (user, 2026-09-07: "Right Now seems to be bigger than Ladder
   div. We could make that one smaller to fit whats there, and the other one wider"). grow:0 sizes
   it to its content; the other band absorbs the rest. Both still wrap to full width when narrow,
   because the shared min-width is untouched. */
.band-live { flex-grow:0;
             --bandglow:color-mix(in srgb, var(--accent) 11%, transparent);
             --bandink:color-mix(in srgb, var(--accent) 55%, var(--fg2)) }
.band-past { flex-grow:1; --bandglow:color-mix(in srgb, var(--fg3) 7%, transparent) }
.bandhead { display:flex; align-items:center; gap:8px; margin:0 0 10px;
            font-size:11.5px; font-weight:700; letter-spacing:1px; text-transform:uppercase;
            color:var(--bandink) }
/* ⭐ THE WINDOW PICKER LOOKS LIKE A CONTROL NOW. It was caption type with a 4px caret - it read
   as a heading that happened to have a speck next to it, and nobody could tell it was the thing
   that changes what both panels show (user, 2026-09-07: "The dropdown for selecting 24h, last
   hour, etc. is not very noticeable ... the dropdown be more noticeable, looking like a button").
   A pill: its own border, its own glass fill, a caret with room around it, and it lights on hover
   and while focused. It still wears the caption's type, because it IS still the band's caption. */
.bandsel { position:relative; display:inline-flex; align-items:center;
           padding:3px 8px 3px 10px; border-radius:999px;
           border:1px solid color-mix(in srgb, var(--line) 90%, transparent);
           background:color-mix(in srgb, var(--bg2) 70%, transparent);
           -webkit-backdrop-filter:blur(10px) saturate(140%);
           backdrop-filter:blur(10px) saturate(140%);
           transition:border-color .12s ease, background-color .12s ease }
.bandsel:hover { border-color:color-mix(in srgb, var(--accent) 55%, var(--line));
                 background:color-mix(in srgb, var(--bg2) 88%, transparent) }
.bandsel:focus-within { border-color:var(--accent) }
.bandsel::after { content:''; position:absolute; right:9px; top:50%; margin-top:-1px;
                  width:0; height:0; pointer-events:none;
                  border-left:3.5px solid transparent; border-right:3.5px solid transparent;
                  border-top:4px solid var(--bandink) }
.bandsel select { font:inherit; color:inherit; background:transparent; border:0; margin:0;
                  padding:0 16px 0 0; cursor:pointer; outline:none;
                  -webkit-appearance:none; appearance:none }
/* The pill itself shows focus (:focus-within above), so the control does not also need a ring
   around the bare <select> inside it. */
.bandsel select:focus-visible { outline:none }
/* The open list is painted by the OS, which does not inherit the caption's shouting. */
.bandsel option { text-transform:none; letter-spacing:0; font-weight:400;
                  color:var(--fg); background:var(--bg2) }
/* A slow pulse on the live band only: it says "this number is changing under you" without a
   single word of caption. */
.banddot { width:7px; height:7px; border-radius:50%; background:var(--ok); flex:0 0 auto;
           animation:bandpulse 2.6s ease-out infinite }
/* The history band gets a dot too, so the two captions are the same shape and the picker beside
   it reads as a caption rather than as stray furniture (user, 2026-09-07). It is NOT green and it
   does NOT pulse: green-and-breathing is how the live band says "changing under you", and a
   window of finished time is the opposite claim. */
.band-past .banddot { background:var(--fg3); animation:none;
                      box-shadow:0 0 0 3px color-mix(in srgb, var(--fg3) 16%, transparent) }
@keyframes bandpulse {
  0%   { box-shadow:0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent) }
  70%  { box-shadow:0 0 0 7px transparent }
  100% { box-shadow:0 0 0 0 transparent } }
@media (prefers-reduced-motion: reduce) { .banddot { animation:none } }
/* Inside a band the frame IS the container, so the panels drop their own card chrome - a card
   inside a card reads as clutter. */
.band .panel { background:transparent; border:0; box-shadow:none; padding:0 0 10px }
/* ⚠ COLUMN GAP, not the row gap. Two tables abutting at 22px read as one wide table with a
   stray header in the middle (user: "Winning transport is way too close Join ladder"). */
.band .panels { gap:var(--s2) 38px }
/* ⚠⚠ AND THE GAP HAS TO BE THE ONLY THING BETWEEN THEM. `.panels > .panel` carries
   min-width:230px, which is right for a free-standing card but not inside a band, where a panel
   has no chrome of its own: a 2-column table narrower than 230px was padded out to it, and the
   dead space read as a WIDER GAP than the identical 38px between two wide tables (user,
   2026-09-07: "the break between PLAYERS BY COUNTRY and GAME VERSIONS is bigger than between
   JOIN LADDER and WINING TRANSPORT"). Both gaps were always 38px; only one had padding beside
   it. Content-sized here, so the gap is the gap. */
.band .panels > .panel { min-width:0 }
.morerow td { padding-top:8px; border-bottom:0 }
.morelink { cursor:pointer; color:var(--accent); font-size:12.5px; user-select:none }
.morelink:hover { text-decoration:underline }
.panels tr:last-child td { border-bottom:0 }
.panels th:last-child, .panels td:last-child { padding-right:0 }
.panelsub { margin:-4px 0 8px; font-size:11.5px }
.panels .empty { padding:12px 10px }

/* ⚠⚠ `.card span` (app.css:172) sets uppercase + letter-spacing + --fg3 + 12px on EVERY span
   inside a card, and these panels ARE cards - at (0,1,1) it beats a bare class. That single rule
   greyed out and shouted the transport names for days. Neutralised here; the semantic spans in a
   number cell are scoped tighter still, so they keep their green/red. */
.panels td span { text-transform:none; letter-spacing:0; font-size:inherit; color:inherit }
/* A transport is a name, not disabled text. */
.panels td .tname { color:var(--accent); font-weight:500 }

/* ---- join ladder: a rung, then how it broke down ---------------------------------------------
   ⚠ THE SUB-ROWS DO NOT TOUCH THE COLOURS. They reuse the same .ok/.fail/.warn spans as the rung
   above them and only step DOWN in weight and size, so the green/red reading the user asked to
   keep is identical at both levels (user, 2026-09-05: "I like the current style with green/red
   numbers so dont break it while enhancing").
   ⚠ These come AFTER `.panels tr:last-child td` on purpose - equal specificity, so source order
   is what lets a sub-row drop its border. */
.panels tr.subrow td { border-bottom:0; padding-top:2px; padding-bottom:2px; font-size:13px }
.panels tr.subrow td.plabel { padding-left:15px; color:var(--fg2); font-weight:400 }
.panels tr.subrow td.pnum { font-weight:500 }
/* The rung row keeps its full weight and gets the rule that separates one group from the next. */
.panels tr.rungrow td { border-bottom:0; padding-top:7px }
.panels tr.rungrow:not(:first-child) td { border-top:1px solid
                                          color-mix(in srgb,var(--line) 55%,transparent) }

/* -- stat cards: the number is the point, the label is not -----------------------------------
 * User, 2026-09-04: "Make numbers bigger, text short", and the sub-line needed to be readable
 * rather than a whisper - it was --fg3, the same grey as disabled text. */
/* ⚠ THE VALUE SHRINKS RATHER THAN WRAPPING. Most are two or three digits, but a few are words -
   "3 m 39 s ago" wrapped onto a second line and pushed the label out of the card (user,
   2026-09-04). statValue() in app.js picks the bucket from the string length, so this is plain
   CSS with no container-query support to depend on. */
.card b { font-size:34px; line-height:1.05; letter-spacing:-1px; white-space:nowrap;
          display:block; overflow:hidden; text-overflow:ellipsis }
.card b.v-med { font-size:24px; letter-spacing:-.5px }
.card b.v-long { font-size:18px; letter-spacing:0 }
.card b.v-xlong { font-size:15px; letter-spacing:0 }
.card span { font-size:11.5px }
.card b.ok { color:var(--ok) } .card b.warn { color:var(--warn) } .card b.fail { color:var(--fail) }
.card .sub { display:block; color:var(--fg2); font-size:11.5px; margin-top:3px;
             text-transform:none; letter-spacing:0 }
/* A trim, not a squeeze: 190 -> 168 fits one more card per row on a wide screen without the
   numbers starting to crowd their labels (user: "SLIGHTLY less width, but dont reduce
   extremelly"). */
.cards.wide { grid-template-columns:repeat(auto-fill,minmax(168px,1fr)) }

/* ⭐ A CARD IS AS WIDE AS ITS LABEL NEEDS. The grid gave every card an identical track, so a long
   label had nowhere to go but a second line - "RECEIVED FROM PLAYERS" wrapped on both the Bridge
   and the Live Service tabs (user, 2026-09-07: "perhaps just wider tab in such case? So that it
   gets wider if description text needs it?"). Shortening the label was the other option and it
   loses meaning: "Received by server" says less, not more.
   Flex sizes each card to its CONTENT and then shares the leftover width, so a long label widens
   its own card instead of pushing text onto a second line, and rows still come out flush. The
   min-widths are the old grid tracks, so nothing gets narrower than it used to be.
   ⚠ The two grid-template rules above are left in place deliberately: they are dead under
   `display:flex`, and they are the fallback if this block is ever removed. */
/* ⚠ `flex-grow:0` IS THE WHOLE POINT, and getting it wrong once made these stretch edge to edge
   on any tab with only a few cards (user, 2026-09-07: "these buttons are very wide, to fit 100%
   of width of the screen. That's not what I asked for"). The grid this replaces used `auto-fill`,
   which lays down full-width tracks and leaves the spare ones EMPTY - cards never stretched. So:
   grow 0, shrink 0, size to content, floor at the old track width. A card is then exactly as wide
   as it was before UNLESS its label or its value needs more, which was the entire request. */
.cards { display:flex; flex-wrap:wrap; align-items:stretch }
.cards > .card { flex:0 0 auto; min-width:190px }
.cards.wide > .card { min-width:168px }
/* ...and the label must actually REFUSE to wrap, or flex has no reason to make room for it. */
.cards .card > span { white-space:nowrap }

/* ================ STAT-CARD GRIDS: the same glass the bands are made of ======================
 * User, 2026-09-07: "this text 'Matches', 'Peer links' is grayed, while it's actually quite
 * important information to read what it is ... these grids are common and deserve CSS
 * improvement to be WOW but without ruining modern style ... description with some different
 * background blurred so that it doesnt look like separate, but like mica-effect".
 *
 * So this is NOT a new look - it is the .band treatment (translucent fill, backdrop blur, a lit
 * top edge, a corner glow) brought to the cards, so the page reads as one material instead of
 * two. Three moves, in order of what the reader notices:
 *   1. the VALUE stays the hero - untouched, see the specificity warning below;
 *   2. the LABEL climbs out of disabled-grey (--fg3) to --fg2 with real weight;
 *   3. the SUB becomes a footer BAND inside the card - full-bleed, tinted, hairline above -
 *      so it belongs to the card rather than floating under it.
 *
 * ⚠⚠ NOTHING HERE MAY SET `color` ON `.card b`. `.cards .card b` and `.card b.ok` have the SAME
 * specificity (0,2,1), so a colour here would win on source order and silently kill the green /
 * amber / red numbers the user asked twice to keep ("I like the current style with green/red
 * numbers so dont break it while enhancing"). The card is restyled AROUND the number.
 * ⚠ Scoped to `.cards .card` on purpose: `.card` alone is also the generic block container and
 * the panel inside a .band, neither of which wants any of this.
 */
/* ⚠⚠ THE FIRST ATTEMPT PUT THE EMPHASIS EXACTLY BACKWARDS, and it is worth recording why.
 * The sub-line got the tinted, hairline-topped "mica" band and the label stayed grey - so the
 * least important thing on the card (a repeated "today") became the most decorated, while the
 * one word that says WHAT the number is stayed nearly invisible (user, 2026-09-07: "description
 * is almost invisible, while less-important 'today', 'today' has its own background ... realize
 * whats important and should be visible, and what's not and should be less noticeable").
 *
 * The order of importance on one of these cards is not arguable:
 *      1. THE NUMBER      - the reason the card exists. 34px, untouched below.
 *      2. THE LABEL       - what that number is. Caps, tinted, properly legible.
 *      3. the sub-line    - a qualifier ("today", "reporting traffic"). Quiet. No decoration.
 * Decoration now follows that order instead of contradicting it. The card KEEPS the glass
 * surface - that part was never the problem, and it is what matches the .band panels.
 */
.cards .card {
  overflow:hidden;
  padding:12px 14px; border-radius:12px;
  border:1px solid color-mix(in srgb, var(--line) 88%, transparent);
  background:radial-gradient(125% 145% at 0% 0%, var(--cardglow), transparent 62%),
             color-mix(in srgb, var(--bg2) 74%, transparent);
  -webkit-backdrop-filter:blur(14px) saturate(140%);
  backdrop-filter:blur(14px) saturate(140%);
  --cardglow:color-mix(in srgb, var(--accent) 7%, transparent);
}
/* The lit top edge - the whole trick that stops a translucent panel reading as a flat box. It is
   copied deliberately from .band::before rather than re-invented, so the two match exactly. */
.cards .card::before { content:''; position:absolute; left:0; right:0; top:0; height:1px;
                       background:linear-gradient(90deg, transparent,
                                  color-mix(in srgb, var(--fg) 16%, transparent), transparent) }
/* ⭐ THE LABEL, RANK 2. It was --fg3 - the same grey this sheet uses for DISABLED text, which is
   why it read as ignorable furniture. Caps and letter-spacing were already right; what it needed
   was a colour of its own. Accent-tinted rather than plain --fg so it is legibly DIFFERENT from
   the number above it instead of competing with it, and the mix keeps real contrast in both
   themes (light: deep blue on white; dark: pale blue on near-black). */
.cards .card > span {
  color:color-mix(in srgb, var(--accent) 66%, var(--fg));
  font-weight:650; font-size:11px; letter-spacing:.85px;
}
/* ⭐ THE SUB-LINE, RANK 3 - and rank 3 gets NO background, NO border and no band. It sits
   directly under the label it qualifies (an `auto` top margin pushed it to the card's floor and
   made it read as a separate thing, which is the opposite of belonging to the label). */
.cards .card .sub {
  display:block; margin:3px 0 0; padding:0;
  background:none; border:0;
  color:var(--fg3); font-size:10.5px; line-height:1.3;
  text-transform:none; letter-spacing:0;
}
/* A linkable card lights its glow rather than only its border - the hover then reads on the
   whole surface, which is what the glass is for. .card.link already owns border/arrow/transform. */
.cards .card.link:hover { --cardglow:color-mix(in srgb, var(--accent) 16%, transparent) }

/* A stat card that is also the way IN to the tab explaining it. */
.card.link { cursor:pointer; transition:border-color .12s ease, transform .12s ease }
.card.link:hover { border-color:var(--accent) }
.card.link:hover b { color:var(--accent) }
.card.link:active { transform:translateY(1px) }
.card.link:focus-visible { outline:2px solid var(--accent); outline-offset:2px }
.card.link::after { content:'\2192'; position:absolute; top:9px; right:11px; color:var(--fg3);
                    font-size:13px; opacity:0; transition:opacity .12s ease }
.card.link:hover::after { opacity:1 }
.cards .card { position:relative }
@media (prefers-reduced-motion:reduce) { .card.link { transition:none } }

/* -- toolbar: the primary action first, the destructive one last and apart ------------------- */
.bar .push { margin-left:auto }
.bar .msg { color:var(--fg3); font-size:13px }
.bar .msg.ok { color:var(--ok) }
.bar .msg.fail { color:var(--fail) }

/* =============================================================================================
 *  CHART SERIES PALETTE - computed and validated 2026-09-04, not chosen by eye.
 *
 *  ⚠ THE OLD ONE FAILED COLOURBLIND SEPARATION. It was
 *  #4ea1ff,#3fb950,#d29922,#a371f7,#f85149,#39c5cf,#db61a2,#e3b341 - and the green/amber pair
 *  (#3fb950 / #d29922) measures OKLab ΔE 5.1 under both protanopia and deuteranopia, against a
 *  floor of 6 and a target of 8. On the Pings tab those are two players' lines on one chart, so
 *  ~8% of men could not tell player 2 from player 3. Five of the eight were also outside the
 *  lightness band for the dark surface.
 *
 *  ⚠ IT ALSO REUSED THE STATUS COLOURS. --ok/--warn/--fail were series 2/3/5, so "green line"
 *  and "green = healthy" were the same green in the same view.
 *
 *  These two sets share ONE hue order (212, 48, 332, 140, 260, 160, 288, 0), so a player keeps
 *  their identity when the theme changes; only the lightness step differs, picked against each
 *  surface. Both sets measure: worst adjacent CVD ΔE 18.0 (target 8), worst normal-vision ΔE
 *  25.2 (floor 15), every colour ≥ 3:1 against its own surface.
 *
 *  ⚠ A CHART USES THESE THROUGH style.stroke = 'var(--sr3)', NEVER a hard-coded hex, and never
 *  as an SVG presentation attribute - a var() in stroke="" does not resolve. That is what makes
 *  the theme switch free.
 *  ⚠ Slots are assigned in FIXED ORDER and never cycled or re-assigned. A ninth player folds
 *  into "Other" rather than reusing slot 1, or the reader compares two different players by the
 *  same colour.
 * ============================================================================================= */
:root {
  --sr1:#0b9db3; --sr2:#b44f00; --sr3:#bb08af; --sr4:#33a803;
  --sr5:#035ede; --sr6:#0a7d51; --sr7:#8a6fff; --sr8:#f90f86;
}
:root[data-theme="dark"], :root:not([data-theme="light"]) {
  --sr1:#19a2b8; --sr2:#e2670d; --sr3:#ed03de; --sr4:#35ad03;
  --sr5:#498dfd; --sr6:#15ab70; --sr7:#8e76ff; --sr8:#ff1d8b;
}
@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) {
  --sr1:#0b9db3; --sr2:#b44f00; --sr3:#bb08af; --sr4:#33a803;
  --sr5:#035ede; --sr6:#0a7d51; --sr7:#8a6fff; --sr8:#f90f86;
} }

/* -- chart anatomy: recessive grid, real axis labels, a crosshair readout -------------------- */
.chart { position:relative }
.chart .ctitle { font-size:13px; color:var(--fg2); margin-bottom:6px }
.chart svg .grid { stroke:var(--line); stroke-width:1; vector-effect:non-scaling-stroke }
.chart svg .gridzero { stroke:var(--fg3); stroke-width:1; vector-effect:non-scaling-stroke }
.chart .yaxis { position:absolute; left:8px; top:0; pointer-events:none;
                font-size:11px; color:var(--fg3); font-variant-numeric:tabular-nums }
.chart .yaxis span { position:absolute; transform:translateY(-50%);
                     background:color-mix(in srgb,var(--bg2) 82%,transparent); padding:0 3px }
/* The crosshair is a DOM rule, not an SVG line: the viewBox is stretched horizontally, so a
   1px SVG line is not 1px on screen. */
.chart .cross { position:absolute; top:0; bottom:0; width:1px; background:var(--fg3);
                pointer-events:none; display:none }
.chart .tip { position:absolute; z-index:5; pointer-events:none; display:none;
              background:var(--bg2); border:1px solid var(--line); border-radius:6px;
              box-shadow:0 6px 20px rgba(0,0,0,.22); padding:7px 9px; font-size:12.5px;
              min-width:150px; max-width:280px }
.chart .tip b { display:block; margin-bottom:4px; font-size:11px; letter-spacing:.5px;
                text-transform:uppercase; color:var(--fg3) }
.chart .tip .row { display:flex; align-items:center; gap:6px; white-space:nowrap; line-height:1.6 }
.chart .tip .row i { width:9px; height:9px; border-radius:2px; flex:0 0 auto }
.chart .tip .row .v { margin-left:auto; font-variant-numeric:tabular-nums; font-weight:600 }
/* Direct labels at the end of a line: identity without a legend round-trip, for <= 4 series. */
.chart .dlab { position:absolute; font-size:11px; font-weight:650; white-space:nowrap;
               transform:translateY(-50%); pointer-events:none;
               text-shadow:0 0 3px var(--bg2), 0 0 3px var(--bg2) }
.legend label, .legend span { display:inline-flex; align-items:center; gap:5px }
.legend .off { opacity:.42 }

/* -- the in-game ping panel, reproduced -------------------------------------------------------
 * A REPLICA, so it is monospaced and pre-formatted: the game right-aligns those numbers with
 * spaces and any other font makes the columns wander. Same idea as .logview, but framed like
 * the overlay it is imitating rather than like a log. */
.ingame { font-family:var(--mono); font-size:13px; line-height:1.45; white-space:pre;
          overflow-x:auto; padding:12px 14px; border-radius:var(--r);
          background:#0b0d10; color:#e8ecf2; border:1px solid var(--line);
          text-shadow:0 1px 0 rgba(0,0,0,.6) }
/* Dark on both themes on purpose - in game it is white text over the 3D view, and a light
   version would not be the thing being reproduced. */
:root[data-theme="light"] .ingame, :root:not([data-theme="dark"]) .ingame { border-color:#2a313b }

/* The other Launcher, offered as a LINK once the first one visibly did not take. Never fired
   automatically: a second navigation in the same tab tears down the browser's pending
   "Open …?" prompt for the first, which is exactly how Join came to do nothing at all. */
.joinalt { display:inline-block; margin:0 0 14px; font-size:13px; color:var(--accent) }

/* -- "Sign in on your phone" (QR) + the ?lt= sign-in link page (lib/loginlinks.php) ---------- */
/* A device-pairing card: the code in the middle, the countdown right under it, the steps under that.
   Everything except the QR has a fixed height (~480 px with the backdrop padding), so the QR takes what the
   window has left: the card fits without a scroll bar down to a ~640 px tall window. */
.qrback{ z-index:70 }
.qrwrap{ width:min(680px, 100%); max-height:calc(100vh - 32px); max-height:calc(100dvh - 32px); overflow:auto;
         gap:0; padding:0; animation:qrin .24s cubic-bezier(.2,.8,.2,1) }
@keyframes qrin{ from{ opacity:0; transform:translateY(12px) scale(.985) } to{ opacity:1; transform:none } }

.qrtop{ position:relative; display:flex; flex-wrap:wrap; justify-content:center; align-items:center;
        gap:0 12px; padding:18px 48px 0; text-align:center;
        background:radial-gradient(110% 120% at 50% -20%, color-mix(in srgb, var(--accent) 17%, transparent), transparent 72%) }
.qrtop .x{ position:absolute; top:10px; right:10px; font:inherit; font-size:22px; line-height:1; padding:4px 9px;
           border:0; border-radius:9px; background:none; color:var(--fg3); cursor:pointer }
.qrtop .x:hover{ color:var(--fg); background:var(--bg3) }
.qrbadge{ width:40px; height:40px; display:grid; place-items:center; font-size:21px; border-radius:12px;
          background:color-mix(in srgb, var(--accent) 16%, var(--bg3));
          box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent) }
.qrwrap .qrtitle{ margin:0; font-size:22px; font-weight:750; letter-spacing:-.3px; color:var(--fg) }
.qrlead{ flex-basis:100%; margin:6px auto 0; max-width:560px; font-size:14.5px; line-height:1.45; color:var(--fg2) }
.qrlead .pfnick{ white-space:nowrap }

.qrstage{ display:flex; flex-direction:column; align-items:center; gap:10px; padding:12px 28px 0 }
.qrframe{ position:relative; padding:12px; border-radius:22px }
/* Viewfinder brackets, not a box: they say "point a camera here" without a word. */
.qrframe::before{ content:""; position:absolute; inset:0; pointer-events:none; --c:var(--accent);
  background:
    linear-gradient(var(--c),var(--c)) top left / 34px 4px no-repeat,
    linear-gradient(var(--c),var(--c)) top left / 4px 34px no-repeat,
    linear-gradient(var(--c),var(--c)) top right / 34px 4px no-repeat,
    linear-gradient(var(--c),var(--c)) top right / 4px 34px no-repeat,
    linear-gradient(var(--c),var(--c)) bottom left / 34px 4px no-repeat,
    linear-gradient(var(--c),var(--c)) bottom left / 4px 34px no-repeat,
    linear-gradient(var(--c),var(--c)) bottom right / 34px 4px no-repeat,
    linear-gradient(var(--c),var(--c)) bottom right / 4px 34px no-repeat;
  border-radius:6px; animation:qrbrackets 2.4s ease-in-out infinite }
@keyframes qrbrackets{ 0%,100%{ opacity:.55 } 50%{ opacity:1 } }
.qrframe.is-used::before{ --c:var(--ok); animation:none; opacity:1 }
.qrframe.is-expired::before{ --c:var(--warn); animation:none; opacity:.8 }
/* Always black on white: phone cameras read dark-on-light whatever the page theme is. The size follows the window height. */
.qrbox{ --qr:clamp(160px, calc(100vh - 480px), 288px);
        width:min(var(--qr), calc(100vw - 110px)); aspect-ratio:1; display:grid; place-items:center; background:#fff;
        border-radius:14px; line-height:0; box-shadow:0 14px 40px rgba(0,0,0,.28), 0 0 0 1px rgba(0,0,0,.06);
        transition:opacity .25s ease, filter .25s ease }
@supports (height:100dvh){ .qrbox{ --qr:clamp(160px, calc(100dvh - 480px), 288px) } }
.qrbox svg{ width:100%; height:auto; border-radius:14px }
.qrframe.is-used .qrbox, .qrframe.is-expired .qrbox{ opacity:.12; filter:blur(4px) }
.qrcover{ position:absolute; inset:12px; display:none; flex-direction:column; align-items:center; justify-content:center;
          gap:4px; text-align:center; padding:12px }
.qrframe.is-used .qrcover, .qrframe.is-expired .qrcover{ display:flex; animation:qrin .24s ease }
.qrcovericon{ width:58px; height:58px; display:grid; place-items:center; border-radius:50%; font-size:30px; font-weight:800;
              color:#fff; background:var(--ok); box-shadow:0 10px 28px color-mix(in srgb, var(--ok) 45%, transparent);
              margin-bottom:4px }
.qrframe.is-expired .qrcovericon{ background:var(--warn); box-shadow:0 10px 28px color-mix(in srgb, var(--warn) 45%, transparent) }
.qrcovertitle{ font-size:18px; font-weight:750; color:var(--fg) }
.qrcoversub{ font-size:13px; color:var(--fg2) }

/* The countdown, right under the code it belongs to. */
.qrtimer{ display:flex; align-items:center; gap:12px; width:min(340px, 100%); padding:7px 14px 7px 11px; border-radius:14px;
          background:var(--bg3); border:1px solid var(--line) }
.qrclock{ width:24px; height:24px; flex:none; color:var(--accent) }
.qrhand{ transform-origin:12px 12px; animation:qrhand 6s linear infinite }
@keyframes qrhand{ to{ transform:rotate(360deg) } }
.qrtimetxt{ display:flex; flex-direction:column; line-height:1.1; min-width:62px }
.qrtimelbl{ font-size:10.5px; font-weight:600; letter-spacing:.5px; text-transform:uppercase; color:var(--fg2) }
.qrtime{ font-size:21px; font-weight:750; font-variant-numeric:tabular-nums; color:var(--fg) }
.qrtrack{ flex:1; height:6px; border-radius:99px; overflow:hidden; background:color-mix(in srgb, var(--fg3) 24%, transparent) }
.qrtrack i{ display:block; height:100%; width:100%; border-radius:inherit; background:var(--accent);
            transition:width 1s linear, background-color .3s ease }
.qronce{ flex:none; font-size:11.5px; font-weight:600; padding:3px 8px; border-radius:99px; color:var(--accent);
         background:color-mix(in srgb, var(--accent) 13%, transparent) }
.qrtimer.low .qrclock, .qrtimer.low .qrtime{ color:var(--warn) }
.qrtimer.low .qrtrack i{ background:var(--warn) }
.qrtimer.is-expired .qrclock, .qrtimer.is-expired .qrtime{ color:var(--warn) }
.qrtimer.is-used .qrclock, .qrtimer.is-used .qrtime{ color:var(--ok) }
.qrtimer.is-used .qrhand, .qrtimer.is-expired .qrhand{ animation:none }
.qrtimer.is-used .qronce, .qrtimer.is-expired .qronce{ visibility:hidden }

/* Steps as compact rows: number on the left, words on the right. */
.qrsteps{ list-style:none; margin:0; padding:14px 28px 0; display:grid; grid-template-columns:repeat(3, 1fr); gap:10px }
.qrsteps li{ --c:var(--accent); display:grid; grid-template-columns:auto 1fr; column-gap:10px; align-items:center;
             padding:9px 12px; text-align:left; border-radius:12px;
             background:color-mix(in srgb, var(--c) 9%, var(--bg3));
             border:1px solid color-mix(in srgb, var(--c) 30%, var(--line));
             animation:qrup .38s cubic-bezier(.2,.8,.2,1) both }
.qrsteps li:nth-child(2){ --c:var(--rung-mona); animation-delay:.07s }
.qrsteps li:nth-child(3){ --c:var(--ok); animation-delay:.14s }
@keyframes qrup{ from{ opacity:0; transform:translateY(8px) } to{ opacity:1; transform:none } }
.qrstepn{ grid-row:1 / span 2; width:30px; height:30px; display:grid; place-items:center; border-radius:50%;
          font-size:14px; font-weight:800; color:#fff; background:var(--c);
          box-shadow:0 5px 14px color-mix(in srgb, var(--c) 38%, transparent) }
.qrsteps b{ font-size:14.5px; font-weight:650; line-height:1.25; color:var(--fg) }
.qrstepsub{ font-size:12.5px; line-height:1.25; color:var(--fg2) }

.qrstat{ display:flex; align-items:center; justify-content:center; gap:8px; min-height:20px; padding:10px 28px 0;
         font-size:14px; color:var(--fg2); text-align:center }
.qrdot{ display:none; width:8px; height:8px; flex:none; border-radius:50%; background:var(--accent); animation:pulse 1.4s ease-in-out infinite }
.qrstat.wait .qrdot{ display:inline-block }
.qrstat.ok{ color:var(--ok); font-weight:650 }
.qrstat.warn{ color:var(--warn) }
.qrstat.fail{ color:var(--fail) }

.qrwarn{ display:flex; align-items:flex-start; gap:9px; margin:10px 28px 0; padding:8px 12px; border-radius:11px;
         font-size:12.5px; line-height:1.4; color:var(--fg2);
         background:color-mix(in srgb, var(--warn) 9%, var(--bg3));
         border:1px solid color-mix(in srgb, var(--warn) 30%, var(--line)) }
.qrwarnicon{ font-size:14px; line-height:1.3 }

.qrfoot{ display:flex; justify-content:flex-end; gap:10px; margin-top:14px; padding:11px 28px 13px;
         border-top:1px solid var(--line); background:color-mix(in srgb, var(--bg3) 50%, var(--bg2)) }
.qrfoot button{ font-size:14px; padding:8px 18px; border-radius:10px }
.qrfoot .pfcancel:disabled{ opacity:.55; cursor:default }

@media (max-width:640px){
  .qrsteps{ grid-template-columns:1fr; gap:6px }
  .qronce{ display:none }
  .qrtop{ padding:16px 44px 0 }
}
/* Short windows: the badge is decoration, the code is not. */
@media (max-height:720px){
  .qrbadge{ display:none }
  .qrtop{ padding-top:14px }
  .qrlead{ margin-top:4px }
}
@media (prefers-reduced-motion:reduce){
  .qrwrap, .qrsteps li, .qrcover{ animation:none }
  .qrframe::before, .qrhand, .qrdot{ animation:none }
  .qrtrack i, .qrbox{ transition:none }
}
.llcard p{ line-height:1.5 }
.llrow{ display:flex; flex-direction:column; gap:8px; margin-top:12px }
.llform{ margin:0 }

/* -- Admin -> Signed-in Devices (assets/sessions.js, lib/sessions.php) ---------------------------- */
.sdwrap{ max-width:1180px }
.sdbar{ display:flex; flex-wrap:wrap; align-items:center; gap:10px }
.sdsearch{ flex:1 1 320px; min-width:0; font:inherit; font-size:14px; padding:9px 12px; border-radius:10px;
           border:1px solid var(--line); background:var(--bg2); color:var(--fg) }
.sdsearch:focus{ outline:none; border-color:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent) }
.sdstats{ display:flex; flex-wrap:wrap; gap:6px }
.sdstat{ display:inline-flex; align-items:baseline; gap:5px; padding:5px 10px; border-radius:99px; font-size:12.5px;
         color:var(--fg2); background:var(--bg3); border:1px solid var(--line) }
.sdstat b{ font-size:14px; color:var(--fg); font-variant-numeric:tabular-nums }
.sdstat.live b{ color:var(--ok) }
.sdrefresh{ font:inherit; font-size:13px; padding:7px 12px; border-radius:9px; border:1px solid var(--line);
            background:var(--bg3); color:var(--fg2); cursor:pointer }
.sdrefresh:hover{ color:var(--fg); border-color:var(--fg3) }
.sdlist{ display:flex; flex-direction:column; gap:10px }

.sdcard{ background:var(--bg2); border:1px solid var(--line); border-radius:14px; overflow:hidden }
.sdcard.open{ border-color:color-mix(in srgb, var(--accent) 40%, var(--line)) }
.sdhead{ display:flex; align-items:center; gap:12px; padding:11px 14px; flex-wrap:wrap;
         background:linear-gradient(180deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent) }
.sdavatar{ width:38px; height:38px; flex:none; display:grid; place-items:center; border-radius:11px; font-weight:800;
           font-size:17px; color:#fff; background:linear-gradient(135deg, var(--accent), var(--subsel)) }
.sdwho{ flex:1 1 220px; min-width:0 }
.sdwho1{ display:flex; align-items:center; flex-wrap:wrap; gap:6px }
.sdwho1 .pfnick{ font-size:15px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:100% }
.sdwho2{ font-size:12.5px; color:var(--fg2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.sdbadge{ display:inline-block; font-size:11px; font-weight:650; line-height:1; padding:3px 7px; border-radius:99px;
          color:var(--fg2); background:var(--bg3); border:1px solid var(--line); white-space:nowrap }
.sdbadge.uid{ font-variant-numeric:tabular-nums }
.sdbadge.admin{ color:var(--warn); border-color:color-mix(in srgb, var(--warn) 45%, var(--line)) }
.sdbadge.me{ color:var(--accent); border-color:color-mix(in srgb, var(--accent) 45%, var(--line));
             background:color-mix(in srgb, var(--accent) 12%, var(--bg3)) }
.sdbadge.via{ font-weight:600 }
.sdcount{ font-size:12.5px; color:var(--fg2); white-space:nowrap }
.sdtoggle{ font:inherit; font-size:12.5px; padding:6px 10px; border-radius:9px; cursor:pointer;
           border:1px solid var(--line); background:var(--bg3); color:var(--fg2) }
.sdtoggle:hover{ color:var(--fg); border-color:var(--fg3) }
.sdout{ font:inherit; font-size:12.5px; font-weight:600; padding:6px 11px; border-radius:9px; cursor:pointer; white-space:nowrap;
        color:var(--fail); border:1px solid color-mix(in srgb, var(--fail) 40%, var(--line));
        background:color-mix(in srgb, var(--fail) 8%, var(--bg2)) }
.sdout:hover{ background:color-mix(in srgb, var(--fail) 18%, var(--bg2)); border-color:var(--fail) }
.sdout:disabled{ opacity:.6; cursor:default }

.sddevs{ border-top:1px solid var(--line) }
.sddev{ display:grid; grid-template-columns:34px minmax(0, 1fr) 118px 118px 96px; align-items:center; gap:12px;
        padding:9px 14px; border-top:1px solid color-mix(in srgb, var(--line) 60%, transparent) }
.sddev:first-child{ border-top:0 }
.sddev.mine{ background:color-mix(in srgb, var(--accent) 5%, transparent) }
.sddevicon{ font-size:20px; text-align:center }
.sddevtitle{ display:flex; align-items:center; flex-wrap:wrap; gap:6px }
.sddevtitle b{ font-size:14px; font-weight:650; color:var(--fg) }
.sddevsub{ display:flex; align-items:center; gap:6px; font-size:12px; color:var(--fg2); overflow:hidden; white-space:nowrap; margin-top:3px }
.sddevsub > span:last-child{ overflow:hidden; text-overflow:ellipsis; min-width:0 }
.sddevsub .flag{ margin-right:0 }
.sdcountry{ color:var(--fg) }
.sdip{ font-family:var(--mono); font-size:11.5px; color:var(--fg2) }
.sdsep{ color:var(--fg3) }
.sdbadge.recovered{ color:var(--fg3); border-style:dashed; font-weight:600 }
.sddevcol{ display:flex; flex-direction:column; line-height:1.25 }
.sdcollbl{ font-size:10.5px; font-weight:600; letter-spacing:.4px; text-transform:uppercase; color:var(--fg3) }
.sdcolval{ display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--fg); font-variant-numeric:tabular-nums }
.sdcolval.soon{ color:var(--warn) }
.sddot{ width:8px; height:8px; border-radius:50%; background:var(--fg3); flex:none }
.sddot.live{ background:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 25%, transparent) }
.sddevact{ text-align:right }

.sdsettings{ border-top:1px solid var(--line); padding:10px 14px 12px; background:color-mix(in srgb, var(--bg3) 45%, var(--bg2)) }
.sdtablewrap{ overflow-x:auto }
.sdtable{ width:100%; border-collapse:collapse; font-size:12.5px }
.sdtable th{ text-align:left; font-size:10.5px; font-weight:650; letter-spacing:.4px; text-transform:uppercase;
             color:var(--fg3); padding:5px 8px; border-bottom:1px solid var(--line); white-space:nowrap }
.sdtable td{ padding:6px 8px; border-bottom:1px solid color-mix(in srgb, var(--line) 55%, transparent); white-space:nowrap }
.sdtable tr:last-child td{ border-bottom:0 }
.sdtable td.set{ color:var(--fg); font-weight:600 }
.sdtable td.def{ color:var(--fg3) }
.sdwhere b{ display:block; color:var(--fg); font-weight:650 }
.sdwhere span{ display:block; font-size:11px; color:var(--fg3) }
.sdnone{ font-size:13px; color:var(--fg3) }
.sdfoot{ font-size:12px }

@media (max-width:760px){
  .sddev{ grid-template-columns:28px minmax(0, 1fr) auto; row-gap:6px }
  .sddevcol{ grid-column:2 }
  .sddevact{ grid-column:3; grid-row:1 / span 3; align-self:start }
  .sddevcol{ flex-direction:row; gap:6px; align-items:baseline }
}
