/* ===========================================================================
   game.css — the frame around the canvas, and the fallback when there is none
   ---------------------------------------------------------------------------
   Two layouts live in this file and exactly one class switches between them:

     (no class)   the pre-JS / still-loading / boot-failed state. The stage is
                  an ordinary scrolling column: heading, blurb, five link cards,
                  plaque. Nothing here depends on JS having run.

     .is-ready    main.js adds it only after the sprite sheets decoded AND the
                  static layers prerendered. The canvas appears, the heading and
                  blurb become screen-reader-only, and the same five anchors turn
                  into transparent hit boxes tracked to the world.

   Never make the game state the default. If .is-ready is the fallback then a
   404 on one PNG produces a blank green rectangle with five invisible links in
   it, which is worse than no game at all because a visitor cannot tell that
   anything is wrong.

   WHY #game IS z-index 0
   ----------------------
   brokeh-nav injects header (1002), menu (1000) and scrim (999) as siblings
   ahead of #game in the DOM. `position:fixed; z-index:0` on #game makes it a
   stacking context, so every descendant -- door anchors, sign plates, focus
   rings, the plaque -- is painted inside that one context and can never rise
   above the nav no matter what z-index it is given. brokeh-nav.css's header
   comment documents the last time nav layering was fought one z-index at a
   time; a single low context makes the whole class of bug unreachable here.
   =========================================================================== */

:root {
  /* Forest palette, identical to resume.html so the nav inherits the theme with
     no per-page override of its own colours. */
  --bg:       #1a2417;  /* deep forest floor */
  --bg-deep:  #0f170d;  /* gradient partner  */
  --card:     #24301f;  /* mossy panel       */
  --card2:    #2b3826;  /* raised moss       */
  --accent:   #d9a441;  /* lantern amber     */
  --emerald:  #5cad6a;  /* canopy green      */
  --text:     #efe6d2;  /* warm parchment    */
  --muted:    #a89b80;  /* dry grass         */
  --line:     #3a4a30;  /* moss border       */

  --font-body: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-display: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;

  /* Page-local nav cosmetics. The header floats over live grass here, and its
     stock transparent background left the logo and hamburger illegible over the
     bright tiles. A top fade keeps both readable without boxing the header in. */
  --nav-header-bg: linear-gradient(to bottom, rgba(15, 23, 13, 0.92), rgba(15, 23, 13, 0.55) 55%, rgba(15, 23, 13, 0));
  --nav-header-pad: 1rem 1.25rem;

  /* Integer upscale factor, written by main.js. Used to size the sign plates so
     they grow with the art instead of floating free of it. */
  --gs: 3;
}

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

/* Deliberately NOT in the universal rule above: a direct font-family
   declaration beats an inherited one, so `* { font-family }` also lands on the
   nav's own <a> elements and overrides what .menu sets from --nav-font. Setting
   it on <body> lets the nav keep its stack. */
body {
  font-family: var(--font-body);
  color: var(--text);
  background: linear-gradient(160deg, var(--bg), var(--bg-deep));
  min-height: 100dvh;
}

/* Scroll lock is added by JS, not written here unconditionally: the pre-JS
   fallback list is taller than a short phone and has to keep scrolling. */
body.game-ready { overflow: hidden; }

/* ===== ROOT ============================================================== */

.game {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* 100dvh, not 100vh: under a mobile browser toolbar 100vh overshoots the
     visible area and buries the bottom of the page. main.js overwrites this
     with an explicit innerHeight in px, which also wins over the `bottom` a
     shorthand `inset` would set. */
  height: 100dvh;
  z-index: 0;
  overflow: auto;
  background: linear-gradient(160deg, var(--bg), var(--bg-deep));
}

/* clip, not hidden. `overflow:hidden` still makes a SCROLL CONTAINER -- it just
   hides the bars -- so when Tab reaches a door anchor that the camera has not
   panned to yet, the browser helpfully scrolls it into view and drags the
   canvas and the whole door overlay along with it. The view ends up offset from
   the world by a few hundred pixels with no way back. `overflow:clip` is not
   scrollable at all, which makes that impossible; main.js keeps a scrollTop
   reset as a fallback for engines that ignore it. Panning the camera to the
   focused door is what replaces the browser's behaviour. */
.game.is-ready { overflow: hidden; overflow: clip; }

.game__stage {
  position: relative;
  min-height: 100%;
  padding: 5.5rem 1.25rem calc(2.5rem + env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.25rem;
}

.game.is-ready .game__stage {
  display: block;
  padding: 0;
  height: 100%;
  overflow: hidden;
  overflow: clip;   /* see .game.is-ready above */
}

/* ===== CANVAS ============================================================
   Backing store is world pixels; CSS multiplies by an integer scale. Because
   the only upscale happens on a fully composed 1x image, atlas bleed between
   neighbouring tiles is structurally impossible and a phone rasterises tens of
   thousands of pixels a frame instead of millions. */
.game__canvas { display: none; }

.game.is-ready .game__canvas {
  display: block;
  position: absolute;   /* left/top set by main.js -- see layout() */
  /* Order matters: the last declaration a browser understands wins. crisp-edges
     goes first as the fallback for engines without `pixelated`; pixelated goes
     last because it is the standard keyword and the one that guarantees
     nearest-neighbour at integer scales. Reversed, Chromium resolved to
     crisp-edges and never saw the keyword we actually meant. */
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  /* Without this a vertical drag scrolls the page instead of steering. */
  touch-action: none;
}

/* Vignette + moss frame. The sheet's grass is a limey #7bad2c while every other
   green on the site is hue 97-131, so an unframed canvas edge reads as a hard
   colour cut against the chrome. Darkening the rim reconciles them and doubles
   as the letterbox when the world is narrower than the viewport. Sits above the
   canvas but below the door anchors, so it can never dim a sign plate. */
.game.is-ready .game__stage::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  box-shadow:
    inset 0 0 0 2px var(--line),
    inset 0 0 6rem 2.5rem rgba(15, 23, 13, 0.55);
}

/* ===== HEADING + BLURB ===================================================
   Kept in the accessibility tree in both states. display:none would drop the
   page's only <h1> and its control instructions the moment the game booted. */
.game__title {
  font-family: var(--font-display);
  font-size: clamp(1.6rem, 7vw, 2.4rem);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
  text-align: center;
}

.game__lede {
  max-width: 34rem;
  text-align: center;
  line-height: 1.65;
  color: var(--muted);
}

.game.is-ready .game__title,
.game.is-ready .game__lede {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ===== DOORS: FALLBACK LIST ============================================== */

.game__doors {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  width: 100%;
  max-width: 22rem;
}

.door {
  display: block;
  position: relative;
  padding: 0.85rem 1.1rem;
  border: 1px solid var(--line);
  border-left: 4px solid var(--emerald);
  border-radius: 6px;
  background: var(--card);
  color: var(--text);
  text-decoration: none;
  font-family: var(--font-display);
  font-size: 0.95rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  transition: background 0.18s ease, border-color 0.18s ease;
  /* Cuts the ~300ms tap delay without disabling pinch-zoom on the page. */
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}

.door:hover,
.door:focus-visible { background: var(--card2); border-left-color: var(--accent); }

.door:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }

.game__note {
  max-width: 34rem;
  text-align: center;
  font-size: 0.82rem;
  line-height: 1.6;
  color: var(--muted);
}

.game__note b { color: var(--text); font-weight: 600; }

/* ===== DOORS: IN-WORLD ===================================================
   Transparent boxes tracked to the world by doors.js with transform (never
   left/top, which would relayout five elements every camera step). All the
   inputs are integers, so each box lands on an exact CSS pixel and the focus
   ring stays crisp. */

.game.is-ready .game__doors {
  display: block;
  position: absolute;   /* left/top/width/height set to match the canvas box */
  max-width: none;
  z-index: 2;
  /* This container is stretched over the ENTIRE canvas so its absolutely
     positioned children can be placed in canvas coordinates. That makes it a
     full-screen sheet sitting above the play area, so without this it silently
     eats every pointer event that is not on a door: taps on doors still worked
     (they hit a child), but tap-to-move and drag-to-steer were dead everywhere,
     desktop and mobile alike. Only the door rects should be targets, so the
     sheet is transparent to hit testing and each door opts back in below. */
  pointer-events: none;
  /* Kills the iOS long-press magnifier over the play area. Applied only in the
     ready state: the fallback list is ordinary prose and must stay selectable. */
  user-select: none;
  -webkit-user-select: none;
}

.game.is-ready .door {
  position: absolute;
  top: 0;
  left: 0;
  padding: 0;
  border: 0;
  border-radius: 2px;
  background: none;
  transition: none;
  /* Opt back in: the parent sheet is pointer-events:none. The sign plate is a
     child of this and inherits auto, so the label stays tappable too. */
  pointer-events: auto;
  /* width/height come from the door's world rect x scale */
}

.game.is-ready .door:hover { background: rgba(217, 164, 65, 0.14); }

.game.is-ready .door:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
  background: rgba(217, 164, 65, 0.14);
}

/* The sign plate. Hung above the doorway rather than centred on it, so it reads
   as a shop sign over a door and never covers the sprite walking through. */
.game.is-ready .door > span {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 3px);
  transform: translateX(-50%);
  white-space: nowrap;
  padding: 0.18em 0.5em;
  border: 1px solid var(--line);
  border-radius: 3px;
  background: rgba(15, 23, 13, 0.88);
  color: var(--text);
  font-family: var(--font-display);
  /* Grows with the pixel art, but stays legible at scale 2 on a phone. */
  font-size: clamp(11px, calc(var(--gs) * 3.4px), 17px);
  line-height: 1.35;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  box-shadow: 0 2px 0 rgba(15, 23, 13, 0.5);
}

/* Set by doors.js on the one door the player is standing at. Paired with an
   in-canvas ring around the same doorway, so the cue exists for someone reading
   the pixels and for someone reading the DOM. */
.game.is-ready .door.is-near > span {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--bg-deep);
  animation: door-bob 1.4s ease-in-out infinite;
}

@keyframes door-bob {
  0%, 100% { transform: translateX(-50%) translateY(0); }
  50%      { transform: translateX(-50%) translateY(-2px); }
}

/* A door with nothing to see. Its structure paints no tiles, so without this
   its plate would hang in mid-air over empty grass and announce the secret from
   across the map. Fading it in on approach makes the tile itself the discovery.

   opacity, not display or visibility. The plate is the anchor's accessible
   name, and doors.js pans the camera to whichever door has focus -- so tabbing
   to it walks the view over to a patch of grass and names it, which is a
   perfectly good second way to find the thing. Removing it from the a11y tree
   would trade that away for mystery that only sighted mouse users experience.

   Only the village has one of these; the projects room's doors are furniture
   you can see.

   THE TRANSITION IS ON THE REVEAL, NOT ON THE HIDE, and that is load-bearing.
   A transition uses the timing of the state being moved TO. main.js adds
   .is-ready after the sheets decode, so this rule starts applying to an element
   that was already on screen as a link card in the fallback list -- and if the
   hidden state carried the transition, every single page load would show the
   words "Character Sheet" hanging over empty grass and then fade them out. The
   secret would be given away to anyone who blinked at the right moment.

   Hiding instantly and fading in on approach costs nothing and has no such
   frame. Walking away snaps it off, which reads fine. */
.game.is-ready .door--secret > span {
  opacity: 0;
}

.game.is-ready .door--secret.is-near > span,
.game.is-ready .door--secret:focus-visible > span {
  opacity: 1;
  transition: opacity 0.18s ease;
}

/* ===== PLAQUE ============================================================ */

.game.is-ready .game__note {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 3;
  max-width: none;
  padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom));
  font-size: 0.68rem;
  line-height: 1.5;
  color: var(--muted);
  background: linear-gradient(to top, rgba(15, 23, 13, 0.92), rgba(15, 23, 13, 0));
  /* Decoration over the play area: a tap here should reach the canvas. */
  pointer-events: none;
}

/* ===== MOTION ============================================================ */

@media (prefers-reduced-motion: reduce) {
  .door { transition: none; }
  .game.is-ready .door.is-near > span { animation: none; }
  /* Still hidden, still revealed -- just without the fade. */
  .game.is-ready .door--secret.is-near > span,
  .game.is-ready .door--secret:focus-visible > span { transition: none; }
}
