  /* ============ FONTS ============ */
  /* Newspaper look: a heavy Didone masthead + a workhorse serif for body/letters,
     and a slab serif for the small-caps utility labels (LEFT / SCORE / WORDS). */
  @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800;900&family=Spectral:wght@400;500;600;700&family=Zilla+Slab:wght@500;600;700&display=swap');

  /* ============ THEME (newsprint) ============ */
  :root {
    --paper:   #f0ede2;   /* newsprint cream */
    --paper-2: #e9e5d7;   /* slightly darker panel wash */
    --ink:     #1a1712;   /* near-black text */
    --ink-dim: #6b6459;   /* muted labels */
    --rule:    #211d16;   /* hairline rules / borders */
    --rule-soft:#bdb6a4;  /* lighter grid rules */
    --accent:  #9c2b2b;   /* newsprint red (used sparingly: counts, selection) */
    --danger:  #9c2b2b;

    --tile-size: clamp(30px, 8.6vw, 58px);
    --gap: 0px;                 /* ruled grid, cells share borders */
    --radius: 0px;             /* newspaper = no rounded corners */

    --font-display: 'Playfair Display', Georgia, serif;
    --font-serif:   'Spectral', Georgia, 'Times New Roman', serif;
    --font-mono:    'Zilla Slab', Georgia, serif;   /* utility/small-caps labels */
  }

  * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }

  html, body {
    height: 100%;
    /* Dynamic viewport height: on mobile browsers 100% (and 100vh) include the
       area behind the collapsing address bar, so the bottom of the page sat
       off-screen until you scrolled. 100dvh tracks the space actually visible. */
    min-height: 100dvh;
    font-family: var(--font-serif);
    color: var(--ink);
    background: var(--paper);
    overflow-x: hidden;
    user-select: none;
    -webkit-user-select: none;
    touch-action: none;
  }

  /* paper grain + soft vignette so the cream reads as printed newsprint */
  body::after {
    content: "";
    position: fixed; inset: 0;
    pointer-events: none;
    opacity: .04;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
    z-index: 999;
  }
  /* Vignette: warm reddish-brown rather than neutral black, so the edges read
     as aged newsprint — ink and foxing darkening toward the margins — instead
     of a grey photographic falloff. Two stacked gradients: a broad warm wash
     over the whole sheet, then a tighter, redder darkening in the corners. */
  body::before {
    content: "";
    position: fixed; inset: 0;
    pointer-events: none;
    background:
      radial-gradient(112% 72% at 50% 42%, transparent 46%, #6b2f1f2b 80%, #4e1e1250 100%),
      radial-gradient(145% 115% at 50% 50%, transparent 55%, #7a352026 100%);
    z-index: 998;
  }

  .app {
    min-height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 10px 14px 32px;
    gap: 10px;
  }
  /* viewport-fit=cover means the page now extends under the notch and the home
     indicator, so pad by the insets rather than letting content sit there. */
  .app {
    padding-top: calc(10px + env(safe-area-inset-top, 0px));
    padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
    padding-left: calc(14px + env(safe-area-inset-left, 0px));
    padding-right: calc(14px + env(safe-area-inset-right, 0px));
  }
  /* the printed page: a bordered sheet the whole game sits on */
  .app > * { width: 100%; max-width: 620px; }

  /* ---- MOBILE (default): flatten stage/columns so everything stacks in one
     column, and restore the original vertical order:
     masthead → logo → hud → wordlist → wordbar → board → hint. ---- */
  .stage, .board-col, .side-col { display: contents; }

  /* Because .stage/.board-col/.side-col are display:contents, the child
     combinator `.app > *` does NOT match the flattened items (it walks the DOM
     tree, where they're still grandchildren). So set their box width HERE, or
     they collapse to content width as bare flex items of .app. The board-wrap
     keeps its max-content width (it hugs the tiles) and is centered by its own
     auto margins; JS sizes the tiles from the full panel width. */
  .wordlist, .wordbar, .hint, .daily-banner {
    width: 100%;
    max-width: 620px;
  }
  .board-wrap { align-self: center; }

  /* order the flattened children (masthead-rule=0, header=1 by default).
     BOARD FIRST on mobile, then the words to find. The board is what you look
     at and touch; the list is a reference you glance down to. Putting the list
     first pushed the grid below the fold on a phone, so you scrolled to play.
     (Versus keeps its scoreboard in the masthead above all of this.) */
  .wordbar      { order: 3; }
  .board-col > .board-wrap,
  .board-wrap   { order: 4; }
  .daily-banner { order: 5; }   /* sits directly above the words panel */
  .wordlist     { order: 6; }
  .hint         { order: 7; }

  /* ============ HEADER ============ */
  /* triple-rule masthead device across the top of the sheet */
  .masthead-rule {
    height: 5px;
    border: none;
    background:
      linear-gradient(var(--rule),var(--rule)) top/100% 2px no-repeat,
      linear-gradient(var(--rule),var(--rule)) center/100% 1px no-repeat,
      linear-gradient(var(--rule),var(--rule)) bottom/100% 2px no-repeat;
    margin-bottom: 0;
  }

  header.top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 14px;
    padding-top: 2px;
    padding-bottom: 2px;
  }
  .logo {
    font-family: var(--font-display);
    font-weight: 900;
    font-size: clamp(24px, 6.4vw, 38px);
    line-height: .9;
    letter-spacing: 0.5px;
    text-transform: uppercase;
    color: var(--ink);
    text-shadow: 0 1px 0 #00000018;   /* faint letterpress ink */
  }

  /* ---- in-game masthead controls ----
     Far right of the masthead, after the HUD. `margin-left:auto` claims the
     free space so the logo stays hard left whether or not the HUD is present —
     on desktop placeHud() moves it out to the side column, and without this the
     buttons would drift to the middle. */
  .head-tools {
    display: flex; gap: 6px;
    margin-left: auto;
    align-self: center;
    order: 3;                       /* always last, whatever the HUD does */
  }

  /* Built from the same parts as a .stat cell — mono small-caps, 1.5px rule,
     square corners — so it reads as part of the masthead furniture rather than
     a web button dropped onto it. */
  .head-btn {
    font-family: var(--font-mono);
    font-size: 11px; font-weight: 600; letter-spacing: 1.5px;
    text-transform: uppercase;
    color: var(--ink-dim); background: transparent;
    border: 1.5px solid var(--rule); border-radius: 0;
    padding: 9px 14px; line-height: 1;
    cursor: pointer;
    transition: color .15s, background .15s;
  }
  .head-btn:hover { color: var(--paper); background: var(--ink); }
  .head-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

  /* On mobile the HUD stays in the masthead, directly before these buttons, so
     they need air between them; margin-left:auto alone would leave them flush.
     On desktop the HUD moves to the side column and the auto margin does the
     work on its own. */
  .hud + .head-tools { margin-left: 10px; }

  /* Below the desktop breakpoint the HUD stays in the masthead, so the band has
     to hold logo + scoreboard + buttons. Under ~560px that is one element too
     many: the stat cells compress until the clock wraps ("0:5 0"). Let the
     header wrap and send the buttons to their own row, aligned right under the
     scoreboard, rather than squeezing all three onto one line. */
  @media (max-width: 560px) {
    header.top { flex-wrap: wrap; row-gap: 8px; }
    .head-tools { flex-basis: 100%; justify-content: flex-end; margin-left: 0; }
    .hud + .head-tools { margin-left: 0; }
    .head-btn { padding: 7px 12px; }
    /* .no-hud (set by placeHud) means the masthead holds only the logo and
       Quit — they fit one line, and wrapping Quit to its own row would waste a
       band of vertical space on a single small button. The rule above still
       applies in versus, where the scoreboard IS in the masthead. */
    header.top.no-hud .head-tools { flex-basis: auto; margin-left: auto; }
  }

  /* The confirm card stacks its two buttons; the cancel sits under the action. */
  #mCancel, #mShare { margin-top: 8px; }
  /* The replay GIF on the win card. Capped by height so the card still fits a
     phone screen with both buttons under it. */
  .replay-preview {
    display: block; margin: 0 auto 14px;
    max-width: 100%; max-height: 34vh;
    border: 1px solid var(--rule);
  }
  .replay-preview[hidden] { display: none; }
  /* `.btn` sets display:inline-block, which outranks the plain `hidden`
     attribute — the same trap `.hud[hidden]` above had to work around. Without
     this the cancel button stayed on screen for every card after a confirm,
     doing nothing. */
  .btn[hidden] { display: none; }

  /* ============ HUD — three bordered stat cells (LEFT / SCORE / WORDS) ======= */
  .hud {
    display: flex;
    align-self: center;
    border: 1.5px solid var(--rule);
  }
  /* `display:flex` above outranks the plain `hidden` attribute, so the dormant
     scoreboard would keep showing. Only one is ever on screen (see placeHud). */
  .hud[hidden] { display: none; }
  .stat {
    min-width: 68px;
    padding: 8px 14px 10px;
    text-align: center;
    background: transparent;
    display: flex; flex-direction: column; gap: 3px;
  }
  .stat + .stat { border-left: 1px solid var(--rule); }
  .stat .label {
    font-family: var(--font-mono);
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 1.5px;
    text-transform: uppercase;
    color: var(--ink-dim);
  }
  .stat .value {
    font-family: var(--font-display);
    font-size: clamp(22px, 5.5vw, 30px);
    font-weight: 800;
    line-height: 1;
    color: var(--ink);
    /* Tabular figures where the font supports them — steadies the rising score.
       The clock does NOT rely on this (see .value .d below): tnum needs a feature
       table the fallback face may not have. */
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum" 1;
  }
  /* Fixed-width digit cells for the clock, so a ticking readout never shifts
     regardless of whether the rendered font has tabular figures. Each digit gets
     an identical box and is centred inside it; the colon gets a narrower one so
     "0:00" doesn't read as oddly gappy. */
  .stat .value .d,
  .stat .value .sep {
    display: inline-block;
    text-align: center;
  }
  .stat .value .d   { width: .62em; }
  .stat .value .sep { width: .28em; }


  /* ============ CURRENT WORD ============ */
  /* Live current-word display: a compact strip directly between the "Board fill
     words" panel and the board (no tall empty row, so the board sits high). It's
     blank until a drag starts, then shows the current word + points. */
  .wordbar {
    height: clamp(24px, 6vw, 34px);
    min-height: clamp(24px, 6vw, 34px);
    margin: clamp(-10px, -2vw, -4px) 0;   /* tighter around the word on mobile */
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-family: var(--font-display);
    font-weight: 800;
    font-size: clamp(16px, 4.5vw, 26px);
    letter-spacing: clamp(2px, 1vw, 4px);
    text-transform: uppercase;
    color: var(--ink);
  }
  .wordbar.valid { color: var(--accent); }
  .wordbar.invalid { color: var(--ink-dim); text-decoration: line-through; }

  /* ============ BOARD — a ruled letter grid ============ */
  /* the grid sits centered on the sheet; the wrap hugs the grid so the frame
     lines up exactly with the ruled cells */
  .board-wrap {
    position: relative;
    padding: 0;
    background: var(--paper);
    /* thin, light rule everywhere — the top+left of the grid; cells add the
       right+bottom sides, so every grid line is identical */
    border-top: 1px solid var(--rule-soft);
    border-left: 1px solid var(--rule-soft);
    width: max-content;
    max-width: 100%;
    margin: 4px auto 0;
  }
  .board {
    position: relative;
    display: grid;
    gap: var(--gap);
    touch-action: none;
  }
  /* svg link overlay */
  .links {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    pointer-events: none;
    /* Between the tiles' fill and their letters: a selected tile sets no
       z-index (see .tile.sel), so its .val (z-index 2) stacks with this layer
       and paints over the line, while the red background paints under it. */
    z-index: 1;
    overflow: visible;
  }
  /* short faint links across the borders between selected tiles. The red
     tiles alone run together on a long or bent drag; a lighter red bar at each
     step shows the path without touching the letters. */
  .links line {
    stroke: color-mix(in srgb, var(--accent) 45%, var(--paper));
    stroke-width: 5;
    stroke-linecap: round;
  }

  /* colored "pencil loops" drawn around each FOUND target word (like a printed
     word-search you circle by hand). Purely decorative — never intercepts drags,
     so the letters underneath stay fully selectable. */
  .found-loops {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    pointer-events: none;
    z-index: 15;              /* above tiles and the drag line */
    overflow: visible;
  }
  .found-loops .loop {
    fill: none;
    stroke-width: 3.5;
    stroke-linecap: round;
    stroke-linejoin: round;
    opacity: .9;
  }
  /* Every loop is a thick round-capped polyline along the word's path (a pill
     for a straight word, following the turns for a bent one), hollowed out by
     an SVG mask so only the rim shows. The band width is set inline per-word
     from the live tile size via --band-w, so it must win over the 3.5 above. */
  .found-loops .loop.band {
    stroke-width: var(--band-w, 3.5);
  }
  /* only the just-found loop animates its draw-on; already-drawn loops render
     statically (no replay when another word is found or on resize). */
  .found-loops .loop.draw {
    stroke-dasharray: var(--len);
    stroke-dashoffset: var(--len);
    /* linear, so the pen moves at a steady speed and the letter flashes timed
       from its position (celebrateFind) stay in step with it. The duration is
       set inline from LOOP_DRAW_MS. */
    animation: loopDraw .4s linear forwards;
  }
  @keyframes loopDraw { to { stroke-dashoffset: 0; } }

  /* ============ TILES — ruled cells, serif capitals ============ */
  .tile {
    position: relative;
    width: var(--tile-size);
    height: var(--tile-size);
    border-right: 1px solid var(--rule-soft);
    border-bottom: 1px solid var(--rule-soft);
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: var(--font-serif);
    font-weight: 500;
    font-size: calc(var(--tile-size) * .5);
    color: var(--ink);
    cursor: pointer;
    background: var(--paper);
    transition: background .12s ease, color .12s ease;
  }
  .tile.empty {
    background: var(--paper);
    cursor: default;
    pointer-events: none;
  }
  /* a tile already consumed by a target word — inked in (reversed) */
  .tile.used {
    background: var(--ink);
    color: var(--paper);
    cursor: default;
    pointer-events: none;
    animation: lockUsed .28s ease;
  }
  @keyframes lockUsed {
    0% { transform: scale(1.14); }
    100% { transform: scale(1); }
  }
  .tile .val { position: relative; z-index: 2; }

  /* "used in a small word already" — a small newsprint-red dot tucked into the
     bottom-right corner of the tile. */
  .tile.found::after {
    content: "";
    position: absolute; bottom: 2px; right: 2px;
    width: 5px; height: 5px; border-radius: 50%;
    background: var(--accent);
    z-index: 3;
  }

  /* hover: soft newsprint wash (pointer devices only) */
  @media (hover: hover) {
    .tile:hover { background: var(--paper-2); }
  }
  /* selected tile: ringed in red ink (wins over .found) */
  .tile.sel, .tile.found.sel {
    background: var(--accent);
    color: var(--paper);
  }

  /* ---- first-time tutorial (TUTORIAL in wordcrush.html) ----
     The demo trace glows rather than going solid red, so it reads as "like
     this" and never as a selection the player made. The newest tile pulses. */
  .tile.tut-glow {
    background: color-mix(in srgb, var(--accent) 16%, var(--paper));
    /* Inset only, and no z-index: a z-index would make the tile its own
       stacking context, and the trail (.tut-trail) would then paint over the
       letter instead of slipping between fill and letter like .links does. */
    box-shadow:
      inset 0 0 0 2px color-mix(in srgb, var(--accent) 60%, transparent),
      inset 0 0 14px 2px color-mix(in srgb, var(--accent) 35%, transparent);
    transition: background .2s ease, box-shadow .2s ease;
  }
  .tile.tut-head { animation: tutPulse .6s ease-out; }
  /* Pulses the glow, not the size: a transform would lift the letter under
     the trail for the length of the animation (see .tut-glow). */
  @keyframes tutPulse {
    0%   { background: color-mix(in srgb, var(--accent) 38%, var(--paper)); }
    100% { background: color-mix(in srgb, var(--accent) 16%, var(--paper)); }
  }
  .tut-trail {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    pointer-events: none;
    z-index: 1;                  /* like .links: under the letters */
    overflow: visible;
  }
  .tut-trail polyline {
    fill: none;
    stroke: color-mix(in srgb, var(--accent) 40%, transparent);
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  .tut-trail circle { fill: color-mix(in srgb, var(--accent) 55%, transparent); }
  .wordbar.tut { color: color-mix(in srgb, var(--accent) 70%, var(--ink)); }

  .tut-card {
    position: absolute;
    left: 50%; bottom: 10px;
    transform: translateX(-50%);
    width: min(88%, 340px);
    z-index: 30;                 /* over the found-word loops */
    padding: 12px 14px 12px;
    background: var(--paper);
    border: 1px solid var(--rule);
    box-shadow: 3px 3px 0 var(--rule);
    animation: tutCardIn .3s cubic-bezier(.22,.7,.3,1) both;
  }
  .tut-card.at-top { bottom: auto; top: 10px; }
  .tut-card[hidden] { display: none; }
  @keyframes tutCardIn {
    from { opacity: 0; transform: translate(-50%, 8px); }
    to   { opacity: 1; transform: translate(-50%, 0); }
  }
  .tut-title {
    font-family: var(--font-mono);
    font-size: 12px;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--accent);
    margin-bottom: 4px;
  }
  .tut-text {
    margin: 0 0 10px;
    font-size: 14px;
    line-height: 1.4;
    color: var(--ink);
  }
  .tut-ok {
    font: inherit;
    font-size: 13px;
    padding: 5px 14px;
    background: var(--ink);
    color: var(--paper);
    border: 0;
    cursor: pointer;
  }
  .tut-ok:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  @media (prefers-reduced-motion: reduce) {
    .tile.tut-head, .tut-card { animation: none; }
  }

  /* falling animation (unused in this mode, kept for the classic lineage) */
  .tile.falling { animation: drop .32s cubic-bezier(.34,1.3,.64,1); }
  @keyframes drop { from { transform: translateY(var(--fall)); } to { transform: translateY(0); } }

  .tile.spawn { animation: spawnPop .3s cubic-bezier(.34,1.56,.64,1); }
  @keyframes spawnPop { 0% { transform: translateY(var(--fall)) scale(.6); opacity:0; } 100% { transform: translateY(0) scale(1); opacity:1; } }

  .tile.pop { animation: pop .3s ease forwards; z-index: 7; }
  @keyframes pop {
    0% { transform: scale(1.12); }
    40% { transform: scale(1.35) rotate(6deg); }
    100% { transform: scale(0) rotate(20deg); opacity: 0; }
  }

  /* ---- special tile styles ---- */
  .tile.t-bomb { background: linear-gradient(180deg,#ff9a6c,#ff5e3a); color:#fff; box-shadow: inset 0 -4px 0 #c23b1c, 0 4px 8px #00000055; }
  .tile.t-bomb::before { content:""; position:absolute; top:1px; left:3px; font-size:11px; z-index:1; }
  .tile.t-rainbow { background: linear-gradient(120deg,#ff5da2,#ffd23f,#3ddc97,#37c6ff); background-size:200% 200%; animation: rainbowShift 3s linear infinite; color:#fff; }
  @keyframes rainbowShift { to { background-position:200% 200%; } }
  .tile.t-rainbow .val { text-shadow: 0 1px 2px #0006; }
  .tile.t-frozen { background: linear-gradient(180deg,#dff6ff,#a9e2ff); color:#12506e; box-shadow: inset 0 -4px 0 #6fb6d8, 0 4px 8px #00000040; }
  .tile.t-frozen.f2 { box-shadow: inset 0 0 0 3px #37c6ff, inset 0 -4px 0 #6fb6d8, 0 4px 8px #00000040; }
  .tile.t-frozen::after { content:""; position:absolute; top:0; right:3px; font-size:11px; opacity:.7; }
  .tile.t-gold { background: linear-gradient(180deg,#fff3b0,#ffcf33); color:#7a5200; box-shadow: inset 0 -4px 0 #c99a12, 0 0 12px #ffd23f88, 0 4px 8px #00000040; }
  .tile.t-gold::after { content:""; position:absolute; top:0; right:3px; font-size:10px; color:#b8860b; }
  .tile.ice { position: relative; }
  .tile.ice::before {
    content:""; position:absolute; inset:0; border-radius:var(--radius);
    background: repeating-linear-gradient(45deg, #bfe9ff88, #bfe9ff88 4px, #e8f8ff88 4px, #e8f8ff88 8px);
    border: 2px solid #ffffffaa;
    z-index: 3; pointer-events:none;
  }
  .tile.ice.ice2::before { opacity: 1; }
  .tile.ice.ice1::before { opacity: .5; }

  /* combo flash */
  .combo-flash {
    position: fixed; top: 40%; left: 50%;
    transform: translate(-50%,-50%);
    font-family: var(--font-display); font-weight: 900;
    font-size: clamp(34px, 12vw, 68px);
    color: var(--accent);
    pointer-events: none; z-index: 200;
    animation: comboZoom .9s ease forwards;
  }
  @keyframes comboZoom {
    0% { transform: translate(-50%,-50%) scale(.3) rotate(-8deg); opacity: 0; }
    30% { transform: translate(-50%,-50%) scale(1.15) rotate(3deg); opacity: 1; }
    70% { transform: translate(-50%,-50%) scale(1) rotate(0); opacity: 1; }
    100% { transform: translate(-50%,-55%) scale(1) ; opacity: 0; }
  }

  /* hidden-word celebration banner */
  .hidden-flash {
    position: fixed; top: 42%; left: 50%;
    transform: translate(-50%,-50%);
    display: flex; flex-direction: column; align-items: center; gap: 4px;
    pointer-events: none; z-index: 250; text-align: center;
    animation: comboZoom 2s ease forwards;
  }
  .hidden-flash .hf-sub {
    font-family: var(--font-mono); font-size: clamp(12px, 3vw, 16px);
    color: var(--ink-dim); letter-spacing: 2px; text-transform: uppercase;
  }
  .hidden-flash .hf-word {
    font-family: var(--font-display); font-weight: 900;
    font-size: clamp(40px, 13vw, 84px); letter-spacing: 2px;
    text-transform: uppercase; color: var(--ink);
    background: var(--paper); padding: 4px 18px;
    border-top: 3px solid var(--rule); border-bottom: 3px solid var(--rule);
  }
  .hidden-flash .hf-bonus {
    font-family: var(--font-display); font-weight: 800;
    font-size: clamp(18px, 5vw, 28px); color: var(--accent);
  }
  .confetti {
    position: fixed; top: -20px;
    width: 8px; height: 12px;
    z-index: 249; pointer-events: none;
    animation: confettiFall linear forwards;
  }
  @keyframes confettiFall {
    0% { transform: translateY(0) translateX(0) rotate(0); opacity: 1; }
    100% { transform: translateY(105vh) translateX(calc(var(--dx) * 30vw)) rotate(var(--rot)); opacity: .9; }
  }

  /* ===== "Words to find" panel — a bordered clipping from the paper ===== */
  /* Reversed out of the ink, so it reads as a printed dateline strip rather
     than another panel competing with the words below it. */
  .daily-banner {
    display: flex; align-items: baseline; justify-content: center;
    gap: 10px; flex-wrap: wrap;
    background: var(--ink); color: var(--paper);
    border: 1.5px solid var(--ink);
    padding: 7px 12px;
    margin-bottom: -1px;            /* share the rule with the panel below */
    text-align: center;
  }
  .daily-banner[hidden] { display: none !important; }
  .daily-banner .db-label {
    font-family: var(--font-mono); font-weight: 700;
    font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
    opacity: .72;
  }
  .daily-banner .db-theme {
    font-family: var(--font-display); font-weight: 800;
    font-size: 16px; letter-spacing: 1px; text-transform: uppercase;
  }

  .wordlist {
    background: var(--paper);
    border: 1.5px solid var(--rule);
    padding: 12px 14px 14px;
  }
  .wordlist .wl-head {
    display: flex; align-items: baseline; justify-content: space-between;
    padding-bottom: 8px;
    margin-bottom: 12px;
    font-family: var(--font-mono);
    font-size: 13px; font-weight: 600; letter-spacing: 2px;
    text-transform: uppercase; color: var(--ink);
    border-bottom: 1px solid var(--rule);
  }
  .wordlist .wl-head b { color: var(--accent); font-size: 15px; }
  #revealFound { color: var(--ink-dim); letter-spacing: 1.5px; }
  /* 4 columns -> 7 words always fit in exactly 2 rows (4 + 3), centered.
     Container query: chip type scales with the PANEL width (cqw), not the
     viewport, so a masked word always fits its cell — desktop and mobile. */
  .wordlist .wl-body {
    container-type: inline-size;
    display: grid;
    grid-template-columns: repeat(4, 1fr);   /* 4 equal columns -> uniform boxes */
    gap: min(9px, 1.6cqw);
  }
  .wordlist .wl-empty { grid-column: 1 / -1; color: var(--ink-dim); font-family: var(--font-mono); font-size: 12px; }

  /* each target word sits in its own hairline box. The box is a fixed grid cell
     (width:100%), so it FILLS the column and never resizes as letters are
     revealed (A****** -> AUTHORS). Type sized in cqw so the widest masked word
     always fits at any screen size. */
  .wordlist .ww.mask {
    width: 100%;
    box-sizing: border-box;
    text-align: center;
    font-family: var(--font-serif);
    font-size: min(15px, 3.5cqw);
    font-weight: 600;
    letter-spacing: min(4px, 0.4cqw);
    padding: min(7px, 1.6cqw) 4px;
    border: 1px solid var(--rule);
    background: var(--paper);
    color: var(--ink);
    cursor: default;
    white-space: nowrap;
    overflow: hidden;
  }
  .wordlist .ww.mask.done {
    background: var(--ink);
    color: var(--paper);
    border-color: var(--ink);
  }
  /* Fully uncovered by crossing words, but not yet traced on the board. Reads as
     "you can see it — now go draw it", distinct from the solid .done box.
     Marked by COLOUR alone: a dashed rule read as a broken box rather than a
     state, and made the single-player panel look unlike the multiplayer one,
     where every box is a solid hairline. */
  .wordlist .ww.mask.ready {
    border-color: var(--accent);
    box-shadow: inset 0 0 0 1px #9c2b2b22;
  }
  /* each masked character is its own span so revealed letters can animate.
     A letter's final color comes from the CASCADE (ink on cream boxes, cream on
     completed black boxes), never from the animation — so it's always legible. */
  .wordlist .ww.mask .ch { display: inline-block; position: relative; color: var(--ink); }
  .wordlist .ww.mask.done .ch { color: var(--paper); }
  /* A letter just uncovered STAMPS in big and red. No fill-mode/color at 100%,
     so when it ends the color reverts to the cascade value above. */
  @keyframes letterStamp {
    0%   { transform: scale(0)   rotate(-30deg); color: var(--accent); }
    40%  { transform: scale(2.1) rotate(8deg);   color: var(--accent); }
    70%  { transform: scale(1.25) rotate(0);     color: var(--accent); }
    100% { transform: scale(1)   rotate(0); }
  }
  .wordlist .ww.mask .ch.lit {
    animation: letterStamp .6s cubic-bezier(.3,1.5,.5,1);
    z-index: 2;
  }
  /* the whole box flashes a red ring so you notice WHICH word gained a letter */
  @keyframes boxFlash {
    0%   { box-shadow: 0 0 0 0 var(--accent); border-color: var(--accent); }
    60%  { box-shadow: 0 0 0 3px #9c2b2b33; border-color: var(--accent); }
    100% { box-shadow: 0 0 0 0 transparent; border-color: var(--rule); }
  }
  .wordlist .ww.mask.just-revealed { animation: boxFlash .7s ease both; }
  /* a word's box that has just been completed lands like a rubber stamp */
  @keyframes boxStamp {
    0%   { transform: scale(1.35) rotate(-4deg); }
    55%  { transform: scale(.94)  rotate(1deg); }
    100% { transform: scale(1)    rotate(0); }
  }
  .wordlist .ww.mask.done.just-revealed {
    animation: boxStamp .45s cubic-bezier(.3,1.4,.5,1) both, boxFlash .7s ease both;
  }

  /* ---- finding a word (celebrateFind) ----
     Each letter inside the new loop flashes its colour (--hit) as the loop's
     pen passes it (--hit-delay), then settles back to ink. */
  @keyframes letterHit {
    0%   { color: var(--hit); transform: scale(1); }
    35%  { color: var(--hit); transform: scale(1.35); }
    70%  { color: var(--hit); transform: scale(.95); }
    100% { transform: scale(1); }
  }
  .tile.hit .val {
    display: inline-block;
    animation: letterHit .35s cubic-bezier(.3,1.4,.5,1) var(--hit-delay, 0s) forwards;
  }
  /* ...and when the loop closes, a copy of it blooms into a soft glow and
     fades. A blur spreads evenly all round, where scaling would push a long
     loop's ends out far more than its sides. */
  @keyframes loopEcho {
    0%   { filter: blur(0);   opacity: 1; }
    100% { filter: blur(6px); opacity: 0; }
  }
  .found-loops .loop-echo {
    /* hidden until the loop closes: `forwards`, not `both`, so the 0% frame
       (fully opaque) doesn't show the whole loop while it is still drawing */
    opacity: 0;
    animation: loopEcho .45s ease-out forwards;   /* delay set inline: LOOP_DRAW_MS */
  }
  @media (prefers-reduced-motion: reduce) {
    .tile.hit .val { animation-name: letterHitCalm; }
    @keyframes letterHitCalm { 0%, 70% { color: var(--hit); } }
    .found-loops .loop-echo { display: none; }
    .wordlist .ww.mask.done.just-revealed { animation: boxFlash .7s ease both; }
  }
  .tile.reveal-hi {
    box-shadow: inset 0 0 0 3px var(--accent) !important;
    z-index: 9;
  }

  /* ============ START SCREEN ============
     The menu as a page, not a popup. Both .app screens occupy the same place
     in the flow; only one is visible at a time, and the swap is a crossfade
     rather than a modal opening over a board you were never playing. */
  /* Hidden screens must not be reachable by tab or screen reader, and must not
     take up space — display:none is the only thing that gives all three. The
     animating class overrides it for the length of the crossfade. */
  .is-hidden { display: none; }
  .is-fading { display: flex; opacity: 0; pointer-events: none; }

  /* The outgoing screen lifts and dissolves; the incoming one settles down onto
     the page. Opposite directions read as one replacing the other, rather than
     two things cross-dissolving in place. */
  .start-app, .game-app {
    transition: opacity .34s ease, transform .34s cubic-bezier(.22,.7,.3,1);
  }
  .start-app.is-fading { transform: translateY(-10px) scale(.985); }
  .game-app.is-fading  { transform: translateY(10px)  scale(.99); }

  /* Starting a game: the board's parts arrive just after the screen does, in
     reading order, so the page assembles itself instead of snapping in. */
  .game-app.is-entering .board-wrap { animation: pieceIn .42s .04s cubic-bezier(.22,.7,.3,1) both; }
  .game-app.is-entering .wordlist   { animation: pieceIn .42s .10s cubic-bezier(.22,.7,.3,1) both; }
  .game-app.is-entering .hint       { animation: pieceIn .42s .16s cubic-bezier(.22,.7,.3,1) both; }
  .game-app.is-entering .hud        { animation: pieceIn .42s .02s cubic-bezier(.22,.7,.3,1) both; }
  @keyframes pieceIn {
    from { opacity: 0; transform: translateY(12px); }
    to   { opacity: 1; transform: translateY(0); }
  }

  /* Each letter fades up as the grid lands. The per-tile delay is set inline by
     renderBoard() so it can follow the diagonal. */
  .game-app.is-entering .tile {
    animation: tileIn .3s both cubic-bezier(.22,.7,.3,1);
    animation-delay: var(--tile-delay, 0s);
  }
  @keyframes tileIn {
    from { opacity: 0; transform: scale(.9); }
    to   { opacity: 1; transform: scale(1); }
  }

  .start-top { justify-content: center; }
  .start-top .logo { text-align: center; }

  /* Same double-framed sheet the modal used, so the menu keeps its look while
     sitting in the page. */
  .start-sheet {
    background: var(--paper);
    border: 2px solid var(--rule);
    box-shadow: 0 0 0 6px var(--paper), 0 0 0 7.5px var(--rule);
    padding: 30px 28px;
    text-align: center;
    margin-top: 6px;
    animation: startIn .4s ease both;
  }
  @keyframes startIn {
    from { opacity: 0; transform: translateY(10px); }
    to   { opacity: 1; transform: translateY(0); }
  }

  /* The sheet opens on the description now — the masthead carries the title —
     so it takes the rule the title used to sit on. */
  .start-desc {
    color: var(--ink-dim); line-height: 1.55; font-size: 15px;
    margin: 0 0 14px; padding-bottom: 12px;
    border-bottom: 2px solid var(--rule);
  }

  /* The sheet is as wide as the board (620px), but a menu reading edge to edge
     at that width looks stretched — the modal was 420px for a reason. Rather
     than centring each child (which leaves them on different measures and reads
     as lopsided), hold the whole column to one width. */
  .start-inner { max-width: 430px; margin: 0 auto; }

  /* The lobby and difficulty controls are shared with the modal, so inherit
     its rules by matching the same shapes inside the sheet. */
  .start-sheet .goal-list { text-align: left; margin: 16px 0; font-family: var(--font-serif); font-size: 14px; }
  .start-sheet .goal-list li { list-style: none; padding: 8px 0; color: var(--ink); border-bottom: 1px solid var(--rule-soft); }

  @media (prefers-reduced-motion: reduce) {
    .start-app, .game-app { transition: none; transform: none; }
    .start-sheet { animation: none; }
    .game-app.is-entering .board-wrap,
    .game-app.is-entering .wordlist,
    .game-app.is-entering .hint,
    .game-app.is-entering .hud,
    .game-app.is-entering .tile { animation: none; }
  }

  /* ============ MODAL ============ */
  /* ============ MODAL — a framed newspaper notice ============ */
  .modal-scrim {
    position: fixed; inset: 0; z-index: 300;
    background: #1a171288;
    display: flex; align-items: center; justify-content: center;
    padding: 20px;
    opacity: 0; pointer-events: none; transition: opacity .3s;
  }
  .modal-scrim.show { opacity: 1; pointer-events: auto; }
  .modal {
    width: 100%; max-width: 420px;
    background: var(--paper);
    border: 2px solid var(--rule);
    box-shadow: 0 0 0 6px var(--paper), 0 0 0 7.5px var(--rule);   /* double frame */
    padding: 30px 28px;
    text-align: center;
    transform: translateY(8px); transition: transform .3s ease;
  }
  .modal-scrim.show .modal { transform: translateY(0); }
  .modal h2 {
    font-family: var(--font-display); font-weight: 900;
    font-size: clamp(26px, 7vw, 34px); text-transform: uppercase;
    letter-spacing: .5px; margin-bottom: 6px; line-height: 1.05;
    border-bottom: 2px solid var(--rule); padding-bottom: 10px;
  }
  .modal p { color: var(--ink-dim); margin: 10px 0; line-height: 1.55; font-size: 15px; }
  .modal .goal-list { text-align:left; margin: 16px 0; font-family: var(--font-serif); font-size: 14px; }
  .modal .goal-list li { list-style: none; padding: 8px 0; color: var(--ink); border-bottom: 1px solid var(--rule-soft); }
  /* ---- Difficulty picker (start / play-again modal) ----
     A 2x2 grid of newsprint tab buttons. The selected one inverts to solid ink,
     matching the .done word boxes and the primary button, so "chosen" reads the
     same way everywhere in the UI. */
  /* ---- mode tabs (Single player / Multiplayer) ----
     The top-level switch. Heavier than the lobby's own Create/Join tabs below
     so the two levels of tab don't read as the same thing: these sit on the
     sheet's own rule, like a section header in the masthead. */
  .mode-tabs {
    display: grid; grid-template-columns: 1fr 1fr;
    gap: 0;
    margin: 0 0 20px;
    border-bottom: 2px solid var(--ink);
  }
  .mode-tab {
    font-family: var(--font-mono); font-weight: 700;
    font-size: 12px; letter-spacing: 1.8px; text-transform: uppercase;
    color: var(--ink-dim); background: transparent;
    border: 0; border-bottom: 3px solid transparent;
    margin-bottom: -2px;                 /* sit the underline ON the rule */
    padding: 12px 6px; cursor: pointer;
    transition: color .12s ease, border-color .12s ease;
  }
  /* Hover previews the underline in a soft rule, so there is something to see
     before the bar slides — colour alone gave almost no feedback. */
  .mode-tab:hover { color: var(--ink); border-bottom-color: var(--rule-soft); }
  .mode-tab:active { transform: translateY(1px); }
  .mode-tab.on { color: var(--ink); border-bottom-color: var(--ink); }

  /* Both panes are in the DOM; only the active one is shown. */
  .mode-pane[hidden] { display: none !important; }

  #sPaneVs { text-align: center; }

  /* ---- motion ----
     Panes fade+lift in when their tab is picked. The underline is a transform
     on a pseudo-element so it SLIDES between tabs rather than jumping, which is
     what makes the switch feel like one control instead of two buttons. */
  .mode-pane { animation: paneIn .22s ease both; }
  @keyframes paneIn {
    from { opacity: 0; transform: translateY(4px); }
    to   { opacity: 1; transform: none; }
  }
  .mp-pane:not([hidden]) { animation: paneIn .18s ease both; }

  .mode-tabs { position: relative; }
  .mode-tabs::after {
    content: '';
    position: absolute; left: 0; bottom: -2px;
    width: 50%; height: 3px; background: var(--ink);
    transform: translateX(var(--tab-x, 0));
    transition: transform .26s cubic-bezier(.4, 0, .2, 1);
  }
  /* The per-tab border is the fallback for no-JS/reduced-motion; with the
     sliding bar above it would double up, so it stays transparent. */
  .mode-tab.on { border-bottom-color: transparent; }

  /* The blurb swaps text on every pick; cross-fade so it doesn't snap. */
  .diff-blurb { transition: opacity .18s ease; }
  .diff-blurb.swapping { opacity: 0; }

  /* Difficulty buttons: ease the locked/unlocked change too, not just hover. */
  .diff-btn { transition: background .12s ease, color .12s ease,
                          border-color .18s ease, opacity .18s ease; }

  @media (prefers-reduced-motion: reduce) {
    .mode-pane, .mp-pane:not([hidden]) { animation: none; }
    .mode-tabs::after { transition: none; }
    .diff-blurb { transition: none; }
    .diff-blurb.swapping { opacity: 1; }
  }

  /* ---- primary play button (two lines, like the daily) ---- */
  /* Narrower than the sheet: full width made them read as banners rather than
     buttons. Centred, with a max so they stay button-shaped on a wide screen
     and still fill a phone. */
  .btn.play-btn {
    display: flex; flex-direction: column; align-items: center;
    gap: 4px; width: 100%; max-width: 300px; margin-left: auto; margin-right: auto;
    line-height: 1.3; padding: 13px 18px;
  }
  .play-main { display: block; font-size: 15px; letter-spacing: 2px; }
  .play-sub {
    display: block; font-size: 10px; letter-spacing: 1.1px;
    opacity: .62; font-weight: 400;
  }

  /* Locked: the choice is made elsewhere (host's board) or already committed
     (a room is waiting). Reads as "shown, not offered" — the selected one keeps
     enough contrast to still be legible as information. */
  .diff.locked .diff-btn {
    cursor: default;
    opacity: .45;
    border-color: var(--rule);
    border-style: dashed;
  }
  .diff.locked .diff-btn.on { opacity: .72; }
  /* Locked means inert: kill BOTH the border and the background hover the
     toggle rules above add, or a picker you cannot use still lights up. */
  .diff.locked .diff-btn:hover,
  .diff.locked .diff-btn.on:hover {
    border-color: var(--rule);
    background: var(--paper);
  }
  .diff.locked .diff-btn.on:hover { background: var(--paper-2); }
  .diff.locked .diff-btn:active { transform: none; }
  .diff.locked + .diff-blurb { font-style: normal; opacity: .75; }

  .diff {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 6px;
    margin: 18px 0 10px;
  }
  /* A TOGGLE, not an action — see the "two visual languages" note above .btn.
     Selected reads as a stamped/pressed box: heavier rule, panel wash, and a
     red marker in the corner, the way a newspaper form marks a ticked option.
     Solid ink fill is reserved for buttons you press. */
  .diff-btn {
    position: relative;
    font-family: var(--font-mono);
    font-weight: 700;
    font-size: 12px; letter-spacing: 1.5px; text-transform: uppercase;
    color: var(--ink);
    background: var(--paper);
    border: 1px solid var(--rule-soft);
    border-radius: 0;
    padding: 11px 6px;
    cursor: pointer;
    transition: background .12s ease, color .12s ease,
                border-color .12s ease, box-shadow .12s ease;
  }
  .diff-btn:hover:not(.on) {
    border-color: var(--rule);
    background: var(--paper-2);
  }
  .diff-btn:active:not(:disabled) { transform: translateY(1px); }
  .diff-btn.on {
    background: var(--paper-2);
    color: var(--ink);
    border-color: var(--rule);
    box-shadow: inset 0 0 0 1px var(--rule);   /* doubled rule = pressed in */
  }
  /* The tick: a small red square in the top-left, like a marked ballot box. */
  .diff-btn.on::before {
    content: '';
    position: absolute; left: 6px; top: 6px;
    width: 6px; height: 6px;
    background: var(--accent);
  }
  /* Selected is still hoverable — re-clicking it should not feel dead. */
  .diff-btn.on:hover { background: var(--paper); }
  /* one-line description of the highlighted difficulty; fixed min-height so the
     modal doesn't jump as the text length changes between picks */
  .diff-blurb {
    font-family: var(--font-serif);
    font-style: italic;
    font-size: 13px;
    color: var(--ink-dim);
    min-height: 2.6em;
    margin: 0 0 2px !important;
  }

  /* ============ TWO VISUAL LANGUAGES ============
     The menu mixes ACTIONS (press to do something) with TOGGLES (pick one of
     a set), and they used to share one treatment — solid ink fill meant both
     "this is the primary button" and "this option is selected". A selected
     difficulty was indistinguishable from the Play button, and hovering
     "Create game" made it look like a selected "Host" tab.

       ACTION  -> solid ink fill, inverts on hover        (.btn)
       TOGGLE  -> cream + rule, doubled rule + red tick   (.diff-btn, .mp-tab)

     Keep new controls inside one of the two; don't fill a toggle solid. */
  .btn {
    margin-top: 16px;
    display: inline-block;
    font-family: var(--font-mono);
    font-weight: 700;
    font-size: 15px; letter-spacing: 2px; text-transform: uppercase;
    color: var(--paper);
    background: var(--ink);
    border: 2px solid var(--rule);
    border-radius: 0;
    padding: 12px 32px;
    cursor: pointer;
    transition: background .12s ease, color .12s ease;
  }
  .btn:hover:not(:disabled) { background: var(--paper); color: var(--ink); }
  .btn:active:not(:disabled) { transform: translateY(1px); }
  .btn:disabled { opacity: .45; cursor: default; }

  /* ---- one focus treatment for every control ----
     Only .head-btn had a visible focus ring, so keyboard users lost their place
     everywhere else on the menu. An offset red outline reads as a proof-mark
     against the newsprint and never recolours the control itself. */
  .btn:focus-visible,
  .diff-btn:focus-visible,
  .mp-tab:focus-visible,
  .mode-tab:focus-visible,
  .mp-copy:focus-visible,
  .mp-input:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }

  .hint {
    font-family: var(--font-serif); font-size: 13px; font-style: italic;
    color: var(--ink-dim); text-align:center; line-height: 1.6;
    border-top: 1px solid var(--rule-soft); padding-top: 12px;
  }

  /* ============================================================
     DESKTOP / WIDE — horizontal newspaper layout
     Masthead across the top (logo left, score cells right) with a
     thin divider rule beneath it, then two columns:
     LEFT = the letter board, RIGHT = "Words to find" panel + hint.
     Mobile keeps the vertical stack above.
     ============================================================ */
  @media (min-width: 900px) {
    /* let the sheet breathe wider than the mobile 620px column. The sheet max
       (1180px) matches .stage so the masthead spans exactly the two columns. */
    .app { padding: 30px 28px 40px; }
    .app > * { max-width: 1180px; }

    /* masthead spans the full sheet width; a thin divider rule sits under it.
       The header now holds only the logo, centered vertically between the top
       masthead rule and this divider (equal top/bottom padding). */
    .masthead-rule { max-width: 1180px; width: 100%; }
    header.top {
      max-width: 1180px; width: 100%;
      align-items: center;                  /* center the title in the band */
      padding-top: 6px;
      padding-bottom: 7px;
      border-bottom: 1px solid var(--rule); /* thin divider under the header */
      margin-bottom: 2px;
    }
    /* Optical centering: this all-caps Didone has no descenders, so its line box
       carries empty space below the baseline that makes the caps read HIGH.
       line-height:1 + a small downward nudge seats the caps in the band. */
    header.top .logo {
      line-height: 1;
      transform: translateY(-0.12em);
    }

    /* re-form the columns: contents flattening is cancelled here.
       The left column is flush with the masthead's left edge (no side padding),
       so the board aligns to the header. Columns are PROPORTIONAL (~7:4) so as
       the layout widens the "Words to find" panel scales with the board and the
       ratio between them stays roughly constant — the board isn't the only part
       that grows. The side column has a min floor so it never gets cramped. */
    .stage {
      display: grid;
      grid-template-columns: minmax(0, 7fr) minmax(320px, 4fr);
      align-items: start;
      gap: clamp(32px, 3.4vw, 56px);
      max-width: 1180px;
      width: 100%;
    }
    .board-col {
      display: flex;
      flex-direction: column;
      align-items: flex-start;   /* board hugs the left, aligned to the header */
      min-width: 0;
      max-width: none;           /* board column doesn't obey the mobile 620px cap */
    }
    .side-col {
      display: flex;
      flex-direction: column;
      gap: 18px;
      order: 0;                  /* neutralize the mobile ordering */
    }

    /* neutralize the mobile flatten-order on the real children. The daily
       banner MUST be listed: without it, it keeps its mobile order and sinks
       to the bottom of the side column instead of sitting above the words. */
    .hud, .wordlist, .wordbar, .board-wrap, .hint, .daily-banner { order: 0; }
    /* It is a strip attached to the panel below it, so it shouldn't inherit
       the column's 18px gap. */
    .daily-banner { align-self: stretch; margin-bottom: calc(-18px - 1px); }

    /* HUD sits at the top of the right column, above the "Words to find" panel,
       spanning its full width, aligned with the top of the board. */
    .hud { align-self: stretch; }
    .stat { flex: 1; }

    /* board is flush-left in its column (kill the base auto side margins) and
       JS sizes --tile-size to this column's width */
    .board-wrap { align-self: flex-start; margin: 0; }

    /* the current-word strip sits above the board, hugging the left edge */
    .wordbar { margin: 0 0 6px; justify-content: flex-start; align-self: stretch; }

    /* Align the right column (HUD, then panel) with the TOP OF THE BOARD, not
       the word-strip above it: offset by the strip's block height + margin. */
    .side-col { margin-top: calc(clamp(24px, 6vw, 34px) + 6px); }

    /* hint reads as a footnote under the word panel */
    .hint { text-align: left; margin-top: 2px; }
  }

  /* Bigger "Words to find" box on desktop: more padding, larger word chips,
     and roomier gaps so it reads as a substantial clipping. */
  @media (min-width: 900px) {
    .wordlist { padding: 18px 20px 20px; }
    .wordlist .wl-head { font-size: 15px; padding-bottom: 12px; margin-bottom: 16px; }
    /* 2 words per row on desktop: there's room, so each box is ~2x wider and the
       words can render much bigger. */
    .wordlist .wl-body { grid-template-columns: repeat(2, 1fr); gap: 12px; }
    .wordlist .ww.mask {
      /* Type + spacing sized so the widest fully-REVEALED 7-letter word (real
         letter glyphs are far wider than the '*' mask) still fits the wider box.
         Sizes are driven by cqw (container = .wl-body) so the words scale WITH
         the panel as the layout widens — the px caps are just an upper bound.
         letter-spacing is applied to the whole run, then pulled back on the
         right by an equal margin so trailing space never clips the last letter. */
      font-size: min(32px, 6.6cqw);
      letter-spacing: min(3px, 0.5cqw);
      padding: min(18px, 3cqw) 6px;
    }
    .wordlist .ww.mask .ch:last-child { margin-right: calc(-1 * min(2px, 0.7cqw)); }
  }

  /* ============================================================
     MULTIPLAYER
     Two players race on one board. The single new colour idea: each player owns
     a hue (blue / red), and it is used consistently for their circled words,
     their live drag, and their side of the scoreboard — so "who got what" is
     readable at a glance without a legend. Everything else stays newsprint:
     square corners, ruled borders, the same type scale.
     ============================================================ */

  :root {
    --mp-you: #2f6fdb;     /* slot 0 */
    --mp-foe: #e8563f;     /* slot 1 */
  }

  /* ---- versus scoreboard (replaces .hud during a match) ---- */
  .hud.vs { display: flex; }
  /* must outrank .hud.vs above, which is more specific than the plain
     .hud[hidden] rule — otherwise the dormant versus board shows in
     single-player. */
  .hud.vs[hidden] { display: none; }
  .hud.vs .stat { min-width: 84px; position: relative; }
  /* the owner's colour as a top rule over their column */
  .hud.vs .vs-you::before,
  .hud.vs .vs-foe::before {
    content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
    background: var(--me, var(--ink));
  }
  .hud.vs .stat .sub {
    font-family: var(--font-mono);
    font-size: 9px; letter-spacing: 1px; text-transform: uppercase;
    color: var(--ink-dim);
  }
  .hud.vs .vs-time .value { font-size: clamp(18px, 4.4vw, 24px); }

  /* ---- the opponent's live drag ----
     Its own layer so it can sit under, and fainter than, your own .links line. */
  .foe-trail {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    pointer-events: none;
    z-index: 19;                 /* under the found-word loops, over the tiles */
    overflow: visible;
  }
  /* Opponent presence is ambient — it must never compete with YOUR selection
     (solid red tiles) or with the claimed-word loops. Both cues below are
     deliberately light: a tint you notice in peripheral vision, not a highlight
     that pulls your eye off your own hunt.

     --mp-foe is set at runtime by applyMpColors() from the live roster, so this
     is always the OTHER player's hue whichever slot you are. */

  /* tiles they're dragging over */
  .tile.foe-sel {
    background: color-mix(in srgb, var(--mp-foe) 10%, var(--paper));
    box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--mp-foe) 45%, transparent);
  }
  /* the tile their cursor is merely resting on — fainter still, so "looking
     here" never reads as "tracing this". 5% rounded away to plain paper on an
     8-bit screen, so the tint is 8%: present, but well under the drag's. */
  .tile.foe-hover {
    background: color-mix(in srgb, var(--mp-foe) 8%, var(--paper));
    box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--mp-foe) 30%, transparent);
  }
  .tile.foe-hover.foe-sel {
    background: color-mix(in srgb, var(--mp-foe) 10%, var(--paper));
    box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--mp-foe) 45%, transparent);
  }

  /* ---- claimed words in the word list ----
     The box takes the claimer's colour (--own, set inline from mpColorFor), so
     the list reads as a running scoreboard. The `.done` rule above already
     inverts the text to paper, which stays legible on both hues. */
  .wordlist .ww.mask.done[style*="--own"] {
    background: var(--own);
    border-color: var(--own);
  }
  /* your own claims get a heavier frame, so "mine vs theirs" survives for
     anyone who reads the two hues as similar */
  .wordlist .ww.mask.done.mine { box-shadow: inset 0 0 0 2px var(--paper); }

  /* ---- a word the opponent already claimed ----
     Shaken in the word list when you try to trace it, so the reason it didn't
     score is visible rather than silent. */
  @keyframes takenShake {
    0%,100% { transform: translateX(0); }
    20% { transform: translateX(-4px); }
    40% { transform: translateX(4px); }
    60% { transform: translateX(-3px); }
    80% { transform: translateX(3px); }
  }
  .wordlist .ww.taken-flash { animation: takenShake .45s ease both; }

  /* ---- daily puzzle ---- */
  .daily-entry { margin-top: 14px; }
  /* Two stacked lines (theme + difficulty), so the button says what you get
     before you commit your one play for the day. */
  /* .btn is inline-block; the daily needs two stacked lines, so raise the
     specificity to .btn.daily-btn rather than fight the cascade.

     Play and the daily are two ways into the same mode, so they are the SAME
     button: solid fill, same padding, same two-line shape. Only the words
     differ. (It was a ghost link, then a cream card; both made it read as a
     lesser thing than it is.) The .btn base supplies fill, border and hover —
     this only adds the two-line layout, which .btn alone can't do. */
  .btn.daily-btn {
    display: flex; flex-direction: column; align-items: center;
    gap: 4px; width: 100%; max-width: 300px; margin: 10px auto 0;
    line-height: 1.3; padding: 13px 18px;
  }
  .daily-main { display: block; font-size: 15px; letter-spacing: 2px; }
  .daily-sub {
    display: block;
    font-size: 10px; letter-spacing: 1.1px;
    opacity: .62; font-weight: 400;
  }
  /* Completed: stays on screen (so the feature doesn't disappear) but reads as
     spent rather than broken — a rules-soft border and no hover response, so
     it is visibly not a thing you can press today. */
  /* Spent for today: dimmed, and its hover does not invert (nothing to press). */
  .btn.daily-btn.done { opacity: .5; cursor: default; }
  .btn.daily-btn.done:hover { background: var(--ink); color: var(--paper); }

  /* ---- lobby (inside the Multiplayer mode pane) ---- */
  .btn.ghost {
    background: var(--paper); color: var(--ink);
    font-size: 13px; padding: 10px 22px;
  }
  .btn.ghost:hover:not(:disabled) { background: var(--ink); color: var(--paper); }

  /* The lobby's OWN tabs — lighter than .mode-tabs above on purpose, so the
     two levels stay visually distinct. */
  .mp-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 16px; }
  /* Host / Join are TOGGLES, so they follow the same language as .diff-btn
     rather than filling solid — a hovered "Create game" button used to look
     exactly like a selected "Host" tab. They had no hover and no transition
     at all before, so a tap gave no feedback either. */
  .mp-tab {
    position: relative;
    font-family: var(--font-mono); font-weight: 700;
    font-size: 12px; letter-spacing: 1.5px; text-transform: uppercase;
    color: var(--ink); background: var(--paper);
    border: 1px solid var(--rule-soft); border-radius: 0;
    padding: 10px 6px; cursor: pointer;
    transition: background .12s ease, border-color .12s ease, box-shadow .12s ease;
  }
  .mp-tab:hover:not(.on) { border-color: var(--rule); background: var(--paper-2); }
  .mp-tab:active { transform: translateY(1px); }
  .mp-tab.on {
    background: var(--paper-2); color: var(--ink);
    border-color: var(--rule);
    box-shadow: inset 0 0 0 1px var(--rule);
  }
  .mp-tab.on::before {
    content: '';
    position: absolute; left: 6px; top: 6px;
    width: 6px; height: 6px;
    background: var(--accent);
  }
  .mp-tab.on:hover { background: var(--paper); }
  .mp-hint {
    font-family: var(--font-serif); font-size: 13px; font-style: italic;
    color: var(--ink-dim); margin-bottom: 12px !important;
  }
  .mp-input {
    font-family: var(--font-display); font-weight: 800;
    font-size: 30px; letter-spacing: 8px; text-align: center; text-transform: uppercase;
    /* Block + auto margins so the Join button sits UNDER the code rather than
       beside it: the two read as one step (type, then join) instead of a row
       that gets cramped at narrow widths. */
    display: block;
    width: 100%; max-width: 220px;
    margin: 0 auto 10px;
    color: var(--ink); background: var(--paper-2);
    border: 2px solid var(--rule); border-radius: 0;
    padding: 10px 6px 10px 14px;   /* extra left pad offsets the trailing letter-space */
  }
  #sMpJoinBtn { margin-top: 0; }
  .mp-input:focus { outline: none; background: var(--paper); box-shadow: inset 0 0 0 2px var(--accent); }

  .mp-code-box { margin-top: 16px; border-top: 1px solid var(--rule-soft); padding-top: 14px; }
  .mp-code-label {
    font-family: var(--font-mono); font-size: 10px; font-weight: 600;
    letter-spacing: 1.5px; text-transform: uppercase; color: var(--ink-dim);
  }
  .mp-code {
    font-family: var(--font-display); font-weight: 900;
    font-size: clamp(38px, 11vw, 52px); letter-spacing: 10px;
    line-height: 1.15; color: var(--ink);
    padding-left: 10px;            /* optical centring against the letter-spacing */
  }
  .mp-copy {
    font-family: var(--font-mono); font-size: 11px; font-weight: 700;
    letter-spacing: 1.2px; text-transform: uppercase;
    color: var(--ink); background: transparent;
    border: 1px solid var(--rule); border-radius: 0;
    padding: 6px 14px; cursor: pointer;
  }
  .mp-copy:hover { background: var(--ink); color: var(--paper); }
  .mp-wait {
    font-family: var(--font-serif); font-style: italic; font-size: 13px;
    color: var(--ink-dim); margin-top: 12px;
  }
  .mp-error {
    font-family: var(--font-mono); font-size: 12px; letter-spacing: .5px;
    color: var(--danger); margin-top: 12px !important;
  }
  /* Word boxes in a versus match hold up to 9 letters, but the panel type is
     sized for 7 (see the .ww.mask rules above), so FORTUNATE overflowed its box.
     Scale by the longest word actually on the board: the class is set from
     renderRevealPanel(), so a board of short words keeps the bigger type. */
  /* MOBILE: the default 4-column grid leaves ~85px per box, which a 9-letter
     word cannot fit at any readable size. Halve the column count for long-word
     boards and shrink the type to match. */
  .wordlist.len8 .wl-body,
  .wordlist.len9 .wl-body { grid-template-columns: repeat(2, 1fr); }
  .wordlist.len8 .ww.mask { font-size: min(15px, 5.4cqw); letter-spacing: min(2px, 0.3cqw); }
  .wordlist.len9 .ww.mask { font-size: min(15px, 4.7cqw); letter-spacing: min(1px, 0.2cqw); }
  @media (min-width: 900px) {
    .wordlist.len8 .ww.mask { font-size: min(27px, 5.6cqw); letter-spacing: min(2px, 0.34cqw); }
    .wordlist.len9 .ww.mask { font-size: min(24px, 4.9cqw); letter-spacing: min(1px, 0.24cqw); }
  }

  /* ---- PHONE: fit the whole game on one screen ----
     Board first, then a compact word list under it. Everything here buys
     vertical space: the list is the only thing that can give it up without
     making the board harder to touch. */
  @media (max-width: 899px) {
    .app {
      gap: 6px;
      padding-top: calc(6px + env(safe-area-inset-top, 0px));
      padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
      padding-left: calc(10px + env(safe-area-inset-left, 0px));
      padding-right: calc(10px + env(safe-area-inset-right, 0px));
    }
    header.top { padding-top: 0; padding-bottom: 0; }

    .daily-banner { padding: 5px 10px; gap: 8px; }
    .daily-banner .db-label { font-size: 9px; letter-spacing: 1.4px; }
    .daily-banner .db-theme { font-size: 13px; }
    .wordlist { padding: 7px 9px 8px; }
    .wordlist .wl-head {
      font-size: 10px; letter-spacing: 1.2px;
      padding-bottom: 5px; margin-bottom: 7px;
    }
    .wordlist .wl-head b { font-size: 12px; }
    /* THREE columns for the long-word boards (.len8/.len9 force 2 above, which
       is 7 rows for 14 words). Three fits 14 in 5 rows and still leaves a box
       wide enough for a 9-letter word at this type size — the selectors below
       carry .len8/.len9 so they outrank that rule rather than racing it.
       Short-word boards keep the 4-column default. */
    .wordlist .wl-body { gap: min(4px, 1cqw); }
    .wordlist.len8 .wl-body,
    .wordlist.len9 .wl-body { grid-template-columns: repeat(3, 1fr); }
    .wordlist .ww.mask,
    .wordlist.len8 .ww.mask,
    .wordlist.len9 .ww.mask {
      font-size: min(11px, 3.2cqw);
      letter-spacing: 0;
      padding: min(4px, 1cqw) 1px;
    }
    .wordbar { margin: 0; min-height: 0; }
    /* The hint paragraph is a first-game explainer, not something to keep on
       screen every game — it is the single biggest block of text down there. */
    .hint { display: none; }
  }

  /* A bigger grid means smaller tiles; keep the letters legible on a phone. */
  @media (max-width: 520px) {
    .hud.vs .stat { min-width: 0; padding: 7px 7px 8px; }
    /* the clock must never wrap to two lines ("0:" / "00") */
    .hud.vs .vs-time { white-space: nowrap; }
    .hud.vs .vs-time .value { font-size: 17px; }
    .hud.vs .stat .label { font-size: 9px; letter-spacing: .8px; }
    .hud.vs .stat .value { font-size: 22px; }
    .mp-code { letter-spacing: 8px; }
  }
