    :root {
      --bg:              #F0EDE6;
      --surface:         #FFFFFF;
      --ink:             #1C1C1C;
      --ink-mid:         #575757;
      --ink-light:       #9A9A9A;
      --border:          #DDD7CD;
      --border-light:    #EAE5DC;

      --tile:            #FFFFFF;
      --tile-shadow:     0 1px 3px rgba(0,0,0,0.09);

      --revealed-bg:     #C05220;
      --revealed-text:   #FFFFFF;

      --locked-bg:       #E7F4ED;
      --locked-border:   #2F8B50;
      --locked-text:     #1B5E35;

      --hint-bg:         #6589C4;
      --hint-bg-hover:   #5578B4;
      --hint-border:     #7599D4;

      --group-a-pale:    #F3E4BE;
      --group-a-border:  #CCBA70;
      --group-a-locked:  #9A7A00;

      --group-b-pale:    #C9DFEF;
      --group-b-border:  #94BDD8;
      --group-b-locked:  #4A7699;

      --hint-locked:     #3F5F8F;

      --btn-primary:     #1C1C1C;
      --btn-text:        #FFFFFF;
      --accent:          #C05220;
      --select-ring:     rgba(192,82,32,0.28);
      --gold:            #9A7A00;

      /* Logo theming — every Crosslatch mark/wordmark draws its structural
         bars from --logo-ink (via currentColor), the "CROSSLATCH" lettering
         from --logo-text, and the latched "A" cut-out from --logo-cut, so a
         future dark theme can recolour every instance by overriding these
         tokens. The key/lock rows keep their brand orange/blue in both themes.
         --logo-ink and --logo-text are intentionally distinct: the bars are a
         hair lighter than the near-black wordmark type. */
      --logo-ink:        #2A2A2A;
      --logo-text:       #1C1C1C;
      --logo-cut:        #FFFFFF;

      --success:         #2F8B50;
      --error:           #C05220;
      --info-bg:         #EDF2F7;

      /* Board sizing — --cell is recomputed per puzzle by fitGameToViewport()
         so the grid + pile + Check button fit the visible viewport on mobile
         without scrolling. Falls back to the historic 52px on desktop. */
      --cell:            52px;
      --tile-gap:        5px;

      /* Half-width of the central content column (board ≈ 360px, cards 380px).
         The header brand/menu and the welcome menu button are inset to this
         column via `max(minInset, calc(50% - --content-half))`, so on wide
         desktop screens they hug the edges of the content block instead of
         flying out to the far corners of the viewport. On narrow screens the
         `max()` falls back to the minimum inset, leaving mobile untouched. */
      --content-half:    190px;

      /* Height of the cookie banner while it is on screen, 0 once it is
         dismissed. The banner is position:fixed at the foot of the
         viewport, so without reserving this much the layout simply runs
         underneath it: on a 375x600 phone it covered Play as Guest
         entirely, with the page already scrolled to its end and no way
         to reach the button. Set by the banner script in index.html,
         which measures the banner because its text wraps to two lines
         on narrow screens, and read back by fitGameToViewport(). */
      --cookie-h:        0px;
    }

    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    /* Always reserve the vertical scrollbar's width, whether or not the page
       actually needs to scroll. Without this, a page tall enough to scroll
       (e.g. Statistics with real history) is a few px narrower than one that
       isn't (the game screen, which never scrolls; a short Groups list),
       and every 50%-based header inset shifts by that gap — the mark and
       close cross drift left of the game header's logo and hamburger.
       scrollbar-gutter: stable doesn't reliably reserve space for the root's
       own viewport scrollbar across engines, so this uses the older, more
       dependable trick instead: force the scrollbar to always render. */
    html { overflow-y: scroll; }

    body {
      font-family: 'Outfit', system-ui, sans-serif;
      background: var(--bg);
      color: var(--ink);
      min-height: 100vh;
      /* Reserve the banner strip. Shortening .screen below is not enough
         on its own: a screen taller than the viewport still ENDS at its
         content, so scrolled to the bottom the last --cookie-h pixels sit
         behind the banner. This is what puts empty space there instead. */
      padding-bottom: var(--cookie-h);
    }

    /* ═══════════════════════ SCREEN MANAGEMENT ═══════════════════════ */
    /* svh, not vh or dvh. On mobile `100vh` is the *toolbar-collapsed* height —
       taller than what's actually on screen — so a vertically centred card
       (welcome / done / auth) gets a big gap above and its bottom pushed below
       the fold. `100dvh` tracks the toolbar but is dynamic, so it can still
       resolve to the taller value and shifts as the toolbar hides. `100svh` is
       the *smallest* viewport height (toolbar fully shown), so these screens
       always fit without scrolling and never move. The plain vh line is the
       fallback for browsers without svh support.
       The game screen keeps dvh on purpose — it wants every pixel for the board. */
    /* min-height twice for progressive enhancement (svh where supported),
       then once more minus the cookie banner so a screen ends above it
       rather than behind it. --cookie-h is 0px whenever the banner is
       not showing, which is every visit after the first. */
    .screen { display: none; min-height: 100vh; min-height: 100svh; min-height: calc(100svh - var(--cookie-h)); flex-direction: column; }
    .screen.active { display: flex; }

    /* The game board locks to the visible viewport so the Check button is
       always reachable without scrolling. dvh tracks the mobile toolbar; the
       plain vh line above is the fallback for browsers without dvh support.
       Tiles are scaled by fitGameToViewport() to fit this fixed height. */
    #game-screen.active {
      height: 100vh;
      height: 100dvh;
      height: calc(100dvh - var(--cookie-h));
      min-height: 0;
      overflow: hidden;
    }

    /* ═══════════════════════ SPLASH ═══════════════════════ */
    /* Shown while boot() works out whether anyone is signed in. It shares the
       page background, so in an installed PWA it continues Android's own
       launch splash (background_color in site.webmanifest is the same --bg)
       rather than cutting to a different screen.

       The mark fades in on a delay instead of painting immediately: a warm
       start settles well inside 200ms, and a logo that appears and vanishes
       that fast reads as a flicker. Slow starts — the ones this exists for —
       are past the delay before anyone sees it. */
    .splash-screen {
      align-items: center;
      justify-content: center;
      padding: 2rem 1.5rem;
    }

    .splash-logo {
      opacity: 0;
      animation: splash-in 0.3s ease-out 0.2s forwards;
    }

    @keyframes splash-in { to { opacity: 1; } }

    @media (prefers-reduced-motion: reduce) {
      .splash-logo { animation: none; opacity: 1; }
    }

    /* ═══════════════════════ AUTH SCREENS ═══════════════════════ */
    .auth-screen {
      align-items: center;
      justify-content: center;
      padding: 2rem 1.5rem;
    }

    .auth-card {
      max-width: 380px;
      width: 100%;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 18px;
      padding: 2.5rem 2rem 2rem;
    }

    .auth-logo {
      text-align: center;
      margin-bottom: 1.8rem;
    }

    .auth-title {
      font-size: 1.1rem;
      font-weight: 700;
      letter-spacing: 0.18em;
      text-transform: uppercase;
      text-align: center;
      margin-bottom: 0.3rem;
    }

    .auth-subtitle {
      font-size: 0.85rem;
      color: var(--ink-light);
      text-align: center;
      margin-bottom: 1.8rem;
    }

    .form-group {
      margin-bottom: 1rem;
    }

    .form-label {
      display: block;
      font-size: 0.72rem;
      font-weight: 600;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      color: var(--ink-mid);
      margin-bottom: 0.35rem;
    }

    /* The exact word a type-to-confirm field wants. .form-label is already
       uppercase and letter-spaced, so weight alone does not separate the
       literal from the instruction around it — "TYPE DELETE TO CONFIRM"
       reads as four equal words. Colour plus a chip makes it a string to
       copy rather than emphasis. The extra right padding pays back the
       trailing letter-spacing on the last character. */
    .type-literal {
      font-weight: 800;
      color: var(--accent);
      background: rgba(192, 82, 32, 0.10);
      border: 1px solid rgba(192, 82, 32, 0.28);
      border-radius: 4px;
      padding: 0.1em 0.3em 0.1em 0.42em;
    }

    .form-input {
      width: 100%;
      font-family: 'Outfit', sans-serif;
      font-size: 0.95rem;
      padding: 0.7rem 0.9rem;
      border: 1.5px solid var(--border);
      border-radius: 8px;
      background: var(--bg);
      color: var(--ink);
      outline: none;
      transition: border-color 0.15s;
    }
    .form-input:focus { border-color: var(--accent); }
    .form-input.error { border-color: var(--error); }

    .form-error {
      font-size: 0.78rem;
      color: var(--error);
      margin-top: 0.25rem;
      min-height: 1rem;
    }

    .auth-alert {
      font-size: 0.82rem;
      padding: 0.6rem 0.8rem;
      border-radius: 6px;
      margin-bottom: 1rem;
      text-align: center;
      display: none;
    }
    .auth-alert.success { display: block; background: var(--locked-bg); color: var(--locked-text); border: 1px solid var(--locked-border); }
    .auth-alert.error   { display: block; background: #FFF0EC; color: var(--error); border: 1px solid var(--accent); }

    .btn-auth {
      font-family: 'Outfit', sans-serif;
      font-size: 0.88rem;
      font-weight: 700;
      letter-spacing: 0.18em;
      text-transform: uppercase;
      background: var(--btn-primary);
      color: var(--btn-text);
      border: none;
      border-radius: 7px;
      padding: 0.8rem 0;
      width: 100%;
      cursor: pointer;
      transition: background 0.15s, transform 0.12s;
      margin-top: 0.5rem;
    }
    .btn-auth:hover { background: #333; transform: translateY(-1px); }

    /* Same type, same box, no fill. For rows where two buttons sit side by
       side and neither should outrank the other. .btn-auth has no border, so
       the padding gives back the 1.5px this one adds — otherwise an outlined
       button is 3px taller than a filled one beside it. */
    .btn-auth.outline {
      background: none;
      color: var(--ink);
      border: 1.5px solid var(--ink);
      padding: calc(0.8rem - 1.5px) 0;
    }
    .btn-auth.outline:hover { background: var(--surface); }

    /* The delete-account commit button: accent, and inert until DELETE has
       been typed. Disabled has to look disabled — a dimmed button that still
       reacted to hover would read as broken, which is the failure this whole
       change is about. */
    .btn-auth.danger { background: var(--accent); }
    .btn-auth.danger:hover { background: #A8451A; }
    .btn-auth:disabled,
    .btn-auth.danger:disabled {
      background: var(--border);
      color: var(--ink-light);
      cursor: not-allowed;
      transform: none;
    }

    /* Shown under the Create Account button. GDPR Art. 13 wants the
       information given when the data is obtained, and this is that
       moment; it is also the only place the Terms' age requirement is
       put in front of someone signing up. */
    .legal-consent {
      font-size: 0.78rem;
      line-height: 1.55;
      color: var(--ink-light);
      text-align: center;
      margin-top: 1rem;
    }
    .legal-consent a { color: var(--ink-mid); text-underline-offset: 3px; }
    .legal-consent a:hover { color: var(--ink); }

    /* Sits OUTSIDE .auth-card, under it, on all three auth screens. The
       cookie banner also links the policy, but it is dismissible and
       gone for good after one tap; this is the route that stays. */
    .auth-legal {
      display: flex;
      justify-content: center;
      gap: 0.9rem;
      margin-top: 1.4rem;
      font-size: 0.78rem;
    }
    .auth-legal a { color: var(--ink-light); text-decoration: none; transition: color 0.13s; }
    .auth-legal a:hover { color: var(--ink-mid); text-decoration: underline; text-underline-offset: 3px; }
    .auth-legal span { color: var(--border); }

    .auth-links {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 0.5rem;
      margin-top: 1.2rem;
    }

    .auth-link {
      font-family: 'Outfit', sans-serif;
      font-size: 0.82rem;
      color: var(--ink-mid);
      background: none;
      border: none;
      cursor: pointer;
      text-decoration: underline;
      text-underline-offset: 3px;
      transition: color 0.13s;
    }
    .auth-link:hover { color: var(--ink); }

    .auth-divider {
      display: flex;
      align-items: center;
      gap: 0.8rem;
      margin: 1.4rem 0;
    }
    .auth-divider::before, .auth-divider::after {
      content: '';
      flex: 1;
      height: 1px;
      background: var(--border);
    }
    .auth-divider span {
      font-size: 0.72rem;
      color: var(--ink-light);
      text-transform: uppercase;
      letter-spacing: 0.1em;
    }

    .btn-guest {
      font-family: 'Outfit', sans-serif;
      font-size: 0.82rem;
      font-weight: 600;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      background: transparent;
      color: var(--ink-mid);
      border: 1.5px solid var(--border);
      border-radius: 7px;
      padding: 0.7rem 0;
      width: 100%;
      cursor: pointer;
      transition: background 0.15s, color 0.15s, border-color 0.15s;
    }
    .btn-guest:hover { background: var(--bg); color: var(--ink); border-color: var(--ink-mid); }

    /* Password strength meter */
    .pw-strength {
      height: 3px;
      border-radius: 2px;
      background: var(--border);
      margin-top: 0.4rem;
      overflow: hidden;
    }
    .pw-strength-bar {
      height: 100%;
      border-radius: 2px;
      width: 0;
      transition: width 0.25s, background 0.25s;
    }

    /* ═══════════════════════ WELCOME SCREEN ═══════════════════════ */
    /* #welcome-screen deliberately carries no padding or centring of its own.
       It used to duplicate the body wrapper's (2rem on both = 128px of vertical
       padding), which pushed the card past the bottom of a short phone screen.
       position:relative is gone too — the menu button now lives in the card. */

    /* Body of a single-card screen (welcome / done). The card is centred with
       `margin:auto` rather than align-items/justify-content: when it fits, the
       auto margins centre it; when it doesn't, they collapse to 0 so the card
       starts at the top and scrolls normally. Flex centring instead pins the
       overflow half above the container, where it can't be scrolled to — which
       is what left a gap on top with the bottom of the card unreachable. */
    .card-screen-body {
      flex: 1;
      min-height: 0;
      display: flex;
      justify-content: center;
      overflow-y: auto;
      padding: 1.5rem;
    }
    .card-screen-body > .welcome-card { margin: auto; }

    /* A definite height (not just .screen's min-height) is what lets the body
       above shrink and scroll inside itself. With min-height alone the body
       grows to fit the card and takes the whole page with it, so the window
       scrolls and the card's bottom ends up under the fold. */
    #welcome-screen.active,
    #done-screen.active {
      height: 100vh;
      height: 100svh;
    }

    .welcome-card {
      position: relative; /* positioning context for .welcome-menu-btn */
      max-width: 380px;
      width: 100%;
      display: flex;
      flex-direction: column;
      align-items: center;
      text-align: center;
      padding: 3.5rem 2.5rem 2.2rem;
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: 18px;
    }

    .welcome-logo { margin-bottom: 2.2rem; }

    .welcome-title {
      font-size: 1.7rem;
      font-weight: 700;
      letter-spacing: 0.32em;
      text-transform: uppercase;
      color: var(--ink);
      margin-bottom: 0.55rem;
    }

    .welcome-divider {
      width: 36px; height: 1px;
      background: var(--ink-light);
      margin: 0 auto 1.4rem;
    }

    .welcome-tagline {
      font-size: 0.95rem;
      font-weight: 400;
      color: var(--ink-mid);
      line-height: 1.65;
      max-width: 250px;
      margin: 0 auto 2rem;
    }

    .welcome-puzzle-info {
      font-size: 0.68rem;
      font-weight: 600;
      letter-spacing: 0.16em;
      text-transform: uppercase;
      color: var(--ink-light);
      margin-bottom: 2rem;
    }

    .welcome-user-greeting {
      font-size: 0.82rem;
      font-weight: 500;
      color: var(--ink-mid);
      margin-bottom: 1rem;
    }

    .btn-play {
      font-family: 'Outfit', sans-serif;
      font-size: 0.92rem;
      font-weight: 700;
      letter-spacing: 0.2em;
      text-transform: uppercase;
      background: var(--btn-primary);
      color: var(--btn-text);
      border: none;
      border-radius: 7px;
      padding: 0.88rem 0;
      width: 100%;
      max-width: 240px;
      cursor: pointer;
      transition: background 0.15s, transform 0.12s;
      margin-bottom: 1.1rem;
    }
    .btn-play:hover { background: #333; transform: translateY(-1px); }

    .welcome-htp-link {
      font-family: 'Outfit', sans-serif;
      font-size: 0.85rem;
      font-weight: 400;
      color: var(--ink-mid);
      text-decoration: underline;
      text-underline-offset: 3px;
      background: none;
      border: none;
      cursor: pointer;
      margin-bottom: 2.8rem;
      transition: color 0.13s;
    }
    .welcome-htp-link:hover { color: var(--ink); }

    /* Completed-state replacement for the How-to-play link: direct links to
       Statistics and Groups, side by side. Matches .welcome-htp-link's
       bottom margin so the Sign-out link below sits identically either way.
       display is toggled to flex by updateWelcomePlayButton(). */
    .welcome-result-links {
      display: none;
      align-items: center;
      justify-content: center;
      gap: 0.7rem;
      margin-bottom: 2.8rem;
    }
    .welcome-result-links button {
      font-family: 'Outfit', sans-serif;
      font-size: 0.85rem;
      font-weight: 400;
      color: var(--ink-mid);
      text-decoration: underline;
      text-underline-offset: 3px;
      background: none;
      border: none;
      cursor: pointer;
      transition: color 0.13s;
    }
    .welcome-result-links button:hover { color: var(--ink); }
    .welcome-result-sep { color: var(--ink-light); font-size: 0.85rem; }

    .welcome-signout-link {
      font-family: 'Outfit', sans-serif;
      font-size: 0.78rem;
      font-weight: 400;
      color: var(--ink-light);
      text-decoration: underline;
      text-underline-offset: 3px;
      background: none;
      border: none;
      cursor: pointer;
      margin-top: -1.9rem;
      margin-bottom: 2rem;
      transition: color 0.13s;
    }
    .welcome-signout-link:hover { color: var(--ink-mid); }

    .welcome-footer {
      font-size: 0.7rem;
      color: var(--ink-light);
      letter-spacing: 0.05em;
    }

    /* ═══════════════════════ GAME SCREEN ═══════════════════════ */
    /* Three zones — mark | attempts | clock + menu. The outer columns are equal
       fractions, so the attempt dots stay optically centred even as the timer
       grows a digit. Padding is tight: every pixel here comes out of the board. */
    .game-header {
      display: grid;
      grid-template-columns: 1fr auto 1fr;
      align-items: center;
      /* Horizontal padding pulls the brand + menu in to the content column on
         wide screens (calc branch) while never dropping below the base 1.1rem
         gutter on narrow ones (max branch). The border-bottom still spans the
         full viewport because the padding lives inside the full-width header. */
      padding: 0.5rem max(1.1rem, calc(50% - var(--content-half)));
      background: var(--bg);
      border-bottom: 1px solid var(--border-light);
      position: sticky;
      top: 0;
      z-index: 100;
    }

    .header-brand {
      display: flex;
      align-items: center;
      gap: 0.55rem;
    }

    .header-attempts {
      display: flex;
      justify-content: center;
      align-items: center;
    }

    .header-title {
      font-size: 0.88rem;
      font-weight: 700;
      letter-spacing: 0.28em;
      text-transform: uppercase;
      color: var(--ink);
    }

    .header-right {
      display: flex;
      align-items: center;
      justify-self: end;
      gap: 0.5rem;
    }

    /* The padlock belongs to the clock, so it sits tight against the digits and
       keeps its distance from the menu button instead of floating between them.
       Right-aligned, so a digit rolling over (9:59 → 10:00) grows leftwards and
       never nudges the menu button. */
    .timer-group {
      display: flex;
      align-items: center;
      gap: 3px;
    }

    .timer-display {
      font-size: 0.82rem;
      font-weight: 500;
      color: var(--ink-mid);
      font-variant-numeric: tabular-nums;
      letter-spacing: 0.04em;
      text-align: right;
    }

    /* Padlock beside the timer — shown once the game is committed (first
       Check made): the clock is now server-owned and keeps running even if
       you sign out or switch device. Hidden until then and after the game
       ends; updateTimerLock() toggles it. */
    .timer-lock {
      display: none;
      align-items: center;
      color: var(--accent, var(--ink-mid));
    }
    .timer-lock svg { display: block; }

    .menu-btn {
      width: 38px; height: 38px;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 8px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      color: var(--ink);
      transition: background 0.13s, border-color 0.13s;
    }
    .menu-btn:hover { border-color: var(--ink-mid); background: var(--bg); }

    .welcome-menu-btn {
      /* Anchored inside the card's top-right corner (see .welcome-card
         position:relative) rather than floating in the screen margin, so it
         reads as part of the card instead of an orphaned corner control. */
      position: absolute;
      top: 0.9rem;
      right: 0.9rem;
      z-index: 2;
    }

    .game-main {
      flex: 1;
      min-height: 0;
      display: flex;
      align-items: flex-start;
      justify-content: center;
      padding: 0.4rem 1rem 0.8rem;
      gap: 1rem;
      overflow: hidden;
    }
    /* Fallback for screens too small to fit the board even at the minimum
       tile size: re-enable scrolling here only, so the Check button in
       .game-bottom stays pinned. Toggled by fitGameToViewport(). */
    .game-main.cramped { overflow-y: auto; align-items: flex-start; }

    .grid-section { display: flex; flex-direction: column; align-items: center; }

    .grid-outer {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 9px;
    }

    .grid {
      display: grid;
      grid-template-columns: repeat(3, var(--cell));
      gap: var(--tile-gap);
    }

    .cell {
      width: var(--cell); height: var(--cell);
      display: flex; align-items: center; justify-content: center;
      border: 1.5px solid var(--border);
      border-radius: 8px;
      font-family: 'Outfit', sans-serif;
      font-weight: 600;
      font-size: calc(var(--cell) * 0.4);
      background: var(--tile);
      user-select: none;
      transition: background 0.13s, border-color 0.13s, transform 0.12s, box-shadow 0.13s;
      position: relative;
    }
    .cell.empty {
      cursor: pointer;
      background: rgba(255,255,255,0.55);
      border-style: dashed;
      border-color: var(--border);
    }
    .cell.empty:hover, .cell.empty.drag-over {
      border-color: var(--accent);
      border-style: solid;
      transform: scale(1.04);
    }
    .cell.occupied {
      cursor: pointer;
      box-shadow: var(--tile-shadow);
    }
    .cell.occupied:hover { border-color: var(--accent); }

    /* Keyboard focus ring for the board and the pile. :focus-visible, not
       :focus, so a mouse or touch player never sees it — clicking a tile
       focuses it now that it carries a tabindex, and drawing a ring on every
       click would be a visible change to a game nobody asked to change.
       --accent rather than the fainter --select-ring used elsewhere: this has
       to stay legible against a dashed empty cell AND a filled tile, and the
       selected-tile tint disappears against the latter. 2px + 2px offset fits
       inside the 5px --tile-gap, so a ring never overlaps its neighbour. */
    .cell:focus-visible,
    .pile-tile:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 2px;
    }
    .cell.revealed {
      background: var(--revealed-bg);
      color: var(--revealed-text);
      border-color: var(--revealed-bg);
      cursor: default;
      font-weight: 700;
      font-size: calc(var(--cell) * 0.4);
    }
    .cell.locked {
      cursor: default;
      pointer-events: none;
    }
    /* Locked-in-place indicator: a darker shade of the cell's own colour,
       painted with box-shadow (not border) so it never nudges the tile's
       size. Only applied while .locked-live is present — renderGrid() omits
       it once the game is over, so a finished board looks exactly as it did
       before this indicator existed rather than every tile carrying a ring. */
    .cell.locked.locked-live[data-group="0"] { box-shadow: inset 0 0 0 2px var(--group-a-locked); }
    .cell.locked.locked-live[data-group="1"] { box-shadow: inset 0 0 0 2px var(--group-b-locked); }
    .cell.locked.locked-live:not([data-group]) { box-shadow: inset 0 0 0 2px var(--locked-border); }
    .cell.locked.locked-live.hint-row { box-shadow: inset 0 0 0 2px var(--hint-locked) !important; }
    .cell.blocked {
      background: transparent;
      border: none;
      box-shadow: none;
      pointer-events: none;
    }

    .cell.empty[data-group="0"]    { background: var(--group-a-pale); border-color: var(--group-a-border); border-style: solid; }
    .cell.occupied[data-group="0"] { background: var(--group-a-pale); }
    .cell.empty[data-group="1"]    { background: var(--group-b-pale); border-color: var(--group-b-border); border-style: solid; }
    .cell.occupied[data-group="1"] { background: var(--group-b-pale); }

    .cell.hint-row {
      background: var(--hint-bg) !important;
      border-color: var(--hint-bg) !important;
      border-style: solid !important;
      cursor: pointer;
    }
    .cell.hint-row:hover, .cell.hint-row.drag-over {
      background: var(--hint-bg-hover) !important;
      transform: scale(1.04);
    }
    .cell.hint-row.occupied {
      color: var(--revealed-text) !important;
      box-shadow: var(--tile-shadow);
    }

    .cell.pop   { animation: cellPop   0.22s ease; }
    .cell.shake { animation: cellShake 0.2s ease; }
    @keyframes cellPop {
      0%   { transform: scale(0.7);  opacity: 0.5; }
      60%  { transform: scale(1.1); }
      100% { transform: scale(1);    opacity: 1; }
    }
    @keyframes cellShake {
      0%,100% { transform: translateX(0); }
      25%     { transform: translateX(-4px); }
      75%     { transform: translateX(4px); }
    }

    /* ─── Check animation v2 ─── correct cells briefly flash green and then
       settle back to their own column colour, staying locked (immovable via
       .cell.locked's pointer-events: none) but not recoloured; incorrect
       cells launch a flying tile that arcs back to the pile. The old
       reveal-flip / reveal-fade keyframes are kept so any residue references
       continue to work, but the check loop now uses flash-correct + the
       flying-tile DOM element instead.

       NB: flashCorrect used to END on the green --locked-* tokens, back when
       correct cells stayed green. They no longer do, and the green end-state
       had become unreachable anyway: renderGrid() rebuilds every cell from
       scratch after the check resolves, so the node carrying .flash-correct
       (and its `forwards` end-state) is discarded, and .cell.locked sets no
       background of its own — .cell.occupied[data-group] wins on specificity.
       The keyframe now resolves back to the cell's own colour on its own
       rather than relying on that re-render to hide a stale one. */

    .cell.reveal-flip { animation: revealFlip 0.4s ease; }
    @keyframes revealFlip {
      0%   { transform: scaleX(1); }
      45%  { transform: scaleX(0); }
      55%  { transform: scaleX(0); }
      100% { transform: scaleX(1); }
    }

    .cell.flash-correct {
      animation: flashCorrect 1.0s ease-out forwards;
    }

    .cell.flash-wrong {
      animation: flashWrong 1.125s ease-out forwards;
    }
    @keyframes flashWrong {
      0%   { background: var(--tile); transform: scale(1);    box-shadow: var(--tile-shadow); }
      25%  { background: #FF6B6B;     transform: scale(1.12); box-shadow: 0 0 22px rgba(220,60,60,0.85), 0 0 8px rgba(220,60,60,0.5); border-color: #c0392b; color: #fff; }
      65%  { background: #FF8E8E;     transform: scale(1.04); box-shadow: 0 0 14px rgba(220,60,60,0.55); border-color: #c0392b; color: #fff; }
      100% { background: var(--tile); transform: scale(1);    box-shadow: var(--tile-shadow); }
    }
    /* `background` is deliberately absent from the 0% and 100% stops: an
       omitted property takes the element's OWN base value at that offset, i.e.
       whatever --group-a-pale / --group-b-pale / --hint-bg the cell already
       is. Naming a colour here instead would pin every column to the same one
       (var(--tile) is plain #FFF), and because the check resolves column by
       column over ~4.5s while each cell's flash lasts 1.0–1.5s, an early
       column would then sit that wrong colour for seconds before renderGrid()
       tidied up. border-color and color are omitted at those stops for the
       same reason. */
    @keyframes flashCorrect {
      0%   {                               transform: scale(1);    box-shadow: var(--tile-shadow); }
      25%  { background: #6FD58A;          transform: scale(1.18); box-shadow: 0 0 22px rgba(80,200,120,0.85), 0 0 8px rgba(80,200,120,0.5); border-color: #2F8B50; color: #fff; }
      65%  { background: #6FD58A;          transform: scale(1.05); box-shadow: 0 0 14px rgba(80,200,120,0.65); border-color: #2F8B50; color: #fff; }
      100% {                               transform: scale(1);    box-shadow: var(--tile-shadow); }
    }


    /* The transient tile that arcs from a wrong cell back to the pile. */
    .flying-tile {
      position: fixed;
      width: 52px;
      height: 52px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: #FFE0D6;
      color: var(--accent);
      border: 1.5px solid var(--accent);
      border-radius: 8px;
      font-family: 'Outfit', sans-serif;
      font-weight: 600;
      font-size: 1.28rem;
      z-index: 9999;
      pointer-events: none;
      box-shadow: 0 6px 16px rgba(192,82,32,0.35);
      will-change: transform, opacity;
    }

    /* ─── Win celebration ─── modal-style banner with a medal whose colour
       reflects how many attempts the player used (1 = gold, 2 = silver,
       3 = bronze). Confetti rains down behind it. */
    .celebrate-overlay {
      position: fixed;
      inset: 0;
      background: rgba(28, 28, 28, 0.45);
      backdrop-filter: blur(2px);
      z-index: 10000;
      display: flex;
      align-items: center;
      justify-content: center;
      animation: fadeIn 0.3s ease;
    }
    .celebrate-banner {
      background: var(--surface);
      border-radius: 24px;
      padding: 2.4rem 3rem 2rem;
      text-align: center;
      box-shadow: 0 20px 80px rgba(0,0,0,0.4);
      animation: bannerPop 0.55s cubic-bezier(0.5, 1.6, 0.5, 1.0) forwards;
      max-width: 90vw;
      min-width: 320px;
      pointer-events: auto;
    }
    @keyframes bannerPop {
      0%   { transform: scale(0) rotate(-8deg); opacity: 0; }
      60%  { transform: scale(1.08) rotate(2deg); opacity: 1; }
      100% { transform: scale(1) rotate(0deg);    opacity: 1; }
    }
    .celebrate-medal {
      font-size: 5.4rem;
      line-height: 1;
      margin: 0 auto 0.6rem;
      display: inline-block;
      animation: medalSpin 1.0s cubic-bezier(0.3, 1.6, 0.4, 1.0) forwards,
                 medalGlow 2.4s ease-in-out 1s infinite alternate;
      transform-origin: center;
    }
    @keyframes medalSpin {
      0%   { transform: rotate(-720deg) scale(0); }
      70%  { transform: rotate(20deg)  scale(1.2); }
      100% { transform: rotate(0deg)    scale(1); }
    }
    @keyframes medalGlow {
      0%   { filter: brightness(1)     drop-shadow(0 4px 8px rgba(0,0,0,0.18)); }
      100% { filter: brightness(1.35) drop-shadow(0 4px 18px currentColor); }
    }
    .celebrate-medal.tier-gold   { color: #D4AF37; }
    .celebrate-medal.tier-silver { color: #B5B5B5; }
    .celebrate-medal.tier-bronze { color: #CD7F32; }
    .celebrate-medal.tier-fail   { color: #8a8a8a; animation: none; transform: none; }

    .celebrate-banner h2 {
      font-size: 2rem;
      font-weight: 700;
      letter-spacing: 0.04em;
      margin: 0.4rem 0 0.3rem;
      color: var(--ink);
    }
    .celebrate-banner .celebrate-tier-label {
      font-size: 0.85rem;
      letter-spacing: 0.22em;
      text-transform: uppercase;
      color: var(--ink-mid);
      margin-bottom: 0.8rem;
    }
    .celebrate-words {
      font-weight: 700;
      letter-spacing: 0.1em;
      color: var(--accent);
      font-size: 1.05rem;
      margin: 0.7rem 0 0.4rem;
      word-spacing: 0.4em;
    }
    .celebrate-time {
      color: var(--ink-mid);
      font-size: 0.95rem;
      margin-bottom: 0.4rem;
    }
    .celebrate-countdown {
      color: var(--ink-mid);
      font-size: 0.9rem;
      font-weight: 600;
      margin-bottom: 1.4rem;
      font-variant-numeric: tabular-nums;
    }
    .celebrate-close {
      background: var(--btn-primary);
      color: var(--btn-text);
      border: none;
      padding: 0.7rem 1.6rem;
      border-radius: 10px;
      font-family: inherit;
      font-size: 1rem;
      font-weight: 600;
      cursor: pointer;
      transition: transform 0.12s ease, box-shadow 0.12s ease;
    }
    .celebrate-close:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 14px rgba(0,0,0,0.18);
    }
    .celebrate-stats-link {
      display: block;
      margin-top: 0.9rem;
      font-size: 0.88rem;
      color: var(--ink-mid);
      text-decoration: underline;
      cursor: pointer;
      background: none;
      border: none;
      font-family: inherit;
      padding: 0;
    }
    .celebrate-stats-link:hover { color: var(--ink); }
    /* The banner's own link row (Check stats · Share result). groups.js also
       puts .celebrate-stats-link buttons on the banner, inside their own
       .celebrate-group-line wrappers below this row — they rely on the class's
       own margin-top, so the margin is moved onto the row here rather than
       changed on the shared class. */
    .celebrate-links {
      display: flex;
      justify-content: center;
      gap: 1.4rem;
      margin-top: 0.9rem;
    }
    .celebrate-links .celebrate-stats-link { margin-top: 0; }

    /* Confetti — DOM-based pieces that fall + spin */
    .confetti-piece {
      position: fixed;
      top: -14px;
      width: 9px;
      height: 14px;
      z-index: 9999;
      pointer-events: none;
      will-change: transform, opacity;
      animation: confettiFall var(--dur, 3.6s) linear var(--delay, 0s) forwards;
    }
    @keyframes confettiFall {
      0%   { transform: translate3d(0, 0, 0) rotate(0deg);   opacity: 1; }
      90%  { opacity: 0.9; }
      100% { transform: translate3d(var(--dx, 0), 110vh, 0) rotate(var(--rot, 720deg)); opacity: 0; }
    }

    .pile-section { display: flex; flex-direction: column; }

    /* position:relative — the Check button is centred against this card. */
    .pile-outer {
      position: relative;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 9px;
      overflow-y: auto;
    }

    .pile {
      display: grid;
      grid-template-columns: repeat(2, var(--cell));
      grid-auto-rows: var(--cell);
      grid-auto-flow: column;
      gap: var(--tile-gap);
      align-content: start;
    }

    .pile-tile {
      width: var(--cell); height: var(--cell);
      display: flex; align-items: center; justify-content: center;
      background: var(--tile);
      border: 1.5px solid var(--border);
      border-radius: 8px;
      font-family: 'Outfit', sans-serif;
      font-weight: 600;
      font-size: calc(var(--cell) * 0.4);
      cursor: pointer;
      user-select: none;
      transition: background 0.13s, border-color 0.13s, transform 0.13s, box-shadow 0.13s;
    }
    .pile-tile:hover {
      border-color: var(--accent);
      transform: translateY(-2px);
      box-shadow: 0 4px 10px rgba(0,0,0,0.12);
    }
    .pile-tile.selected {
      border-color: var(--accent);
      background: #FFF3EE;
      box-shadow: 0 0 0 3px var(--select-ring), var(--tile-shadow);
      transform: translateY(-2px);
    }
    .pile-tile.dragging { opacity: 0.3; transform: none; }

    /* Greyed out: a letter that has already been tried (and was wrong) at the
       hovered/selected cell, OR a cell where the hovered/selected pile letter
       has already been tried (and was wrong). Visual hint only — the user can
       still click/drag if they really want to. */
    .pile-tile.dimmed {
      opacity: 0.32;
      filter: grayscale(0.7);
      text-decoration: line-through;
      text-decoration-thickness: 2px;
    }
    .pile-tile.dimmed:hover {
      transform: none;
      box-shadow: none;
    }
    .cell.empty.dimmed {
      opacity: 0.32;
      filter: grayscale(0.7);
    }

    /* Carries no padding or border of its own, so with the deadline banner
       closed it collapses to zero height and the board gets the space. */
    .game-bottom {
      position: relative;
      background: var(--bg);
      display: flex;
      flex-direction: column;
    }

    /* Status line as a floating pill just above the bottom edge — so it costs
       the board no permanent height for the ~95% of the game it's empty.
       Anchored to the top of .game-bottom, so it rides above the deadline
       banner when that opens. pointer-events:none: it must never eat a tap
       meant for a tile underneath it. */
    .message {
      position: absolute;
      bottom: calc(100% + 0.4rem);
      left: 50%;
      transform: translateX(-50%);
      max-width: calc(100vw - 2rem);
      text-align: center;
      font-size: 0.82rem;
      font-weight: 500;
      line-height: 1.35;
      color: #FFFFFF;
      background: rgba(28, 28, 28, 0.93);
      padding: 0.4rem 0.9rem;
      border-radius: 999px;
      pointer-events: none;
      z-index: 60;
      animation: fadeIn 0.2s ease;
    }
    .message:empty { display: none; }
    .message.win  { font-weight: 600; }
    .message.lose { color: #F2A17C; font-weight: 600; }
    .message.warn { color: #F5D77A; }

    .attempt-dots { display: flex; gap: 6px; }
    .dot {
      width: 10px; height: 10px;
      border-radius: 50%;
      background: var(--ink);
      transition: background 0.35s, transform 0.2s;
    }
    .dot.used { background: var(--border); transform: scale(0.8); }

    /* Centred on the pile card and anchored to it, not laid out beneath the
       remaining tiles — so the tile count can't shift it as the last letters go
       down, which is exactly when the player is reaching for it. Two tiles wide,
       lining up with the pile columns above. Hidden until updateCheckButton()
       adds .visible at CHECK_REVEAL_AT tiles remaining. */
    .btn-check {
      position: absolute;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      width: calc(2 * var(--cell) + var(--tile-gap));
      display: none;
      font-family: 'Outfit', sans-serif;
      font-size: 0.82rem;
      font-weight: 700;
      letter-spacing: 0.16em;
      text-transform: uppercase;
      background: var(--btn-primary);
      color: var(--btn-text);
      border: none;
      border-radius: 7px;
      padding: 0.62rem 0.3rem;
      cursor: pointer;
      transition: background 0.15s, transform 0.12s;
      animation: fadeIn 0.25s ease;
    }
    .btn-check.visible { display: block; }
    /* Keep the centring transform — a bare translateY would knock it off-centre. */
    .btn-check:hover:not(:disabled):not(.not-ready) { background: #333; transform: translate(-50%, calc(-50% - 1px)); }
    .btn-check:disabled, .btn-check.not-ready { background: var(--border); color: var(--ink-light); cursor: not-allowed; }

    /* Focus ring for every solid dark button — Check, the end-of-game actions,
       and the auth and Play buttons. v89 gave the board's tiles a ring but left
       these to the browser's own, which is black, on a --btn-primary (#1C1C1C)
       background: black on black, effectively invisible. Reported from real
       keyboard play on Check; the same defect sits on all eighteen of them, and
       .btn-play is the button you press to reach the board in the first place.
       The offset puts the ring just outside the button, against the page rather
       than the button, where --accent reads clearly — the same ring the tiles
       use, so the board and its buttons look like one thing. Drawing it inside
       instead would need a light colour, which would then vanish the moment a
       button is disabled and turns pale.
       It matters most on the end-of-game buttons: winning moves focus onto one
       of them (see rehomeFocusAfterGame in game.js), so an invisible ring loses
       the player at exactly the moment they have just won. */
    .btn-check:focus-visible,
    .pile-done-actions button:focus-visible,
    .btn-auth:focus-visible,
    .btn-play:focus-visible,
    .celebrate-close:focus-visible,
    .celebrate-stats-link:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 2px;
    }

    /* End-of-game actions filling the now-empty pile card, centred on it like
       the Check button they replace. Same width as Check so they line up with
       the pile columns; stacked because the card is only two tiles wide.
       Toggled by updateCheckButton() adding .visible once the game is over. */
    .pile-done-actions {
      position: absolute;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      width: calc(2 * var(--cell) + var(--tile-gap));
      display: none;
      flex-direction: column;
      gap: 0.55rem;
      animation: fadeIn 0.25s ease;
    }
    .pile-done-actions.visible { display: flex; }
    .pile-done-actions button {
      font-family: 'Outfit', sans-serif;
      font-size: 0.8rem;
      font-weight: 700;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      background: var(--btn-primary);
      color: var(--btn-text);
      border: none;
      border-radius: 7px;
      padding: 0.62rem 0.3rem;
      cursor: pointer;
      transition: background 0.15s, transform 0.12s;
      white-space: nowrap;
      overflow: hidden;
    }
    .pile-done-actions button:hover { background: #333; transform: translateY(-1px); }

    .btn-next {
      font-family: 'Outfit', sans-serif;
      font-size: 0.82rem;
      font-weight: 600;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      background: transparent;
      color: var(--accent);
      border: 1.5px solid var(--accent);
      border-radius: 7px;
      padding: 0.62rem 1.1rem;
      cursor: pointer;
      display: none;
      transition: background 0.15s, color 0.15s;
    }
    .btn-next:hover { background: var(--accent); color: #fff; }

    /* ═══════════════════════ ROTATE-TO-PORTRAIT OVERLAY ═══════════════════════ */
    /* Crosslatch is a tall, portrait-first board. Installed PWAs are locked to
       portrait via the manifest; in a browser tab we can't force orientation,
       so on phones held in landscape (short viewport) we show this prompt.
       max-height keeps tablets and desktop landscape unaffected. */
    .rotate-overlay { display: none; }
    @media (orientation: landscape) and (max-height: 500px) {
      .rotate-overlay {
        display: flex;
        position: fixed; inset: 0;
        z-index: 3000;
        align-items: center;
        justify-content: center;
        padding: 1.5rem;
        background: var(--bg);
        color: var(--ink);
        text-align: center;
      }
      .rotate-inner {
        max-width: 320px;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 0.55rem;
      }
      .rotate-icon { color: var(--accent); animation: rotateHint 2.6s ease-in-out infinite; }
      .rotate-title { font-size: 1.05rem; font-weight: 700; }
      .rotate-text { font-size: 0.85rem; color: var(--ink-mid); }
    }
    @keyframes rotateHint {
      0%, 55%, 100% { transform: rotate(0deg); }
      28%           { transform: rotate(-90deg); }
    }

    /* ═══════════════════════ MENU PANEL ═══════════════════════ */
    .menu-backdrop {
      display: none;
      position: fixed; inset: 0;
      background: rgba(0,0,0,0.22);
      z-index: 1001;
    }
    .menu-backdrop.open { display: block; }

    .menu-panel {
      position: fixed;
      top: 0; right: -280px;
      width: 260px; height: 100%;
      background: var(--surface);
      border-left: 1px solid var(--border);
      box-shadow: -4px 0 24px rgba(0,0,0,0.09);
      z-index: 1002;
      transition: right 0.28s cubic-bezier(0.4,0,0.2,1);
      display: flex;
      flex-direction: column;
    }
    .menu-panel.open { right: 0; }

    .menu-panel-header {
      padding: 1rem 1.2rem;
      border-bottom: 1px solid var(--border-light);
      font-size: 0.72rem;
      font-weight: 700;
      letter-spacing: 0.22em;
      text-transform: uppercase;
      color: var(--ink);
      display: flex;
      align-items: center;
      justify-content: space-between;
    }

    .menu-user-info {
      padding: 0.9rem 1.2rem;
      border-bottom: 1px solid var(--border-light);
    }
    .menu-user-name {
      font-size: 0.9rem;
      font-weight: 600;
      color: var(--ink);
    }
    .menu-user-email {
      font-size: 0.75rem;
      color: var(--ink-light);
      margin-top: 0.15rem;
    }

    .menu-close-btn {
      background: none; border: none;
      cursor: pointer;
      font-size: 1.4rem;
      color: var(--ink-light);
      line-height: 1; padding: 0;
      transition: color 0.13s;
    }
    .menu-close-btn:hover { color: var(--ink); }

    .menu-list { list-style: none; padding: 0.5rem 0; flex: 1; }
    /* `a` is here for the admin dashboard item, which is a real link to a
       separate page rather than a button that swaps screens. */
    .menu-list li button,
    .menu-list li a {
      width: 100%;
      padding: 0.8rem 1.2rem;
      background: none; border: none;
      text-align: left;
      font-family: 'Outfit', sans-serif;
      font-size: 0.95rem;
      font-weight: 500;
      color: var(--ink-mid);
      text-decoration: none;
      cursor: pointer;
      transition: background 0.12s, color 0.12s;
      display: flex;
      align-items: center;
      gap: 0.7rem;
    }
    .menu-list li button:hover,
    .menu-list li a:hover { background: var(--bg); color: var(--ink); }
    .menu-list li button svg,
    .menu-list li a svg { flex-shrink: 0; }

    .menu-separator {
      height: 1px;
      background: var(--border-light);
      margin: 0.3rem 1.2rem;
    }

    .menu-footer {
      padding: 1rem 1.2rem;
      border-top: 1px solid var(--border-light);
    }
    .menu-footer button {
      width: 100%;
      padding: 0.65rem;
      background: none;
      border: 1.5px solid var(--border);
      border-radius: 7px;
      font-family: 'Outfit', sans-serif;
      font-size: 0.82rem;
      font-weight: 600;
      color: var(--ink-mid);
      cursor: pointer;
      letter-spacing: 0.08em;
      transition: background 0.13s, color 0.13s;
    }
    .menu-footer button:hover { background: var(--bg); color: var(--ink); }

    /* ═══════════════════════ STATISTICS PAGE ═══════════════════════ */
    .stats-page {
      align-items: center;
      padding: 0;
    }

    /* Shared secondary-screen header: interlocking mark (left) · title
       (centred) · close cross / actions (right). The 1fr auto 1fr grid keeps
       the title optically centred no matter how many icons sit on the right
       (e.g. the group dashboard's gear + cross). */
    .stats-header {
      width: 100%;
      display: grid;
      grid-template-columns: 1fr auto 1fr;
      align-items: center;
      gap: 0.5rem;
      /* Match the game header exactly — same --content-half inset (not just
         the same formula with a different half-width), so the mark and close
         cross sit at the same distance apart as the game header's logo and
         hamburger regardless of how wide this screen's own body column is.
         Previously used a separate --panel-half (240px, 270px for How to
         Play) tied to each body's own width, which left the header visibly
         wider on desktop even though it's a distinct fixed bar, not part of
         the scrolling body. The border-bottom still spans full width.
         Vertical padding matches .game-header's 0.5rem exactly too, so the
         mark and close cross sit at the same height as well — otherwise they
         visibly drop by the padding difference when navigating from the game
         to a secondary screen. */
      padding: 0.5rem max(1.1rem, calc(50% - var(--content-half)));
      background: var(--bg);
      border-bottom: 1px solid var(--border-light);
      position: sticky;
      top: 0;
      z-index: 100;
    }

    /* Every logo container drives its marks' ink through currentColor, so the
       inline SVGs stay theme-agnostic and recolour from --logo-ink alone. */
    .splash-logo, .auth-logo, .welcome-logo, .header-brand, .stats-logo { color: var(--logo-ink); }

    /* The interlocking key-and-lock mark on the left — the game logo cropped
       to just the symbol, no wordmark. */
    .stats-logo {
      justify-self: start;
      display: flex;
      align-items: center;
    }
    .stats-logo svg { height: 30px; width: auto; display: block; }

    .stats-actions {
      justify-self: end;
      display: flex;
      align-items: center;
      gap: 0.5rem;
    }

    .stats-back, .stats-close, .stats-icon {
      width: 38px; height: 38px;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 8px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      color: var(--ink);
      transition: background 0.13s, border-color 0.13s;
    }
    .stats-back:hover, .stats-close:hover, .stats-icon:hover { background: var(--bg); }
    .stats-close {
      font-size: 1.5rem;
      line-height: 1;
      color: var(--ink-mid);
    }
    .stats-close:hover { color: var(--ink); border-color: var(--ink-mid); }

    .stats-title {
      justify-self: center;
      text-align: center;
      font-size: 0.78rem;
      font-weight: 700;
      letter-spacing: 0.22em;
      text-transform: uppercase;
    }

    .stats-body {
      width: 100%;
      max-width: 480px;
      padding: 1.5rem 1.2rem 3rem;
      margin: 0 auto;
    }

    .stats-grid {
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      gap: 0.6rem;
      margin-bottom: 2rem;
    }

    .stat-card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 10px;
      padding: 0.9rem 0.5rem;
      text-align: center;
    }

    .stat-value {
      font-size: 1.6rem;
      font-weight: 700;
      color: var(--ink);
      line-height: 1;
      margin-bottom: 0.3rem;
    }

    .stat-label {
      font-size: 0.62rem;
      font-weight: 600;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: var(--ink-light);
      line-height: 1.3;
    }

    .stats-section {
      margin-bottom: 1.8rem;
    }

    .stats-section-title {
      font-size: 0.66rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.22em;
      color: var(--ink-light);
      margin-bottom: 0.8rem;
      padding-bottom: 0.3rem;
      border-bottom: 1px solid var(--border-light);
    }

    /* Distribution chart */
    .dist-row {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      margin-bottom: 0.4rem;
    }

    .dist-label {
      font-size: 0.82rem;
      font-weight: 600;
      color: var(--ink-mid);
      width: 1.8rem;
      text-align: right;
      flex-shrink: 0;
    }

    .dist-bar-wrap {
      flex: 1;
      height: 26px;
      background: var(--border-light);
      border-radius: 5px;
      overflow: hidden;
      position: relative;
    }

    .dist-bar {
      height: 100%;
      border-radius: 5px;
      min-width: 2px;
      transition: width 0.6s cubic-bezier(0.4,0,0.2,1);
      display: flex;
      align-items: center;
      justify-content: flex-end;
      padding-right: 0.5rem;
    }
    .dist-bar.att-1 { background: var(--locked-border); }
    .dist-bar.att-2 { background: var(--hint-bg); }
    .dist-bar.att-3 { background: var(--gold); }
    .dist-bar.att-x { background: var(--accent); }
    /* Abandoned — the same gold-and-grey hatch the group consistency grid
       uses for this state, so one outcome reads the same in both places. */
    .dist-bar.att-a {
      background-image: repeating-linear-gradient(135deg,
        var(--gold) 0 2px, var(--border-light) 2px 5px);
      forced-color-adjust: none;
    }
    /* The hatch has no flat area to sit text on, so the count gets its own
       solid chip rather than relying on contrast against moving stripes. */
    .dist-bar.att-a .dist-count {
      color: var(--ink);
      background: var(--surface);
      padding: 0 0.28rem;
      border-radius: 3px;
    }

    /* Reconciliation line — the bars sum to the Played card above. */
    .dist-total {
      margin-top: 0.5rem;
      font-size: 0.68rem;
      color: var(--ink-light);
      text-align: right;
      font-variant-numeric: tabular-nums;
    }

    .dist-count {
      font-size: 0.72rem;
      font-weight: 700;
      color: #fff;
    }

    /* Streak section */
    .streak-cards {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 0.6rem;
    }

    /* The Streaks section stacks two of these: solved, then perfect. */
    .streak-cards + .streak-cards { margin-top: 0.6rem; }

    .streak-note {
      margin-top: 0.5rem;
      font-size: 0.68rem;
      color: var(--ink-light);
    }

    .streak-card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 10px;
      padding: 1rem;
      text-align: center;
    }

    .streak-icon {
      font-size: 1.2rem;
      margin-bottom: 0.3rem;
    }

    .streak-value {
      font-size: 1.8rem;
      font-weight: 800;
      color: var(--ink);
      line-height: 1;
      margin-bottom: 0.25rem;
    }

    .streak-label {
      font-size: 0.68rem;
      font-weight: 600;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: var(--ink-light);
    }

    /* History calendar */
    .cal-head {
      display: flex; align-items: center; justify-content: space-between;
      margin-bottom: 0.6rem;
    }
    .cal-month {
      font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em;
      text-transform: uppercase; color: var(--ink);
    }
    .cal-nav {
      width: 30px; height: 30px; padding: 0;
      background: var(--surface); border: 1px solid var(--border);
      border-radius: 7px; cursor: pointer;
      font-size: 1rem; line-height: 1; color: var(--ink-mid);
    }
    .cal-nav:hover:not(:disabled) { background: var(--bg); color: var(--ink); }
    .cal-nav:disabled { opacity: 0.35; cursor: default; }

    .cal-dow, .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
    .cal-dow {
      margin-bottom: 3px;
      font-size: 0.62rem; color: var(--ink-light); text-align: center;
    }
    .cal-day { display: flex; flex-direction: column; align-items: center; gap: 2px; }
    .cal-day-num {
      font-size: 0.58rem; color: var(--ink-light); line-height: 1;
      font-variant-numeric: tabular-nums;
    }

    /* Every state is background/box-shadow only — never `border` — so each
       cell occupies the identical box and the columns stay aligned across
       rows, the same constraint the group consistency grid documents. */
    .cal-cell {
      appearance: none; -webkit-appearance: none;
      width: 100%; aspect-ratio: 1; padding: 0;
      border: none; border-radius: 4px;
      display: flex; align-items: center; justify-content: center;
      font-family: inherit; font-size: 0.66rem; font-weight: 700;
      font-variant-numeric: tabular-nums;
      background: var(--border-light); color: transparent;
      cursor: pointer;
    }
    .cal-cell.solved { background: var(--success); color: #FFFFFF; }
    .cal-cell.failed { background: var(--surface); box-shadow: inset 0 0 0 1.5px var(--gold); color: var(--gold); }
    .cal-cell.none   { background: var(--border-light); }
    /* Same two-tone 135deg hatch as the group consistency grid, but a longer
       period. These cells are ~3x that grid's, and at this size the grid's
       2px/5px gold coverage stops reading as texture and starts dominating
       the month — an abandoned day would draw more attention than a solve.
       Two solid tones are kept (not gold-on-transparent) for the same reason
       documented there: the band boundary has to survive grayscale. */
    .cal-cell.abandoned {
      background-image: repeating-linear-gradient(135deg,
        var(--gold) 0 2px, var(--border-light) 2px 7px);
      forced-color-adjust: none;
    }
    /* Before sign-up / still to come — not results, so not clickable. */
    .cal-cell.pre {
      background-color: transparent;
      background-image: repeating-linear-gradient(45deg, var(--border) 0 2px, transparent 2px 5px);
      opacity: 0.5; cursor: default;
    }
    .cal-cell.future { background: transparent; cursor: default; }
    .cal-cell:focus-visible { outline: 2px solid var(--hint-bg); outline-offset: 1px; }

    .cal-summary {
      margin-top: 0.7rem; font-size: 0.72rem; color: var(--ink-mid);
      text-align: center;
    }
    .cal-detail {
      margin-top: 0.25rem; min-height: 1.1rem;
      font-size: 0.72rem; color: var(--ink-light); text-align: center;
    }
    .cal-key {
      display: flex; flex-wrap: wrap; justify-content: center;
      gap: 0.7rem; margin-top: 0.7rem;
      font-size: 0.62rem; color: var(--ink-light);
    }
    .cal-key span { display: flex; align-items: center; gap: 0.3rem; }
    .cal-k { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
    .cal-k.solved { background: var(--success); }
    .cal-k.failed { background: var(--surface); box-shadow: inset 0 0 0 1.5px var(--gold); }
    .cal-k.none   { background: var(--border-light); }
    .cal-k.abandoned {
      background-image: repeating-linear-gradient(135deg,
        var(--gold) 0 2px, var(--border-light) 2px 5px);
      forced-color-adjust: none;
    }
    .cal-k.pre {
      background-color: transparent;
      background-image: repeating-linear-gradient(45deg, var(--border) 0 2px, transparent 2px 5px);
      opacity: 0.5;
    }

    .stats-empty {
      text-align: center;
      padding: 2.5rem 1rem;
      color: var(--ink-light);
      font-size: 0.9rem;
    }

    /* ═══════════════════════ ACCOUNT PAGE ═══════════════════════ */
    .account-body {
      width: 100%;
      max-width: 420px;
      padding: 1.5rem 1.2rem 3rem;
      margin: 0 auto;
    }

    .account-section {
      margin-bottom: 1.6rem;
    }

    .account-section-title {
      font-size: 0.66rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.22em;
      color: var(--ink-light);
      margin-bottom: 0.8rem;
      padding-bottom: 0.3rem;
      border-bottom: 1px solid var(--border-light);
    }

    .account-field {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0.7rem 0;
      border-bottom: 1px solid var(--border-light);
    }
    .account-field:last-child { border-bottom: none; }

    .account-field-label {
      font-size: 0.78rem;
      font-weight: 600;
      color: var(--ink-mid);
      text-transform: uppercase;
      letter-spacing: 0.1em;
    }

    .account-field-value {
      font-size: 0.9rem;
      color: var(--ink);
    }

    .account-field-right { display: flex; align-items: center; gap: 0.7rem; }
    .account-edit-btn {
      font-family: 'Outfit', sans-serif;
      font-size: 0.8rem;
      font-weight: 600;
      color: var(--accent);
      background: none;
      border: none;
      cursor: pointer;
      padding: 0.1rem 0.2rem;
    }
    .account-edit-btn:hover { text-decoration: underline; }

    .btn-danger {
      font-family: 'Outfit', sans-serif;
      font-size: 0.82rem;
      font-weight: 600;
      letter-spacing: 0.08em;
      background: none;
      color: var(--accent);
      border: 1.5px solid var(--accent);
      border-radius: 7px;
      padding: 0.6rem 1.2rem;
      cursor: pointer;
      transition: background 0.15s, color 0.15s;
      width: 100%;
      margin-top: 0.5rem;
    }
    .btn-danger:hover { background: var(--accent); color: #fff; }

    /* Armed state for arm-then-fire (armButton in auth.js). The filled
       accent is the whole signal that the next press commits, so it has to
       read as a change even on the buttons that were already outlined in
       accent — hence solid fill rather than a border tweak. */
    .btn-danger.armed,
    .grp-mini-btn.armed {
      background: var(--accent);
      color: #fff;
      border-color: var(--accent);
    }
    /* Armed labels say what is about to happen, so they are longer than the
       resting label. Let them wrap rather than overflow the narrow inline
       rows the mini-buttons sit in. */
    .grp-mini-btn.armed { white-space: normal; }

    .btn-secondary {
      font-family: 'Outfit', sans-serif;
      font-size: 0.82rem;
      font-weight: 600;
      letter-spacing: 0.08em;
      background: none;
      color: var(--ink-mid);
      border: 1.5px solid var(--border);
      border-radius: 7px;
      padding: 0.6rem 1.2rem;
      cursor: pointer;
      transition: background 0.15s, color 0.15s;
      width: 100%;
    }
    .btn-secondary:hover { background: var(--bg); color: var(--ink); }

    /* Language preference toggle (Account screen) */
    .account-section-hint {
      font-size: 0.8rem;
      color: var(--ink-mid);
      margin-bottom: 0.7rem;
    }
    .lang-toggle {
      display: flex;
      gap: 0.5rem;
    }
    .lang-option {
      flex: 1;
      font-family: 'Outfit', sans-serif;
      font-size: 0.82rem;
      font-weight: 600;
      letter-spacing: 0.04em;
      background: none;
      color: var(--ink-mid);
      border: 1.5px solid var(--border);
      border-radius: 7px;
      padding: 0.6rem 0.8rem;
      cursor: pointer;
      transition: background 0.15s, color 0.15s, border-color 0.15s;
    }
    .lang-option:hover:not(:disabled):not(.active) { background: var(--bg); color: var(--ink); }
    .lang-option.active {
      color: #fff;
      background: var(--accent);
      border-color: var(--accent);
      cursor: default;
    }
    .lang-option:disabled { opacity: 0.5; cursor: not-allowed; }
    .lang-note {
      min-height: 1rem;
      margin-top: 0.5rem;
      font-size: 0.78rem;
      color: var(--ink-mid);
    }
    .lang-note.warn  { color: var(--accent); }
    .lang-note.error { color: var(--error); }

    /* ═══════════════════════ HOW TO PLAY ═══════════════════════ */
    .how-to-play {
      display: none;
      position: fixed; inset: 0;
      background: var(--bg);
      z-index: 2000;
      flex-direction: column;
      animation: fadeIn 0.2s ease;
    }
    .how-to-play.open { display: flex; }

    @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

    /* The shared header bar sits fixed at the top; the content scrolls beneath. */
    .htp-scroll {
      flex: 1;
      overflow-y: auto;
      display: flex;
      justify-content: center;
      padding: 2.5rem 1.5rem 4rem;
    }

    .htp-inner { max-width: 540px; width: 100%; }

    .htp-title {
      text-align: center;
      margin-bottom: 0.3rem;
    }
    .htp-subtitle {
      color: var(--ink-light);
      font-size: 0.92rem;
      text-align: center;
      letter-spacing: 0.08em;
    }
    .htp-divider {
      width: 44px; height: 1px;
      background: var(--border);
      margin: 0.9rem auto 2rem;
    }
    /* Top-level grouping. The panel's two halves -- how groups work, and how
       the numbers work -- each sit on their own card, borrowing the
       dashboard's .grp-block treatment (--surface on --bg, 14px radius, the
       same hairline and tile shadow) so this reads in the app's existing
       visual language rather than inventing one. The card edge does the job
       the divider used to, so the divider goes with it. */
    .htp-group {
      background: var(--surface);
      border: 1px solid var(--border-light);
      border-radius: 14px;
      box-shadow: var(--tile-shadow);
      padding: 1.5rem 1.1rem 1.2rem;
      margin-bottom: 1.4rem;
    }
    .htp-group .htp-subtitle { margin-bottom: 1.4rem; }
    .htp-group > .htp-section:last-child { margin-bottom: 0; }

    .htp-section { margin-bottom: 1.6rem; }

    /* Embedded how-to-play demo video */
    .htp-video {
      position: relative;
      width: 100%;
      max-width: 420px;
      margin: 0 auto;
      aspect-ratio: 4 / 5;
      border: 1px solid var(--border);
      border-radius: 14px;
      overflow: hidden;
      background: var(--bg);
      box-shadow: 0 8px 22px rgba(0,0,0,0.08);
    }
    .htp-video iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }
    @media (max-width: 400px) { .htp-video { aspect-ratio: 3 / 4; } }
    .htp-video-note {
      max-width: 420px;
      margin: 0.6rem auto 0;
      text-align: center;
      font-size: 0.82rem;
      line-height: 1.5;
      color: var(--ink-light);
    }
    /* Level 2: the dashboard's time windows, which own the sections beneath
       them. Pitched between .htp-subtitle above (0.92rem, centred) and
       .htp-section-title below (0.66rem, --ink-light, underlined) -- larger
       and darker than a section label so it plainly outranks one, but
       left-aligned and smaller than the centred banner so it stays under it.
       No divider either: those now mark only the top-level breaks. */
    .htp-view-title {
      font-size: 0.78rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.16em;
      color: var(--ink-mid);
      margin: 2rem 0 0.9rem;
    }

    .htp-section-title {
      font-size: 0.66rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.22em;
      color: var(--ink-light);
      margin-bottom: 0.6rem;
      padding-bottom: 0.3rem;
      border-bottom: 1px solid var(--border-light);
    }
    .htp-section p {
      font-size: 0.94rem;
      line-height: 1.65;
      color: var(--ink-mid);
      margin-bottom: 0.5rem;
    }
    .htp-list { list-style: none; }
    .htp-list li {
      font-size: 0.94rem;
      line-height: 1.65;
      color: var(--ink-mid);
      padding: 0.12rem 0 0.12rem 1.2rem;
      position: relative;
    }
    .htp-list li::before {
      content: "·";
      position: absolute;
      left: 0.3rem;
      color: var(--accent);
      font-size: 1.2rem;
      line-height: 1.45;
    }
    .htp-list li strong { color: var(--ink); }

    /* Consistency-grid key. Reuses .grp-cg-k -- the very swatches the
       dashboard's own key renders -- so the two can never drift apart, and a
       reader matches a square to its meaning by sight instead of decoding a
       colour name. Positioned like .htp-list's "·" marker so wrapped lines
       keep the hanging indent. */
    .htp-cg-key { list-style: none; margin-top: 0.15rem; }
    .htp-cg-key li {
      font-size: 0.94rem;
      line-height: 1.65;
      color: var(--ink-mid);
      padding: 0.12rem 0 0.12rem 1.5rem;
      position: relative;
    }
    .htp-cg-key .grp-cg-k {
      position: absolute;
      left: 0.1rem;
      top: 0.52rem;
      width: 13px;
      height: 13px;
      /* On the dashboard these squares sit on .grp-block's white; this panel
         is --bg cream, so "Not played" (--border-light) and the "Before
         joining" hatch had almost nothing to read against. The hairline
         outline gives every swatch an edge at 13px -- outline, not border,
         so it adds no layout box, and not box-shadow, which .played already
         uses for its gold ring. --ink-mid, not --border: measured against
         this panel, --border is 1.22:1 and --ink-light 2.41:1, both under
         the 3:1 a UI shape needs to be made out. --ink-mid is 6.18:1. */
      outline: 1px solid var(--ink-mid);
    }
    /* Restore the white ground the real cell has, so the hatch reads. Needs
       the state class to outrank .grp-cg-k.pre's own transparent background,
       and drops that rule's 0.5 opacity, which would fade the outline too. */
    .htp-cg-key .grp-cg-k.pre { background-color: var(--surface); opacity: 1; }
    .htp-cg-key strong { color: var(--ink); }

    .htp-example {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 8px;
      padding: 0.9rem 1.1rem;
      margin-top: 0.7rem;
    }
    .htp-example-grid-row {
      display: flex;
      justify-content: center;
      margin-bottom: 0.7rem;
    }
    .htp-example-grid {
      width: 122px;
      display: block;
    }
    .htp-example-rowtag {
      font-weight: 800;
      letter-spacing: 0.08em;
    }
    .htp-example-cards {
      display: flex;
      flex-direction: column;
      gap: 0.45rem;
    }
    .htp-example-card {
      display: flex;
      align-items: center;
      gap: 0.6rem;
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: 9px;
      padding: 0.5rem 0.7rem;
    }
    .htp-example-chip {
      font-size: 0.6rem;
      font-weight: 700;
      letter-spacing: 0.05em;
      padding: 0.2rem 0.5rem;
      border-radius: 999px;
      flex: 0 0 auto;
      white-space: nowrap;
    }
    .htp-chip-key { background: var(--group-a-pale); color: #8a5a00; }
    .htp-chip-lock { background: var(--group-b-pale); color: #2c5a86; }
    .htp-example-word {
      font-weight: 700;
      font-size: 0.88rem;
      color: var(--ink);
      flex: 0 0 auto;
      min-width: 72px;
    }
    .htp-example-reason {
      font-size: 0.76rem;
      color: var(--ink-mid);
      line-height: 1.4;
    }

    /* ═══════════════════════ CHANGE PASSWORD MODAL ═══════════════════════ */
    .modal-backdrop {
      display: none;
      position: fixed; inset: 0;
      background: rgba(0,0,0,0.3);
      z-index: 3000;
      align-items: center;
      justify-content: center;
      padding: 1.5rem;
    }
    .modal-backdrop.open { display: flex; }

    .modal-card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 14px;
      padding: 2rem;
      max-width: 380px;
      width: 100%;
      animation: fadeIn 0.2s ease;
    }

    .modal-title {
      font-size: 1rem;
      font-weight: 700;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      margin-bottom: 1.2rem;
    }

    .modal-text {
      font-size: 0.9rem;
      color: var(--ink-mid);
      line-height: 1.55;
      margin-bottom: 1.4rem;
    }

    /* Last ten minutes before a puzzle closes. In-flow at the top of
       .game-bottom, so fitGameToViewport gives it room out of the board's
       height rather than letting it cover the Check button. */
    .deadline-warning {
      display: none;
      text-align: center;
      background: var(--ink);
      color: #FFFFFF;
      border: 1.5px solid var(--accent);
      border-radius: 8px;
      padding: 0.4rem 0.7rem;
      font-size: 0.8rem;
      font-weight: 500;
      line-height: 1.35;
      animation: fadeIn 0.3s ease;
    }
    /* Margin lives on the banner, not the container, so .game-bottom stays at
       zero height whenever the banner is closed. */
    .deadline-warning.open { display: block; margin: 0.5rem 1.1rem 0.7rem; }
    .deadline-warning strong {
      font-weight: 700;
      font-variant-numeric: tabular-nums; /* stops the countdown jittering */
      color: #F2A17C;
    }

    .modal-text {
      font-size: 0.94rem;
      color: var(--ink-mid);
      line-height: 1.6;
      margin-bottom: 1.4rem;
    }

    .modal-close {
      position: absolute;
      top: 0.8rem; right: 0.8rem;
      background: none; border: none;
      cursor: pointer; font-size: 1.3rem;
      color: var(--ink-light);
    }

    .feedback-textarea {
      width: 100%;
      font-family: 'Outfit', sans-serif;
      font-size: 0.95rem;
      padding: 0.7rem 0.9rem;
      border: 1.5px solid var(--border);
      border-radius: 8px;
      background: var(--bg);
      color: var(--ink);
      outline: none;
      resize: vertical;
      min-height: 120px;
      max-height: 300px;
      transition: border-color 0.15s;
      box-sizing: border-box;
    }
    .feedback-textarea:focus { border-color: var(--accent); }
    .feedback-char-count {
      font-size: 0.75rem;
      color: var(--ink-light);
      text-align: right;
      margin-top: 0.25rem;
    }
    .feedback-char-count.near-limit { color: var(--accent); }

    /* The admin panel's styles have moved to admin.css, which only
       /admin.html loads. They were shipped to every player before. */

    /* ═══════════════════════ WHAT'S NEW ═══════════════════════ */

    /* The dot on the hamburger. Without it the badge inside the menu is
       only discoverable by opening the menu, which is the thing it exists
       to prompt. Drawn as ::after on the button so it works for both
       hamburgers without adding an element to either.

       Only the game-header button needs `position: relative`.
       .welcome-menu-btn is already `position: absolute`, anchored to the
       welcome card's top-right corner, and absolute is itself a
       positioning context — so the dot lands correctly there already.
       Setting relative on .menu-btn generally DOES match it, at equal
       specificity and later in the file, which overrode that anchoring and
       dropped the button into normal flow on top of the logo. */
    .menu-btn:not(.welcome-menu-btn) { position: relative; }
    .menu-btn.has-news::after {
      content: '';
      position: absolute;
      top: 6px; right: 6px;
      width: 8px; height: 8px;
      border-radius: 50%;
      background: var(--accent);
      border: 2px solid var(--surface);
      box-sizing: content-box;
    }

    .menu-badge {
      margin-left: auto;
      min-width: 18px;
      height: 18px;
      padding: 0 5px;
      border-radius: 999px;
      background: var(--accent);
      color: #FFF;
      font-size: 0.7rem;
      font-weight: 700;
      display: inline-flex;
      align-items: center;
      justify-content: center;
    }

    .news-item {
      background: var(--surface);
      border: 1px solid var(--border-light);
      border-radius: 12px;
      padding: 1rem 1.1rem;
      margin-bottom: 0.85rem;
    }
    /* Unread gets an edge, not a fill: this list is read top to bottom and
       a block of coloured cards would be harder to read, not easier. */
    .news-item.unread { border-left: 4px solid var(--accent); }

    .news-meta {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      flex-wrap: wrap;
      margin-bottom: 0.4rem;
    }
    .news-kind {
      font-size: 0.66rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.06em;
      padding: 0.12rem 0.45rem;
      border-radius: 4px;
      background: var(--info-bg);
      color: var(--hint-locked);
    }
    .news-kind.welcome { background: var(--locked-bg);     color: var(--locked-text); }
    .news-kind.fix     { background: var(--group-a-pale);  color: var(--group-a-locked); }
    .news-kind.notice  { background: #FBEAE3;              color: #8A3512; }
    .news-date { font-size: 0.75rem; color: var(--ink-light); }
    .news-new {
      font-size: 0.66rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.06em;
      color: var(--accent);
    }

    .news-title { font-size: 1.05rem; font-weight: 700; margin-bottom: 0.4rem; }
    .news-body { font-size: 0.92rem; line-height: 1.6; color: var(--ink-mid); }
    .news-body p { margin-bottom: 0.6rem; }
    .news-body p:last-child { margin-bottom: 0; }
    .news-edited {
      margin-top: 0.6rem;
      font-size: 0.72rem;
      color: var(--ink-light);
      font-style: italic;
    }
    .news-empty {
      text-align: center;
      color: var(--ink-light);
      font-size: 0.9rem;
      padding: 2.5rem 1rem;
      line-height: 1.6;
    }

    /* ═══════════════════════ COOKIE BANNER ═══════════════════════ */
    .cookie-banner {
      position: fixed;
      bottom: 0; left: 0; right: 0;
      z-index: 9000;
      background: var(--ink);
      color: #fff;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 1rem;
      padding: 0.85rem 1.25rem;
      flex-wrap: wrap;
    }
    .cookie-banner[hidden] { display: none; }
    .cookie-text {
      font-size: 0.88rem;
      margin: 0;
      color: rgba(255,255,255,0.85);
    }
    .cookie-link {
      color: #fff;
      font-weight: 600;
      text-underline-offset: 2px;
    }
    .cookie-btn {
      background: var(--accent);
      color: #fff;
      border: none;
      border-radius: 6px;
      padding: 0.4rem 1rem;
      font-family: inherit;
      font-size: 0.88rem;
      font-weight: 600;
      cursor: pointer;
      white-space: nowrap;
    }
    .cookie-btn:hover { opacity: 0.88; }

    /* ═══════════════════════ ACCOUNT DATA BUTTONS ═══════════════════════ */
    .btn-export {
      display: block;
      width: 100%;
      padding: 0.75rem 1rem;
      background: var(--surface);
      color: var(--ink);
      border: 1.5px solid var(--border);
      border-radius: 10px;
      font-family: inherit;
      font-size: 0.95rem;
      font-weight: 600;
      cursor: pointer;
      text-align: center;
      margin-bottom: 0.6rem;
      transition: border-color 0.15s, background 0.15s;
    }
    .btn-export:hover { border-color: var(--accent); background: #FFF3EE; }

    /* ══════════════════════ GROUPS ══════════════════════ */

    /* Groups list — the panel someone with no groups yet lands on. Two
       labelled halves of the same shape: heading, control, .btn-auth. Both
       buttons are the same button because both routes into a group are
       equally ordinary, and the one that used to whisper (an outlined "Join"
       beside a text box, under the smallest label on the page) is the one
       most people actually need. */
    .grp-intro {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 1rem;
      margin-bottom: 1.4rem;
    }
    .grp-intro-text {
      font-size: 0.86rem;
      color: var(--ink-mid);
      line-height: 1.5;
      margin-bottom: 0.75rem;
    }

    .grp-or {
      display: flex;
      align-items: center;
      gap: 0.7rem;
      margin: 1rem 0 0.9rem;
      font-size: 0.68rem;
      font-weight: 700;
      letter-spacing: 0.16em;
      text-transform: uppercase;
      color: var(--ink-light);
    }
    .grp-or::before, .grp-or::after {
      content: "";
      flex: 1;
      height: 1px;
      background: var(--border-light);
    }

    .grp-code-field { display: flex; flex-direction: column; gap: 0.5rem; }
    /* .btn-auth carries a top margin for the auth screens, and .auth-alert a
       bottom one; inside this stack the gap does both jobs. A hidden alert is
       display:none, so it contributes no gap either. */
    .grp-code-field .btn-auth   { margin-top: 0; }
    .grp-code-field .auth-alert { margin-bottom: 0; }
    .grp-code-input { text-transform: uppercase; letter-spacing: 0.12em; }
    /* Stands in for a control the viewer's caps have taken away — the
       Create button, the invite code, the share link. Same panel look as
       .grp-locked-note, so "you can't do this yet" reads consistently
       wherever it appears. */
    .grp-cap-note {
      background: var(--info-bg);
      border-radius: 12px;
      padding: 0.9rem 1rem;
      font-size: 0.85rem;
      color: var(--ink-mid);
      line-height: 1.5;
      margin-bottom: 1.4rem;
    }
    .grp-manage .grp-cap-note { margin-bottom: 0; }
    /* On the list screen the note follows the groups rather than replacing a
       control above them, so it needs air above and none below. */
    .grp-list + .grp-cap-note { margin: 1rem 0 0; }

    /* The pinned action bar — everything below is the returning-player view.
       A sibling of .stats-body, not a child, so it mirrors the sticky header:
       hairline across the full width, controls in the same 480px column as
       the cards above them.

       Sticky rather than fixed. A fixed bar sits outside the layout, so the
       list would need bottom padding of exactly the bar's height to stop the
       last card hiding underneath — a number that goes stale the moment the
       copy in the bar wraps to another line. Sticky keeps it in the flow and
       the layout reserves the space itself.

       .stats-body grows to fill the screen so the bar rides at the bottom
       even when the list is short. That leaves a gap of background above it
       with only a group or two, which is the right trade: the alternative
       strands a full-width bordered strip mid-screen with nothing below it,
       and the buttons stay put whether you are in two groups or ten. */
    #challenges-screen .stats-body { flex: 1; }
    .grp-bar {
      /* .stats-page centres its children, so this has to claim the width the
         same way .stats-header and .stats-body do. */
      width: 100%;
      position: sticky;
      bottom: 0;
      background: var(--bg);
      border-top: 1px solid var(--border);
    }
    .grp-bar[hidden] { display: none; }
    .grp-bar-inner {
      max-width: 480px;
      margin: 0 auto;
      /* Matches .stats-body's horizontal padding, so the buttons line up with
         the edges of the cards. */
      padding: 0.7rem 1.2rem 0.5rem;
    }
    .grp-bar-row { display: flex; gap: 0.5rem; }
    .btn-auth.grp-bar-btn {
      flex: 1;
      margin-top: 0;
      font-size: 0.72rem;
      letter-spacing: 0.12em;
      /* Same optical height as a filled .btn-auth: see .btn-auth.outline. */
      padding: calc(0.66rem - 1.5px) 0;
    }
    .grp-bar .grp-about-link { margin-top: 0.3rem; padding: 0.3rem; }

    .grp-list-title {
      font-size: 0.66rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.22em;
      color: var(--ink-light);
      margin-bottom: 0.8rem;
      padding-bottom: 0.3rem;
      border-bottom: 1px solid var(--border-light);
    }
    .grp-list { display: flex; flex-direction: column; gap: 0.6rem; }

    .grp-card {
      display: flex;
      align-items: center;
      gap: 0.6rem;
      width: 100%;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 0.9rem 1rem;
      cursor: pointer;
      text-align: left;
      font-family: inherit;
      transition: border-color 0.15s, transform 0.12s;
    }
    .grp-card:hover { border-color: var(--accent); transform: translateY(-1px); }
    .grp-card-main { flex: 1; min-width: 0; }
    .grp-card-name {
      font-size: 1rem;
      font-weight: 700;
      color: var(--ink);
      display: flex;
      align-items: center;
      gap: 0.5rem;
    }
    .grp-card-sub { font-size: 0.8rem; color: var(--ink-mid); margin-top: 0.2rem; }
    .grp-card-chevron { color: var(--ink-light); flex-shrink: 0; }

    .grp-owner-tag {
      font-size: 0.58rem;
      font-weight: 700;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--accent);
      background: #FFF0EC;
      border: 1px solid var(--accent);
      border-radius: 4px;
      padding: 0.05rem 0.35rem;
    }

    .grp-empty { text-align: center; padding: 2rem 1rem; }
    .grp-empty-title { font-weight: 700; font-size: 1.05rem; margin-bottom: 0.5rem; }
    .grp-empty-text { color: var(--ink-mid); font-size: 0.9rem; line-height: 1.5; }

    /* Tabs */
    .grp-tabs {
      display: flex;
      gap: 0.3rem;
      background: var(--border-light);
      border-radius: 10px;
      padding: 0.25rem;
      margin-bottom: 1.4rem;
    }
    .grp-tab {
      flex: 1;
      font-family: inherit;
      font-size: 0.8rem;
      font-weight: 600;
      color: var(--ink-mid);
      background: none;
      border: none;
      border-radius: 8px;
      padding: 0.55rem 0.4rem;
      cursor: pointer;
      transition: background 0.15s, color 0.15s;
    }
    .grp-tab.active { background: var(--surface); color: var(--ink); box-shadow: var(--tile-shadow); }

    /* Today tab */
    .grp-locked-note {
      background: var(--info-bg);
      border-radius: 10px;
      padding: 0.8rem 1rem;
      font-size: 0.85rem;
      color: var(--ink-mid);
      line-height: 1.45;
      margin-bottom: 1rem;
    }
    .grp-today-list { list-style: none; display: flex; flex-direction: column; gap: 0.4rem; }
    .grp-today-row {
      display: flex;
      align-items: center;
      gap: 0.7rem;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 10px;
      padding: 0.7rem 0.9rem;
    }
    .grp-today-row.solved { border-color: var(--locked-border); }
    .grp-today-row.failed { opacity: 0.75; }
    .grp-today-row.waiting { opacity: 0.65; }
    .grp-rank {
      width: 1.6rem;
      flex-shrink: 0;
      font-weight: 700;
      color: var(--ink-mid);
      text-align: center;
    }
    .grp-today-row.solved .grp-rank { color: var(--locked-text); }
    .grp-name {
      flex: 1;
      min-width: 0;
      font-weight: 600;
      color: var(--ink);
      display: flex;
      align-items: center;
      gap: 0.45rem;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .grp-today-meta { display: flex; align-items: center; gap: 0.7rem; flex-shrink: 0; }
    .grp-guesses { font-size: 0.82rem; font-weight: 600; color: var(--ink); }
    .grp-time { font-size: 0.82rem; color: var(--ink-mid); }
    .grp-status { font-size: 0.78rem; font-weight: 600; }
    .grp-status.played { color: var(--locked-text); }
    .grp-status.waiting { color: var(--ink-light); }
    .grp-status.failed-txt { color: var(--accent); }

    /* Points tabs (30 day / all time) */
    .grp-pts-caption {
      font-size: 0.8rem;
      color: var(--ink-mid);
      line-height: 1.45;
      margin-bottom: 0.9rem;
    }
    .grp-pts-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
    .grp-pts-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 0.82rem;
      white-space: nowrap;
    }
    .grp-pts-table th {
      font-size: 0.6rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.08em;
      color: var(--ink-light);
      text-align: right;
      padding: 0.5rem 0.5rem;
      border-bottom: 1px solid var(--border);
    }
    .grp-pts-table th:nth-child(2) { text-align: left; }
    .grp-pts-table td {
      padding: 0.6rem 0.5rem;
      text-align: right;
      color: var(--ink-mid);
      border-bottom: 1px solid var(--border-light);
    }
    .grp-pts-rank { color: var(--ink-light); font-weight: 700; }
    .grp-pts-name {
      text-align: left !important;
      color: var(--ink) !important;
      font-weight: 600;
      white-space: normal;
      min-width: 7rem;
    }
    .grp-pts-name .grp-owner-tag { margin-left: 0.35rem; }
    .grp-pts-pts { font-weight: 700; color: var(--ink) !important; }
    .grp-since { font-size: 0.68rem; color: var(--ink-light); font-weight: 500; margin-top: 0.15rem; }

    /* ── Dashboard blocks ──────────────────────────────────────────── */
    .grp-block {
      background: var(--surface);
      border: 1px solid var(--border-light);
      border-radius: 14px;
      padding: 0.9rem;
      box-shadow: var(--tile-shadow);
    }
    .grp-block + .grp-block { margin-top: 0.7rem; }
    .grp-block-title {
      font-size: 0.66rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.18em;
      color: var(--ink-light);
      margin-bottom: 0.75rem;
    }

    /* Name text truncates; owner / you tags never shrink */
    .grp-name-txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
    .grp-owner-tag, .grp-you-tag { flex-shrink: 0; }
    .grp-you-tag {
      font-size: 0.58rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
      color: var(--hint-bg); background: #EDF3FB; border: 1px solid var(--hint-bg);
      border-radius: 4px; padding: 0.05rem 0.35rem;
    }

    /* Viewer's own row, on every board */
    .grp-today-row.me, .grp-lb-row.me { background: #F4F8FC; border-color: var(--hint-bg); }

    /* Top 5 visible, scroll for the rest (groups cap at 12) */
    .grp-today-list, .grp-lb-list { max-height: 16rem; overflow-y: auto; }
    .grp-today-list::-webkit-scrollbar, .grp-lb-list::-webkit-scrollbar { width: 5px; }
    .grp-today-list::-webkit-scrollbar-thumb, .grp-lb-list::-webkit-scrollbar-thumb {
      background: var(--border); border-radius: 3px;
    }

    /* Overall / Form / Turnout sort toggle, nested inside the Leaderboard
       block — same visual language as the screen-level .grp-tabs, sized
       down since it's a secondary control rather than the primary nav. */
    .grp-sort-seg {
      display: flex; gap: 0.2rem;
      background: var(--border-light); border-radius: 8px; padding: 0.2rem;
      margin-bottom: 0.7rem;
    }
    .grp-sort-btn {
      flex: 1; font-family: inherit; font-size: 0.72rem; font-weight: 700;
      color: var(--ink-mid); background: none; border: none; border-radius: 6px;
      padding: 0.4rem 0.3rem; cursor: pointer; transition: background 0.15s, color 0.15s;
    }
    .grp-sort-btn.active { background: var(--surface); color: var(--ink); box-shadow: var(--tile-shadow); }

    /* Points tile: rank · name (+ sub-line, which changes with the sort) ·
       one big number, stats on tap. The sub-line is what makes .grp-name a
       two-row block here — elsewhere (the Today board) it stays single-line. */
    .grp-lb-list { list-style: none; display: flex; flex-direction: column; gap: 0.4rem; }
    .grp-lb-row {
      display: flex; align-items: center; gap: 0.55rem;
      background: var(--surface); border: 1px solid var(--border);
      border-radius: 10px; padding: 0.6rem 0.75rem; cursor: pointer;
    }
    .grp-lb-row:focus-visible { outline: 2px solid var(--select-ring); outline-offset: 2px; }
    .grp-lb-row .grp-name {
      flex-direction: column; align-items: flex-start; gap: 0.1rem;
      overflow: visible; white-space: normal;
    }
    .grp-lb-name-top {
      display: flex; align-items: center; gap: 0.45rem; max-width: 100%;
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    .grp-lb-subline {
      font-size: 0.72rem; font-weight: 500; color: var(--ink-light);
      max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    .grp-lb-pts { flex-shrink: 0; font-weight: 700; font-size: 0.92rem; color: var(--ink); font-variant-numeric: tabular-nums; text-align: right; }
    .grp-lb-pts small { font-weight: 600; font-size: 0.66rem; color: var(--ink-light); }

    /* Skill map */
    /* Full scale / Zoom to group toggle — same visual language as
       .grp-sort-seg (and, one level up, the screen's .grp-tabs). */
    .grp-mx-bar { display: flex; gap: 0.2rem; background: var(--border-light);
      border-radius: 8px; padding: 0.2rem; margin-bottom: 0.6rem; }
    .grp-mx-btn { flex: 1; font-family: inherit; font-size: 0.72rem; font-weight: 700;
      color: var(--ink-mid); background: none; border: none; border-radius: 6px;
      padding: 0.4rem 0.3rem; cursor: pointer; transition: background 0.15s, color 0.15s; }
    .grp-mx-btn.active { background: var(--surface); color: var(--ink); box-shadow: var(--tile-shadow); }

    .grp-skillmap svg { display: block; width: 100%; height: auto; }
    .grp-skillmap svg text { font-family: 'Outfit', sans-serif; }
    .grp-dot { cursor: pointer; }
    .grp-dot:focus-visible { outline: 2px solid var(--select-ring); }

    /* Consistency grid */
    .grp-cg { display: flex; flex-direction: column; gap: 0.42rem; }
    .grp-cg-row { display: flex; align-items: center; gap: 0.5rem; }
    .grp-cg-name {
      width: 4.2rem; flex: none; font-size: 0.8rem; font-weight: 600; color: var(--ink);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    .grp-cg-row.me .grp-cg-name { color: var(--hint-bg); }
    .grp-cg-cells { display: flex; gap: 3px; flex: 1; }
    /* All four states are plain background/box-shadow — never `border`.
       A cell with a border and one without can render at very slightly
       different sizes at the fractional widths flex:1 produces across a
       14-cell row, which visibly knocked cells out of column alignment
       between rows. box-shadow and background-image never affect box size,
       so every state now occupies the exact same box regardless of state. */
    .grp-cg-cell { flex: 1; aspect-ratio: 1; border-radius: 3px; background: var(--border-light); }
    .grp-cg-cell.solved { background: var(--success); }
    .grp-cg-cell.played { background: var(--surface); box-shadow: inset 0 0 0 1.5px var(--gold); }
    .grp-cg-cell.missed { background: var(--border-light); }
    /* Committed, then abandoned — a real outcome (it costs a point), not a
       neutral non-event like "missed", so it needs its own mark rather than
       falling back to the plain missed fill. Opposite diagonal (135deg) so
       it doesn't read as a recolour of "before joining" (45deg, --border).
       Two solid tones, not gold-on-transparent — gold and border-light sit
       ~110 luma points apart, so the band boundary survives a true
       grayscale conversion instead of collapsing to a uniform faded gold
       wash. forced-color-adjust keeps the pattern itself (not just a
       system-color swap) visible under Windows high-contrast, where an
       unadorned background-image would otherwise be stripped. */
    .grp-cg-cell.unfinished {
      background-image: repeating-linear-gradient(135deg,
        var(--gold) 0 2px, var(--border-light) 2px 5px);
      forced-color-adjust: none;
    }
    /* Before they joined — recessed, so it reads as "not applicable" rather
       than a missed day. A faded hatch keeps it clearly distinct from the
       solid gold "tried" ring without introducing a border of its own. */
    .grp-cg-cell.pre {
      background-color: transparent;
      background-image: repeating-linear-gradient(45deg, var(--border) 0 2px, transparent 2px 5px);
      opacity: 0.5;
    }
    .grp-cg-streak { flex: none; width: 2rem; text-align: right; font-size: 0.72rem; color: var(--ink-mid); font-variant-numeric: tabular-nums; }
    .grp-cg-key { display: flex; gap: 0.85rem; margin-top: 0.7rem; font-size: 0.68rem; color: var(--ink-light); }
    .grp-cg-key span { display: flex; align-items: center; gap: 0.3rem; }
    .grp-cg-k { width: 11px; height: 11px; border-radius: 2px; display: inline-block; }
    .grp-cg-k.solved { background: var(--success); }
    .grp-cg-k.played { background: var(--surface); box-shadow: inset 0 0 0 1.5px var(--gold); }
    .grp-cg-k.missed { background: var(--border-light); }
    .grp-cg-k.unfinished {
      background-image: repeating-linear-gradient(135deg,
        var(--gold) 0 2px, var(--border-light) 2px 5px);
      forced-color-adjust: none;
    }
    .grp-cg-k.pre {
      background-color: transparent;
      background-image: repeating-linear-gradient(45deg, var(--border) 0 2px, transparent 2px 5px);
      opacity: 0.5;
    }

    /* Head-to-head
       The summary and every row share one column grid — a fixed name column,
       the bar, then the record — so all the bars start and end on the same
       two vertical lines. That alignment is the point: each bar is 100% of
       its own cell with flex-proportional segments, so a bar encodes the
       *share* of won/drawn/lost rather than the number of days, and shares
       are only comparable by eye when the bars they sit in are the same
       length. The name column shrinks on narrow phones rather than eating
       the bar, but resolves to one value per viewport so rows stay aligned. */
    .grp-h2h { --h2h-name-w: clamp(4.75rem, 26vw, 7rem); --h2h-rec-w: 3.6rem; }
    .grp-h2h-summary {
      display: flex; align-items: center; gap: 0.6rem;
      background: var(--border-light); border-radius: 10px;
      /* Transparent 1px matches the rows' border so the columns line up
         exactly rather than being one pixel out. */
      border: 1px solid transparent;
      padding: 0.62rem 0.75rem; margin-bottom: 0.6rem;
    }
    .grp-h2h-summary .grp-name-txt { font-weight: 800; }
    .grp-h2h-summary .grp-h2h-record { font-weight: 800; }
    .grp-h2h-summary-pct { font-size: 0.68rem; color: var(--ink-mid); }
    .grp-h2h-list { list-style: none; display: flex; flex-direction: column; gap: 0.4rem; }
    .grp-h2h-row {
      display: flex; align-items: center; gap: 0.6rem;
      background: var(--surface); border: 1px solid var(--border);
      border-radius: 10px; padding: 0.55rem 0.75rem;
    }
    /* Stacked, so the owner tag (and the summary's win rate) sits under the
       name instead of claiming its own slice of the row's width. */
    .grp-h2h-row .grp-name, .grp-h2h-summary .grp-name {
      flex: 0 0 var(--h2h-name-w); max-width: var(--h2h-name-w);
      flex-direction: column; align-items: flex-start; gap: 0.15rem;
    }
    .grp-h2h-row .grp-name-txt, .grp-h2h-summary .grp-name-txt { max-width: 100%; }
    /* Stacked under the name the tag is a secondary cue, not a peer of the
       name the way it is inline on the leaderboard — so it's sized down here
       to sit quietly beneath. Same colours, just smaller. */
    .grp-h2h-row .grp-owner-tag {
      font-size: 0.5rem; letter-spacing: 0.06em;
      padding: 0 0.25rem; border-radius: 3px;
    }
    .grp-h2h-mid { flex: 1 1 auto; min-width: 3rem; }
    .grp-h2h-none { font-size: 0.72rem; color: var(--ink-light); }
    /* Fixed, not min-width: a record that sized to its own content would make
       the summary's (always the widest — it's the sum of the rows) push its
       own bar shorter than everyone else's. */
    .grp-h2h-record {
      flex: 0 0 var(--h2h-rec-w, 3.6rem); text-align: right;
      font-weight: 700; font-size: 0.82rem; font-variant-numeric: tabular-nums;
    }
    .grp-h2h-bar {
      display: flex; height: 12px; border-radius: 6px; overflow: hidden;
      background: var(--border-light); width: 100%;
    }
    /* A thin divider between segments so two adjacent colours don't blur
       together at this width — most useful for win/draw and draw/loss,
       which otherwise sit flush against each other. */
    .grp-h2h-seg:not(:last-child) { border-right: 2px solid var(--surface); }
    /* Win/draw/loss use green/blue/orange — three hues spread as far apart
       as this palette allows, rather than green/gold/orange, where gold and
       orange sit close enough in hue to be hard to tell apart in a narrow bar. */
    .grp-h2h-seg.win  { background: var(--success); }
    .grp-h2h-seg.draw { background: var(--hint-bg); }
    .grp-h2h-seg.loss { background: var(--error); }

    /* Worked example in the rules screen. It's built from the real .grp-h2h-*
       classes rather than a hand-drawn mock, so the colours it teaches can't
       drift out of step with the board it's explaining. */
    .htp-h2h-demo { margin: 0.9rem 0 1rem; }
    .htp-h2h-key {
      display: flex; flex-wrap: wrap; gap: 0.9rem; margin-top: 0.6rem;
      font-size: 0.78rem; color: var(--ink-mid);
    }
    .htp-h2h-key span { display: flex; align-items: center; gap: 0.35rem; }
    .grp-h2h-k { width: 11px; height: 11px; border-radius: 2px; display: inline-block; flex: none; }
    .grp-h2h-k.win  { background: var(--success); }
    .grp-h2h-k.draw { background: var(--hint-bg); }
    .grp-h2h-k.loss { background: var(--error); }
    .htp-h2h-caption {
      font-size: 0.8rem !important; color: var(--ink-light) !important;
      margin-top: 0.55rem; line-height: 1.5;
    }

    /* Tap-to-show-stats tooltip */
    .grp-stat-tip {
      position: fixed; z-index: 2500; pointer-events: none;
      background: var(--ink); color: #fff; border-radius: 8px;
      padding: 0.5rem 0.7rem; box-shadow: 0 6px 18px rgba(0,0,0,0.22);
      max-width: 16rem; text-align: center; animation: grpTipIn 0.12s ease;
    }
    .grp-stat-tip strong { display: block; font-size: 0.82rem; font-weight: 700; }
    .grp-stat-tip span { display: block; font-size: 0.72rem; color: #D7CBC4; margin-top: 0.15rem; line-height: 1.45; }
    .grp-stat-tip .grp-stat-since {
      font-size: 0.68rem; color: #A79C94;
      margin-top: 0.3rem; padding-top: 0.3rem;
      border-top: 1px solid rgba(255,255,255,0.14);
    }
    @keyframes grpTipIn { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }

    /* A cluster tooltip describes several players at once, so it widens and
       left-aligns rather than using the plain tip's centred single line. */
    .grp-stat-tip.grp-tip-cluster { max-width: 20rem; text-align: left; }
    .grp-tip-tbl { width: 100%; border-collapse: collapse; margin-top: 0.3rem; font-size: 0.68rem; }
    .grp-tip-tbl td {
      padding: 0.12rem 0 0.12rem 0.55rem; text-align: right; white-space: nowrap; color: #D7CBC4;
    }
    .grp-tip-tbl td:first-child { text-align: left; padding-left: 0; color: #A79C94; }
    .grp-tip-tbl .tip-hd td {
      font-weight: 700; color: #fff; padding-bottom: 0.25rem;
      border-bottom: 1px solid rgba(255,255,255,0.16);
    }
    .grp-tip-tbl .tip-hd td.me { color: #F0B27A; }

    /* Per-block "How this is scored" links (Stage 6) — smaller and
       right-aligned, since each sits inside a block that already has its
       own title, unlike .grp-about-link's single page-level placement. */
    .grp-how-link {
      display: block; width: 100%; text-align: right; margin-top: 0.6rem;
      background: none; border: none; font-family: inherit; cursor: pointer;
      font-size: 0.72rem; font-weight: 600; color: var(--ink-light); padding: 0.15rem 0;
    }
    .grp-how-link:hover, .grp-how-link:focus-visible { color: var(--hint-bg); }

    /* About these stats link */
    .grp-about-link {
      display: block; width: 100%; text-align: center; margin-top: 0.9rem;
      background: none; border: none; font-family: inherit; cursor: pointer;
      font-size: 0.8rem; font-weight: 600; color: var(--ink-mid); padding: 0.5rem;
      text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border);
    }
    .grp-about-link:hover { color: var(--ink); }

    /* Manage panel */
    .grp-manage {
      margin-top: 1.8rem;
      padding-top: 1.4rem;
      border-top: 1px solid var(--border-light);
    }
    .grp-code-line {
      font-size: 0.85rem;
      color: var(--ink-mid);
      margin-top: 0.6rem;
      display: flex;
      align-items: center;
      gap: 0.6rem;
      flex-wrap: wrap;
    }
    .grp-code-line strong { letter-spacing: 0.14em; color: var(--ink); }
    /* Its own row, so arming the button inside cannot reflow it onto a new
       line and out from under the finger — see the note in managePanel(). */
    .grp-regen-line { margin-top: 0.5rem; }
    .grp-member-list { list-style: none; display: flex; flex-direction: column; gap: 0.4rem; }
    .grp-member-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 0.6rem;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 9px;
      padding: 0.6rem 0.8rem;
    }
    .grp-member-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
    .grp-mini-btn {
      font-family: inherit;
      font-size: 0.72rem;
      font-weight: 600;
      color: var(--ink-mid);
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: 6px;
      padding: 0.35rem 0.6rem;
      cursor: pointer;
      transition: background 0.15s, color 0.15s, border-color 0.15s;
    }
    .grp-mini-btn:hover { border-color: var(--ink-mid); color: var(--ink); }
    .grp-mini-btn.danger { color: var(--accent); border-color: var(--accent); }
    .grp-mini-btn.danger:hover { background: var(--accent); color: #fff; }

    /* Invite banner (register screen) */
    .invite-banner {
      background: #FFF0EC;
      border: 1px solid var(--accent);
      border-radius: 8px;
      padding: 0.7rem 0.9rem;
      font-size: 0.85rem;
      line-height: 1.4;
      color: var(--ink);
      margin-bottom: 1rem;
    }

    /* The sign-in way out, directly under the invite banner. Centred and
       given its own breathing room so it reads as an alternative to the form
       rather than a caption on the banner above it. */
    .invite-signin {
      text-align: center;
      margin: -0.4rem 0 1.1rem;
    }

    /* Post-game standing line inside the celebration banner */
    .celebrate-group-line { margin-top: 0.6rem; }

    /* Toast */
    .grp-toast {
      position: fixed;
      left: 50%;
      bottom: 2rem;
      transform: translateX(-50%) translateY(1rem);
      background: var(--ink);
      color: #fff;
      font-size: 0.85rem;
      font-weight: 600;
      padding: 0.7rem 1.2rem;
      border-radius: 8px;
      z-index: 4000;
      opacity: 0;
      transition: opacity 0.25s, transform 0.25s;
      pointer-events: none;
    }
    .grp-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

    /* ═══════════════════ REDUCED MOTION ═══════════════════
       Honours the OS "reduce motion" setting (vestibular disorders, migraine,
       motion sickness). The rule followed here is: remove MOVEMENT, keep
       MEANING. Anything that travels, spins or bounces goes; anything that
       carries information stays, expressed as colour or opacity instead.

       So the green/red check flashes are NOT disabled — that colour is the
       only feedback telling a player which letters were right. Their keyframes
       are redefined below without the scale and glow, keeping the signal and
       dropping the motion. Redefining a @keyframes of the same name inside the
       media query is what makes that work: the later definition wins, but only
       while the query matches — which is also why this block must stay LAST in
       the file. Above the originals it would be silently overwritten.

       Plain opacity fades (fadeIn) are left alone — they are not movement and
       are not what the setting is asking about. The locked-tile ring is a
       static box-shadow, so it is untouched. */
    @media (prefers-reduced-motion: reduce) {

      /* Purely decorative motion — remove outright. */
      .cell.pop,
      .cell.shake,
      .cell.reveal-flip   { animation: none; }
      .celebrate-banner   { animation: none; }
      .celebrate-medal    { animation: none; }   /* was a 720° spin + endless glow */
      .rotate-icon        { animation: none; }   /* the text beside it says the same thing */
      .confetti-piece     { display: none; }     /* also skipped at source in game.js */
      .flying-tile        { display: none; }     /* likewise */

      /* Hover/selected lifts: drop the transform only. Every one of these also
         changes border or background, which is what actually conveys the
         state, so nothing is lost. */
      .cell.empty:hover, .cell.empty.drag-over,
      .cell.hint-row:hover, .cell.hint-row.drag-over,
      .pile-tile:hover, .pile-tile.selected,
      .pile-done-actions button:hover,
      .celebrate-close:hover { transform: none; }

      /* Informational flashes, motion removed but colour intact. */
      @keyframes flashCorrect {
        0%   { }
        25%  { background: #6FD58A; border-color: #2F8B50; color: #fff; }
        65%  { background: #6FD58A; border-color: #2F8B50; color: #fff; }
        100% { }
      }
      @keyframes flashWrong {
        0%   { background: var(--tile); }
        25%  { background: #FF6B6B; border-color: #c0392b; color: #fff; }
        65%  { background: #FF8E8E; border-color: #c0392b; color: #fff; }
        100% { background: var(--tile); }
      }

      /* Slide-ins become straight fades. Both the resting and .show states are
         pinned to the same transform, so only opacity animates — neutralising
         just .show would still leave the 1rem slide to animate away from. */
      @keyframes grpTipIn { from { opacity: 0; } to { opacity: 1; } }
      .grp-toast, .grp-toast.show { transform: translateX(-50%); }
    }
