/* Hallmark · macrostructure: single-stage arcade overlays · genre: playful arcade · theme: mid-autumn tin
 * states: default · hover · focus · active · disabled · loading · error · success
 * contrast: pass (40–41) · nav: none · footer: Ft1 · slop: pass (42–45)
 * honest: pass (46) · chrome: pass (47) · tokens: legacy exceptions · responsive/mobile: pass (34,49–57)
 * Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V4 */
  *{box-sizing:border-box; -webkit-tap-highlight-color: transparent;}
  html,body{
    height:100%; margin:0; overflow-x:clip; overflow-y:hidden; background:var(--color-ink);
    font-family:var(--font-body);
  }
  #stage-wrap{
    position:fixed; inset:0; display:flex; align-items:center; justify-content:center;
    background:#000;
  }
  #stage{
    position:relative;
    width:100%; height:100%;
    max-width:520px;
    background:#000;
    overflow:hidden;
    box-shadow:0 0 60px rgba(0,0,0,.8);
  }
  #stage::after{
    content:""; position:absolute; inset:-5%; z-index:49; pointer-events:none; opacity:0;
    background:
      radial-gradient(circle at 18% 42%,rgba(73,185,204,.13),transparent 30%),
      radial-gradient(circle at 82% 58%,rgba(232,73,98,.12),transparent 32%),
      radial-gradient(ellipse at 50% 50%,transparent 34%,rgba(4,2,9,.26) 82%);
    mix-blend-mode:screen; transform:translate3d(0,0,0) scale(1.04);
  }
  #stage.total-eclipse::after{ opacity:1; animation:eclipseVertigo 2.8s ease-in-out infinite alternate; }
  #stage.total-eclipse #vignette{ animation:eclipseVignetteBreath 2.2s ease-in-out infinite alternate; }
  @keyframes eclipseVertigo{
    0%{ transform:translate3d(-1.2%,-.6%,0) scale(1.045) rotate(-.18deg); filter:hue-rotate(-5deg) saturate(1.08); }
    52%{ transform:translate3d(.8%,.9%,0) scale(1.06) rotate(.16deg); filter:hue-rotate(5deg) saturate(1.2); }
    100%{ transform:translate3d(1.3%,-.3%,0) scale(1.05) rotate(-.08deg); filter:hue-rotate(-2deg) saturate(1.12); }
  }
  @keyframes eclipseVignetteBreath{ to{ opacity:.72; filter:contrast(1.08); } }
  /* Landscape is not a scaled-down portrait board: at 812x375 the play field
     would be 375px tall, crushing the arc between spawn (95% height) and box
     (27% height) into nothing. Letterbox to a portrait column instead so the
     shot geometry is identical in both orientations. resize() re-reads the
     stage box on the resize event that orientation change fires. */
  @media (orientation: landscape){
    #stage{ width:auto; height:100%; max-width:none; aspect-ratio: 9 / 16; }
  }

  /* Measured by game.js to keep the swipe/spawn zone clear of the iOS home
     indicator, whose system swipe-up gesture would otherwise eat flicks that
     start at the very bottom edge. Not display:none — that would zero it. */
  #safeProbe{
    position:fixed; bottom:0; left:0; width:1px; pointer-events:none;
    visibility:hidden; z-index:-1;
    height: env(safe-area-inset-bottom, 0px);
  }

  .visually-hidden{
    position:absolute; width:1px; height:1px; margin:-1px; padding:0;
    overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0;
  }

  /* ---------------- background ---------------- */
  /* Front View is the only camera. The Side View crossfade was removed —
     bg-side.jpg has its own hoop painted into the photo, which blended
     confusingly behind the real interactive hoop at mid-streak. */
  .bg-layer{
    position:absolute; inset:0; width:100%; height:100%;
    object-fit:cover; object-position:center top;
    filter:brightness(.92);
  }
  #bgFront{ z-index:1; }
  #eclipseDarkness{
    position:absolute; inset:0; z-index:2; pointer-events:none; opacity:0;
    background:linear-gradient(rgba(3,5,12,.72),rgba(8,3,8,.46));
    transition:opacity .45s ease;
  }
  #eclipseMoon{
    --eclipse-lum:1; --eclipse-warm:0; --eclipse-th:26deg; --eclipse-w:5deg;
    --eclipse-glow:0; --eclipse-dip:.04; --eclipse-scale:1; --eclipse-cover-x:112%;
    position:absolute; z-index:2; top:15.2%; left:79%; width:clamp(62px,18vw,90px); aspect-ratio:1;
    transform:translateX(-50%) scale(var(--eclipse-scale)); border-radius:50%; pointer-events:none;
    box-shadow:0 0 14px rgba(255,241,196,.66),0 0 36px rgba(236,201,125,.28);
    opacity:.9; transition:box-shadow .4s ease,opacity .4s ease; will-change:transform;
  }
  .eclipse-moon-surface{
    position:absolute; inset:0; z-index:1; width:100%; height:100%; border-radius:50%;
    filter:brightness(var(--eclipse-lum)) contrast(calc(1 + var(--eclipse-warm) * .3)) sepia(calc(var(--eclipse-warm) * .38)) saturate(calc(1 + var(--eclipse-warm) * 1.15)) hue-rotate(calc(var(--eclipse-warm) * -12deg));
  }
  .eclipse-moon-layer{ position:absolute; inset:0; z-index:2; border-radius:50%; }
  .eclipse-moon-rim,.eclipse-moon-bloom{
    background:conic-gradient(from calc(var(--eclipse-th) - 180deg),
      rgb(198 38 16 / calc(var(--eclipse-dip) * 100%)) 0deg,
      rgb(224 62 22 / calc(var(--eclipse-dip) * 130%)) calc(90deg - var(--eclipse-w) * .5),
      #e8471a calc(180deg - var(--eclipse-w)),#ff7a33 calc(180deg - var(--eclipse-w) * .35),
      #ffc48a 180deg,#ff7a33 calc(180deg + var(--eclipse-w) * .35),#e8471a calc(180deg + var(--eclipse-w)),
      rgb(224 62 22 / calc(var(--eclipse-dip) * 130%)) calc(270deg + var(--eclipse-w) * .5),
      rgb(198 38 16 / calc(var(--eclipse-dip) * 100%)) 360deg);
    opacity:var(--eclipse-glow);
  }
  .eclipse-moon-rim{
    -webkit-mask:radial-gradient(circle closest-side,transparent 0 97.6%,#000 99.2%,#000 100%);
    mask:radial-gradient(circle closest-side,transparent 0 97.6%,#000 99.2%,#000 100%);
    filter:blur(.7px);
  }
  .eclipse-moon-bloom{
    position:absolute; z-index:0; border-radius:50%; inset:-5.5%;
    -webkit-mask:radial-gradient(circle closest-side,transparent 0 88%,#000 94.5%,transparent 100%);
    mask:radial-gradient(circle closest-side,transparent 0 88%,#000 94.5%,transparent 100%);
    filter:blur(3px); opacity:calc(var(--eclipse-glow) * .75);
  }
  .eclipse-moon-spill{
    background:conic-gradient(from calc(var(--eclipse-th) - 180deg),
      rgb(214 70 30 / calc(var(--eclipse-dip) * 60%)) 0deg,transparent calc(90deg - var(--eclipse-w) * .5),
      transparent calc(180deg - var(--eclipse-w) * 2.6),rgba(236,104,46,.38) calc(180deg - var(--eclipse-w) * 1.35),
      rgba(255,170,110,.85) 180deg,rgba(236,104,46,.38) calc(180deg + var(--eclipse-w) * 1.35),
      transparent calc(180deg + var(--eclipse-w) * 2.6),transparent calc(270deg + var(--eclipse-w) * .5),
      rgb(214 70 30 / calc(var(--eclipse-dip) * 60%)) 360deg);
    -webkit-mask:radial-gradient(circle closest-side,transparent 0 92%,#000 99.5%);
    mask:radial-gradient(circle closest-side,transparent 0 92%,#000 99.5%);
    mix-blend-mode:screen; opacity:calc(var(--eclipse-glow) * .6); filter:blur(1px);
  }
  #eclipseMoon i{
    position:absolute; inset:0; z-index:2; border-radius:50%; background:radial-gradient(circle at 42% 35%,#17141b,#05040a 72%);
    box-shadow:inset -5px -3px 12px rgba(0,0,0,.9); transform:translateX(var(--eclipse-cover-x));
    transition:transform .82s cubic-bezier(.16,1,.3,1); will-change:transform;
  }
  /* The charge phases stay subtle -- they are a hint that something is coming.
     Totality is not a hint, so it steps well clear of them: at .28 the rush
     looked like a slightly moodier version of normal play, which is the one
     thing it must never look like. The mooncakes and the rim are drawn on the
     canvas ABOVE this layer, so darkening the stage dims the scene without
     dimming the things being aimed at. */
  #stage.eclipse-phase-1 #eclipseDarkness{ opacity:.05; }
  #stage.eclipse-phase-2 #eclipseDarkness{ opacity:.11; }
  #stage.eclipse-phase-3 #eclipseDarkness{ opacity:.19; }
  #stage.total-eclipse #eclipseDarkness{ opacity:.54; }
  /* A slow sway for the whole six seconds, distinct from the sharp rumbles that
     punctuate it (activation, each hit, the final second). Those are declared
     later in this file and therefore win while they run. Kept to ~1.5px and a
     slow period: a fast shake over a game where the player is aiming is not
     atmosphere, it is a handicap. */
  #stage.total-eclipse{ animation:stageEclipseSway 1.6s ease-in-out infinite; }
  @keyframes stageEclipseSway{
    0%,100%{ transform:translate3d(0,0,0); }
    25%{ transform:translate3d(1.1px,-.7px,0); }
    50%{ transform:translate3d(-.6px,1.2px,0); }
    75%{ transform:translate3d(-1.2px,-.4px,0); }
  }
  #stage.total-eclipse #eclipseMoon{
    opacity:1;
    box-shadow:0 0 8px 2px rgba(226,92,61,.92),0 0 28px 8px rgba(174,47,42,.42),0 0 54px rgba(237,145,84,.2);
  }
  #stage.total-eclipse #eclipseMoon i{ opacity:0; }
  #vignette{
    position:absolute; inset:0; z-index:3;
    background:
      radial-gradient(ellipse at 50% 0%, rgba(0,0,0,0) 40%, rgba(0,0,0,.55) 100%),
      linear-gradient(to bottom, rgba(0,0,0,.35) 0%, rgba(0,0,0,0) 18%);
    pointer-events:none;
  }
  #stage.total-eclipse #vignette{ animation:eclipseVignette .8s ease-in-out infinite alternate; }
  @keyframes eclipseVignette{ to{ box-shadow:inset 0 0 42px rgba(125,20,27,.3),inset 0 0 84px rgba(8,6,18,.5); } }
  canvas#game{
    position:absolute; inset:0; width:100%; height:100%; z-index:4;
    touch-action:none; user-select:none;
  }

  /* ---------------- HUD ---------------- */
  #hud{
    position:absolute; top:0; left:0; right:114px;
    padding:max(8px, env(safe-area-inset-top)) 0 0 10px;
    z-index:20; pointer-events:none;
  }
  /* Same trick as .cover-frame: the frame is locked to the banner art's own
     aspect ratio, so every value below can be positioned as a plain percentage
     of the artwork. Positioning against #hud instead would fold the padding
     into the percentages and skew every value vertically. */
  .hud-frame{
    position:relative; width:100%; aspect-ratio: 2250 / 381;
    container-type:inline-size;
  }
  /* SCORE / TIME / HIGH SCORE used to be baked into the banner art, which made
     them untranslatable. hud-top-banner-blank.webp is the same art with the
     three words erased; these rules put them back as live text at the measured
     positions of the originals (bboxes read off the original PNG:
     SCORE x179-408 y59-116, TIME x1053-1199 y73-116, HIGH SCORE x1737-2072).
     Sizes are cqw so they track the art rather than the viewport. */
  .hud-label{
    position:absolute; transform:translate(-50%,-50%);
    font-family:var(--font-display); font-weight:400; line-height:1;
    letter-spacing:.01em; white-space:nowrap; text-transform:uppercase;
  }
  .hud-label--score{ left:13.0%; top:23.0%; font-size:3.5cqw; color:#a5000c; }
  .hud-label--time{ left:50.0%; top:24.8%; font-size:2.6cqw; color:#fff; }
  .hud-label--high{ left:84.6%; top:24.8%; font-size:2.6cqw; color:#fff; }
  /* Chinese sets shorter than the English at the same size and the display face
     has no CJK glyphs, so the label drops to the body stack and gains a little
     size to keep the same optical weight as the erased lettering. */
  :lang(zh) .hud-label{ font-family:var(--font-body); font-weight:800; letter-spacing:.02em; }
  :lang(zh) .hud-label--score{ font-size:3.9cqw; }
  :lang(zh) .hud-label--time,
  :lang(zh) .hud-label--high{ font-size:3.0cqw; }
  #hudBanner{
    position:absolute; inset:0; width:100%; height:100%;
  }
  /* Field centres measured off hud-top-banner.png (2250x381). The three baked-in
     labels share a baseline ending at y=116 (30.4%); the plates end at y=365, so
     each value centres at 63.1%. Label centres are x 13.1% / 50.2% / 84.6%. */
  .hud-val{
    position:absolute;
    font-family:var(--font-numeric);
    font-weight:400;
    color:var(--color-gold);
    text-shadow:0 0 10px rgba(240,200,96,.5);
    line-height:1;
    font-variant-numeric: tabular-nums;
    white-space:nowrap;
    transform:translate(-50%,-50%);
  }
  #scoreVal{
    left:13.1%; top:63.1%;
    font-size:clamp(16px, 5.4vw, 26px);
    font-weight:700;
    /* matches the red of the baked-in "SCORE" label (#a5000c) */
    color:#a5000c; text-shadow:none;
  }
  #timeVal{
    left:50.2%; top:63.1%;
    font-size:clamp(24px, 8vw, 38px);
    letter-spacing:-.035em;
    text-shadow:0 0 14px rgba(240,200,96,.72);
  }
  #highVal{
    left:84.6%; top:63.1%;
    font-size:clamp(14px, 4.6vw, 22px);
  }

  /* ---------------- score multiplier — plain text below Score ---------------- */
  #combo{
    position:absolute; top:21.2%; left:15.2%;
    transform:translateX(-50%); z-index:15; pointer-events:none;
  }
  .fx-eclipse{
    --ink:#f5f1df; --ink-2:#dc6a4d; --ink-3:#a9c9ef;
    --eclipse-word-left:-15%; --eclipse-word-opacity:0; --eclipse-active-opacity:.72;
    position:absolute; left:50%; top:50%; transform:translate(-50%,7vh);
    color:var(--ink); font:700 clamp(34px,10.5vw,52px)/1.1 var(--font-numeric); letter-spacing:.1em;
    text-shadow:0 0 8px color-mix(in srgb,var(--ink-2) 72%,transparent),0 0 20px color-mix(in srgb,var(--ink-2) 42%,transparent),0 2px 4px rgba(0,0,0,.92);
    white-space:nowrap; opacity:0; pointer-events:none;
    transition:opacity .72s var(--ease-out),filter .72s var(--ease-out),transform .28s var(--ease-out);
  }
  /* Set by showFinalCountdown() for the 520ms the digit is on screen. The digit
     is centred on the stage and this word sits 7vh below it, close enough that
     a two-glyph Chinese label lands directly under the numeral. Shifting rather
     than hiding keeps the rush readable -- the word is what tells the player to
     keep swiping. */
  #stage.is-final-countdown .fx-eclipse{ transform:translate(-50%,15vh); }
  #tierBanner.eclipse-building .fx-eclipse{ opacity:calc(var(--eclipse-word-opacity) * .7); filter:brightness(.96); }
  /* Blinking, not glowing. The word is the one piece of the rush that says
     "act now, swipe": a steady label is scenery, and scenery is what a player
     stops seeing three rounds in. The pulse runs on opacity and brightness
     only, so it costs no layout. */
  .fx-eclipse.show{
    opacity:var(--eclipse-active-opacity); filter:brightness(1.04);
    animation:eclipseWordBlink .78s steps(1,end) infinite;
  }
  @keyframes eclipseWordBlink{
    0%,49%{ opacity:1; filter:brightness(1.45) saturate(1.15); }
    50%,100%{ opacity:calc(var(--eclipse-active-opacity) * .42); filter:brightness(.72); }
  }
  .fx-eclipse::after{
    content:""; position:absolute; top:50%; left:var(--eclipse-word-left); width:1.3em; height:1.3em; border-radius:50%;
    background:#05040c; box-shadow:0 0 8px 2px color-mix(in srgb,var(--ink-2) 85%,transparent),inset -4px -3px 8px rgba(0,0,0,.9);
    transform:translateY(-50%); opacity:0; transition:left .82s cubic-bezier(.16,1,.3,1),opacity .55s ease;
  }
  #tierBanner.eclipse-building .fx-eclipse::after{ opacity:.78; }
  #comboInner{
    font-family:var(--font-numeric); font-weight:700;
    font-size:clamp(32px,9.5vw,46px); letter-spacing:0;
    padding:0; border:0; border-radius:0; background:none;
    color:var(--color-accent-ink); opacity:0; transform:translateY(-5px) scale(.88);
    transition:opacity var(--dur-short) var(--ease-out), transform var(--dur-short) var(--ease-out);
    white-space:nowrap;
  }
  #comboInner.show{ opacity:1; transform:translateY(0) scale(1); }
  #comboInner.t1{ color:var(--color-accent-ink); text-shadow:0 2px 7px rgba(0,0,0,.9); }
  #comboInner.t2{ color:#dce9ff; text-shadow:0 0 15px rgba(190,215,255,.9),0 2px 7px rgba(0,0,0,.85); }
  #comboInner.t3{ color:#ffe27a; text-shadow:0 0 17px rgba(255,210,80,.92),0 2px 7px rgba(0,0,0,.85); }
  #comboInner.t4{
    color:#ffe6a8;
    text-shadow:0 0 12px rgba(255,210,105,.82),0 2px 7px rgba(0,0,0,.85);
  }
  #stage.total-eclipse #comboInner.t4{
    color:#f8fbff;
    text-shadow:0 0 7px #fff,0 0 17px rgba(220,106,77,.92),0 2px 7px rgba(0,0,0,.85);
    animation:eclipseMultiplierGlow .8s ease-in-out infinite alternate;
  }
  @keyframes eclipseMultiplierGlow{ to{ filter:brightness(1.2); } }

  .iconBtn{
    position:absolute; right:10px; top:max(8px, env(safe-area-inset-top));
    z-index:24; width:44px; min-width:44px; min-height:44px; padding:0;
    border:1px solid color-mix(in oklch, var(--color-paper) 35%, transparent);
    border-radius:var(--radius-pill); background:color-mix(in oklch, var(--color-ink) 78%, transparent);
    color:var(--color-paper); font:700 19px/1 var(--font-button); cursor:pointer;
  }
  #helpBtn{ right:62px; font-family:var(--font-button); font-size:21px; }
  @keyframes buttonVibrate{
    0%,100%{ transform:translateX(0) rotate(0); }
    20%{ transform:translateX(-2px) rotate(-.7deg); }
    40%{ transform:translateX(2px) rotate(.7deg); }
    60%{ transform:translateX(-1.5px) rotate(-.5deg); }
    80%{ transform:translateX(1.5px) rotate(.5deg); }
  }
  @media (hover:hover) and (pointer:fine){
    .iconBtn:hover{ background:var(--color-ink-2); animation:buttonVibrate .22s linear 1; }
  }
  .iconBtn:active{ transform:translateY(1px); }
  .iconBtn:focus-visible{ outline:3px solid var(--color-focus); outline-offset:2px; }
  .iconBtn:disabled{ opacity:0; pointer-events:none; }

  .help-overlay{
    position:absolute; inset:0; z-index:63; display:grid; place-items:center;
    padding:20px; background:color-mix(in oklch,var(--color-ink) 72%,transparent);
  }
  .help-overlay[hidden]{ display:none; }
  .help-card{
    position:relative; width:min(100%,380px); max-height:calc(100% - 20px); overflow:auto; padding:32px 24px 24px;
    border:8px solid var(--color-result-frame); border-radius:30px;
    background:var(--color-result-paper); color:var(--color-result-ink);
    box-shadow:inset 0 0 0 3px var(--color-result-gold),var(--shadow-result);
  }
  .help-card h2{ margin:0 36px 14px; text-align:center; font:700 28px/1 var(--font-numeric); letter-spacing:.06em; text-transform:uppercase; white-space:nowrap; }
  @media (max-width:380px){
    .help-card h2{ margin:0 30px 14px; font-size:clamp(18px,6.2vw,28px); letter-spacing:.03em; }
  }
  .help-card p{ margin:0; text-align:center; color:var(--color-ink-2); font-size:15px; line-height:1.45; }
  .help-steps{ display:grid; gap:11px; margin-top:17px; text-align:left; }
  .help-steps section{ padding-left:14px; border-left:3px solid var(--color-result-gold); }
  .help-steps h3{ margin:0 0 3px; color:var(--color-result-ink); font:700 14px/1.15 var(--font-numeric); letter-spacing:.055em; text-transform:uppercase; }
  .help-steps section p{ text-align:left; font-size:13px; line-height:1.38; }
  .help-close{ position:absolute; top:10px; right:12px; width:36px; height:36px; border:0; border-radius:50%; background:transparent; color:var(--color-result-ink); font:700 28px/1 var(--font-button); cursor:pointer; }
  .help-close:hover{ background:color-mix(in oklch,var(--color-result-ink) 10%,transparent); animation:buttonVibrate .22s linear 1; }
  .help-close:focus-visible{ outline:3px solid var(--color-focus); outline-offset:2px; }

  #timeVal.time-low{ color:var(--color-gold); text-shadow:0 0 16px color-mix(in oklch, var(--color-gold) 85%, transparent); }
  #timeVal.time-critical{
    color:#ff3344; background:none; border:0; border-radius:0; padding:0;
    text-shadow:0 0 8px rgba(255,35,55,.95),0 0 20px rgba(255,35,55,.9),0 0 34px rgba(255,35,55,.7);
  }

  /* ---------------- tier-up celebration banner ---------------- */
  /* Pops over live (unpaused) gameplay for ~1.5s when a combo threshold is
     crossed, then fades — camera/background never changes for this, only
     the overlay. See registerScore() in game.js. */
  #tierBanner{
    position:absolute; inset:0; z-index:35;
    display:flex; align-items:center; justify-content:center;
    pointer-events:none;
    opacity:0;
    transition:opacity .35s ease;
  }
  #tierBanner.show,#tierBanner.eclipse-show,#tierBanner.eclipse-building{ opacity:1; }
  #tierBanner.eclipse-show #tierBannerImg,#tierBanner.eclipse-building #tierBannerImg{ opacity:0; }
  #tierBannerImg{
    width:84.6%; max-width:441px; height:auto;
    transform:translateY(7vh);
    filter:drop-shadow(0 8px 30px rgba(0,0,0,.6));
  }

  /* ---------------- streak float text ---------------- */
  .floatText{
    position:absolute; font-family:var(--font-numeric); font-weight:700; pointer-events:none; z-index:30;
    text-shadow:0 2px 6px rgba(0,0,0,.6);
    animation: floatUp .75s ease-out forwards;
  }
  @keyframes floatUp{
    0%{ transform:translate(-50%,-50%) scale(.6); opacity:0; }
    15%{ opacity:1; transform:translate(-50%,-60%) scale(1.15); }
    100%{ transform:translate(-50%,-140%) scale(1); opacity:0; }
  }

  /* ---------------- overlays (Start / End) ---------------- */
  .overlay{
    position:absolute; inset:0; z-index:50;
    display:flex; align-items:center; justify-content:center;
    opacity:0; pointer-events:none; transition:opacity .3s ease;
    background:#000;
  }
  .overlay.show{ opacity:1; pointer-events:auto; }
  .overlay[hidden]{ display:none; }
  .start-shell{
    position:relative; z-index:3; display:flex; flex-direction:column; align-items:center;
    width:100%; max-width:520px; transform:translateY(-2.2%);
  }
  /* cover-frame is locked to the art's own aspect ratio so every overlaid
     value/button below can be positioned as a plain percentage of the art
     itself — object-fit:contain on a bare image element would letterbox these
     (tall, non-stage-shaped) images and throw off every percentage. */
  .cover-frame{
    position:relative; width:100%; margin:auto;
  }
  /* 10px narrower than it was, and it shakes. A permanent wobble on a piece of
     art this large is motion sickness, so the burst is short and the rest of
     the cycle is still: 0.9s of shake, 2.7s of nothing. */
  #startFrame{
    aspect-ratio: 1148 / 1215; width:calc(100% - 30px); margin:0 auto;
    animation:startCoverShake 3.6s ease-in-out infinite;
    will-change:transform;
  }
  @keyframes startCoverShake{
    0%,72%,100%{ transform:translate3d(0,0,0) rotate(0deg); }
    75%{ transform:translate3d(-2.4px,1.2px,0) rotate(-.55deg); }
    79%{ transform:translate3d(2.4px,-1.2px,0) rotate(.55deg); }
    83%{ transform:translate3d(-1.8px,-1.1px,0) rotate(-.42deg); }
    87%{ transform:translate3d(1.5px,1.1px,0) rotate(.3deg); }
    91%{ transform:translate3d(-.9px,0,0) rotate(-.16deg); }
    95%{ transform:translate3d(.4px,0,0) rotate(0deg); }
  }
  #endFrame{ aspect-ratio: 1455 / 2020; }
  .cover-img{
    position:absolute; inset:0; width:100%; height:100%; object-fit:fill;
  }
  /* Start screen: Front View + a dark scrim behind the cover art, per spec */
  #startOverlay{ background:#000; }
  #startOverlay::before{
    content:''; position:absolute; inset:0;
    background-image:url('../assets/images/bg-front.jpg');
    background-size:cover; background-position:center top;
  }
  #startOverlay::after{
    content:''; position:absolute; inset:0; background:rgba(0,0,0,.55);
  }
  /* The Start/Leaderboard buttons no longer sit layered on the cover art
     (the new title art has no baked-in button area to align them to) — they
     flow normally below it now, so the art needs no bottom crop. */
  .start-cover-art{ position:absolute; inset:0; z-index:1; overflow:hidden; }
  .start-tagline{
    margin:14px 0 16px; color:#fff; text-align:center; white-space:nowrap;
    font:400 clamp(10px,3.9vw,17px)/1.3 var(--font-display); letter-spacing:.01em;
    text-shadow:0 3px 10px rgba(0,0,0,.65);
  }
  #startOverlay .cover-img{ z-index:1; }
  .start-loading{
    position:absolute; inset:0; z-index:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px;
    padding:24px; color:var(--color-gold); text-align:center; text-shadow:0 3px 12px #000;
    transition:opacity .18s ease;
  }
  .start-loading strong{ font:700 clamp(28px,9vw,46px)/1.05 var(--font-display); letter-spacing:.025em; text-transform:uppercase; }
  .start-loading span{ font:700 15px/1 var(--font-numeric); letter-spacing:.12em; text-transform:uppercase; }
  #startOverlay.cover-ready .start-loading{ opacity:0; visibility:hidden; }
  #startOverlay.cover-pending .startHit{ opacity:0; pointer-events:none; }
  #startOverlay.game-assets-pending .startHit,
  #startOverlay.game-assets-error .startHit{
    display:grid; place-items:center; background:#8dcc3d; border:3px solid rgba(255,255,255,.8);
    color:#fff; font:700 clamp(24px,8vw,40px)/1 var(--font-display); text-transform:uppercase;
    box-shadow:0 8px 24px rgba(0,0,0,.35);
  }
  #startOverlay.game-assets-pending .startHit span,
  #startOverlay.game-assets-error .startHit span{ opacity:1; font-size:inherit; color:inherit; }
  #startOverlay.game-assets-pending .startHit img,
  #startOverlay.game-assets-error .startHit img{ opacity:0; }
  #startOverlay.game-assets-pending .startHit{ cursor:wait; opacity:.92; }
  .start-help-btn{ z-index:5; right:10px; top:max(10px,env(safe-area-inset-top)); font-family:var(--font-button); font-size:21px; }
  /* Second corner button, left of the ? -- same 44px target as .iconBtn so it
     clears the tap-size floor, and it stays on the start screen only: mid-round
     is the wrong moment to offer a language change. */
  .lang-toggle{
    z-index:5; right:62px; top:max(10px,env(safe-area-inset-top));
    font-family:var(--font-button); font-size:15px; font-weight:700; letter-spacing:.04em;
  }
  .lang-toggle span{ display:block; }
  /* Start and Leaderboard are both baked-art buttons now (see docs/HANDOFF.md):
     an invisible hitbox sized to the button graphic's own aspect ratio, with
     hidden text for assistive tech. They flow normally in .start-shell,
     stacked under the cover art and tagline. */
  .startHit,.leaderboardHit{
    position:relative; width:58.5%; margin-top:14px;
    border:0; border-radius:999px; padding:0; overflow:hidden; background:transparent; cursor:pointer;
    transition:filter var(--dur-fast) var(--ease-out),transform var(--dur-fast) var(--ease-out),opacity var(--dur-fast) var(--ease-out);
  }
  .startHit{ aspect-ratio:1015 / 266; margin-top:0; }
  .leaderboardHit{ aspect-ratio:1015 / 248; }
  .startHit img,.leaderboardHit img{ position:absolute; inset:0; width:100%; height:100%; object-fit:fill; transition:opacity var(--dur-fast) var(--ease-out); }
  .startHit span,.leaderboardHit span{ position:relative; z-index:1; opacity:0; font-size:0; color:transparent; }
  /* Text variants: the START and LEADERBOARD art carried baked English, so the
     two flat coloured plates are drawn in CSS and the word is live text. Same
     footprint and aspect ratio as the art they replace, so .start-shell's
     layout is untouched. */
  .startHit--text,.leaderboardHit--text{
    display:grid; place-items:center; overflow:visible;
    border:3px solid rgba(255,255,255,.82);
    box-shadow:0 8px 24px rgba(0,0,0,.35);
  }
  .startHit--text{ background:#8dcc3d; }
  .leaderboardHit--text{ background:#f2941f; }
  .startHit--text span,.leaderboardHit--text span{
    opacity:1; color:#fff; font-family:var(--font-display); font-weight:400;
    text-transform:uppercase; letter-spacing:.03em; line-height:1;
    text-shadow:0 2px 6px rgba(0,0,0,.28);
  }
  .startHit--text span{ font-size:clamp(24px,8vw,40px); }
  .leaderboardHit--text span{ font-size:clamp(19px,6.4vw,32px); }
  :lang(zh) .startHit--text span,
  :lang(zh) .leaderboardHit--text span{ font-family:var(--font-body); font-weight:800; letter-spacing:.06em; }
  .startHit:focus-visible,.leaderboardHit:focus-visible{ outline:3px solid var(--color-focus); outline-offset:3px; }
  .startHit:active,.leaderboardHit:active{ transform:translateY(1px) scale(.99); }
  .startHit:disabled,.leaderboardHit:disabled{ opacity:.55; cursor:not-allowed; }
  @media (hover:hover) and (pointer:fine){
    .startHit:hover,.leaderboardHit:hover{ filter:brightness(1.08) drop-shadow(0 8px 13px color-mix(in oklch,var(--color-result-green) 30%,transparent)); animation:buttonVibrate .22s linear 1; }
  }
  .start-copyright{
    position:absolute; left:50%; bottom:max(4px,env(safe-area-inset-bottom)); z-index:3;
    transform:translateX(-50%); color:color-mix(in oklch,var(--color-paper) 72%,transparent);
    font:600 10px/1.2 var(--font-body); letter-spacing:.035em; white-space:nowrap;
  }

  /* Baked-in button hitboxes, calibrated to the art (see docs/HANDOFF.md).
     Invisible — the real button graphic is part of the PNG; these are just
     the clickable regions until cutout art exists. Positioned relative to
     .cover-frame, which matches the art's own pixel proportions exactly. */
  /* The button graphic is baked into the cover PNG, so these carry no art of
     their own — but they still need to react. Without this the Start button
     had no hover, no press, and no visible keyboard focus at all. Label text
     is present for assistive tech and clipped visually. */
  .hiddenBtn{
    position:absolute; z-index:2;
    background:transparent; border:none; padding:0; margin:0; cursor:pointer;
    border-radius:999px;
    font-size:0; color:transparent; overflow:hidden;
    -webkit-appearance:none; appearance:none;
    transition:background-color .15s ease, transform .12s cubic-bezier(0,0,.2,1);
  }
  .hiddenBtn span{ font-size:0; color:transparent; }
  /* Hover only where hovering is real — on touch this would stick after a tap. */
  @media (hover:hover) and (pointer:fine){
    .hiddenBtn:hover{ background:transparent; box-shadow:none; animation:none; }
  }
  .hiddenBtn:active{ transform:scale(.97); background:rgba(0,0,0,.14); }
  .hiddenBtn:focus-visible{
    outline:3px solid var(--color-focus);
    outline-offset:3px;
    background:rgba(255,255,255,.14);
  }
  #stage.eclipse-rumble{ animation:stageEclipseRumble .52s linear both; }
  #stage.eclipse-hit-rumble{ animation:stageEclipseHitRumble .18s linear both; }
  #stage.eclipse-final-rumble{ animation:stageEclipseFinalRumble .42s linear both; }
  @keyframes stageEclipseRumble{
    0%,100%{transform:translate3d(0,0,0)} 12%{transform:translate3d(-3px,1px,0)} 24%{transform:translate3d(3px,-2px,0)}
    38%{transform:translate3d(-2px,2px,0)} 54%{transform:translate3d(2px,-1px,0)} 72%{transform:translate3d(-1px,1px,0)} 88%{transform:translate3d(1px,0,0)}
  }
  @keyframes stageEclipseHitRumble{ 0%,100%{transform:translate3d(0,0,0)} 35%{transform:translate3d(-1.5px,1px,0)} 70%{transform:translate3d(1.5px,-1px,0)} }
  @keyframes stageEclipseFinalRumble{ 0%,100%{transform:translate3d(0,0,0)} 25%{transform:translate3d(-2px,0,0)} 50%{transform:translate3d(2px,1px,0)} 75%{transform:translate3d(-1px,-1px,0)} }
  #endOverlay{
    height:100%; max-height:100dvh; overflow:hidden; align-items:flex-start;
    padding:max(10px, env(safe-area-inset-top)) 10px max(10px, env(safe-area-inset-bottom));
    background:
      linear-gradient(color-mix(in oklch, var(--color-ink) 68%, transparent), color-mix(in oklch, var(--color-ink) 88%, transparent)),
      url('../assets/images/bg-front.jpg') center top / cover;
  }
  .result-card{
    position:relative; width:min(100%,420px); max-height:100%; overflow:auto;
    /* Auto block margins rather than the container's align-items, so the card
       centres when there's spare height but collapses to 0 (never negative)
       when it fills the viewport — plain `align-items:center` would clip the
       card's top out of reach once it overflows. #endOverlay keeps
       align-items:flex-start for exactly that reason; this overrides it. */
    margin-block:auto;
    padding:clamp(22px,4.5vh,34px) clamp(18px,5vw,30px) clamp(22px,4.2vh,32px);
    border:clamp(9px,2.4vw,13px) solid var(--color-result-frame);
    border-radius:clamp(32px,8vw,46px);
    background:
      radial-gradient(circle at 50% 14%,var(--color-result-white),transparent 45%),
      linear-gradient(145deg,var(--color-result-paper),color-mix(in oklch,var(--color-result-paper) 86%,var(--color-result-gold)));
    color:var(--color-result-ink); text-align:center;
    box-shadow:inset 0 0 0 3px var(--color-result-gold),inset 0 0 0 7px var(--color-result-paper),var(--shadow-result);
    scrollbar-width:none;
  }
  @supports(height:100dvh){
    .result-card{ max-height:calc(100dvh - max(10px, env(safe-area-inset-top)) - max(10px, env(safe-area-inset-bottom)) - 16px); }
  }
  .result-card::-webkit-scrollbar{ display:none; }
  .result-card::before{ display:none; }
  .result-card > *{ position:relative; z-index:1; }
  .result-card > .result-close{ position:absolute; z-index:6; top:var(--space-xs); right:var(--space-xs); }
  .result-close:active{ transform:translateY(1px); }
  .result-close:disabled{ opacity:.55; cursor:not-allowed; }
  .result-kicker{ margin:0; color:var(--color-result-red); font:700 clamp(16px,4.55vw,21px)/1 var(--font-numeric); letter-spacing:.08em; text-transform:uppercase; }
  .result-score-stage{ position:relative; width:min(95%,266px); margin:10px auto 0; }
  /* Grade | high score | best streak. The grade keeps its stamped-on tilt, but
     it now occupies a column instead of floating over one, so nothing it
     overlaps has to be guessed at. */
  .result-summary{
    display:grid; grid-template-columns:auto minmax(0,1fr);
    align-items:center; column-gap:2px; margin-top:-2px;
  }
  .result-summary .result-stats{ transform:none; }
  .result-tier{
    z-index:3; display:inline-flex; align-items:center;
    color:var(--color-competition-orange);
    font-family:var(--font-numeric); text-transform:uppercase; pointer-events:none;
    opacity:0; transform:rotate(-14deg) scale(1.42);
  }
  .result-tier.show{ animation:resultTierStamp 280ms var(--ease-out) both; }
  @keyframes resultTierStamp{
    0%{ opacity:0; transform:rotate(-14deg) scale(1.42); }
    72%{ opacity:1; transform:rotate(-9deg) scale(.94); }
    100%{ opacity:1; transform:rotate(-9deg) scale(1); }
  }
  .tier-badge{ position:relative; display:inline-grid; flex:0 0 auto; place-items:center; aspect-ratio:1; }
  .tier-badge img{ grid-area:1/1; width:100%; height:100%; object-fit:contain; filter:drop-shadow(0 2px 3px color-mix(in oklch,var(--color-ink) 24%,transparent)); }
  .tier-badge b{ grid-area:1/1; z-index:3; color:var(--color-result-white); font-family:var(--font-numeric); font-weight:700; line-height:1; text-shadow:0 1px 2px var(--color-ink),0 0 3px var(--color-ink); }
  .tier-badge--result{ width:clamp(57px,16.15vw,70px); }
  .tier-badge--result b{ font-size:clamp(21px,5.9vw,27px); }
  .tier-badge-glare{
    position:absolute; inset:0; z-index:2; overflow:hidden; pointer-events:none;
    -webkit-mask:var(--tier-badge-mask) center/contain no-repeat;
    mask:var(--tier-badge-mask) center/contain no-repeat;
  }
  .tier-badge-glare::after{
    content:""; position:absolute; top:-20%; bottom:-20%; left:35%; width:24%;
    background:linear-gradient(90deg,transparent,color-mix(in oklch,var(--color-result-white) 82%,transparent),transparent);
    opacity:.82; transform:translateX(-360%) rotate(16deg);
    animation:tierBadgeGlare 1.8s var(--ease-in-out) .45s infinite;
  }
  @keyframes tierBadgeGlare{ 0%,35%{transform:translateX(-360%) rotate(16deg)} 70%,100%{transform:translateX(360%) rotate(16deg)} }
  .result-score{ margin:0; color:var(--color-result-ink); font-family:var(--font-numeric); line-height:.86; }
  .result-score span{ font-size:clamp(55px,16.15vw,80px); font-weight:700; letter-spacing:-.04em; }
  .result-score small{ display:block; margin-top:12px; font:700 clamp(12px,3.4vw,16px)/1 var(--font-numeric); letter-spacing:.12em; text-transform:uppercase; }
  .result-competitive{ min-height:1em; margin:var(--space-2xs) 0 5px; color:var(--color-competition-orange); font:700 clamp(8px,2.25vw,10px)/1.2 var(--font-numeric); letter-spacing:.035em; text-transform:uppercase; }
  .result-competitive:empty{ display:none; }
  .result-competitive.is-streak{ animation:resultNoticePulse 1.45s ease-in-out infinite; }
  @keyframes resultNoticePulse{ 0%,100%{opacity:.58;filter:brightness(.9)} 50%{opacity:1;filter:brightness(1.32)} }
  .result-message{ margin:0; }
  .result-divider{ position:relative; display:flex; align-items:center; gap:9px; margin:0 0 5px; color:var(--color-result-red); }
  .result-divider::before,.result-divider::after{ content:""; flex:1; height:1px; background:var(--color-result-line); }
  .result-divider span{ font-size:13px; }
  .result-stats{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); margin:0; transform:translateY(-5px); }
  .result-stats div{ min-width:0; padding:0 5%; }
  .result-stats div+div{ border-left:1px solid var(--color-result-line); }
  .result-stats dt{ color:var(--color-result-green); font:700 clamp(9px,2.65vw,11px)/1 var(--font-numeric); letter-spacing:.04em; text-transform:uppercase; }
  .result-stats dd{ margin:var(--space-3xs) 0 0; color:var(--color-result-ink); font:700 clamp(24px,6.65vw,32px)/1 var(--font-numeric); }
  /* Zero, and that is a deliberate 8px gain rather than a rounding. The
     composite overflows its own box upward (it is taller than the box and
     anchored bottom:-23px), so Chang'e's hair already sits 8px under the stats
     row -- the margin on top of that was pure dead band, and anything more
     negative puts her hair through the "1240". Everything below rides up with
     it, which is where the room was wanted. */
  .result-illustration{ position:relative; z-index:2; height:clamp(99px,14.25vh,127px); margin-top:0; overflow:visible; pointer-events:none; }
  .result-character-composite{
    position:absolute; left:0; bottom:-23px; display:block; width:100%; height:auto; object-fit:contain;
    opacity:0; transform:translateY(6px); transition:opacity var(--dur-short) var(--ease-out),transform var(--dur-short) var(--ease-out);
  }
  .result-character-composite.show{ opacity:1; transform:translateY(0); }
  .result-actions{ z-index:3; display:grid; gap:10px; margin-top:0; padding-inline:var(--space-xs); }
  /* Side by side. Stacked, these two read as a queue of three equal choices
     under Play Again; paired, they read as the two side doors they are. The
     labels wrap rather than shrink -- "What Mooncake Are You?" at half width
     would otherwise fit only by dropping below the leaderboard button's size,
     and the two must look like siblings. */
  .result-secondary-actions{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:var(--space-2xs); margin-top:0; align-items:stretch; }
  .result-secondary-actions:has(.gameBtn[hidden]){ grid-template-columns:minmax(0,1fr); }
  .result-secondary-actions .gameBtn{
    min-height:56px; color:var(--color-ink);
    white-space:normal; line-height:1.06; text-wrap:balance;
  }
  .result-secondary-actions .gameBtn[hidden],.result-card > .gameBtn[hidden]{ display:none; }
  .result-pending-note{
    display:block; min-height:1.2em; margin-top:calc(var(--space-3xs) * -1);
    color:var(--color-competition-orange); font:700 10px/1.2 var(--font-numeric);
    letter-spacing:.08em; text-transform:uppercase;
  }
  .gameBtn--judge:disabled{
    opacity:.58; cursor:not-allowed; filter:saturate(.6);
  }
  .result-name-form{ min-height:118px; margin:var(--space-sm) var(--space-xs) 0; padding:14px var(--space-xs) var(--space-2xs); border:0; border-radius:14px; background:color-mix(in oklch,var(--color-result-white) 38%,transparent); }
  .result-name-form[hidden]{ display:none; }
  .result-name-form label{ display:block; color:var(--color-result-green); font:700 12px/1 var(--font-numeric); letter-spacing:.06em; text-transform:uppercase; }
  .result-name-form p{ margin:var(--space-3xs) 0 var(--space-2xs); color:var(--color-ink-2); font:600 9px/1.3 var(--font-body); }
  .result-name-form > div{ display:grid; grid-template-columns:minmax(0,1fr) auto; gap:var(--space-2xs); transform:translateY(10px); }
  /* Name and Save on one row. The third field only exists on the "that name is
     taken" branch, so the two-column layout is the normal one and the
     three-column layout is the exception -- the reverse of how this read when
     everyone had to invent a PIN. */
  .result-name-form > .result-name-row{ grid-template-columns:minmax(0,1fr) auto; transform:none; margin-top:var(--space-3xs); }
  /* Three fields on one 300px row leaves the name 59px wide, which is not
     enough to show the name the player is being asked to confirm. On this
     branch the name takes the full first row and the code + button share the
     second. */
  .result-name-form > .result-name-row:has(#resultPlayerPin:not([hidden])){
    grid-template-columns:78px minmax(0,1fr); row-gap:var(--space-2xs);
  }
  .result-name-form > .result-name-row:has(#resultPlayerPin:not([hidden])) #resultPlayerName{
    grid-column:1 / -1;
  }
  .result-name-form #resultPlayerPin[hidden]{ display:none; }
  .result-name-form > .result-name-row #resultPlayerPin{ text-align:center; letter-spacing:.22em; padding-inline:4px; }
  .result-name-form > .result-name-row .gameBtn--primary{ padding-inline:var(--space-sm); font-size:clamp(15px,4.2vw,18px); }
  /* Guest is an answer, not an escape hatch: it sits under the field it opts
     out of, styled as the quieter of two real choices. */
  .result-guest-link{
    display:block; width:100%; margin-top:2px; padding:6px 0;
    border:0; background:none; color:var(--color-ink-2); cursor:pointer;
    font:700 11px/1 var(--font-numeric); letter-spacing:.06em; text-transform:uppercase;
    text-decoration:underline; text-underline-offset:3px;
  }
  .result-guest-link:hover{ color:var(--color-result-ink); }
  .result-guest-link:focus-visible{ outline:3px solid var(--color-focus); outline-offset:2px; border-radius:8px; }
  /* Shown once, after the browser has minted a recovery code the player never
     had to think up. It replaces the form, so it costs no height of its own. */
  .result-recovery-note,.player-recovery-note{
    margin:var(--space-2xs) var(--space-xs) 0; padding:9px 12px;
    border:1px dashed var(--color-competition-rule); border-radius:12px;
    color:var(--color-result-green); font:700 11px/1.4 var(--font-body);
    text-align:center; word-break:break-word;
  }
  .result-recovery-note[hidden],.player-recovery-note[hidden]{ display:none; }
  .result-name-form input{ min-width:0; height:44px; padding:0 var(--space-2xs); border:1px solid var(--color-competition-rule); border-radius:12px; outline:2px solid transparent; background:var(--color-result-white); color:var(--color-result-ink); font:700 15px/1 var(--font-numeric); text-transform:uppercase; }
  .result-name-form input::placeholder{ text-transform:none; }
  .result-name-form input:focus-visible{ outline-color:var(--color-focus); outline-offset:1px; }
  .result-name-form .gameBtn{ min-height:44px; padding-inline:var(--space-xs); font-size:15px; }
  /* No reserved height. This slot is empty on every successful save, and an
     always-on 1.3em box is what pushed "Play as guest" away from the button it
     opts out of. An error appearing shifts the link down by its own height,
     which is the correct time for the form to change size. */
  .result-name-form output{ display:block; margin-top:var(--space-3xs); color:var(--color-error); font:700 10px/1.3 var(--font-body); }
  .result-name-form output:empty{ display:none; }
  .gameBtn{
    min-height:55px; border:2px solid transparent; border-radius:var(--radius-pill); padding:0 var(--space-md);
    font:800 16px/1 var(--font-button); white-space:nowrap; cursor:pointer;
    transition:transform var(--dur-fast) var(--ease-out), background-color var(--dur-short) var(--ease-out), color var(--dur-short) var(--ease-out), border-color var(--dur-short) var(--ease-out), box-shadow var(--dur-short) var(--ease-out);
  }
  .gameBtn--primary{ background:linear-gradient(180deg,color-mix(in oklch,var(--color-result-red) 82%,var(--color-result-white)),var(--color-result-red)); color:var(--color-result-white); border-color:color-mix(in oklch,var(--color-result-white) 82%,var(--color-result-red)); box-shadow:0 8px 18px color-mix(in oklch,var(--color-result-red) 32%,transparent); }
  .gameBtn--primary{ font-family:var(--font-button); font-size:clamp(20px,5.7vw,28px); font-weight:700; letter-spacing:.075em; text-transform:uppercase; }
  .gameBtn--secondary{ display:flex; align-items:center; justify-content:center; background:transparent; color:var(--color-ink); border-color:transparent; font-family:var(--font-button); font-size:22px; text-transform:uppercase; }
  .result-secondary-actions .gameBtn{ min-width:0; padding-inline:var(--space-xs); font-size:clamp(15px,4vw,18px); }
  /* A rainbow button in the mooncake palette: a gradient that slides behind the
     button's own opaque face, so only the 2px it overhangs reads as a moving
     ring, plus a blurred copy beneath it for the lamp glow. Literal rainbow
     hues were the reference; gold-to-chilli-to-jade keeps the effect and drops
     the blue-violet half that has no business on this card.
     The slide is a background-position animation on a single element rather
     than a rotating layer, so nothing needs clipping and the glow can escape
     the button's box. */
  .gameBtn--judge{
    --rb: linear-gradient(90deg,#f5cf5e,#f2b134,#ec5a08,#c8260e,#1a6b4a,#f2b134,#f5cf5e);
    position:relative; overflow:visible; border:2px solid transparent;
    background:
      linear-gradient(var(--color-result-paper),var(--color-result-paper)) padding-box,
      var(--rb) border-box;
    background-size:100% 100%,280% 100%;
    animation:rainbowSlide 3.2s linear infinite;
    font-size:clamp(12px,3.5vw,16px) !important;
  }
  /* The lamp glow. z-index:-1 works here only because the button is positioned
     but does NOT isolate: the layer drops out to .result-actions' stacking
     context and lands behind the button's own face. Adding isolation:isolate
     would pull it back in front and flood the button. */
  .gameBtn--judge::after{
    content:""; position:absolute; z-index:-1; inset:16% 5% -7px;
    border-radius:inherit; pointer-events:none;
    background:var(--rb); background-size:280% 100%;
    filter:blur(10px); opacity:.5;
    animation:rainbowSlide 3.2s linear infinite;
  }
  .gameBtn--judge span{ position:relative; z-index:1; }
  @keyframes rainbowSlide{ to{ background-position:-280% 0,-280% 0; } }
  /* Amber fill, dark ink. The reference sets white type on this button, but at
     the size it runs here that is 2:1 against the amber -- below the threshold
     for any text, large or not. Ink keeps the warm/white pairing the reference
     is actually after and stays readable. */
  /* Flat #f7941d, and the border is painted the same colour rather than removed:
     .gameBtn sets a border width every button shares, so dropping it here would
     shrink this one by 2px against its sibling and the pair would stop matching.
     Type stays dark ink -- white on #f7941d measures 2.16:1, which no size of
     text passes at. Ink on the same orange is 6.9:1. */
  .gameBtn--leaderboard{
    background:#f7941d;
    border-color:#f7941d;
    color:var(--color-result-ink);
    box-shadow:0 6px 14px color-mix(in oklch,var(--color-ink) 16%,transparent);
  }
  /* Two lines by markup (see setLeaderboardLabel) -- "Leaderboard" is one word,
     so there is no wrap opportunity for CSS to take. .gameBtn is display:flex,
     so the spans need the column direction or they sit side by side and the
     split does nothing. */
  .gameBtn--leaderboard{ flex-direction:column; }
  .gameBtn--leaderboard span{ display:block; }
  @media (hover:hover) and (pointer:fine){
    .gameBtn:hover{ animation:buttonVibrate .22s linear 1; }
    .gameBtn--primary:hover{ background:var(--color-result-red-hover); box-shadow:0 11px 22px color-mix(in oklch,var(--color-result-red) 38%,transparent); }
    .gameBtn--secondary:hover{ background:color-mix(in oklch,var(--color-result-white) 62%,transparent); border-color:var(--color-result-gold); box-shadow:0 8px 18px color-mix(in oklch,var(--color-ink) 14%,transparent); }
    .gameBtn--leaderboard:hover{ background:color-mix(in srgb,#f7941d 86%,#fff); border-color:color-mix(in srgb,#f7941d 86%,#fff); }
  }
  .gameBtn:active{ transform:translateY(1px) scale(.99); }
  .gameBtn:focus-visible{ outline:3px solid var(--color-focus); outline-offset:3px; }
  .gameBtn:disabled{ opacity:.55; cursor:not-allowed; }
  .gameBtn[data-state="loading"]::after{ content:'…'; margin-left:.3em; }
  .gameBtn[data-state="success"]{ background:var(--color-success); color:var(--color-ink); }
  .gameBtn[data-state="error"]{ border-color:var(--color-error); }

  /* Mooncake personality interstitial: a brief judgement ritual over the
     existing result card, not a second card competing with it. */
  .judging-overlay{
    position:absolute; inset:0; z-index:90; display:grid; place-items:center;
    padding:max(18px,env(safe-area-inset-top)) 16px max(18px,env(safe-area-inset-bottom));
    overflow:hidden; background:rgba(5,3,7,.72);
    -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);
    opacity:0; transition:opacity 180ms ease;
  }
  .judging-overlay[hidden]{ display:none; }
  .judging-overlay.show{ opacity:1; }
  .judging-content{
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    width:min(92vw,390px); color:#fff4db; text-align:center;
    transform:translateY(7px); opacity:0;
    transition:transform 220ms var(--ease-out),opacity 180ms ease;
  }
  .judging-overlay.show .judging-content{ transform:translateY(0); opacity:1; }
  .judging-content h2{
    position:relative; z-index:3; margin:0 0 4px;
    color:#fff4db; font:700 clamp(22px,6.2vw,30px)/1.12 var(--font-numeric);
    letter-spacing:.04em; text-transform:uppercase;
    text-shadow:0 2px 5px rgba(0,0,0,.9),0 0 20px rgba(244,183,78,.3);
  }
  /* The shared footprint Chang'e and the rabbit both sit in. Only her own
     wrap trembles inside this box, so the rabbit's position here is the
     box the tremble used to apply to before it moved one level down. */
  .judging-visual{
    position:relative; width:100%; height:clamp(248px,64vw,310px); margin:-10px auto 0;
  }
  .judging-change-wrap{ position:absolute; inset:0; }
  .judging-change{
    position:absolute; display:block; object-fit:contain; pointer-events:none;
    z-index:1; left:50%; bottom:0; width:72%; height:100%;
    transform:translate(-50%,12px) scale(.98); opacity:0;
  }
  /* Fixed to the visual box's bottom-left, in front of Chang'e, and no longer
     inside her trembling wrap -- it now just watches her act crazy instead of
     shaking along with her. Sitting centred underneath, the rabbit read as a
     second, separate illustration and cost the panel ~145px of height for a
     character that is meant to be an aside; overlapping her keeps the pair
     reading as one picture. */
  .judging-rabbit{
    position:absolute; z-index:2; left:0; bottom:calc(-5% - 50px);
    display:block; width:62%; height:auto; max-height:none;
    object-fit:contain; pointer-events:none;
    transform:translate(-12px,10px) scale(.94); opacity:0;
    filter:drop-shadow(0 6px 12px rgba(0,0,0,.5));
  }
  .judging-overlay.show .judging-change{ animation:judgingChangeIn 240ms var(--ease-out) 50ms both; }
  .judging-overlay.show .judging-change-wrap{ animation:judgingTremble .72s steps(2,end) 320ms infinite; }
  .judging-overlay.show .judging-rabbit{ animation:judgingRabbitIn 210ms var(--ease-out) 140ms both; }
  .judging-status{
    /* +10% of the panel's own width (min(92vw,390px)), added on top of the
       original 2px gap -- the panel has no fixed height to take 10% of, so
       this is the stable axis to scale the nudge against. */
    position:relative; z-index:3; min-height:2.6em; width:100%; margin:calc(2px + clamp(18px,9.2vw,39px)) 0 0;
    display:grid; place-items:center;
    color:#f6c761; font:700 clamp(14px,4.1vw,18px)/1.3 var(--font-numeric);
    letter-spacing:.055em; text-transform:uppercase;
    text-shadow:0 2px 4px rgba(0,0,0,.9);
  }
  .judging-status.swap{ animation:judgingCopySwap 180ms ease both; }
  @keyframes judgingChangeIn{ to{ opacity:1; transform:translate(-50%,0) scale(1); } }
  @keyframes judgingTremble{
    0%,100%{ transform:translate3d(0,0,0) rotate(0); }
    25%{ transform:translate3d(-1px,0,0) rotate(-.18deg); }
    50%{ transform:translate3d(1px,-1px,0) rotate(.18deg); }
    75%{ transform:translate3d(0,1px,0) rotate(-.1deg); }
  }
  @keyframes judgingRabbitIn{ to{ opacity:1; transform:translate(0,0) scale(1); } }
  @keyframes judgingCopySwap{ 0%{ opacity:0; transform:translateY(3px); } 100%{ opacity:1; transform:none; } }

  .leaderboard-overlay{
    position:absolute; inset:0; z-index:62; display:grid; place-items:center;
    padding:var(--space-sm); background:color-mix(in oklch,var(--color-ink) 78%,transparent);
  }
  .leaderboard-overlay[hidden]{ display:none; }
  .leaderboard-card{
    position:relative; width:min(100%,400px); max-height:calc(100% - 20px); overflow:auto;
    padding:var(--space-lg) var(--space-md) var(--space-xs); border:8px solid var(--color-result-frame); border-radius:30px;
    background:var(--color-result-paper); color:var(--color-result-ink);
    box-shadow:inset 0 0 0 3px var(--color-result-gold),var(--shadow-result);
  }
  .leaderboard-card:has(.leaderboard-list:not([hidden])){ overflow:hidden; }
  /* The title is centred across the FULL card width while the close button
     sits absolute top-right, so nothing reserves horizontal room for it -- a
     long title ("SAVE YOUR SCORE") could run straight under the ×. Clearing
     it vertically instead (title's row starts below the ×'s own band) fixes
     that regardless of how wide any given title text is. 28px is measured
     clearance against the ×'s fixed 36px circle, not a guess -- see the
     matching (max-height:740px) override below, which is what actually
     governs on real phone heights and needed the same fix independently. */
  .leaderboard-card h2{ width:100%; min-width:0; margin:calc(var(--space-2xs) + 28px) 0 6px; overflow-wrap:anywhere; text-align:center; font:700 clamp(34px,10vw,44px)/.9 var(--font-numeric); letter-spacing:.025em; text-transform:uppercase; white-space:nowrap; }
  /* Segmented snap rail: two equal flex:1 cells hold the pill's resting
     column count (--n) fixed. The pill is a single pseudo-element sized to
     a column (never to the label text) and translated by whole columns.
     --i is 0/1 for the persisted selection; hovering a cell previews that
     cell's column via :has(), then relaxes back to the real selection. */
  .leaderboard-tabs{
    --n:2; --i:0; position:relative; display:flex; gap:0; padding:3px; margin:0 0 8px;
    border:1px solid var(--color-result-gold); border-radius:14px;
    background:color-mix(in oklch,var(--color-result-white) 40%,transparent);
  }
  /* display:flex above has the same specificity as the UA [hidden]{display:none}
     default, and author styles always win that tie — so hiding these via the
     `hidden` attribute needs an explicit rule here. */
  .leaderboard-tabs[hidden]{ display:none; }
  .leaderboard-tabs::before{
    content:''; position:absolute; top:3px; left:3px;
    width:calc((100% - 6px) / var(--n)); height:calc(100% - 6px);
    border-radius:11px; background:var(--color-result-red);
    transform:translateX(calc(var(--i) * 100%));
    transition:transform var(--dur-short) var(--ease-out);
  }
  .leaderboard-tabs:has(.leaderboard-tab:nth-child(2)[aria-selected="true"]){ --i:1; }
  .leaderboard-tabs:has(.leaderboard-tab:nth-child(1):hover){ --i:0; }
  .leaderboard-tabs:has(.leaderboard-tab:nth-child(2):hover){ --i:1; }
  /* Weekly is hidden, so the row carries a single track: the pill spans the full
     width and never slides. The :nth-child(1) is load-bearing, not decoration --
     it matches the specificity of the [aria-selected] rule above so this later
     rule wins the cascade and cancels its --i:1 shift. Keyed off [hidden] rather
     than hard-coded, so unhiding Weekly restores the two-cell slider. */
  .leaderboard-tabs:has(.leaderboard-tab:nth-child(1)[hidden]){ --n:1; --i:0; }
  /* The reset countdown belongs to the weekly track only. game.js already hides
     it for all-time, but not until leaderboard data lands, so it would flash
     "RESETS IN --" on every open. With weekly hidden there is no track to switch
     back to, so nothing needs the reserved height either. */
  .leaderboard-card:has(.leaderboard-tab:nth-child(1)[hidden]) .leaderboard-reset-note{ display:none; }
  .leaderboard-tab{
    position:relative; z-index:1; flex:1; padding:11px 4px; border:0; border-radius:11px; background:transparent;
    color:var(--color-ink-2); font:700 13px/1 var(--font-button); letter-spacing:.04em; text-transform:uppercase;
    text-align:center; cursor:pointer; transition:color .18s ease;
  }
  .leaderboard-tab[aria-selected="true"]{ color:var(--color-result-white); }
  .leaderboard-reset-note{ margin:6px 0 0; text-align:center; color:var(--color-ink-2); font:700 11px/1.2 var(--font-numeric); letter-spacing:.03em; text-transform:uppercase; }
  .leaderboard-reset-note[hidden]{ display:none; }
  .leaderboard-reset-note b{ color:var(--color-result-red); }
  /* Belt-and-braces, not just relying on game.js calling setLeaderboardListHidden
     before it shows the identity gate: ranking chrome (Weekly/All-Time, the
     reset countdown) is meaningless while the player is naming themselves or
     unlocking an account, so a CSS rule guarantees it stays gone whenever
     #identityGate is the thing on screen, independent of any future JS path. */
  .leaderboard-card:has(#identityGate:not([hidden])) .leaderboard-tabs,
  .leaderboard-card:has(#identityGate:not([hidden])) .leaderboard-reset-note{ display:none; }
  .leaderboard-status{ min-height:1.3em; margin:4px 0 var(--space-2xs); text-align:center; color:var(--color-competition-orange); font:700 13px/1.3 var(--font-numeric); }
  .leaderboard-status:empty{ display:none; }
  .leaderboard-list{ margin:0; padding:0; list-style:none; border-block:1px solid var(--color-result-line); }
  .leaderboard-list li{ display:grid; grid-template-columns:25px minmax(0,1fr) 84px; gap:var(--space-3xs); align-items:center; min-height:48px; padding:var(--space-2xs) var(--space-2xs) var(--space-2xs) 3px; border-bottom:1px solid color-mix(in oklch,var(--color-result-line) 25%,transparent); font-family:var(--font-numeric); font-variant-numeric:tabular-nums; }
  .leaderboard-list li:last-child{ border-bottom:0; }
  /* An unclaimed rank. Deliberately quiet -- it is holding a place, not
     competing with the names above it -- but still a row, so the list is the
     same height whichever track is showing. */
  .leaderboard-list li.is-open{ grid-template-columns:25px 1fr; color:var(--color-ink-2); opacity:.55; }
  .leaderboard-open-label{ font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
  .leaderboard-list li.rank-1{ background:var(--color-rank-gold-paper); color:var(--color-result-ink); box-shadow:inset 0 0 0 1px var(--color-rank-gold); }
  .leaderboard-list li.rank-2{ background:var(--color-rank-silver-paper); color:var(--color-result-ink); }
  .leaderboard-list li.rank-3{ background:var(--color-rank-copper-paper); color:var(--color-result-ink); }
  .leaderboard-list li.is-current{ position:relative; outline:2px solid var(--color-result-red); outline-offset:-2px; background-color:color-mix(in oklch,var(--color-result-red) 7%,var(--color-result-paper)); }
  .leaderboard-list li.rank-1 .leaderboard-rank{ color:var(--color-rank-gold); }
  .leaderboard-list li.rank-2 .leaderboard-rank{ color:var(--color-rank-silver); }
  .leaderboard-list li.rank-3 .leaderboard-rank{ color:var(--color-rank-copper); }
  .leaderboard-rank{ color:var(--color-ink-2); font-weight:700; }
  .leaderboard-identity,.leaderboard-result{ min-width:0; display:grid; align-content:center; }
  .leaderboard-name-line{ min-width:0; display:flex; align-items:center; gap:var(--space-3xs); }
  /* The place medal is a touch larger than the tier medallion beside it: it is
     the more important of the two facts, and the art carries a laurel that
     needs the extra couple of pixels to stay legible at row size. */
  .leaderboard-rank-medal{ flex:0 0 auto; width:24px; height:24px; object-fit:contain;
    filter:drop-shadow(0 1px 2px color-mix(in oklch,var(--color-ink) 30%,transparent)); }
  .leaderboard-tier-badge{ width:20px; }
  .leaderboard-tier-badge b{ display:none; }
  .leaderboard-name{ min-width:0; white-space:nowrap; color:var(--color-result-green); font-size:16px; font-weight:700; line-height:1.05; }
  .leaderboard-score{ color:var(--color-result-ink); font-size:16px; font-weight:700; line-height:1; text-align:right; }
  .leaderboard-streak{ margin-top:3px; color:var(--color-ink-2); font-size:10px; white-space:nowrap; text-align:right; text-transform:uppercase; }
  /* Three facts on one line: whose best, what rank, how many points. The
     streak reading, the "N pts to Top 10" nag and the progress bar that used to
     sit under them are gone -- see renderPlayerPosition(). What is left is 30px
     tall against the old 68px, and those 38px are exactly the row budget that
     lets the list show ten ranks instead of six on the shortest phone. */
  .player-position{ margin-top:var(--space-3xs); padding:6px var(--space-xs); border:1px solid var(--color-result-gold); border-radius:16px; background:color-mix(in oklch,var(--color-result-white) 58%,var(--color-result-paper)); color:var(--color-result-ink); font-family:var(--font-numeric); font-variant-numeric:tabular-nums; }
  .player-position[hidden]{ display:none; }
  .player-position__row{ display:flex; align-items:baseline; column-gap:var(--space-2xs); }
  .player-position__label{ color:var(--color-result-green); font-size:9px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
  .player-position__rank{ color:var(--color-result-red); font-size:20px; line-height:1; }
  .player-position__score{ margin-left:auto; color:var(--color-result-ink); font-size:16px; font-weight:700; line-height:1; }
  .player-position__score small{ margin-left:2px; font-size:9px; font-weight:700; letter-spacing:.06em; }
  .player-position[data-state="number-one"]{ border-color:var(--color-rank-gold); background:var(--color-rank-gold-paper); }
  .player-position[data-state="number-one"] .player-position__rank{ color:var(--color-rank-gold-ink,var(--color-result-ink)); }
  #endOverlay[data-competition-state="new-high-score"] .result-competitive,
  #endOverlay[data-competition-state="top10"] .result-competitive,
  #endOverlay[data-competition-state="number-one"] .result-competitive{ color:var(--color-rank-gold); font-weight:700; }
  #endOverlay[data-competition-state="new-best-streak"] .result-competitive{ color:var(--color-result-green); font-weight:700; }
  .player-account-bar{ margin-top:var(--space-xs); text-align:center; color:var(--color-result-green); font:700 12px/1.2 var(--font-numeric); }
  .player-account-bar strong{ font-size:14px; }
  .player-account-bar[hidden]{ display:none; }
  .identity-gate{ margin-top:14px; }
  .score-submit-form{ margin-top:12px; }
  .score-submit-form label{ display:block; margin-bottom:6px; color:var(--color-result-green); font:700 12px/1 var(--font-numeric); letter-spacing:.04em; text-transform:uppercase; text-align:center; }
  .score-submit-form > div{ display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; margin-bottom:10px; }
  .score-submit-form > div:last-child{ margin-bottom:0; }
  .score-submit-form input{ min-width:0; height:48px; border:2px solid var(--color-result-gold); border-radius:14px; padding:0 12px; background:var(--color-result-white); color:var(--color-result-ink); font:700 16px/1 var(--font-numeric); text-transform:uppercase; }
  .score-submit-form input:focus-visible{ outline:3px solid var(--color-focus); outline-offset:2px; }
  /* The PIN/password itself shouldn't be visually uppercased — that's purely
     a stylistic flourish for the name field and would misrepresent a
     case-sensitive password. */
  #playerCredential,#recoveryCodeInput{ text-transform:none; letter-spacing:.08em; }
  .score-submit-form .gameBtn{ min-height:48px; padding-inline:15px; font-size:14px; }
  .player-recover-form[hidden]{ display:none; }
  /* A 4-digit code, styled like one: full-width, centred, spaced-out digits,
     stacked above its own button rather than squeezed beside it.
     `.player-recover-form` prefix, not just `.recover-code-row`, deliberately:
     `.score-submit-form > div` (below) matches this same element with higher
     specificity (a class + a child combinator beats a lone class), so a
     same-weight override here would silently lose and the row would keep
     rendering as the old cramped [1fr auto] grid. */
  .player-recover-form .recover-code-row{ display:flex; flex-direction:column; gap:8px; margin-bottom:10px; }
  .player-recover-form .recover-code-row input{
    width:100%; text-align:center; font:800 22px/1 var(--font-numeric); letter-spacing:.4em;
    padding-right:calc(12px + .4em); /* letter-spacing adds a trailing gap only
      the LAST character has, which biases centred text left; this pads the
      right side back to match so four spaced digits sit dead centre */
  }
  .player-recover-form .recover-code-row .gameBtn{ width:100%; }
  /* Deliberately NOT another gameBtn pill: stacking two identical-looking
     ghost buttons ("Use a different name" over "Play as Guest") read as one
     confused block with no hierarchy. This is the minor escape hatch --
     Play as Guest stays the one real secondary button below it. */
  .player-text-link{
    display:block; width:100%; margin:2px 0 0; padding:8px; border:0; background:none;
    color:var(--color-ink-2); font:700 12px/1.2 var(--font-numeric); letter-spacing:.03em;
    text-transform:uppercase; cursor:pointer;
  }
  .player-text-link:focus-visible{ outline:2px solid var(--color-focus); outline-offset:2px; border-radius:4px; }

  #countdownNum{
    position:absolute; inset:0; z-index:45; display:flex; align-items:center; justify-content:center;
    font-family:var(--font-numeric); font-size:clamp(84px, 34vw, 150px); font-weight:700; color:var(--color-gold);
    text-shadow:0 0 40px rgba(240,200,96,.8);
    opacity:0; pointer-events:none;
  }
  #countdownNum.final-second{
    color:#ff3b45;
    text-shadow:0 0 12px rgba(255,30,55,.95),0 0 34px rgba(255,30,55,.72),0 4px 10px rgba(0,0,0,.75);
    animation:finalSecond .52s var(--ease-out) both;
  }
  @keyframes finalSecond{ 0%{transform:scale(.7);opacity:0} 22%{transform:scale(1.06);opacity:1} 100%{transform:scale(1);opacity:0} }

  #playHint{
    /* sits just above the resting mooncake at the spawn point (y = 95% of
       stage height), so it reads as pointing at the thing you swipe */
    position:absolute; bottom:20%; left:50%; transform:translate(-50%,0);
    width:min(72%, 320px); z-index:12; text-align:center;
    pointer-events:none;
    transition:opacity var(--dur-short) var(--ease-out);
  }
  #spawnHint{
    display:block; width:100%; height:auto;
    filter:drop-shadow(0 2px 6px rgba(0,0,0,.8));
    animation:pulseHint 1.8s ease-in-out infinite;
  }
  .keyboardHint{ display:block; margin-top:4px; color:var(--color-paper); font-size:12px; font-weight:800; text-shadow:0 2px 5px var(--color-ink); }
  /* swipe-hint.png has "SWIPE TO SHOOT!" drawn into its pixels, so the Chinese
     build was showing English over the court. Rather than commission a second
     PNG, Chinese swaps the image for type -- same string, same display face as
     the rest of the Chinese UI. This is a separate element from .keyboardHint
     above: that one is display:none on touch and becomes the Space-bar hint on
     desktop, so it cannot double as the primary hint. */
  #spawnHintZh{ display:none; }
  :lang(zh) #spawnHint{ display:none; }
  :lang(zh) #spawnHintZh{
    display:block; color:var(--color-paper);
    font-family:var(--font-display-zh); font-weight:900;
    font-size:clamp(26px,7.4vw,38px); line-height:1.15; letter-spacing:.06em;
    text-shadow:0 2px 6px rgba(0,0,0,.85),0 0 14px rgba(0,0,0,.6);
    animation:pulseHint 1.8s ease-in-out infinite;
  }
  :lang(zh) #playHint.is-hidden #spawnHintZh{ animation:none; }
  @keyframes pulseHint{ 0%,100%{opacity:.55;} 50%{opacity:1;} }
  /* Must cancel the animation, not just set opacity: a running animation
     outranks an inline style in the cascade, so the old
     `spawnHint.style.opacity='0'` never actually hid this. */
  #playHint.is-hidden{ opacity:0; }
  #playHint.is-hidden #spawnHint{ animation:none; }

  /* Feedback for the Share button on browsers without the Web Share API,
     where it previously appeared to do nothing. */
  #shareToast{
    position:absolute; left:50%; bottom:8%; transform:translate(-50%, 8px);
    z-index:60; pointer-events:none;
    max-width:80%; text-align:center;
    padding:10px 18px; border-radius:999px;
    background:rgba(18,12,10,.92); color:#fff;
    font-size:14px; line-height:1.3;
    box-shadow:0 6px 24px rgba(0,0,0,.45);
    opacity:0; transition:opacity .2s ease, transform .2s cubic-bezier(0,0,.2,1);
  }
  #shareToast.show{ opacity:1; transform:translate(-50%, 0); }

  .security-check{
    position:absolute; inset:0; z-index:100; display:grid; place-items:center; padding:24px;
    background:rgba(20,7,5,.72); backdrop-filter:blur(5px);
  }
  .security-check[hidden]{ display:none; }
  .security-check-card{
    width:min(100%,360px); padding:28px 22px; border:7px double var(--color-result-gold);
    border-radius:24px; background:var(--color-result-paper); color:var(--color-result-ink); text-align:center;
    box-shadow:var(--shadow-result);
  }
  .security-check-card h2{ margin:0 0 10px; font:700 25px/1.1 var(--font-numeric); text-transform:uppercase; }
  .security-check-card p{ margin:0 0 18px; color:var(--color-ink-2); font:700 13px/1.45 var(--font-body); }
  #turnstileMount{ min-height:65px; display:grid; place-items:center; }

  .asset-recovery-overlay{
    position:absolute; inset:0; z-index:120; display:grid; place-items:center; padding:24px;
    background:rgba(12,5,4,.62); backdrop-filter:blur(3px);
  }
  .asset-recovery-overlay[hidden]{ display:none; }
  .asset-recovery-card{
    width:min(100%,330px); padding:24px 22px; text-align:center;
    border:6px double var(--color-result-gold); border-radius:22px;
    background:var(--color-result-paper); color:var(--color-result-ink);
    box-shadow:var(--shadow-result);
  }
  .asset-recovery-card h2{ margin:12px 0 6px; font:700 20px/1.15 var(--font-numeric); text-transform:uppercase; }
  .asset-recovery-card p{ margin:0; color:var(--color-ink-2); font:700 13px/1.4 var(--font-body); }
  .asset-recovery-card .gameBtn{ width:100%; margin-top:16px; min-height:48px; }
  .asset-recovery-spinner{
    width:34px; height:34px; margin:auto; border:4px solid rgba(158,46,34,.18);
    border-top-color:var(--color-result-red); border-radius:50%; animation:assetRecoverySpin .75s linear infinite;
  }
  .asset-recovery-overlay.is-failed .asset-recovery-spinner{ display:none; }
  @keyframes assetRecoverySpin{ to{ transform:rotate(1turn); } }

  @media (pointer:coarse){ .keyboardHint{ display:none; } }
  @media (max-width:360px){
    #hud{ right:104px; padding-left:6px; }
    .iconBtn{ width:40px; min-width:40px; min-height:40px; }
    #helpBtn{ right:56px; }
    .result-card{ padding:18px 13px 16px; border-width:7px; border-radius:30px; }
    .result-illustration{ height:94px; }
    .result-name-form{ margin-inline:var(--space-xs); }
    .leaderboard-overlay{ padding:8px; }
    .leaderboard-card{ padding-inline:12px; border-width:6px; }
  }
  @media (max-height:740px){
    .leaderboard-card{ padding-block:var(--space-sm) var(--space-xs); }
    /* This short-viewport override was resetting the title back to
       margin-top:0 -- same specificity as the base rule, and it's the one
       that actually applies on real phone heights, which is exactly where
       the title collided with the × close button. 28px is a measured
       clearance, not a guess: the × occupies a fixed 36px circle regardless
       of title length, and 28px is what it actually takes on this breakpoint
       to drop the title's top edge below the ×'s bottom edge. */
    .leaderboard-card h2{ margin-top:32px; margin-bottom:4px; font-size:29px; }
    .leaderboard-tabs{ margin-bottom:6px; }
    .leaderboard-tab{ padding:8px 4px; }
    .leaderboard-reset-note{ margin-top:4px; }
    .leaderboard-status{ margin-block:2px; }
    .leaderboard-list li{ min-height:38px; padding-block:2px; }
    .player-position{ padding:4px var(--space-xs); }
  }
  @media (max-height:680px){
    .result-card{ padding-block:14px 12px; }
    .result-kicker{ font-size:16px; }
    .result-score-stage{ margin-top:6px; }
    .result-score small{ margin-top:7px; }
    .result-competitive{ margin-bottom:5px; }
    .result-divider{ margin:0 0 3px; }
    .result-stats dd{ margin-top:2px; }
    .result-illustration{ height:82px; }
    .result-character-composite{ left:3%; bottom:-17px; width:94%; }
    .result-actions{ gap:var(--space-3xs); margin-top:-5px; }
    .result-secondary-actions{ gap:var(--space-3xs); }
    .result-secondary-actions .gameBtn{ min-height:42px; }
    .result-name-form{ min-height:104px; margin-top:var(--space-2xs); padding:var(--space-2xs) var(--space-3xs) var(--space-3xs); }
    .result-name-form p{ margin:2px 0 var(--space-3xs); }
    .result-name-form output{ min-height:1em; margin-top:2px; }
    .start-shell{ transform:translateY(-1%); }
    #startOverlay .start-leaderboard-btn{ margin-top:6px; }
  }
  @media (prefers-reduced-motion:reduce){
    *,*::before,*::after{ animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; }
    #spawnHint{ animation:none; opacity:.9; }
    .fx-eclipse.show{ filter:none; animation:none; }
    #tierBanner.eclipse-building .fx-eclipse::after{ opacity:0; }
    #stage.eclipse-rumble,#stage.eclipse-hit-rumble,#stage.eclipse-final-rumble,#stage.total-eclipse{ animation:none; transform:none; }
    #startFrame{ animation:none; transform:none; }
    #stage.total-eclipse::after,#stage.total-eclipse #vignette{ animation:none; }
    .result-competitive.is-streak{ animation:none; opacity:1; filter:none; }
    .result-character-composite{ transition-duration:.01ms; transform:none; }
    .result-tier,.result-tier.show{ animation:none; opacity:1; transform:rotate(-9deg); }
    .tier-badge-glare::after,.gameBtn--judge::before,.gameBtn--judge::after{ animation:none; }
    .judging-change-wrap,.judging-change,.judging-rabbit,.judging-status{ animation:none !important; opacity:1; transform:none; }
    .judging-change{ transform:translateX(-50%); }
  }

  /* ---- Personality Result -------------------------------------------------
     The card is a pre-rendered frame per personality, built by
     tools/build_personality_cards.py from the authored 1080x1831 art. The
     script only recolours the ground -- the layout is drawn, not derived.
     Every box below is a percentage measured off that art, so copy is fitted
     into the frame and the frame is never reshaped to fit copy. Re-measure
     them against the panels' inner amber fields if the art is ever redrawn.
     Type scales in `cqw` off the card itself, so one set of numbers holds at
     every screen size instead of a media-query ladder. */
  .personality-overlay{
    position:absolute; inset:0; z-index:95;
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    padding:max(6px,env(safe-area-inset-top)) 6px max(8px,env(safe-area-inset-bottom));
    /* Surround is tinted from the card's own ground so the nine backgrounds
       read as one design rather than a card floating on an unrelated red.
       --pc-deep, not --pc-bg: five of the nine grounds are now pale, and mixing
       a pale ground toward black gives a mid grey-cream the card cannot be seen
       against. renderPersonality() sets --pc-deep to the ground's hue held at a
       fixed dark value, so every card sits on a dark surround of its own
       colour whichever side of the palette it is on. */
    --pc-deep:#0a1f1a;
    background:radial-gradient(circle at 50% 24%,var(--pc-deep) 0%,#08060a 72%);
    opacity:0; transition:opacity 200ms ease,background 240ms ease;
    /* Size container so the card can derive its width from the height left
       over after the action row -- see .personality-card. */
    container-type:size; container-name:pcstage;
    /* The action row is given an explicit height so the reserve is exact
       rather than a guessed constant: an under-estimate here clips the top of
       the card off-screen on a 9:16 phone, which is precisely what 375x667 hit. */
    --pc-actions-h:clamp(40px,6.6vh,50px);
    --pc-gap:clamp(6px,1.4vh,12px);
    --pc-reserve:calc(var(--pc-actions-h) + var(--pc-gap) + 2px);
    gap:var(--pc-gap);
  }
  .personality-overlay[hidden]{ display:none; }
  .personality-overlay.show{ opacity:1; }

  .personality-card{
    container-type:size; container-name:pc;
    position:relative; flex:0 0 auto;
    /* Width is driven off the height that remains once the action row is
       reserved, then clamped by the available width -- so the card fits by
       whichever axis runs out first and is never cropped, letterboxed or
       distorted, and the player never has to scroll to reach the buttons.
       Deriving WIDTH (not height) keeps aspect-ratio authoritative; setting
       both width:auto and height:auto gives a flex item no basis at all and
       collapses it to zero. */
    width:min(100%,calc((100cqh - var(--pc-reserve)) * 1080 / 1831));
    aspect-ratio:1080/1831; height:auto; min-height:0;
    color:var(--pc-cream);
    transform:translateY(10px) scale(.985); opacity:0;
    transition:transform 260ms var(--ease-out),opacity 200ms ease;
  }
  .personality-overlay.show .personality-card{ transform:none; opacity:1; }
  .pc-base{ position:absolute; inset:0; width:100%; height:100%; display:block; }
  .personality-card>:not(.pc-base){ position:absolute; z-index:1; margin:0; }

  .personality-card{
    --pc-cream:#efd08a; --pc-ink-dark:#12241d;
    --pc-ink-red:#7c0d05; --pc-ink-green:#123a2b;
    /* --pc-ink and --pc-accent are set per personality from its config and
       belong ONLY to type printed on the ground: the ground is pale on five
       cards and near-black on four, so this pair flips between a dark ink with
       a deep accent and cream with a bright one. Everything printed on the
       amber panels keeps the fixed inks above, which is why the tier grade is
       --pc-ink-red and not --pc-accent -- the panels never change colour. */
    --pc-ink:#efd08a;
    --pc-accent:#c8260e;
  }

  /* TIER label sits hard against the top of its box so the whole remaining
     height belongs to the grade -- the grade is the thing worth reading. */
  .pc-tier{
    left:9.6%; top:5.2%; width:17.4%; height:10.7%;
    display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
    padding-top:1.1cqw; text-align:center;
  }
  .pc-tier small{ color:var(--pc-ink-dark); font:700 2.7cqw/1 var(--font-numeric); letter-spacing:.1em; text-transform:uppercase; }
  .pc-tier strong{
    flex:1; display:flex; align-items:center;
    color:var(--pc-ink-red); font:400 15cqw/.78 var(--font-display);
  }
  /* "???" for the secret personality needs to shrink or it splits the box. */
  .pc-tier strong.is-wide{ font-size:7.4cqw; letter-spacing:-.02em; }

  .pc-eyebrow{
    left:31%; top:4.3%; width:63%; text-align:center;
    color:var(--pc-ink); font:700 3.55cqw/1.2 var(--font-numeric);
    letter-spacing:.06em; text-transform:uppercase;
  }
  /* The box runs 4.4% past the mooncake's top edge, and z-index:0 puts it
     BEHIND the cake (which is z-index:2) rather than in front. Short names never
     reach that far; a long one lets its last line tuck under the dome instead of
     shrinking further, and because the cake is object-fit:contain its top
     corners are transparent, so the ends of the line still read. Painting order
     still keeps it above .pc-base, which is z-index:auto and earlier in the DOM. */
  .pc-name{
    /* 7.3%, not 7.86%: at 7.86% the title's last line ran 0-3.4px into the top
       of the cake art on six of the nine English cards (worst: MUST WIN IS IT
       over pistachio-kataifi). The cake cannot move down -- these webps are
       cropped to their own pixels, so that would spend clearance at the bottom
       where STEADY BOM PI PI has almost none -- so the title comes up instead,
       and the tighter line-height below pays the other half. Cost is eyebrow
       headroom, which drops to 0.69% of card height at its worst. */
    left:31%; top:7.3%; width:63%; height:17.60%; z-index:0;
    display:flex; flex-direction:column; justify-content:flex-start; align-items:center;
    text-align:center; text-transform:uppercase; font-family:var(--font-display); font-weight:400;
  }
  /* Per-line sizing is set inline by renderPersonality() from each line's own
     length (spec §14), so "HARD" stays huge while "EVERYTHING" still fits. */
  .pc-name span{ display:block; line-height:.92; color:var(--pc-ink); }
  .pc-name span:last-child{ color:var(--pc-accent); }

  /* top is 0.4%, not the 1.4% the eye wants: these cake webps are cropped to
     their own pixels (painted top == box top), so a downward move spends real
     clearance at the BOTTOM, and STEADY BOM PI PI -- the only card whose flavour
     name runs two rows, "DOUBLE YOLK / WHITE LOTUS" -- has just 2.3px there.
     0.4% is the largest drop that leaves every one of the nine at today's
     worst-case clearance. */
  .pc-mooncake{
    left:13.5%; top:21.3%; width:71.5%; height:24.4%; z-index:2;
    object-fit:contain; filter:drop-shadow(0 1.4cqw 1.6cqw rgba(0,0,0,.35));
  }
  /* WAY TOO HARD only: its Lava Custard art sits high in the frame with more
     headroom under the title than the other eight cards, so it alone gets
     nudged down. A shared rule would cost STEADY BOM PI PI its clearance --
     it's the one card at effectively zero room above the flavour name. */
  #personalityCard[data-personality="way-too-hard"] .pc-mooncake{ top:22.8%; }
  /* Anchored to its BOTTOM, against the verdict panel, so the pair grows upward.
     Only one roast in nine needs two rows, and sizing the whole green zone for
     that worst case would cost every other card a smaller mooncake. Growing
     upward spends the second row on the transparent margin under the cake
     instead, which is the one place on the card that is genuinely empty. */
  /* An explicit box, not `top:auto; bottom:41.3%`: with height auto and only
     bottom set, CSS resolves the top from the static position and stretches the
     element to meet it, which made this block three times its content height. */
  .pc-flavour{
    left:5%; top:43.4%; height:13.2%; width:90%;
    display:flex; flex-direction:column; justify-content:flex-end; align-items:center;
    /* The roast is an aside, not a subtitle: at .35cqw it sat on the flavour
       name's baseline and the two read as one wrapped sentence. */
    gap:1.2cqw;
  }
  /* Deliberately NOT the display face: Anton ships in one heavy weight, so the
     flavour name read as a second bold headline competing with the personality
     name above it. Quantico at 400 lets it grow without shouting. */
  /* margin:0 restated: the card's reset only reaches its direct children, and
     these two moved inside .pc-flavour, which handed them back the UA's 1em. */
  /* Long flavour names wrap to a second row rather than shrinking: at one row
     "DOUBLE YOLK WHITE LOTUS" had to drop to 5.7cqw, smaller than the roast
     underneath it and no longer reading as the cake's name. The block is
     bottom-anchored, so the second row is spent upward on the transparent
     margin under the cake -- the one genuinely empty stretch on the card. */
  .pc-mooncake-name{
    margin:0; width:100%; text-align:center;
    color:var(--pc-ink); font:400 7.5cqw/.92 var(--font-numeric);
    letter-spacing:.02em; text-transform:uppercase; text-wrap:balance;
  }
  .pc-mooncake-name.is-long{ font-size:6.3cqw; }
  /* One row, always. The roast is an aside under the flavour name; two rows of
     it read as a second paragraph and pushed the whole block into the cake. */
  .pc-roast{
    margin:0; width:97%; text-align:center; white-space:nowrap;
    color:var(--pc-ink); font:700 3.05cqw/1.15 var(--font-numeric); text-transform:uppercase;
  }
  .pc-roast.is-oneline{ white-space:nowrap; }

  .pc-panel{
    left:8.3%; top:58.5%; width:83.2%; height:10.9%;
    display:grid; grid-template-columns:1fr 1px 1fr; align-items:center;
  }
  .pc-panel::before{
    content:""; grid-area:1/2; width:1px; height:66%; justify-self:center;
    background:color-mix(in srgb,var(--pc-ink-red) 42%,transparent);
  }
  /* Explicit tracks: ::before is the grid's first child, so auto-placement
     would push both columns one track right and stack them in the same half. */
  .pc-col{ min-width:0; padding:0 2.2cqw; text-align:center; }
  .pc-col:nth-of-type(1){ grid-area:1/1; }
  .pc-col:nth-of-type(2){ grid-area:1/3; }
  .pc-col h3{
    margin:0 0 1.1cqw; color:var(--pc-ink-dark);
    font:700 3cqw/1 var(--font-numeric); letter-spacing:.03em; text-transform:uppercase;
  }
  /* Both columns, both stat readings and both match names are levelled to one
     size per row by fitPersonalityCard(): each is fitted on its own, then the
     smallest result is applied across the row. A row of parallel facts printed
     at two different sizes reads as a hierarchy that isn't there. */
  .pc-col p{
    margin:0; color:var(--pc-ink-red);
    font:700 3.7cqw/1.22 var(--font-numeric); text-transform:uppercase;
  }
  /* Three thoughts or a three-line verdict must not push the panel; drop a
     step rather than overflow the locked box (spec §13). */
  .pc-col p.is-tall{ font-size:3.15cqw; line-height:1.18; }

  /* The bar is 71% taller than the supplied art (see the build script), which
     is what lets the readings themselves carry the row instead of the labels. */
  .pc-stats{
    left:17.8%; top:72.6%; width:64.6%; height:7.2%;
    display:grid; grid-template-columns:repeat(3,1fr); align-items:center;
  }
  .pc-stats>div{ min-width:0; text-align:center; line-height:1; }
  .pc-stats>div+div{ border-left:1px solid color-mix(in srgb,var(--pc-ink-red) 34%,transparent); }
  .pc-stats dd{ margin:0; color:var(--pc-ink-red); font:700 8cqw/1 var(--font-numeric); }
  .pc-stats dd.is-long{ font-size:5.8cqw; }
  .pc-stats dt{ margin:.9cqw 0 0; color:var(--pc-ink-dark); font:700 2.6cqw/1 var(--font-numeric); letter-spacing:.04em; text-transform:uppercase; }

  /* Both labels pinned to the top of their boxes and on one shared baseline, so
     the rest of each box belongs to the name it introduces. */
  /* align-self:start on the name, not center: the two boxes are a matched
     pair, and centring each one inside its own row put a one-line name and a
     two-line name on different baselines. Started, they share one. */
  .pc-match{
    top:84.3%; height:10.1%;
    display:grid; grid-template-rows:auto 1fr; align-content:start;
    gap:.8cqw; padding:0 2.4cqw; text-align:center;
  }
  .pc-match--best{ left:8.52%; width:29.07%; }
  .pc-match--worst{ left:61.94%; width:29.54%; }
  .pc-match h3{
    margin:0; color:var(--pc-ink-dark);
    font:700 2.9cqw/1 var(--font-numeric); letter-spacing:.05em; text-transform:uppercase;
  }
  .pc-match p{
    margin:0; align-self:start; color:var(--pc-ink-green);
    font:400 7.2cqw/1.14 var(--font-display); text-transform:uppercase; overflow-wrap:anywhere;
  }
  .pc-match p.is-long{ font-size:5.4cqw; line-height:1.2; }

  .pc-rabbit{
    left:41.02%; top:83.62%; width:17.69%; height:10.65%;
    overflow:hidden; border-radius:50%;
  }
  /* Oversized inside the circle to match the approved card, where the rabbit
     breaks its own frame slightly rather than sitting neatly inside it.
     A negative inset rather than width/height:132%: as a centred grid item the
     percentage height resolved against a row that was itself sized from the
     image, and came out at 174%. */
  .pc-rabbit img{ position:absolute; inset:-16%; width:132%; height:132%; object-fit:contain; }

  .personality-actions{
    flex:0 0 auto; display:flex; gap:8px; width:min(100%,360px);
    height:var(--pc-actions-h);
  }
  .personality-actions .gameBtn{
    flex:1; min-width:0; height:100%; padding:0 10px;
    font-size:clamp(13px,3.6vw,16px); letter-spacing:.04em;
  }
  .personality-actions .gameBtn--secondary{
    color:var(--pc-cream,#efd08a);
    border-color:color-mix(in srgb,#efd08a 55%,transparent);
    background:rgba(20,10,6,.62);
  }
  /* Absolutely positioned: as a flex sibling it would add height the card's
     reserve does not account for, re-clipping the card the moment a share
     message appears. */
  /* Failure notice only. Success and progress are reported on the Share button
     itself (see js/game.js) because this overlay has no free vertical space --
     the card ends at 605px and the action row starts at 614px on a 375x667
     phone, so anything floated here sits on the card's own artwork. That is a
     fair trade for explaining a failure and a bad one for confirming a success. */
  .personality-share-status{
    /* left:0;right:0 + margin auto, NOT left:50% + translateX: an
       absolutely-positioned shrink-to-fit box measured from left:50% can only
       ever be half the overlay wide, which wrapped the message onto two lines
       inside a pill sized for one. */
    position:absolute; left:0; right:0; bottom:calc(var(--pc-actions-h) + 14px);
    z-index:5; display:flex; align-items:center; gap:7px;
    width:fit-content; margin-inline:auto;
    max-width:min(88vw,340px); padding:7px 14px 7px 11px;
    border:1px solid color-mix(in srgb,#e2603f 62%,transparent);
    border-radius:999px; background:rgba(12,8,6,.94);
    box-shadow:0 6px 18px rgba(0,0,0,.55);
    color:var(--color-parchment);
    font:600 clamp(11px,3vw,13px)/1.25 var(--font-numeric); text-align:left;
    pointer-events:none;
    transform:translateY(6px); opacity:0;
    animation:pc-toast-in .22s ease-out forwards;
  }
  /* Generated, so the element's text stays exactly the message -- role="status"
     announces the sentence, not a decorative glyph. */
  .personality-share-status::before{
    content:"!"; flex:0 0 auto; font-size:1.05em; font-weight:700; line-height:1;
    color:#e2603f;
  }
  .personality-share-status:empty{ display:none; }
  @keyframes pc-toast-in{ to{ transform:translateY(0); opacity:1; } }
  @media (prefers-reduced-motion: reduce){
    .personality-share-status{ animation-duration:.01ms; transform:none; opacity:1; }
  }

  @media (prefers-reduced-motion: reduce){
    .personality-overlay,.personality-card{ transition-duration:.01ms; }
    .personality-card{ transform:none; }
  }

  /* Judging is Escape-dismissable on desktop, but a phone has no Escape key —
     without this the overlay is a trap if the personality card fails to load. */
  .judging-close{ position:absolute; z-index:4; top:max(10px,env(safe-area-inset-top)); right:12px; }

  /* ---------------- Traditional Chinese typography ----------------
     Scoped to :lang(zh), which applyStaticTranslations() sets on <html>. The
     Latin faces stay ahead of Noto in every stack, so numbers, x5 and the tier
     letters keep the arcade shapes they were designed with and only the CJK
     runs fall through to Noto.

     This matters most on the personality card: it is redrawn onto a <canvas>
     for sharing, and canvas takes its font from getComputedStyle -- so an
     unstyled Chinese card would export tofu boxes even though it looked fine
     on screen. */
  :lang(zh) body{ font-family:var(--font-body-zh); }
  :lang(zh) .pc-name span,
  :lang(zh) .start-loading strong{ font-family:var(--font-display-zh); font-weight:900; }
  :lang(zh) .pc-mooncake-name,
  :lang(zh) .pc-roast,
  :lang(zh) .pc-col p,
  :lang(zh) .pc-match p,
  :lang(zh) .pc-eyebrow,
  :lang(zh) .pc-col h3,
  :lang(zh) .pc-match h3,
  :lang(zh) .pc-stats dt{ font-family:var(--font-body-zh); font-weight:700; }
  /* Values stay Latin-first: these are digits and percentages. */
  :lang(zh) .pc-stats dd,
  :lang(zh) .hud-val,
  :lang(zh) .floatText,
  :lang(zh) #comboInner{ font-family:var(--font-numeric-zh); }
  /* Chinese has no case, so the uppercasing the Latin design leans on is a
     no-op -- these need their weight and tracking to carry the emphasis instead. */
  :lang(zh) .result-kicker,
  :lang(zh) .leaderboard-tab,
  :lang(zh) .gameBtn{ font-family:var(--font-body-zh); font-weight:800; letter-spacing:.04em; }

  /* Set by the renderer's frame-time watchdog (js/game.js). A full-screen
     backdrop blur is one of the most expensive things a phone can be asked to
     composite, and all three of these sit over artwork that is already dimmed
     by the overlay's own translucent ground -- so dropping the blur costs a
     little depth and nothing else. The overlays stay legible because the
     background colour, not the blur, is what separates them. */
  :root.is-low-quality .judging-overlay,
  :root.is-low-quality .security-check,
  :root.is-low-quality .asset-recovery-overlay{
    -webkit-backdrop-filter:none; backdrop-filter:none;
  }
