: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;
  /* Mike's Mahjong is mobile-first, full stop: on any screen wider than
     a phone, the app renders as this fixed-width mobile column centered
     in whatever space is available, never a desktop-expanded layout.
     430px = the largest real iPhone's CSS width (15/16 Pro Max) - not a
     round-number guess, and script.js reads this SAME variable at
     runtime (see its APP_MAX_WIDTH) so there is exactly one place this
     number lives, not a JS constant that could drift from this one.
     Retune here only - nothing else should hardcode a max width. */
  --app-max-width:430px;
}
*{box-sizing:border-box;}
html{
  height:100%;
  /* The felt background now lives here, not on body - body gets capped
     to --app-max-width and centered (see below), so anything outside
     that column would otherwise show the bare browser background
     instead of a continuation of the felt. */
  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);
}
body{
  margin:0 auto;
  width:100%;
  max-width:var(--app-max-width);
  color:var(--paper);
  font-family:'Manrope', ui-sans-serif, system-ui, sans-serif;
  min-height:100%;
  display:flex;
  flex-direction:column;
  overflow-x:hidden;
  overscroll-behavior:none;
  /* Deliberately NOT a hard height:100% - on a short DESKTOP window
     script.js sizes the board against a generous reference height (see
     its measure()), not the real short viewport, so this column can
     legitimately grow taller than the window; the page just scrolls
     (no overflow:hidden anywhere blocks that). On an actual phone the
     reference height never kicks in, so this still fits exactly as
     before - see measure()'s own comment for the phone/desktop split. */
}
::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;
}

/* "Updated" bar - see script.js's "UPDATE BANNER" section. Sits above
   .topbar in normal flow so it never overlaps game chrome; hidden by
   default via display:none (not just opacity) so it takes no space and
   never intercepts taps when not shown. */
.update-bar{
  display:none;
  align-items:center;
  justify-content:center;
  gap:0.6rem;
  padding:0.4rem 0.9rem;
  background:linear-gradient(180deg, var(--gold-bright), var(--gold));
  color:var(--ink);
  font-size:0.68rem;
  font-weight:700;
  letter-spacing:0.02em;
  text-align:center;
  position:relative;
  z-index:6;
}
.update-bar.show{
  display:flex;
  animation:updateBarIn 0.35s ease;
}
@keyframes updateBarIn{
  from{ transform:translateY(-100%); opacity:0; }
  to{ transform:translateY(0); opacity:1; }
}
.update-bar-close{
  appearance:none; border:none; background:transparent; cursor:pointer;
  color:var(--ink); font-size:1rem; line-height:1; padding:0 0.2rem;
  position:absolute; right:0.6rem; top:50%; transform:translateY(-50%);
}

.topbar{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:0.5rem;
  flex-wrap:wrap;
  padding:0.5rem 0.7rem;
  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.5rem;}
.brand .glyph-mark{
  font-family:'Ma Shan Zheng', cursive;
  font-size:1.5rem;
  line-height:1;
  color:var(--gold-bright);
  text-shadow:0 2px 10px rgba(0,0,0,0.4);
  /* Discreet curator trigger (see index.html's own comment) - a plain
     pointer cursor is the only affordance, deliberately no visible
     button chrome/hover-background, so it stays a normal-looking corner
     mark to anyone who isn't Mike. */
  position:relative;
  cursor:pointer;
}
.brand .wordmark{
  display:flex; flex-direction:column; gap:0.1rem;
}
.brand .wordmark .en{
  font-size:0.62rem;
  letter-spacing:0.28em;
  text-transform:uppercase;
  color:var(--gold);
  font-weight:700;
}

/* ---- CLASSIC | PILES mode switch - sits where the old permanently-
   hidden .tag hint text used to reserve space, right under the
   wordmark, so it adds no new competing row to an already-tight mobile
   topbar. See script.js's "GAME MODE" section. ---- */
.mode-switch{ display:flex; flex-direction:column; align-items:flex-start; gap:0.2rem; margin-top:0.15rem; }
.mode-switch.hide{ display:none; } /* hidden during ?devPile=1 TEST mode - see updateDevPileBanner() */
.mode-toggle{
  display:flex; border:1px solid rgba(205,162,61,0.45); border-radius:7px;
  overflow:hidden;
}
.mode-btn{
  appearance:none; border:none; cursor:pointer;
  background:transparent; color:var(--paper-dim);
  font-family:inherit; font-size:0.6rem; font-weight:700;
  letter-spacing:0.06em; text-transform:uppercase;
  padding:0.25rem 0.5rem;
  transition:background 0.12s ease, color 0.12s ease;
}
.mode-btn + .mode-btn{ border-left:1px solid rgba(205,162,61,0.3); }
/* Mammoth tab's icon is a real asset (assets/mammoth/mammoth-icon2.png),
   not a text glyph like CLASSIC/PILES - needs its own flex centering
   (a masked element doesn't center itself via the shared .mode-btn's
   text layout). Rendered as a solid-color mask rather than an <img> so
   it colorizes like a flat-fill icon instead of staying pinned to
   whatever flat black (or whatever color) the source PNG happens to be.
   Fixed to --paper (not currentColor/the mode-btn's own paper-dim/
   gold-bright active-state swap) so it reads as one consistent icon
   color matching Hint/Undo/Shuffle/Settings, rather than changing look
   with CLASSIC/PILES' own active-state text color. The source PNG is a
   single-color silhouette on transparency, which is exactly what a CSS
   mask wants; height is fixed and width derived from the source's own
   752:529 (~1.422:1) aspect ratio so it isn't stretched. */
.mode-btn-mammoth{ display:inline-flex; align-items:center; justify-content:center; }
.mammoth-face-icon{
  display:block;
  height:1.15rem;
  width:calc(1.15rem * 752 / 529);
  background-color:var(--paper);
  -webkit-mask-image:url('assets/mammoth/mammoth-icon2.png');
  mask-image:url('assets/mammoth/mammoth-icon2.png');
  -webkit-mask-repeat:no-repeat;
  mask-repeat:no-repeat;
  -webkit-mask-position:center;
  mask-position:center;
  -webkit-mask-size:contain;
  mask-size:contain;
}
.mode-btn.active{ background:linear-gradient(180deg, #3a5148, #1e3a33); color:var(--gold-bright); }
.mode-btn:disabled{ opacity:0.35; cursor:not-allowed; }
.mode-forced{
  font-size:0.52rem; color:var(--gold); letter-spacing:0.02em;
  max-width:11rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.mode-forced:empty{ display:none; }

/* ---- hidden curator menu (Part 3) - anchored to .glyph-mark, open only
   via #curator-trigger's click handler in script.js. LIVE piles only,
   never TEST data - see getLivePiles()'s own comment. ---- */
.curator-menu{
  display:none;
  position:absolute; top:100%; left:0; margin-top:0.5rem;
  min-width:11rem; max-width:15rem; max-height:60vh; overflow-y:auto;
  background:linear-gradient(180deg, var(--felt-rail), var(--felt-mid));
  border:1px solid rgba(205,162,61,0.45); border-radius:8px;
  box-shadow:0 10px 28px rgba(0,0,0,0.5);
  z-index:50; padding:0.3rem;
  -webkit-user-select:auto; user-select:auto; /* .topbar disables selection generally - this popover's text should behave normally */
  /* Nested inside .glyph-mark (see index.html) for positioning
     convenience - without this, its text silently inherits
     .glyph-mark's 'Ma Shan Zheng' cursive branding font, which is
     exactly what made the curator's pile names hard to scan. This menu
     is a functional utility list, not brand typography, so it gets its
     own explicit sans-serif (the same one the rest of the app's body
     text uses) regardless of where it happens to sit in the DOM. */
  font-family:'Manrope', ui-sans-serif, system-ui, sans-serif;
}
.curator-menu.open{ display:block; }
.curator-item{
  display:block; width:100%; text-align:left;
  appearance:none; border:none; cursor:pointer;
  background:transparent; color:var(--paper);
  font-family:inherit; font-size:0.7rem; font-weight:600;
  padding:0.4rem 0.5rem; border-radius:5px;
  white-space:normal;
}
.curator-item:hover{ background:rgba(231,196,101,0.14); }
.curator-item.active{ color:var(--gold-bright); }
.curator-sep{ height:1px; background:rgba(205,162,61,0.3); margin:0.25rem 0.1rem; }
.curator-empty{ padding:0.4rem 0.5rem; font-size:0.66rem; color:var(--paper-dim); }

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

.controls{display:flex; gap:0.35rem;}
.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:8px;
  padding:0.4rem 0.55rem;
  font-family:inherit;
  font-size:0.68rem;
  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;}

/* Icon-only utility buttons (Hint/Undo/Shuffle/Settings) - same .tok-btn
   look (background/border/radius/shadow/hover/active/focus/disabled all
   still come from that shared rule above; nothing here overrides them),
   just square and centered around a glyph instead of a padded text
   label. Explicit width/height (not intrinsic content sizing) is what
   guarantees Hint/Undo/Shuffle's new SVG icons and Settings' existing
   Unicode gear render at IDENTICALLY the same box - previously Settings
   was sized incidentally (font-size + padding around one glyph
   character), which two different content types (SVG vs Unicode text)
   can't be trusted to reproduce by coincidence. Sized from Settings'
   own previous rendered box (~26x30px) - effectively unchanged for
   Settings, and what the three new icon buttons now match exactly. */
.icon-btn{
  width:1.875rem;
  height:1.875rem;
  padding:0;
  display:inline-flex;
  align-items:center;
  justify-content:center;
  font-size:1.15rem; /* only actually affects Settings' own "⚙" text glyph - the SVG icons size themselves explicitly below */
  line-height:1;
}
/* Sized well within the button box (not edge-to-edge) so the icon reads
   at a comparable visual weight to the gear glyph beside it, with
   comfortable internal breathing room - stroke is currentColor, so it
   already follows .tok-btn's own color/opacity (including the disabled
   state) with no separate rule needed. */
.icon-btn svg{ display:block; width:1.05rem; height:1.05rem; }
/* Settings' own gear glyph, enlarged relative to the shared .icon-btn
   sizing above (which Hint/Undo/Shuffle's SVGs still use unchanged) -
   a plain Unicode "⚙" reads visually smaller than those SVGs at the
   same box size, so it gets its own bigger box + font-size rather than
   changing the shared rule everything else depends on. */
#btn-settings{ width:2.35rem; height:2.35rem; font-size:1.6rem; }

main{
  flex:1;
  display:flex;
  align-items:flex-start;
  justify-content:center;
  /* Top padding is the ENTIRE tray-to-pile gap: .tray and main are
     adjacent siblings in body's flex column with no other spacing
     between them, and #board-wrap (main's child) starts exactly
     padding-top below main's own border-box - confirmed by measuring
     board-wrap's top vs tray's bottom (12px gap, exactly this value at
     default 16px root) before touching it. Reduced from 0.75rem for a
     modestly tighter tray-to-pile relationship; left/right/bottom
     padding (the board's own side/bottom breathing room) are untouched. */
  padding:0.45rem 1rem 0.75rem;
  min-height:0;
  position:relative;
}

/* WOOLLY MAMMOTH entry ritual - absolutely positioned INSIDE main
   (which is already position:relative, see above), so this can only
   ever cover the board area, never the topbar/tray above it - that's
   what guarantees it "does not block the header" with no extra
   measurement/z-index gymnastics. Not a modal: no backdrop, no card -
   just the mark (and its own soft halo) floating above the tiles as
   they sweep in underneath. Hidden (no .show) the rest of the time, so
   it never consumes layout space. See enterMammoth() in script.js for
   the sequencing - the mark fades in FIRST, THEN assembly starts behind
   it, then the mark drops away (same straight-down exit as
   #intro-screen below, not a fade) timed to finish as assembly does. */
#mammoth-logo{
  position:absolute; inset:0;
  display:none;
  pointer-events:none; /* input is already locked via state.assembling during this whole window - this only guards against the (hidden) element intercepting anything by accident */
  z-index:40;
}
#mammoth-logo.show{ display:block; }
/* Halo and mark are both centered the same way (own element, top:50%/
   left:50%/translate, NOT flex-centering) so the halo can be sized
   larger than the mark without flex layout fighting that - a flex
   child's own box is what flex would center, and I want two
   independently-sized, concentric boxes here. */
#mammoth-logo-halo, #mammoth-logo-mark{
  position:absolute;
  top:50%; left:50%;
  transform:translate(-50%,-50%);
}
/* Soft dark backdrop just behind the mark - its own separate element
   (not a ::before on the mark) so it can be sized independently (wider
   than the mark, its own aspect ratio) and so a glow meant to extend
   past the mark's own edges is never clipped to the mark's box.
   Deliberately a plain radial-gradient fade (no hard edge/ring) so it
   reads as depth/contrast, not a decorative shape of its own - only
   needed once tiles start moving underneath the logo, so its opacity
   fades in on the exact same timeline as the mark and it drops away
   together with the mark as one rigid unit at exit (see
   dropOutMammothLogo() in script.js - both are driven by JS/WAAPI, not a
   CSS transition, so that timeline stays the single source of truth). */
#mammoth-logo-halo{
  width:min(78vw, 340px);
  aspect-ratio:1;
  background:radial-gradient(closest-side, rgba(0,0,0,0.45), rgba(0,0,0,0) 72%);
  opacity:0;
}
/* The approved PNG rendered as-is - it's a finished full-color badge
   (green fill, gold outline/bevel, "WOOLLY MAMMOTH" text all baked into
   the art itself), not the black-line-only source the mask-recolor
   technique below was built for, so it's painted directly as a
   background-image instead of used as a mask. aspect-ratio matches the
   source PNG (1337x1177) so nothing stretches. A soft drop-shadow is the
   only added treatment - restrained on purpose (quiet, not a shiny
   reward screen); opacity itself is driven entirely from script.js via
   WAAPI, not a CSS transition, so the JS-owned fade timeline
   (MAMMOTH_LOGO) stays the single source of truth. */
#mammoth-logo-mark{
  width:min(68vw, 300px);
  aspect-ratio:1337/1177;
  background-image:url('assets/mammoth/woolly-mammoth-logo.png');
  background-repeat:no-repeat;
  background-position:center;
  background-size:contain;
  filter:drop-shadow(0 3px 10px rgba(0,0,0,0.35));
  opacity:0;
}

.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 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.45rem 0.6rem;
}
.tray-well{
  position:relative;
  border-radius:10px;
  /* Darkened recessed interior (was 0.22/0.08) + a single-pixel inset
     highlight along the top inner edge, as if the rim catches a little
     ambient light - deliberately just one low-opacity line, no blur/
     glow/thick border, so it reads as "recessed physical tray" rather
     than a decorative effect. Dimensions/scale (see sizeTrayWell in
     script.js) are completely untouched - this is paint only. */
  background:linear-gradient(180deg, rgba(0,0,0,0.36), rgba(0,0,0,0.16));
  border-bottom:1px solid rgba(205,162,61,0.22);
  box-shadow:
    inset 0 3px 8px rgba(0,0,0,0.45),
    inset 0 1px 0 rgba(255,255,255,0.10),
    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. */
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;}

/* FACE-DOWN TILE BACK (see FACE_DOWN in script.js for the interaction
   rules/tuning). Reuses .face itself rather than a second full card
   element, so the back is guaranteed the exact same border/box-shadow/
   rounded-corner "frame" as the front with zero duplicated rules - only
   .art/.glyph are hidden and .back shown in their place, clipped by
   .face's own existing border-radius+overflow:hidden exactly like .art
   already is. .back's artwork is the approved finished PNG
   (assets/tiles/tile-back.png - see assets/tiles/README.md) via a plain
   background-image, same idiom as .art's own background-image, just
   without .art's per-kind sprite-offset math since this is one static
   image for every hidden tile, not a sprite sheet - no JS sizing needed
   at all. background-size:cover fills the tile with no stretching
   (crops slightly rather than distorting - the source image's aspect
   ratio is close to but not exactly the tile's own, ~814:1106 vs 56:74).
   An earlier CSS/SVG-mask deboss prototype lived here; removed once this
   asset was approved (see assets/tiles/README.md for what replaced it). */
.tile .back{
  position:absolute; inset:0;
  display:none;
  pointer-events:none;
  /* Flat fallback color (background-color, painted underneath) for the
     brief window before tile-back.png has loaded/decoded - same choice
     and reasoning already validated for .tile.mammoth-cheap.face-down
     above (--tile-face-hi, confirmed visually indistinguishable from the
     real artwork on a real device since buried tiles are mostly
     obscured), reused here rather than a second decision. Previously this
     had no fallback at all - a genuinely blank tile-colored rectangle
     until the image arrived (see mike-games-system/SYSTEM.md §01, the
     readiness-gate audit that found this). background-image paints over
     this the instant it's ready, same as background-color always layers
     under background-image. */
  background-color:var(--tile-face-hi);
  background-image:url('assets/tiles/tile-back.png');
  background-size:cover;
  background-position:center;
}
.tile.face-down .face .art,
.tile.face-down .face .glyph{ visibility:hidden; }
.tile.face-down .face .back{ display:block; }
/* The flip itself: .face narrows on its own horizontal axis (scaleX),
   NOT a 3D rotateY/perspective card-flip - "turn around its vertical
   axis" is delivered as the classic 2D width-squash illusion, which is
   also strictly cheaper (compositor-only 2D transform, no perspective/
   backface-visibility compositing cost) - see playTileFlip() in
   script.js, which drives this via two chained WAAPI calls rather than a
   CSS animation, so the art/back content-swap can be synced exactly to
   the animation boundary between them (the true edge-on moment) instead
   of guessed with a separate timer. No CSS keyframe needed here at all.
   Origin stays centered (default) so it narrows evenly from both sides,
   not like a page curling from one edge. */

.tile.blocked{
  pointer-events:none;
}
/* Blocked tiles keep only a slightly heavier resting shadow than free
   tiles (a subtle depth cue) - no saturate/brightness dimming. Mike's
   explicit call (2026-09-04) after seeing the dimmed look live: blocked
   tiles should read visually normal, not shaded/grayed-out. */
.tile.blocked .face{
  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);
}
/* PILE ASSEMBLY - suppresses the permanent per-tile drop-shadow (see
   PERF_TEST_DISABLE_TILE_FILTER in script.js) plus its transition while
   the new-game entrance animation is running (see PILE_ASSEMBLY/
   playPileAssembly in script.js). Filter recomposite across many
   simultaneously-animating tiles is exactly the cost that flag was
   measuring - this keeps the assembly's own staggered transform work
   from paying it too, and reverts automatically the instant assembly
   ends (the class comes off), so the resting pile's permanent look is
   never affected. */
body.pile-assembling .tile{
  filter:none;
  transition: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;}
/* .tok-btn has no tag qualifier, so it already styles #btn-support-coffee
   (a real <a>, see index.html) identically to every button sibling - this
   just resets the underline an anchor gets that a button never would. */
.tok-btn[href]{text-decoration:none;}
/* #btn-settings-feedback sits on its own line ABOVE the Reload/Close row
   (see index.html) - deliberately the plain .tok-btn look, no brand
   coloring like #btn-settings-support below, since this reads as a
   normal Settings action rather than a promotional CTA. */
#btn-settings-feedback{ display:block; width:100%; margin-bottom:0.9rem; }
/* Buy Me a Coffee's own brand yellow rather than this app's usual dark-
   green button fill, so it reads as the actual BMC button on sight. */
#btn-support-coffee{
  background:#ffdd00;
  border-color:#ffdd00;
  color:#222;
}
#btn-support-coffee:hover{background:#ffe94d; border-color:#ffe94d;}
/* #btn-settings-support sits on its own line below the Reload/Close row
   rather than crowding into that flex row as a third item - see
   index.html. Buy Me a Coffee's own brand yellow, same as
   #btn-support-coffee below, so the row and what it opens read as one
   consistent thing. */
#btn-settings-support{
  display:block; width:100%; margin-top:0.9rem;
  background:#ffdd00;
  border-color:#ffdd00;
  color:#222;
}
#btn-settings-support:hover{background:#ffe94d; border-color:#ffe94d;}

/* Every pixel of vertical space here is a pixel the board doesn't get,
   so this stays permanently compact and the footer hint stays hidden
   (used to be a >700px-only "roomy desktop" pair of rules - the app is
   always mobile-width now, so there is no roomy variant to reserve
   these for; see --app-max-width). */
footer{ display:none; }

/* DEV BRIDGE: authored-pile test-mode banner - hidden by default. This
   one stays fully invisible until script.js explicitly adds `.show`,
   since a banner that's WRONG when shown (claiming test mode during
   normal play) is a real bug, not just visual clutter. See
   index.html/script.js. */
#dev-pile-banner{
  display:none;
  position:fixed;
  top:calc(4px + env(safe-area-inset-top, 0px));
  right:calc(4px + env(safe-area-inset-right, 0px));
  z-index:97;
  align-items:center;
  flex-wrap:wrap;
  gap:6px;
  background:#ff8fd1;
  border:2px solid #000;
  padding:3px 6px;
  font-family:ui-monospace, 'SF Mono', monospace;
  font-size:10px;
  color:#000;
  max-width:90vw;
}
#dev-pile-banner.show{ display:flex; }
#dev-pile-banner button, #dev-pile-banner select{
  font-family:inherit;
  font-size:10px;
  max-width:40vw;
}
/* Lives inside #modal-settings now (see index.html) rather than
   floating over the game at all times - see BUILD_TAG/renderDiag in
   script.js for what it shows (a stale-cache tripwire plus the last
   uncaught error, if any). Simple in-flow text within the modal card,
   not its own fixed-position panel anymore, so none of the old
   safe-area/z-index/scroll-containment concerns apply here - the modal
   card already handles its own sizing. Still deliberately plain
   monospace/muted, in the same "looks like debug scaffolding, not a
   shipped feature" register as before. */
#build-tag{
  margin-top:1.1rem;
  font-family:ui-monospace, 'SF Mono', monospace;
  font-size:9px;
  line-height:1.5;
  white-space:pre-line;
  color:rgba(239,230,208,0.45);
  user-select:none;
}
#build-tag:empty{ display:none; }

/* ============================================================
   OPENING INTRO - markup lives in index.html (three independent
   layers: #intro-mike, #intro-apostrophe, #intro-object, the last
   one meant to be swapped per-game later). Purely CSS-timed via
   animation-delay so the sequence starts the instant the page
   paints, with no JS orchestration needed to kick it off - see
   INTRO_ENABLED/initIntro() in script.js, which only ever *removes*
   this element once #intro-screen's own animation finishes (or a
   safety-net timeout fires); it never gates or waits on game boot.
   Timeline (~3.25s total, deliberately exaggerated for local rhythm
   review - see the request that produced this pass) - four DISTINCT,
   separated beats, not a continuous stream: MIKE is simply present
   from first paint (no animation at all, @0ms) and holds ALONE for
   1000ms; apostrophe-s then snaps in near-instantly @1000ms and sits
   with MIKE alone for another 350ms; the tile begins its slide @1350ms
   and stops @1700ms; the complete lockup then holds completely still
   for 1200ms; at 2900ms the ENTIRE #intro-screen - white background
   included - drops straight down and off the bottom of the viewport as
   one rigid unit (ends ~3250ms). MIKE/apostrophe/tile never get their
   own exit animation; #intro-screen's transform is what moves, so
   everything inside it is carried along already perfectly locked
   together - no separate synchronization needed. */
#intro-screen{
  position:fixed;
  inset:0;
  z-index:9999;
  background:#fff;
  display:flex;
  justify-content:center;
  animation:introScreenDrop 350ms cubic-bezier(.4,0,1,1) 2900ms forwards;
  /* MIKE Games System readiness gate (CORE, see mike-games-system/
     SYSTEM.md §01, and initIntro()/finishWhenReady() in script.js) -
     paused by default so the drop can never start counting down its
     2900ms delay until script.js flips this to running. A paused CSS
     animation's own delay does not tick down, so releasing this later
     doesn't skip or compress anything - the timing above is unchanged,
     it just doesn't begin until the gate says so. */
  animation-play-state:paused;
}
#intro-frame{
  position:relative;
  width:100%;
  max-width:var(--app-max-width);
  height:100%;
  padding-top:env(safe-area-inset-top, 0px);
  padding-bottom:env(safe-area-inset-bottom, 0px);
}
/* FAILURE/TIMEOUT-only recovery sheet (see initIntro() in script.js) -
   absolutely positioned within #intro-screen (fixed) so it's taken out of
   flow regardless of #intro-screen's own flex layout, and can never
   disturb #intro-frame's centering. Hidden by default; script.js only
   ever reveals it from showRecovery(), never automatically retries, and
   never appears on a genuine SUCCESS. */
#intro-recovery{
  position:absolute;
  left:16px;
  right:16px;
  bottom:max(48px, calc(env(safe-area-inset-bottom, 0px) + 32px));
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:14px;
  text-align:center;
}
#intro-recovery.hidden{ display:none; }
/* MIKE Games System install prompt - live-toggled Settings row (native
   Chrome/Android install only; see #btn-settings-install in index.html
   and onInstallAvailabilityChange in script.js). */
#btn-settings-install.hidden{ display:none; }
#intro-recovery p{ margin:0; color:#333; font-size:15px; }
#intro-recovery button{
  padding:10px 22px;
  border-radius:6px;
  border:1px solid #333;
  background:#fff;
  color:#333;
  font-size:14px;
  font-weight:600;
  cursor:pointer;
}
#intro-recovery button:hover{ background:#f2f2f2; }
#intro-recovery button:focus-visible{ outline:2px solid #333; outline-offset:2px; }
/* MIKE alone is what's centered - this wrapper shrink-wraps to
   #intro-mike's own box (the only normal-flow child; apostrophe-s is
   taken out of flow below) and is statically centered via its own
   (non-animated) transform, so MIKE's true center always lands on the
   screen's true center regardless of the 's hanging off to its right.
   MIKE/apostrophe-s each animate their OWN transform below, which
   composes fine since a child's transform never fights its parent's. */
#intro-wordmark{
  position:absolute;
  left:50%;
  top:33%;
  transform:translate(-50%,-50%);
}
#intro-wordmark img{ display:block; }
#intro-mike{ display:block; }
#intro-mike img{ height:9vh; width:auto; }
/* Positioned relative to #intro-wordmark (centered on MIKE), not a flex
   sibling of it - left:100% + margin-left is the old flex gap, and
   top:50%/translateY(-50%) reproduces the old align-items:center
   (vertically centered against MIKE's own height, which is what
   #intro-wordmark now shrink-wraps to). */
#intro-apostrophe{
  position:absolute;
  left:100%;
  top:50%;
  transform:translateY(-50%);
  margin-left:0.7vw;
  display:block;
}
/* Sized to match the Klondike Solitaire intro's own final MIKE+'s
   lockup (../klondike-solitaire/style.css: mike=6.2 intro-units,
   apostrophe=2.3 units), not an independent guess - same ratio
   (2.3/6.2 = 0.3710) applied to this app's own 9vh MIKE height:
   9 * 2.3/6.2 = 3.34vh. Previously 6vh (0.667x MIKE), which read much
   bigger than Klondike's 's relative to its own MIKE. */
#intro-apostrophe img{ height:3.34vh; width:auto; }
/* MIKE has no animation at all - it's simply part of the static page,
   present from the very first paint (printed into the white field, not
   animated in), which is what gives Phase 1 its true MIKE-only beat. */
#intro-mike{ opacity:1; }
/* Apostrophe-s stays invisible through the MIKE-only pause, then snaps
   to visible near-instantly at 1000ms - a 10ms "animation" only exists
   so it has a real animationend and a delay to hang the snap on; there
   is no transform/scale/rotation to see it move through. Then sits with
   MIKE alone (its own separate beat) until the tile starts at 1350ms. */
#intro-apostrophe{ opacity:0; animation:introFadeIn 10ms linear 1000ms forwards; animation-play-state:paused; }
/* Same static/animated split as the wordmark: #intro-object-pos
   places the tile's resting spot, #intro-object animates the land. */
#intro-object-pos{
  position:absolute;
  left:50%;
  top:49%;
  transform:translate(-50%,-50%);
  width:72vw;
  max-width:calc(var(--app-max-width) * 0.72);
}
/* filter:drop-shadow (not box-shadow) so the shadow follows the tile's
   actual silhouette from its transparent PNG, not the image canvas
   rectangle - moves with #intro-object's own transform automatically
   since it's painted post-transform. Tight offset + small blur so the
   tile reads as resting directly on the white surface, not hovering. */
#intro-object-pos img{ display:block; width:100%; height:auto; filter:drop-shadow(2px 3px 3px rgba(0,0,0,.3)); }
#intro-object{ display:block; opacity:0; animation:introObjIn 350ms cubic-bezier(.08,.85,.15,1) 1350ms forwards; animation-play-state:paused; }
/* MIKE Games System readiness gate release (see script.js) - script.js
   adds this class to #intro-screen once its critical startup readiness
   contract resolves (sprite sheet + tile-back, decoded - plus the Mammoth
   badge when entering that mode - see criticalAssetsReadyPromise) or a
   generous safety-net elapses. Every animation-delay above starts
   counting from this moment, not from page load. */
#intro-screen.intro-ready,
.intro-ready #intro-apostrophe,
.intro-ready #intro-object{
  animation-play-state:running;
}

@keyframes introFadeIn{ from{ opacity:0; } to{ opacity:1; } }
/* The strongest motion in the sequence: a fast horizontal slide in FROM
   THE LEFT (translateY stays 0 the whole time - no diagonal, no drop
   from above), at its resting -8deg tilt the entire time (no rotation
   change, no scale change - a slide, not a pop/land), that decelerates
   hard into an abrupt stop. cubic-bezier here keeps both y-control-
   points <=1 specifically so the curve can never overshoot past 100%
   (no bounce/rebound), while front-loading almost all the motion into
   roughly the first third of the duration so what's left at the tail
   is too small to read as drift - it just stops. -130% (of the
   element's own width) starts it well past the left edge at every
   supported viewport width. */
@keyframes introObjIn{
  0%{ opacity:0; transform:translate(-130%,0) rotate(-8deg); }
  15%{ opacity:1; }
  100%{ opacity:1; transform:translate(0,0) rotate(-8deg); }
}
/* Exit: the whole screen (white bg + everything in it) translates
   straight down as one rigid unit - no opacity change, no scale, no
   rotation, nothing animated independently inside it. 115% (of the
   element's own height, which is a full 100vh via inset:0) guarantees
   full clearance past the viewport edge with a little margin to spare.
   cubic-bezier(.4,0,1,1) is a pure accelerate curve (both control
   points' y-values are 0 or 1, so it's monotonic - mathematically no
   bounce/recoil): a brief slow start that keeps speeding up the whole
   way, never easing off at the end. */
@keyframes introScreenDrop{
  0%{ transform:translateY(0); }
  100%{ transform:translateY(115%); }
}
@keyframes introFadeOutReduced{ from{ opacity:1; } to{ opacity:0; } }

@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. */

  /* Intro: MIKE is already static and apostrophe already a near-instant
     opacity snap - neither has real motion to trim. Still swap the
     tile's slide and the screen's drop for quick plain fades at the
     same beats, no transform. */
  #intro-object{
    animation-name:introFadeIn;
    animation-duration:150ms;
    animation-timing-function:ease-out;
    animation-delay:1350ms;
    animation-fill-mode:forwards;
  }
  #intro-screen{
    animation-name:introFadeOutReduced;
    animation-duration:200ms;
    animation-timing-function:ease-out;
    animation-delay:2900ms;
    animation-fill-mode:forwards;
  }
}

/* ============================================================
   MAMMOTH LIGHTWEIGHT RENDERING - see MAMMOTH_LIGHTWEIGHT in script.js.
   Only ever applied to a Mammoth tile that promoteMammothTile()/
   demoteMammothTile() have decided is currently "cheap" - Classic and
   Piles never get this class, so this rule can never affect them.

   Deliberately minimal: no box-shadow (the real cost this rendering
   scheme exists to avoid - blur/raster is expensive to compute at this
   scale, unlike a plain border), no gradient, no pseudo-element, no
   transition, no artwork. A flat fill plus plain (non-blurred) borders
   is the cheapest depth cue available - it still reads as "a tile"
   without paying for "a beautiful interactive foreground tile" on a
   buried position the player can't act on yet. Do not add decorative
   effects back here - confirmed on a real iPhone that DOM richness, not
   tile count, was the actual cost.

   border-radius matches the rich tile's own .face radius exactly (7px)
   so a cheap tile's silhouette doesn't visibly mismatch its rich
   neighbors in the pile, and promoting one mostly reads as "the face
   artwork appearing" rather than "the shape changing". The bottom+right
   border pair reads as a slightly thicker "raised card" edge from the
   same top-left implied light direction the rich tile's own box-shadow
   uses, still zero blur/layers.

   Face-down uses --tile-face-hi (the existing lighter-cream custom
   property already used as the rich face's own gradient highlight stop)
   rather than --tile-shade (a brown/gold tone with no relationship to
   the actual tile-back artwork - see assets/tiles/tile-back.png, which
   is pale cream/ivory with a soft mauve accent shape, the same
   porcelain family as the face) - confirmed on a real iPhone against
   the alternative of using that artwork directly as a background-image:
   visually indistinguishable during real play since buried face-down
   tiles are mostly obscured, so the flat approximation ships instead -
   zero new colors defined, zero image paint cost. The "which side is
   thicker" flip (top+left instead of bottom+right) is the only cue
   distinguishing a hidden tile from a buried face-up one, purely via
   border-side selection. */
.tile.mammoth-cheap{
  border-radius:7px;
  border-bottom:2px solid var(--tile-shade);
  border-right:2px solid var(--tile-shade);
  background:var(--tile-face);
  border-top:1px solid var(--tile-edge);
  border-left:1px solid var(--tile-edge);
  /* TEMP: no-op for a plain blank shell (no background-image set here),
     but required once MAMMOTH_FACE_VISIBILITY's tier B paints a sprite
     crop or the tile-back image directly on this same element's own
     background (see drawMammothCheapArt() in script.js) - without this,
     any rounding in the crop math could tile a sliver of the image
     along an edge instead of showing a single clean tile face. */
  background-repeat:no-repeat;
}
.tile.mammoth-cheap.face-down{
  background:var(--tile-face-hi);
  border-bottom:1px solid var(--tile-edge);
  border-right:1px solid var(--tile-edge);
  border-top:2px solid var(--tile-shade);
  border-left:2px solid var(--tile-shade);
}

