/* Word Sleuth — v2 presentation.
   Layout is sized in JS wherever a square matters: CSS aspect-ratio is Safari
   15+ and container units are 16+, and both fail silently on the older iPads
   these run on.

   THE RULE THIS FILE EXISTS TO ENFORCE: the three answers a guess can get must
   be distinguishable three ways over — by fill, by luminance, and by a glyph
   large enough to read. v1 failed all three at once. Measured, it scored a
   contrast ratio of 1.00 between "not in the word" and an untouched tile (the
   miss fill was the same colour as the empty tile's border) and 1.00 between
   "right spot" and "wrong spot" (identical luminance, hue only). So the game's
   answer was invisible, which is indistinguishable from the game being broken.
*/
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; overflow: hidden; overscroll-behavior: none; }
body {
  background: #0f172a;
  color: #e2e8f0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  height: 100vh;
  height: 100dvh;          /* fallback above: dvh is Safari 15.4+ */
  user-select: none; -webkit-user-select: none; touch-action: manipulation;
}
#app { display: flex; flex-direction: column; height: 100%; }

#hdr {
  flex: 0 0 auto; display: flex; align-items: center; gap: 6px;
  padding: calc(6px + env(safe-area-inset-top)) 8px 6px;
  background: #020617;
}
/* Reserved space: a status string that wraps would shove the board under a
   finger that is already mid-tap. */
#hdrTitle {
  flex: 1; min-width: 0; text-align: center; font-weight: 700; font-size: 14px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pill {
  background: #1e293b; color: inherit; border: 0; border-radius: 10px;
  min-width: 40px; height: 36px; font-size: 16px; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
}
/* R-C5: the 44px floor, which this game had been under since it shipped.
   Scoped to the header so a .pill used elsewhere keeps its own size. */
#hdr .pill { min-width: 44px; min-height: 44px; height: auto; }
.pill:active { transform: scale(.94); }
.pill[disabled] { opacity: .35; cursor: default; }
/* The per-game badge that used to live here is gone: an ID selector beats
   `.hintBtn .hintN` on specificity, so leaving it would have quietly kept two
   of the sixteen games on their own private version of the shared control —
   which is the exact failure mode the vocabulary audit was about. */
/* ---- the hint control (docs/offline-pwa.md R-C16) -------------------------
   ONE bulb, and the count as an OVERLAID badge. Absolutely positioned inside
   `.bulb`, so 10 -> 9 -> 8 can never change the button's width and shove
   whatever sits beside it — which is exactly what the inline count did on a
   phone. The number is always how many are LEFT, it is shown at 1 and at 0,
   and at 0 the button is disabled with the badge still on it, because a
   control that vanishes teaches a child nothing about why.
   The badge is dark with a pale ring at every game, on purpose: the games run
   from near-black to amber to a light theme, and a dark pill with a light halo
   is the one combination that reads on all of them. */
.hintBtn { position: relative; overflow: visible; }
.hintBtn .bulb { position: relative; display: inline-block; line-height: 1; }
.hintBtn .hintN {
  position: absolute; right: -9px; bottom: -6px;
  min-width: 16px; height: 16px; padding: 0 3px; border-radius: 999px;
  background: #0f172a; color: #f8fafc; box-shadow: 0 0 0 1.5px rgba(255, 255, 255, .6);
  font: 800 10px/16px inherit; letter-spacing: 0; pointer-events: none;
}
.hintBtn:disabled .hintN, .hintBtn[disabled] .hintN { background: #64748b; }
/* `wide` = the bulb is followed by a WORD. The badge hangs 9px past the
   bulb, so without this it sits on the first letter of the label. */
.hintBtn.wide .bulb { margin-right: 11px; }

.scr { display: none; flex: 1 1 auto; min-height: 0; flex-direction: column; }
.scr.on { display: flex; }

/* ---------- menu ---------- */
/* R-C15b: these screens CENTRE and SCROLL, which is the one combination that
   puts content out of reach. When the content is taller than the box,
   `justify-content: center` pushes the first child ABOVE the top edge and
   scrollTop cannot go negative. Measured at 375x620 — a phone with its URL bar
   showing — word-sleuth's help began at y = -585: the first three cards could
   not be read or scrolled to, and had not been since it shipped.
   Auto margins on the end children centre identically while the content fits
   and collapse to zero once it does not. */
#scr-menu, #scr-how {
  align-items: center; gap: 11px;
  padding: 16px; overflow-y: auto; overscroll-behavior: contain;
}
#scr-menu > :first-child, #scr-how > :first-child { margin-top: auto; }
#scr-menu > :last-child,  #scr-how > :last-child  { margin-bottom: auto; }
#scr-menu h1 { font-size: 30px; }
.sub { color: #94a3b8; font-size: 14px; text-align: center; max-width: 320px; line-height: 1.45; }
#tierRow { display: flex; gap: 8px; width: 100%; max-width: 380px; }
.tierCard {
  flex: 1 1 0; min-width: 0;
  background: #1e293b; color: inherit; border: 2px solid transparent;
  border-radius: 14px; padding: 10px 4px; cursor: pointer;
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  font-weight: 700; font-size: 13px;
}
.tierCard .te { font-size: 22px; }
.tierCard small { color: #94a3b8; font-weight: 500; font-size: 11px; }
.tierCard.sel { border-color: #34d399; background: #064e3b; }

/* The one game option. A switch, not a checkbox: the state has to be readable
   from across a room by a parent handing a tablet over, and the subtitle says
   what the setting DOES rather than repeating its name. */
.optRow {
  width: 100%; max-width: 380px;
  display: flex; align-items: center; gap: 10px;
  background: #1e293b; color: inherit; border: 2px solid transparent;
  border-radius: 14px; padding: 10px 12px; cursor: pointer; text-align: left;
}
.optRow .optText { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.optRow b { font-size: 14px; }
.optRow small { color: #94a3b8; font-size: 11px; }
.optRow .sw {
  flex: 0 0 auto; width: 44px; height: 26px; border-radius: 999px;
  background: #475569; position: relative; transition: background-color .15s ease;
}
.optRow .sw i {
  position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
  border-radius: 50%; background: #e2e8f0; transition: transform .15s ease;
}
.optRow[aria-checked="true"] { border-color: #34d399; background: #064e3b; }
.optRow[aria-checked="true"] .sw { background: #34d399; }
.optRow[aria-checked="true"] .sw i { transform: translateX(18px); background: #04241a; }
@media (prefers-reduced-motion: reduce) {
  .optRow .sw, .optRow .sw i { transition: none; }
}
.big {
  width: 100%; max-width: 380px;
  background: #1e293b; color: inherit; border: 0; border-radius: 14px;
  padding: 14px; font-size: 16px; font-weight: 700; cursor: pointer;
}
.big.primary { background: #34d399; color: #04241a; }
.big:active { transform: translateY(2px); }
#statsLine { color: #94a3b8; font-size: 13px; text-align: center; min-height: 18px; }

/* ---------- how to play ---------- */
.card {
  background: #1e293b; border-radius: 14px; padding: 12px 14px;
  width: 100%; max-width: 380px; font-size: 14px; line-height: 1.5;
}
.card h2 { font-size: 15px; margin-bottom: 6px; }
/* The text is a plain block, NOT a flex item: v1 made the whole legend row a
   flex container, so an inline <b> inside the sentence became its own column
   and the sentence broke into pieces. */
.legend { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
.legend .lt { flex: 1 1 auto; min-width: 0; }
.note { color: #94a3b8; font-size: 13px; margin-top: 8px; }

/* ---------- board ---------- */
#board {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 6px; padding: 6px;
}
.row { display: flex; gap: 6px; }

/* A tile is a column: the letter, then a MARK BAND that is always the same
   size whether or not it currently holds a glyph. Reserving the band means a
   tile never changes height when it is answered, and the glyph gets real room
   instead of being a 13px corner decoration nobody can read. */
.tile {
  position: relative; overflow: hidden;
  display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
  /* deliberately LIGHT: an untouched tile has to read as an empty outline that
     could never be confused with the flat slab a "miss" paints */
  border: 2px solid #64748b; border-radius: 10px;
  font-weight: 800; text-transform: uppercase; color: #e2e8f0;
  background: transparent;
  /* Breathing room above the letter. The mark band sits at the BOTTOM, so
     without this the letter is centred in the space above it and reads as if
     it is touching the ceiling of the tile. Scales with the tile because the
     tile's font-size is set in JS. */
  padding-top: .22em;
  transition: background-color .12s ease, border-color .12s ease;
}
.tile .ch {
  flex: 1 1 auto; display: flex; align-items: center; justify-content: center;
  width: 100%; line-height: 1;
  /* min-height:0 is load-bearing. A flex item will not shrink below its content
     by default, so a tile whose letter + mark band exceed its height pushes the
     letter UP and out under overflow:hidden — which reads as the letter being
     clipped by the ceiling. */
  min-height: 0;
}
/* The band is reserved on EVERY tile, always — not only on answered ones.
   Otherwise a row holding both a marked tile and an unmarked one puts their
   letters at different heights, and the row reads as ragged/broken. */
/* The band is sized in its OWN em, not the tile's, so it stays a proportionate
   strip rather than eating half the tile — letter + band + padding must fit
   inside the square with room to spare. */
.tile .mk {
  flex: 0 0 auto; width: 100%; text-align: center;
  font-style: normal; font-weight: 900; line-height: 1;
  font-size: .5em; height: 1em;
}

/* typed but not yet submitted */
.tile.filled { border-color: #cbd5e1; background: rgba(255, 255, 255, .07); }
/* carried over from a letter already proven right, rather than typed. Tinted
   green so it is obvious where it came from instead of appearing by magic —
   but clearly NOT the solid green of an answered tile, because this row has
   not been submitted yet. */
.tile.auto { border-color: #34d399; background: rgba(52, 211, 153, .16); color: #d1fae5; }
.row.active .tile.auto { border-color: #34d399; }
/* A locked letter carries the SAME ✓ as the answered tile it came from — it is
   the same claim ("right letter, right spot"), so it must not be marked in one
   row and unmarked in the next. */
.tile.auto .mk { color: #34d399; }
/* the fixed-size sample tiles used in How to play */
.tile.sm { width: 42px; height: 42px; font-size: 17px; flex: 0 0 auto; }

/* ---- the three answers ----
   Distinguished FOUR ways at once, because any one channel can fail:
     hue       green / yellow / slate
     luminance .196 / .530 / .055 — every pair far apart, so the distinction
               survives colour blindness, sunlight and a cheap screen
     letter    white on green, near-black on yellow, dimmed grey on slate
     glyph     a full-width ✓ / ◐ / ✕ band, not a 13px corner decoration   */
/* A RICHER green, not a brighter one. Brightening the green to feel more
   rewarding pushes its luminance toward the yellow's and destroys the very
   distinction it is carrying; the bright BORDER supplies the "pop" instead,
   without touching the fill that does the work. */
.tile.hit  { background: #15803d; border-color: #4ade80; color: #fff; }
.tile.hit .mk { color: #dcfce7; }
.tile.near { background: #eab308; border-color: #fde047; color: #1c1917; }
.tile.near .mk { color: #422006; }
/* miss is a FLAT SLAB and an untouched tile is an OUTLINE. Never the same. */
.tile.miss { background: #334155; border-color: #334155; color: #94a3b8; }
.tile.miss .mk { color: #64748b; }

/* the caret: which tile the next letter lands in */
.tile.caret::after {
  content: ''; position: absolute; left: 50%; top: 22%;
  width: 2px; height: 42%; margin-left: -1px;
  background: #38bdf8; border-radius: 1px;
  animation: blink 1.05s steps(1) infinite;
}
@keyframes blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
/* the row you are typing into — brighter than a resting tile, or there is no
   cursor at all and a player cannot tell where their letters will land */
.row.active .tile { border-color: #94a3b8; }
.row.active .tile.filled { border-color: #f1f5f9; }

.row.bad { animation: shake .35s; }
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-7px); }
  40% { transform: translateX(7px); }
  60% { transform: translateX(-4px); }
  80% { transform: translateX(4px); }
}
.tile.pop { animation: pop .12s; }
@keyframes pop { 50% { transform: scale(1.09); } }
/* the reveal. The classes are applied FIRST and the flip is decoration, so a
   browser that refuses the animation still shows a correctly coloured board. */
.tile.flip { animation: flip .34s ease-in-out; }
@keyframes flip {
  0% { transform: rotateX(0); }
  50% { transform: rotateX(90deg); }
  100% { transform: rotateX(0); }
}
.tile.win { animation: hop .5s ease; }
@keyframes hop {
  0%, 100% { transform: translateY(0); }
  40% { transform: translateY(-14px); }
  70% { transform: translateY(0); }
}
/* (the "ghost" letter this file used to carry is gone: since confirmed letters
   are now LOCKED into the next row, a known letter is never absent from it) */

/* ---------- the always-on legend ----------
   It lives on the PLAY screen. A legend a child has to leave the game to read
   is a legend they will never read. */
#legend {
  flex: 0 0 auto; display: flex; justify-content: center; gap: 6px;
  padding: 2px 6px 6px; flex-wrap: wrap;
}
.lg {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: 11px; font-weight: 700; color: #cbd5e1;
  background: #1e293b; border-radius: 999px; padding: 3px 9px 3px 4px;
}
.lg i {
  font-style: normal; width: 17px; height: 17px; border-radius: 5px;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 10px; font-weight: 900;
}
.lg.a i { background: #15803d; color: #fff; }
.lg.b i { background: #eab308; color: #1c1917; }
.lg.c i { background: #334155; color: #94a3b8; }

/* ---------- keyboard ---------- */
#kb {
  flex: 0 0 auto;
  display: flex; flex-direction: column; gap: 6px;
  padding: 0 4px calc(6px + env(safe-area-inset-bottom));
}
.kbrow { display: flex; gap: 4px; justify-content: center; }
/* flex: 1 1 0 with min-width: 0 is what keeps ten keys on ONE row at 320px —
   fixed-width keys wrap, which is the bug this rule exists to prevent. */
.key {
  flex: 1 1 0; min-width: 0; height: 46px;
  background: #475569; color: #f1f5f9; border: 0; border-radius: 7px;
  font-size: 15px; font-weight: 700; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
.key.wide { flex: 1.7 1 0; font-size: 11px; }
.key.go { background: #34d399; color: #04241a; }
.key:active { transform: translateY(2px); }
/* No .key.hit / .key.near: the keyboard only ever says "ruled out". Positional
   truth belongs on the board, where it is exact. */
/* A used-up letter is dimmed HARD rather than struck through. Strike-through
   was tried and reverted: at 15px it draws straight through the glyph, so D
   reads as Ð and Y as ¥ — it corrupts the letter it is trying to annotate.
   The luminance drop (#475569 against #f1f5f9, a ratio of 5.3) is the
   non-colour channel instead, and the authoritative ✓ ◐ ✕ are on the board. */
.key.miss { background: #1e293b; color: #475569; }

/* ---------- overlay ---------- */
#board { position: relative; z-index: 0; }   /* own stacking context */
.ovl {
  position: absolute; inset: 0; z-index: 30;
  display: none; align-items: center; justify-content: center; padding: 20px;
  background: rgba(15, 23, 42, .94);
}
.ovl.on { display: flex; }
.ovlCard {
  background: #1e293b; border-radius: 18px; padding: 20px;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  width: 100%; max-width: 340px; text-align: center;
}
#ovlTitle { font-size: 24px; font-weight: 800; }
#ovlWord { font-size: 26px; font-weight: 800; letter-spacing: 3px; text-transform: uppercase; color: #34d399; }
#ovlSub { color: #94a3b8; font-size: 14px; line-height: 1.45; }

/* ---------- toasts ----------
   A modal is for a decision; a toast is for information. */
.toast-host {
  position: fixed; left: 50%; transform: translateX(-50%);
  top: calc(10px + env(safe-area-inset-top));
  z-index: 9000; display: flex; flex-direction: column; gap: 8px;
  align-items: center; pointer-events: none;
  width: max-content; max-width: calc(100vw - 24px);
}
.toast {
  pointer-events: auto; cursor: pointer;
  padding: 11px 16px; border-radius: 13px;
  background: #f8fafc; color: #0f172a;
  box-shadow: 0 10px 30px rgba(0, 0, 0, .45);
  font-size: 14px; font-weight: 700; max-width: calc(100vw - 24px);
  opacity: 0; transform: translateY(-10px);
  transition: opacity .2s ease, transform .2s ease;
}
.toast.in { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
  .toast, .tile.pop, .row.bad, .tile.flip, .tile.win, .tile.caret::after {
    transition: none; animation: none;
  }
}

/* ---------------------------------------------------------------------------
   Landscape: the board takes the full HEIGHT and everything else moves beside
   it (docs/offline-pwa.md, "A container sized by the thing it is supposed to
   be sizing" — the sibling of that bug).

   A square board is bounded by the smaller of the two dimensions. On a wide
   short window that is the height, and a COLUMN spends the scarce axis on
   furniture: the legend and the on-screen keyboard. Measured with tests/browser/board-fill.js at 844x390, this
   game's board reached only 64% of the space it could have had, while the two
   games that already had a row layout scored 98-99%.

   `min-aspect-ratio: 1/1` rather than `orientation: landscape`, because the
   question is "is this box wider than it is tall", which is also true of a
   short desktop window that no phone would call landscape.

   Grid rather than flex: the board has to span every row of the side column,
   and `#scr-play.on` (1,1,0) is specific enough to beat `.scr { display: none }`
   without also un-hiding the screen when `.on` is absent.
   --------------------------------------------------------------------------- */
/* `and (max-height: 620px)` — the row layout is for a window whose HEIGHT is
   scarce, not for any window that happens to be wide.
   `min-aspect-ratio: 1/1` alone caught two different situations and only one
   of them wanted this. On a 1440x900 desktop the board ended up bounded by the
   side COLUMN while 200px+ of height sat unused; the ordinary column layout is
   larger there. Measured across 1024x600 (row wins), 1280x800 and 1440x900
   (column wins), the crossover sits just above a short laptop, so 620px keeps
   every phone landscape on the row layout and hands every real desktop window
   back to the column. */
@media (min-aspect-ratio: 1/1) and (max-height: 620px) {
  /* Two grid gotchas, both of which this hit:
     `align-items: center` becomes `align-self: center` on every item, so the
     board cell stops STRETCHING and shrink-wraps its content — the container
     sized by the thing it is meant to size, one more time. And a bare `1fr`
     track has an automatic minimum of `min-content`, so a board that measures
     its wrapper and grows can push the very track that was supposed to bound
     it: go's board reached 750px inside a 286px screen. `minmax(0, 1fr)` plus
     an explicit `align-self: stretch` and `min-height: 0` closes both. */
  #scr-play.on {
    display: grid;
    /* minmax(0, 1fr), NOT 1fr — the SAME trap as the rows below, and I fixed
       the rows and left the columns. A `1fr` track has an automatic minimum of
       min-content, so it cannot shrink under its contents; the board measures
       its wrapper, grows, and the track grows with it. On a phone in landscape
       the height budget is small so the board never reaches the limit and the
       bug is invisible. On a DESKTOP window the height is plentiful, so the
       board is sized off the height, exceeds the column, and the play screen
       overflows sideways inside a centred 820px app — measured on production
       at 1680x1050: scrollWidth 1076 in a clientWidth of 820. The board did
       not overflow the WINDOW, which is why every "is it on screen" check
       passed; you just had to drag to reach the far files. */
    grid-template-columns: minmax(0, 1fr) minmax(300px, 52%);
    grid-template-rows: auto minmax(0, 1fr);
    column-gap: 12px; align-items: center; min-height: 0;
  }
  #board  { grid-column: 1; grid-row: 1 / -1; align-self: stretch; min-height: 0; min-width: 0; }
  #legend { grid-column: 2; grid-row: 1; }
  /* The keyboard is WIDE and SHORT, so it is the one thing that genuinely
     wants the side column rather than the bottom: 52% of an 844px window is
     439px, wider than the whole of the phone it was designed for. */
  #kb { grid-column: 2; grid-row: 2; align-self: center; }
  #ovl { grid-area: 1 / 1 / -1 / -1; }
}

/* On "pad the axis you have to spare" (see chess/go): this play screen pads
   NEITHER axis, so it already satisfies the rule and there is nothing to trim.
   Adding the 4px gutter here was a regression — it took landscape from 96% to
   93%, which is exactly the 8px it added on a 290px budget.

   The rule is *do not pad the binding axis*, and zero is the best possible
   compliance. A rule written as "set it to 4" instead of "do not exceed 4"
   raises the floor for everyone already below it — and CSS has no
   `max-padding`, so this has to be a decision per stylesheet rather than one
   shared declaration. */
