:root{
  --felt-deep:#0f2f2a;
  --felt-mid:#153b34;
  --felt-rail:#0b241f;
  --tile-face:#f6efdd;
  --tile-face-hi:#fffaee;
  --tile-edge:#c9ab63;
  --tile-shade:#8a6a35;
  --ink:#20211a;
  --gold:#cda23d;
  --gold-bright:#e7c465;
  --seal:#b23a2e;
  --paper:#efe6d0;
  --paper-dim:#c9c0a6;
}
*{box-sizing:border-box;}
html,body{height:100%;}
body{
  margin:0;
  background:
    radial-gradient(120% 90% at 50% -10%, rgba(255,255,255,0.05), transparent 60%),
    radial-gradient(140% 100% at 50% 110%, rgba(0,0,0,0.35), transparent 55%),
    var(--felt-deep);
  color:var(--paper);
  font-family:'Manrope', ui-sans-serif, system-ui, sans-serif;
  min-height:100vh;
  display:flex;
  flex-direction:column;
  overflow-x:hidden;
  overscroll-behavior:none;
}
::selection{background:var(--gold); color:var(--ink);}

/* Touch-first: this is a tap interface, not a text page. Selection and
   iOS's long-press callout only get in the way of rapid tapping. */
.topbar, .tray, #board, footer{
  -webkit-user-select:none; user-select:none;
  -webkit-touch-callout:none;
}
.tok-btn{
  touch-action:manipulation;
  -webkit-tap-highlight-color:transparent;
}
/* The actual tap surface (board + tray) gets touch-action:none, not just
   manipulation. manipulation still permits Safari's own pan/pinch
   gesture recognizer to engage on the element - which is the leading
   suspect for the genuine pointercancel events seen under rapid tapping
   (a fast, slightly-off-axis series of touches is exactly what a
   pan/scroll recognizer looks for). none suppresses that recognition
   entirely - it still fully implies no double-tap-zoom, same as
   manipulation did - while leaving pointer capture, ordinary buttons,
   and everything outside these two containers untouched. Deliberately
   scoped to just these two interactive surfaces, not html/body. */
.tile, .tray-tile, #board-wrap, .tray{
  touch-action:none;
  -webkit-tap-highlight-color:transparent;
}

.topbar{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:1.25rem;
  flex-wrap:wrap;
  padding:1rem 1.5rem;
  background:linear-gradient(180deg, var(--felt-rail), var(--felt-mid));
  border-bottom:1px solid rgba(205,162,61,0.35);
  box-shadow:0 8px 24px rgba(0,0,0,0.35);
  position:relative;
  z-index:5;
}
.brand{display:flex; align-items:baseline; gap:0.75rem;}
.brand .glyph-mark{
  font-family:'Ma Shan Zheng', cursive;
  font-size:2.4rem;
  line-height:1;
  color:var(--gold-bright);
  text-shadow:0 2px 10px rgba(0,0,0,0.4);
}
.brand .wordmark{
  display:flex; flex-direction:column; gap:0.1rem;
}
.brand .wordmark .en{
  font-size:0.72rem;
  letter-spacing:0.28em;
  text-transform:uppercase;
  color:var(--gold);
  font-weight:700;
}
.brand .wordmark .tag{
  font-size:0.78rem;
  color:var(--paper-dim);
  font-weight:500;
}

.stats{
  display:flex;
  align-items:center;
  gap:1.4rem;
  font-variant-numeric:tabular-nums;
}
.stat{display:flex; flex-direction:column; align-items:center; min-width:3.4rem;}
.stat .num{font-size:1.15rem; font-weight:800; color:var(--paper);}
.stat .lbl{font-size:0.62rem; letter-spacing:0.14em; text-transform:uppercase; color:var(--paper-dim);}

.controls{display:flex; gap:0.5rem;}
.tok-btn{
  appearance:none; border:none; cursor:pointer;
  background:linear-gradient(180deg, #3a5148, #1e3a33);
  color:var(--paper);
  border:1px solid rgba(205,162,61,0.45);
  border-radius:10px;
  padding:0.55rem 0.85rem;
  font-family:inherit;
  font-size:0.78rem;
  font-weight:700;
  letter-spacing:0.02em;
  box-shadow:0 2px 0 rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.08);
  transition:transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.tok-btn:hover{background:linear-gradient(180deg, #43604f, #24463c); transform:translateY(-1px);}
.tok-btn:active{transform:translateY(1px); box-shadow:0 1px 0 rgba(0,0,0,0.35);}
.tok-btn:focus-visible{outline:2px solid var(--gold-bright); outline-offset:2px;}
.tok-btn:disabled{opacity:0.4; cursor:not-allowed; transform:none;}

main{
  flex:1;
  display:flex;
  align-items:flex-start;
  justify-content:center;
  padding:0.75rem 1rem;
  min-height:0;
  position:relative;
}

.table-shadow{
  position:absolute;
  inset:0;
  background:
    radial-gradient(60% 45% at 50% 55%, rgba(0,0,0,0.28), transparent 70%);
  pointer-events:none;
}

#board-wrap{
  position:relative;
  max-width:100%;
  max-height:100%;
  /* Without a z-index here, position:relative alone doesn't open a new
     stacking context, so tiles' own inline z-index (layer*1000+row*10+col,
     easily 3000+ on upper pyramid layers) compared directly against
     page-level overlays like .flight-layer/.modal-layer/#build-tag below
     - letting upper-layer tiles stack in front of all of them. This
     contains that stacking to just the board's own subtree. */
  z-index:1;
}
#board{
  position:relative;
}

/* ONE physical horizontal recessed tray (replaces the old four fixed
   slots - see trayPositions/recenterResidents in script.js). .tray stays
   a full-width invisible flex row purely for horizontal centering;
   .tray-well is the actual visible "physical tray" box, sized in JS to
   comfortably hold up to TRAY_SIZE tiles and no wider - so it reads as
   one container, not four hidden boxes. */
.tray{
  position:relative;
  z-index:4;
  display:flex;
  align-items:center;
  justify-content:center;
  padding:0.6rem 1rem;
}
.tray-well{
  position:relative;
  border-radius:10px;
  background:linear-gradient(180deg, rgba(0,0,0,0.22), rgba(0,0,0,0.08));
  border-bottom:1px solid rgba(205,162,61,0.22);
  box-shadow:inset 0 2px 6px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(205,162,61,0.15);
  /* Width/height are set in JS (sizeTrayWell) from the actual measured
     tile size, never hardcoded here. */
}
.tray-tile{
  position:absolute;
  cursor:pointer;
  pointer-events:auto;
  /* left/top/width/height are set inline per-tile in JS (renderTray) -
     there's no more single fixed slot parent to inherit inset:0 from.
     Repositioning animates `transform` only (see animateResidentMove),
     so this is compositor-only work, same class of cost as a flight. */
  will-change:transform;
}
.tray-tile .face{
  position:relative;
  width:100%; height:100%;
  border-radius:7px;
  background:linear-gradient(155deg, var(--tile-face-hi), var(--tile-face) 60%);
  border:1px solid var(--tile-edge);
  box-shadow:0 2px 0 var(--tile-shade), 0 4px 8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.6);
  display:flex; align-items:center; justify-content:center;
  overflow:hidden;
}
.tray-tile .art{ position:absolute; inset:0; background-repeat:no-repeat; pointer-events:none; }
.tray-tile .glyph{ position:relative; color:var(--ink); }
html.sprite-ready .tray-tile .glyph{opacity:0;}

/* Landing "tock": arrival -> tiny compress -> back to normal. Duration
   comes from --land-ms (set once from ANIM in script.js). The 0.97 dip
   itself is a literal, not var(--land-scale) - Safari has long-standing
   bugs animating a transform value that comes from a custom property
   *inside a keyframe* (works fine as a plain transition, just not
   inside @keyframes), so retuning that number means editing it here
   directly rather than via ANIM.LAND_SCALE. Used both for a tray
   arrival and a board return-arrival (.tile .face.settle below). */
.tray-tile .face.settle{animation:tileSettle var(--land-ms, 65ms) ease-out;}
@keyframes tileSettle{
  0%{transform:scale(1);}
  45%{transform:scale(0.97);}
  100%{transform:scale(1);}
}

/* Hold phase: both matching tiles stay fully visible with a calm gold
   glow long enough to register as a pair before they resolve. The
   bump plays once, right as the pair is recognized (see commitMatch),
   as their one small synchronized "yes, these match" reaction. */
.tray-tile.matched .face{
  border-color:var(--gold-bright);
  box-shadow:0 0 0 2px var(--gold-bright), 0 0 10px rgba(231,196,101,0.6), 0 2px 0 var(--tile-shade), 0 4px 8px rgba(0,0,0,0.4);
  transition:box-shadow 100ms ease;
  animation:matchBump 100ms ease-out;
}
@keyframes matchBump{
  0%{transform:scale(1);}
  45%{transform:scale(1.035);}
  100%{transform:scale(1);}
}
/* Exit phase: fast, decisive - not the same slower fall used elsewhere. */
.tray-tile.matched-exit .face{animation:trayMatchExit 130ms ease forwards;}
@keyframes trayMatchExit{
  to{transform:scale(0.55) rotate(8deg); opacity:0;}
}

.flight-layer{
  position:fixed; inset:0;
  pointer-events:none;
  z-index:80;
}
/* Position/size are set once (left/top/width/height, plain layout
   properties, set a single time at spawn) and never touched again -
   the whole trip animates only `transform` via a plain `transition`
   set from script.js (spawnFlight), with the destination's numbers
   written directly into the transform string rather than driven by
   CSS custom properties inside @keyframes (see the settle bounce above
   for why). The compositor can run a transform transition on its own
   thread with no layout/paint per frame, same as an animation would,
   even with several ghosts flying at once. Duration/easing come from
   ANIM.TRAVEL_MS/TRAVEL_EASE, also set inline per-flight. */
.flight-ghost{
  position:fixed;
  pointer-events:none;
  will-change:transform;
  z-index:2; /* always paints above its own .flight-trail echoes, see below */
}
.flight-ghost .face{
  position:relative;
  width:100%; height:100%;
  border-radius:7px;
  background:linear-gradient(155deg, var(--tile-face-hi), var(--tile-face) 60%);
  border:1px solid var(--tile-edge);
  box-shadow:0 6px 14px rgba(0,0,0,0.45);
  overflow:hidden;
}
.flight-ghost .art{ position:absolute; inset:0; background-repeat:no-repeat; }
.flight-ghost .glyph{display:none;}

/* PROTOTYPE: white motion-trail echo (see TRAIL config in script.js).
   Deliberately a flat, borderless, shadow-free white rectangle - no
   filter/drop-shadow/backdrop-filter/box-shadow of any kind, so each
   echo is exactly as compositor-cheap as the ghost itself. Position and
   opacity are the only animated properties (both driven from JS via
   WAAPI), both compositor-only - no layout or paint-heavy CSS involved. */
.flight-trail{
  position:fixed;
  pointer-events:none;
  background:#fff;
  border-radius:7px;
  will-change:transform, opacity;
  z-index:1;
}

/* PROTOTYPE: sand-disintegration burst (see SAND config + sandLoop in
   script.js). A single canvas rather than one element per grain - see
   the comment on the SAND object for why. No filter/blur/shadow; the
   canvas itself is 2D-context drawing only, positioned to exactly cover
   the viewport within #flight-layer. */
#sand-canvas{
  z-index:3;
}

.tile{
  position:absolute;
  cursor:pointer;
  transition:transform 0.16s ease, filter 0.16s ease, opacity 0.25s ease;
  filter:drop-shadow(0 3px 4px rgba(0,0,0,0.35));
}
/* TEMP PERF A/B TEST - toggled by PERF_TEST_DISABLE_TILE_FILTER in
   script.js. Strips the permanent drop-shadow filter (and its transition)
   from every board tile to test whether it's the source of real-iPhone
   frame stalls. Flip that flag to false (or delete this block) to revert -
   .tile.blocked .face's own filter below is untouched. */
body.perf-no-tile-filter .tile{
  filter:none;
  transition:transform 0.16s ease, opacity 0.25s ease;
}
.tile .face{
  position:relative;
  width:100%; height:100%;
  border-radius:7px;
  background:linear-gradient(155deg, var(--tile-face-hi), var(--tile-face) 60%);
  border:1px solid var(--tile-edge);
  box-shadow:
    0 3px 0 var(--tile-shade),
    0 6px 10px rgba(0,0,0,0.4),
    inset 0 1px 0 rgba(255,255,255,0.6);
  display:flex; align-items:center; justify-content:center;
  overflow:hidden;
}
.tile .face::after{
  content:"";
  position:absolute; inset:3px;
  border:1px solid rgba(140,110,50,0.28);
  border-radius:4px;
  pointer-events:none;
}

/* Printed-face artwork layer. Empty (no background) until
   assets/mahjong/mahjong-sprites.png exists - see script.js's sprite
   detection. The glyph text underneath is the fallback and stays the
   visible face until that file is confirmed loadable. */
.tile .art{
  position:absolute;
  inset:0;
  background-repeat:no-repeat;
  pointer-events:none;
}

.tile .glyph{
  position:relative;
  font-size:2.05rem;
  line-height:1;
  color:var(--ink);
  transform:translateY(-1px);
}
.tile .glyph.man{color:#2b3e73;}
.tile .glyph.sou{color:#2f6b3a;}
.tile .glyph.pin{color:#8a2a2a;}
.tile .glyph.wind{color:#3a3a34;}
.tile .glyph.dragon-red{color:#a3312a;}
.tile .glyph.dragon-green{color:#2f6b3a;}
.tile .glyph.dragon-white{color:#8f8a72;}

/* Once the sprite sheet is confirmed present, hide the glyph fallback
   so only the printed artwork shows. Toggled on <html> in script.js. */
html.sprite-ready .tile .glyph{opacity:0;}

.tile.blocked{
  pointer-events:none;
}
.tile.blocked .face{
  filter:saturate(0.55) brightness(0.72);
  box-shadow:
    0 2px 0 rgba(0,0,0,0.3),
    0 3px 6px rgba(0,0,0,0.35),
    inset 0 1px 0 rgba(255,255,255,0.15);
}
/* TEMP PERF A/B TEST #2 - toggled by PERF_TEST_DISABLE_BLOCKED_FILTER in
   script.js. Strips the blocked-face filter to test whether free<->blocked
   filter churn (not just the permanent per-tile filter from test #1)
   accounts for the remaining stalls. Blocked tiles will look visually
   wrong/undimmed for this test - expected. Flip the flag to false (or
   delete this block) to revert. */
body.perf-no-blocked-filter .tile.blocked .face{
  filter:none;
}
.tile.free:hover{transform:translateY(-4px);}
.tile.free:hover .face{box-shadow:0 5px 0 var(--tile-shade), 0 10px 16px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.7);}
.tile.free:focus-visible{outline:none;}
.tile.free:focus-visible .face{box-shadow:0 0 0 2px var(--gold-bright), 0 5px 0 var(--tile-shade), 0 10px 16px rgba(0,0,0,0.45);}

/* Phase 1: fires on pointerdown, before click/selection - "I touched
   THAT tile." A pickup, not a button-press: it lifts up and slightly
   enlarges rather than shrinking down. --lift-ms/--lift-scale/--lift-y
   are set once from ANIM in script.js. */
.tile.lifted .face{
  transform:translateY(var(--lift-y, -2px)) scale(var(--lift-scale, 1.045));
  box-shadow:0 4px 0 var(--tile-shade), 0 9px 16px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.7);
  transition:transform var(--lift-ms, 45ms) cubic-bezier(.2,.7,.3,1), box-shadow var(--lift-ms, 45ms) ease;
}

.tile.hint .face{
  animation:hintPulse 0.9s ease-in-out 2;
}
@keyframes hintPulse{
  0%,100%{box-shadow:0 0 0 2px var(--gold-bright), 0 3px 0 var(--tile-shade), 0 6px 10px rgba(0,0,0,0.4);}
  50%{box-shadow:0 0 0 4px var(--gold-bright), 0 0 18px rgba(231,196,101,0.85), 0 3px 0 var(--tile-shade), 0 6px 10px rgba(0,0,0,0.4);}
}

@keyframes matchOut{
  to{transform:translateY(-14px) scale(0.6) rotate(6deg); opacity:0;}
}
.tile.removed{display:none;}
/* On the board, a tile returning from the tray stays logically present
   (so neighbors free up immediately) but is hidden - not display:none,
   so its position/size stay readable for the return flight - until the
   flight lands, then it reveals with the same settle bounce as a tray
   arrival. */
.tile.incoming{opacity:0; pointer-events:none;}
.tile .face.settle{animation:tileSettle var(--land-ms, 65ms) ease-out;}
.tile.shake .face{animation:shake 0.28s ease;}
@keyframes shake{
  20%{transform:translateX(-4px);}
  40%{transform:translateX(4px);}
  60%{transform:translateX(-3px);}
  80%{transform:translateX(3px);}
}

.modal-layer{
  position:fixed; inset:0;
  background:rgba(6,16,14,0.72);
  display:flex; align-items:center; justify-content:center;
  z-index:50;
  opacity:0; pointer-events:none;
  transition:opacity 0.25s ease;
}
.modal-layer.show{opacity:1; pointer-events:auto;}
.modal-card{
  background:linear-gradient(180deg, #1a4038, #123028);
  border:1px solid rgba(205,162,61,0.5);
  border-radius:16px;
  padding:2.2rem 2.6rem;
  text-align:center;
  box-shadow:0 20px 60px rgba(0,0,0,0.6);
  max-width:22rem;
  transform:translateY(8px);
  transition:transform 0.25s ease;
}
.modal-layer.show .modal-card{transform:translateY(0);}
.modal-card .seal{
  width:3.4rem; height:3.4rem;
  margin:0 auto 0.9rem;
  border-radius:8px;
  background:var(--seal);
  color:var(--paper);
  display:flex; align-items:center; justify-content:center;
  font-family:'Ma Shan Zheng', cursive;
  font-size:1.7rem;
  box-shadow:0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.15);
  transform:rotate(-4deg);
}
.modal-card h2{margin:0 0 0.4rem; font-size:1.35rem; font-weight:800; color:var(--paper);}
.modal-card p{margin:0 0 1.4rem; color:var(--paper-dim); font-size:0.9rem; line-height:1.5;}
.modal-card .row{display:flex; gap:1.5rem; justify-content:center; margin-bottom:1.4rem;}
.modal-card .row .stat .num{font-size:1.3rem;}
.modal-actions{display:flex; gap:0.6rem; justify-content:center;}

footer{
  text-align:center;
  padding:0.9rem 1rem 1.2rem;
  font-size:0.72rem;
  color:var(--paper-dim);
  letter-spacing:0.03em;
}

/* Phone-first chrome: every pixel of vertical space here is a pixel the
   board doesn't get, so the topbar is deliberately compact and the
   footer hint drops out entirely rather than just shrinking. */
@media (max-width:700px){
  .brand{gap:0.5rem;}
  .brand .glyph-mark{font-size:1.5rem;}
  .brand .wordmark .tag{display:none;}
  .brand .wordmark .en{font-size:0.62rem;}
  .stats{gap:0.7rem;}
  .stat{min-width:2.2rem;}
  .stat .num{font-size:0.95rem;}
  .stat .lbl{font-size:0.56rem;}
  .topbar{padding:0.5rem 0.7rem; gap:0.5rem;}
  .controls{gap:0.35rem;}
  .tok-btn{padding:0.4rem 0.55rem; font-size:0.68rem; border-radius:8px;}
  .tray{padding:0.45rem 0.6rem;}
  footer{display:none;}
}

/* Visible on every screen size (unlike footer, which hides on phones)
   specifically so it's checkable on a real device - see BUILD_TAG in
   script.js. Not part of the game's visual language on purpose: it
   should look like debug scaffolding, not a shipped feature. */
/* TEMP DEV LAYOUT SELECTOR (Part 6) - crude on purpose, not a real UI
   surface. Delete this whole block along with #dev-layout-picker in
   index.html and its listener in script.js once playtesting is done. */
#dev-layout-picker{
  position:fixed;
  top:calc(4px + env(safe-area-inset-top, 0px));
  left:calc(4px + env(safe-area-inset-left, 0px));
  z-index:96;
  display:flex;
  align-items:center;
  gap:4px;
  background:#ffe14d;
  border:2px solid #000;
  padding:3px 6px;
  font-family:ui-monospace, 'SF Mono', monospace;
  font-size:10px;
  color:#000;
}
#dev-layout-picker select{
  font-family:inherit;
  font-size:10px;
}
#build-tag{
  position:fixed;
  bottom:calc(3px + env(safe-area-inset-bottom, 0px));
  right:calc(6px + env(safe-area-inset-right, 0px));
  max-width:min(94vw, 340px);
  /* --diag-safe-top is set by JS (see updateDiagPanelTop) to the
     topbar's real, current bottom edge. This is deliberately max-height,
     not a pinned `top` - setting BOTH top and bottom on a fixed element
     with height left auto forces the browser to STRETCH the box to fill
     that entire span always, even for the one-line default message,
     which is exactly the "full height before starting a game" bug this
     replaced. max-height instead only caps growth: the box still sizes
     to its actual content and stays small by default, and only reaches
     this ceiling (and starts scrolling) once a real report is long
     enough to need it - while still never being able to grow up over the
     topbar's buttons, on any viewport size or topbar wrap state. The
     70px fallback only matters for the instant before JS's first layout
     pass runs. */
  max-height:calc(100vh - var(--diag-safe-top, 70px) - env(safe-area-inset-bottom, 0px) - 13px);
  overflow-y:auto;
  -webkit-overflow-scrolling:touch;
  overscroll-behavior:contain;
  z-index:95;
  font-family:ui-monospace, 'SF Mono', monospace;
  font-size:9px;
  line-height:1.5;
  white-space:pre-line;
  text-align:right;
  color:rgba(255,255,255,0.92);
  background:rgba(4,12,10,0.96);
  border:1px solid rgba(255,255,255,0.08);
  padding:3px 6px;
  border-radius:4px;
  /* Was pointer-events:none (the panel used to be purely decorative and
     never worth intercepting a tap). Now that it can overflow and needs
     to scroll, it must be able to receive touch input itself - scoped to
     touch-action:pan-y so it only ever claims a vertical scroll gesture,
     never anything else, and only while a touch actually starts within
     this box's own bounds; tiles outside it are completely unaffected. */
  pointer-events:auto;
  touch-action:pan-y;
  user-select:none;
}

@media (prefers-reduced-motion:reduce){
  .tile, .tile.free:hover, .modal-layer, .modal-card{transition:none;}
  .tile.hint .face{animation:none; box-shadow:0 0 0 3px var(--gold-bright);}
  .tile.lifted .face{transition:none;}
  .tray-tile .face.settle, .tile .face.settle{animation:none;}
  .tray-tile.matched .face{animation:none;}
  .tray-tile.matched-exit .face{animation:none; opacity:0;}
  /* Reduced motion should trim flourish, not hide the tile's only
     feedback for its core action - script.js shortens the flight
     transition itself when this preference is on, rather than the
     ghost disappearing outright. */
}
