/* dnd-sim spectator UI — "candlelit table".
 *
 * Two themes, one set of tokens. Dark is rich brown rather than near-black;
 * light is pale wood. Every pairing was solved rather than eyeballed, and
 * every run of text clears WCAG AAA — 7:1 — against the worst of the eight
 * surfaces it can land on (page, panel, sunken well, raised control, active
 * row, the line being read aloud, a scene box, the candle pool), because a
 * colour that fails on one surface fails everywhere.
 *
 * The three text tiers are then spaced by ratio rather than by letting the
 * bottom one sag: body text 12:1, the secondary tier 9.5:1, and the quietest
 * metadata — initiative scores, the AC/class line, sequence numbers — 7:1.
 * Names, accents and status colours sit at 8:1. Borders, focus rings and the
 * bar fills clear 4.5:1, half again what 1.4.11 asks of a non-text carrier of
 * meaning, and the map's walls and difficult ground clear 3:1 against the
 * board. `web/tests/test_theme_tokens.py` checks all of it as arithmetic.
 *
 * Which theme: a stored choice on <html data-theme>, else the system's. The
 * inline script in index.html sets the attribute before first paint.
 */

/* ---------- dark: rich brown ---------- */
:root {
  color-scheme: dark;

  --bg:         #2b211a;
  --bg-panel:   #33271f;
  --bg-sunken:  #241c15;
  --bg-raise:   #382a20;
  --bg-active:  #3a2a14;
  --bg-speaking:#3c2b16;
  --bg-scene:   #3a2b1b;
  --glow:       #3b2e22;

  --line:       #5c4939;
  --line-soft:  #4a3a2c;
  --line-strong:#a7957f;

  --ink:        #faf4ec;
  --ink-dim:    #e4dacc;
  --ink-faint:  #ccbdac;

  --accent:     #f2c378;
  --accent-line:#b49357;
  --accent-fill:#e0a648;
  --on-accent:  #1e1409;

  --party:      #9cd0ee;
  --enemy:      #f6b9a9;
  --neutral:    #e0c48e;
  --good:       #a0d681;
  --good-fill:  #8fc96c;
  --on-good:    #12210b;
  --danger:     #f6bdae;
  --danger-fill:#f6bdae;
  --on-danger:  #2a0d06;

  --hp-good:    #a0d681;
  --hp-hurt:    #f2c378;
  --hp-bad:     #f6bdae;

  --scrim:      rgba(12, 8, 5, .84);
  --canvas-grid:#4e4137;
  --wall:       #7e7468;
  --difficult:  #8c6f2e;

  --radius: 9px;
  /* How tall the board is — and, because the map's column is that wide, the
     side of the square the map is drawn in. A length rather than a colour, so
     it is declared once here and re-declared only by the size queries at the
     foot of the file.

     This is the board's share of the stage; the transcript gets the rest,
     whatever the rest turns out to be — nothing here has to add up to the
     viewport any more, which is the one thing this length used to get wrong.
     The middle term keeps the split honest as the screen grows: a tall screen
     can afford a bigger map, up to the point where the map has stopped needing
     the height and the transcript has better uses for it. Clamped at both ends
     because a board under ~280px is a map the size of a stamp, and over ~560px
     it is a picture. */
  --board-h: clamp(280px, calc(100vh - 560px), 560px);
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
  --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

/* ---------- light: pale wood ---------- */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    color-scheme: light;

    --bg:         #efe2ce;
    --bg-panel:   #faf4e9;
    --bg-sunken:  #ecdfcb;
    --bg-raise:   #f2e7d4;
    --bg-active:  #f4e6ca;
    --bg-speaking:#f6e8c6;
    --bg-scene:   #f3e4c9;
    --glow:       #f7edda;

    --line:       #bda682;
    --line-soft:  #d0bb98;
    --line-strong:#756144;

    --ink:        #2a1c10;
    --ink-dim:    #403122;
    --ink-faint:  #54432f;

    --accent:     #633203;
    --accent-line:#805e28;
    --accent-fill:#733a03;
    --on-accent:  #fff7ea;

    --party:      #0d445b;
    --enemy:      #7a1b0c;
    --neutral:    #513b0e;
    --good:       #22470c;
    --good-fill:  #275209;
    --on-good:    #fff7ea;
    --danger:     #7c170b;
    --danger-fill:#7c170b;
    --on-danger:  #fff6ee;

    --hp-good:    #3a7617;
    --hp-hurt:    #945c09;
    --hp-bad:     #a83521;

    --scrim:      rgba(46, 32, 18, .58);
    --canvas-grid:#beab8d;
    --wall:       #7c7468;
    --difficult:  #9b7a2b;
  }
}

:root[data-theme="light"] {
  color-scheme: light;

  --bg:         #efe2ce;
  --bg-panel:   #faf4e9;
  --bg-sunken:  #ecdfcb;
  --bg-raise:   #f2e7d4;
  --bg-active:  #f4e6ca;
  --bg-speaking:#f6e8c6;
  --bg-scene:   #f3e4c9;
  --glow:       #f7edda;

  --line:       #bda682;
  --line-soft:  #d0bb98;
  --line-strong:#756144;

  --ink:        #2a1c10;
  --ink-dim:    #403122;
  --ink-faint:  #54432f;

  --accent:     #633203;
  --accent-line:#805e28;
  --accent-fill:#733a03;
  --on-accent:  #fff7ea;

  --party:      #0d445b;
  --enemy:      #7a1b0c;
  --neutral:    #513b0e;
  --good:       #22470c;
  --good-fill:  #275209;
  --on-good:    #fff7ea;
  --danger:     #7c170b;
  --danger-fill:#7c170b;
  --on-danger:  #fff6ee;

  --hp-good:    #3a7617;
  --hp-hurt:    #945c09;
  --hp-bad:     #a83521;

  --scrim:      rgba(46, 32, 18, .58);
  --canvas-grid:#beab8d;
  --wall:       #7c7468;
  --difficult:  #9b7a2b;
}

* { box-sizing: border-box; }

html, body {
  margin: 0; padding: 0;
  height: 100%;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 15px;
  line-height: 1.5;
  -webkit-text-size-adjust: 100%;
}

body {
  display: flex; flex-direction: column;
  /* The window does not scroll — `.layout` below is the scroll container, so
     that a box inside it can be told to be exactly one viewport tall without
     anyone having to know how tall the top bar came out. */
  overflow: hidden;
  /* a candle pool over the wood, no distraction */
  background-image: radial-gradient(130% 80% at 50% -12%, var(--glow) 0%, var(--bg) 62%);
  background-attachment: fixed;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap;
}

/* ---------- top bar ---------- */
.topbar {
  display: flex; align-items: center; gap: 10px;
  flex-wrap: wrap;
  padding: 8px 14px;
  padding-top: calc(8px + env(safe-area-inset-top));
  border-bottom: 1px solid var(--line);
  background: var(--bg-panel);
  position: sticky; top: 0; z-index: 20;
}
.brand { display: flex; align-items: center; gap: 8px; font-family: var(--serif); letter-spacing: .02em; }
.brand-name { font-size: 17px; color: var(--ink); }
.flame { color: var(--accent); }
.topbar-mid { display: flex; align-items: center; gap: 8px; flex: 1 1 240px; min-width: 0; }
.topbar-mid select { flex: 1 1 auto; min-width: 0; max-width: 380px; }
.topbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap; }

.pill {
  font-family: var(--mono); font-size: 11px; text-transform: uppercase;
  letter-spacing: .06em; padding: 3px 9px; border-radius: 999px;
  border: 1px solid var(--line-strong); color: var(--ink-dim); white-space: nowrap;
}
.pill-quiet { color: var(--ink-faint); border-color: var(--line); }
.pill-running { color: var(--on-good); background: var(--good-fill); border-color: var(--good-fill); }
.pill-paused  { color: var(--on-accent); background: var(--accent-fill); border-color: var(--accent-fill); }
.pill-finished{ color: var(--good); border-color: var(--good); }
.pill-error, .pill-budget_exceeded { color: var(--on-danger); background: var(--danger-fill); border-color: var(--danger-fill); }
.pill-stopped { color: var(--ink-faint); }

.cost { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; color: var(--ink-dim); }
.cost-bar {
  display: inline-block; width: 66px; height: 7px; background: var(--bg-sunken);
  border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden;
}
.cost-bar i { display: block; height: 100%; background: var(--accent-fill); }
.cost-bar.over i { background: var(--danger-fill); }

/* ---------- layout ----------
   Two bands down the page. The BOARD is the map and, beside it, the whole
   table in initiative order — party and enemies in one list, because the order
   is what a fight is read in and three panels of the same people sorted three
   ways spent the width on saying so. It is FIXED in height: a fight with
   nine combatants used to lengthen those panels until the story started below
   the fold, and the roster scrolls inside itself instead.

   Under it the FEED: the transcript, which takes what the stage has left over
   and scrolls internally so a live game never lengthens the page, and under
   that the narrator's transport as one thin bar the full width of it. Those
   three are the STAGE, and the stage is one viewport exactly. Below it, and
   deliberately below the fold, the score and the table controls as a column of
   cards: they are set once a game is running and read rarely after, and the
   height they used to hold beside the narration bar is worth more to the
   story.

   `.layout` is the page's scroll container rather than the window — that is
   what lets the stage be `height: 100%` and mean it. Everything that has to
   be on screen is then sized by measurement, not by a sum of the top bar and
   the gaps and the bar heights, which is a sum that was wrong the first time
   a top bar wrapped to two rows on a tablet. */
.layout {
  flex: 1 1 auto;
  display: flex; flex-direction: column; gap: 12px;
  padding: 12px;
  padding-bottom: calc(12px + env(safe-area-inset-bottom));
  min-height: 0;
  overflow-y: auto; -webkit-overflow-scrolling: touch;
}
/* One viewport, and no part of it is a guess.

   `flex: 0 0 100%` rather than a plain `height`: as a flex item above the card
   column it would otherwise shrink to make room for it, which is the one thing
   it must never do. And `min-height: 0` because a flex item's automatic
   minimum is its content: on a portrait tablet the map's minimum alone was
   taller than 45% of the screen, which grew the stage past the viewport and
   put the narration bar under the fold — the exact bug this wrapper exists to
   end, arriving by the back door. Every band inside is shrinkable and their
   floors are small enough for a 600px window whose top bar has wrapped, so
   pinning the stage costs nothing. */
.stage {
  flex: 0 0 100%; min-height: 0;
  display: flex; flex-direction: column; gap: 12px;
}

/* The map's column is as wide as the board is tall, so the map is drawn in a
   square and fills it rather than sitting as a letterboxed strip; the roster
   takes what is left. On a wide screen the map stops at 60% of the board so
   the two panels stay a pair rather than a picture with a margin.

   Its height is `--board-h`, and it may only shrink — never grow. Spare height
   in the stage goes to the transcript, which is the only thing on the page
   that can use an arbitrary amount of it; but on a stage too short to hold
   the board, the bar and a readable transcript, the map is what gives way,
   because the alternative is the story falling off the bottom of the screen
   and that is the bug this layout exists to prevent. */
.board {
  /* Painted above the feed, though the feed is written first: see the source
     order note in index.html. */
  order: 1;
  display: grid; gap: 12px;
  grid-template-columns: minmax(260px, min(var(--board-h), 60%)) minmax(0, 1fr);
  /* `min-height: 0` because a flex item's automatic minimum is its content,
     and the map's own content minimum is taller than the short-viewport
     `--board-h`: without this the board quietly ignores both the smaller sizes
     the queries at the foot of the file ask it for and the shrink above. */
  /* Never more than 45% of the stage, whatever `--board-h` says. A portrait
     tablet is 1200px tall and its board would otherwise take the 560px cap
     and leave the story the smaller half of its own screen; the percentage
     resolves against the stage, which is a definite height. */
  flex: 0 1 min(var(--board-h), 45%); min-height: 0;
}
.quad {
  display: flex; flex-direction: column;
  height: 100%; min-height: 0;
  overflow: hidden;
}
.panel-body {
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto; -webkit-overflow-scrolling: touch;
  padding-right: 4px;
}

/* Takes the stage's whole remainder, and `min-height: 0` so that what is
   inside it — a scroll region and a bar — divides that remainder rather than
   growing it. The transcript is thousands of pixels of text and would take
   the board with it otherwise. */
.feed { order: 2; flex: 1 1 auto; display: flex; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; }
/* Under the stage: a column, not a row. Side by side, score and controls were
   two columns of a panel each and the taller of them — the controls, once a
   write token is in — set the height of the whole band and took it off the
   transcript. */
.under { display: flex; flex-direction: column; gap: 12px; flex: 0 0 auto; }

.panel {
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 9px 11px 11px;
  min-width: 0;
}
.panel-fill { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.panel-title {
  margin: 0 0 7px; font-family: var(--serif); font-size: 13px; font-weight: 700;
  letter-spacing: .09em; text-transform: uppercase; color: var(--accent);
}
.empty { color: var(--ink-faint); font-style: italic; margin: 4px 0; }
.hint { color: var(--ink-dim); font-size: 12px; margin: 4px 0 10px; }
.err { color: var(--danger); font-size: 13px; }

/* ---------- the table ----------
   One list, in initiative order: a player character's card is two lines (name
   and sheet over bar and hit points), everything else is one, because five
   bandits stacked four lines deep pushed everything under them off the screen.

   It COLUMNATES rather than stretches. A card says its piece in about 300px,
   and a panel three times that wide was answering with one card per row and a
   line of empty gutter between the name and the hit points. Multicolumn rather
   than a grid, so it fills each column before starting the next and the
   initiative order still reads top to bottom; three is the ceiling, because
   six columns of two is a puzzle. */
.roster { columns: 300px 3; column-gap: 14px; }
.roster > .card { break-inside: avoid; margin-bottom: 6px; }
.roster > .empty { margin: 4px 0; }
.card {
  border: 1px solid var(--line-soft); border-radius: 6px;
  padding: 5px 8px 6px; background: var(--bg-sunken);
}
/* Which side, said by the edge of the card: the roster is one list now, so
   this is the only thing that separates the party from what it is fighting. */
.card.side-party   { border-left: 3px solid var(--party); }
.card.side-enemy   { border-left: 3px solid var(--enemy); }
.card.side-neutral { border-left: 3px solid var(--neutral); }
/* Active takes the other three edges, never the left one: the stripe is the
   only thing saying which side a combatant is on now that the roster is one
   list, and `border-color` would have taken it from exactly the card you are
   looking at. The accent sits just inside it as the inset shadow. */
.card.is-active {
  border-top-color: var(--accent-line);
  border-right-color: var(--accent-line);
  border-bottom-color: var(--accent-line);
  box-shadow: inset 3px 0 0 var(--accent);
}
.card.is-down .card-name { color: var(--ink-dim); }
/* Which side, in three letters, beside the stripe that says it in colour —
   because colour on its own says nothing to a screen reader and little to a
   reader who cannot separate those two browns. The full word is on the card
   too, hidden, for the reader who is being read to. */
.card-side {
  flex: 0 0 auto; width: 3.4ch;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em;
  color: var(--ink-faint);
}
.card.side-party .card-side   { color: var(--party); }
.card.side-enemy .card-side   { color: var(--enemy); }
.card.side-neutral .card-side { color: var(--neutral); }

/* The initiative roll, as the card's first column. A fixed width so the names
   line up down the list rather than stepping in and out by a digit. */
.card-init {
  flex: 0 0 auto; width: 2.4ch; text-align: right;
  font-family: var(--mono); font-size: 11px; color: var(--ink-faint);
}
.card.is-active .card-init { color: var(--accent); }
/* Wrapping between the name and the stat line, never inside the name: a
   three-word name broken over three lines is what a card is trying not to be.
   `space-between` rather than an auto margin so that the stat line sits right
   of the name while the two share a line and returns to the left margin when
   it takes one of its own — an auto margin would hold it against the right
   edge on a line where it is the only thing there. */
.card-top {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 8px; flex-wrap: wrap;
}
.card-name { flex: 1 1 auto; font-weight: 600; font-size: 13.5px; }
.card-sub { font-size: 11px; color: var(--ink-faint); font-family: var(--mono); white-space: nowrap; }
.card-vitals { display: flex; align-items: center; gap: 7px; margin-top: 3px; }
.hpbar {
  flex: 1 1 auto; height: 6px; border-radius: 999px;
  background: var(--bg-raise); overflow: hidden; min-width: 40px;
}
.hpbar i { display: block; height: 100%; background: var(--hp-good); transition: width .3s ease; }
.hpbar.hurt i { background: var(--hp-hurt); }
.hpbar.bad  i { background: var(--hp-bad); }
.hpnum { font-family: var(--mono); font-size: 11px; color: var(--ink-dim); white-space: nowrap; }
.tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.tag {
  font-family: var(--mono); font-size: 10px; text-transform: lowercase;
  padding: 0 6px; border-radius: 999px; line-height: 1.6;
  border: 1px solid var(--accent-line); color: var(--accent);
}
.tag.slot { border-color: var(--line-strong); color: var(--ink-dim); }
.tag.conc { border-color: var(--party); color: var(--party); }

/* one row per enemy */
.card-tight { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.card-tight .card-name { flex: 1 1 auto; min-width: 0; font-size: 13px; font-weight: 500; }
/* The bar is the fixed part; the number beside it is not. "0/11 — dead" is how
   every enemy ends a fight and "0/11 — down 0✓/2✗" is what a non-monster on the
   other side reads, and neither fits a box sized for "11/11" — they printed
   straight through the AC. The box takes what the number needs, and the row
   wraps when that is more than the line has. */
.card-tight .card-vitals { flex: 0 1 auto; min-width: 0; margin-top: 0; }
.card-tight .hpbar { flex: 0 0 56px; }
.card-tight .card-sub { flex: 0 0 auto; margin-left: 0; }
.card-tight .tags { flex: 1 0 100%; margin-top: 2px; }

/* ---------- transcript ----------
   Shorter than it was: it is a scroll region, not the page. Its toggles sit
   under the text — they are read once and then never again, and above the
   text they were the first thing between the reader and the story. */
/* It takes the height the viewport has left over once the board is placed, so
   the page itself does not scroll and the story is never a strip at the bottom
   of a tall column. What bounds it from above is the narration bar and the
   controls under it: the transcript takes what the feed has left after those,
   never their space, and the floor below is what the page scrolls for on a
   viewport too short to hold all three.

   The floor for that sits on the PANEL, not on the scroll region inside it. On
   the region it was a length the region would not go below while every
   ancestor was still free to shrink, so on a viewport too short to hold it —
   a 1366x768 laptop, which is most of them — the text and its toggles rendered
   194px BELOW the panel's own border, on bare page background. On the panel it
   is a length the panel will not shrink below, so the page scrolls instead and
   the region flexes inside it. */
.transcript {
  flex: 1 1 auto;
  overflow-y: auto; -webkit-overflow-scrolling: touch;
  min-height: 0;
  padding-right: 6px;
}
/* Whatever the stage has left once the board and the narration bar are placed
   — no arithmetic, so nothing to be wrong about: a top bar that wraps, a
   narration bar that wraps, a game with no narration bar at all, each simply
   changes what is left. The floor is a floor and not a target; a stage too
   short for it takes the difference out of the board above. */
/* The basis is deliberately small and definite. `auto` would make it the
   height of the transcript's text — thousands of pixels — and the feed would
   ask the stage for all of it and squeeze the board down to a strip. It asks
   for 200 and grows into whatever is left instead. */
.feed .panel-fill { flex: 1 1 200px; min-height: 140px; }
.transcript-tools {
  display: flex; gap: 14px; align-items: center;
  margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line);
}
.turn-group { border-left: 2px solid var(--line); margin: 10px 0 4px; padding-left: 10px; }
.turn-head {
  font-family: var(--mono); font-size: 11px; letter-spacing: .05em;
  color: var(--ink-dim); text-transform: uppercase; margin-bottom: 2px;
}
.turn-head .who { color: var(--accent); }

.narration {
  font-family: var(--serif); font-size: 17px; line-height: 1.62;
  margin: 12px 0; color: var(--ink);
}
.dialogue {
  font-family: var(--serif); font-size: 16px; margin: 8px 0 8px 6px;
  border-left: 2px solid var(--accent-line); padding-left: 10px; color: var(--ink);
}
.dialogue .speaker { color: var(--accent); font-style: italic; margin-right: 6px; }
.scene {
  font-family: var(--serif); font-size: 15.5px; margin: 14px 0;
  padding: 10px 12px; border: 1px solid var(--accent-line); border-radius: 6px;
  background: var(--bg-scene); color: var(--ink);
}
.scene .scene-title { display: block; color: var(--accent); letter-spacing: .06em; text-transform: uppercase; font-size: 12px; margin-bottom: 4px; }
.dmnote {
  font-family: var(--sans); font-size: 13px; margin: 10px 0; padding: 8px 10px;
  border: 1px dashed var(--accent-line); border-radius: 6px; color: var(--accent);
}
.mech {
  font-family: var(--mono); font-size: 12.5px; line-height: 1.45;
  color: var(--ink-dim); margin: 1px 0; white-space: pre-wrap; word-break: break-word;
}
.mech.k-attack, .mech.k-damage { color: var(--ink); }
.mech.k-dead, .mech.k-down { color: var(--danger); }
.mech.k-heal, .mech.k-stable { color: var(--good); }
.mech.k-error { color: var(--danger); }
.mech.k-cost { color: var(--ink-faint); }
.mech .seq { color: var(--ink-faint); margin-right: 6px; }
.hide-mech .mech, .hide-mech .turn-details { display: none; }

.turn-details > summary {
  cursor: pointer; font-family: var(--mono); font-size: 11px; color: var(--ink-faint);
  list-style: none; padding: 2px 0;
}
.turn-details > summary::-webkit-details-marker { display: none; }
.turn-details > summary::before { content: "\25B8 "; color: var(--accent); }
.turn-details[open] > summary::before { content: "\25BE "; }
.end-banner {
  margin: 16px 0; padding: 8px 10px; text-align: center;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  font-family: var(--mono); font-size: 12px; color: var(--accent);
  text-transform: uppercase; letter-spacing: .1em;
}

/* ---------- map ---------- */
/* Centred where centring is safe, and pinned to the corner where it is not.
   Below the cell floor the map is bigger than this box, and a plain `center`
   puts half of that overflow before the scroll origin where no scroll can
   reach it — on a short laptop a 12-row map lost the top half of its first
   row. `safe center` is exactly the rule for that, falling back to start
   alignment only when the item overflows.

   But the fallback has to be START, not `center`: `safe` arrived in Safari
   16.4 and this page targets iPad Safari 14 (see the header of app.js), where
   the whole declaration is dropped. A map in the corner of a box too small for
   it is odd-looking; a map with its first row unreachable is broken, and the
   corner is the one of those two an old browser should get. */
.canvas-wrap {
  flex: 1 1 auto; min-height: 0; width: 100%;
  display: flex; align-items: flex-start; justify-content: flex-start;
  overflow: auto;
}
@supports (align-items: safe center) {
  .canvas-wrap { align-items: safe center; justify-content: safe center; }
}
/* Both dimensions are set by drawGrid(), which is the only thing that knows
   how many squares there are: it fits the map to the width AND the height the
   quadrant has, and caps the square at 48px so that a small grid on a wide
   panel does not grow to an inch a square and stop being a map. */
#grid {
  display: block; margin: 0 auto;
  background: var(--bg-sunken); border-radius: 6px; touch-action: manipulation;
}
/* Tight, because every line it wraps to is a line the map does not get: the
   canvas takes the height the quadrant has left after the title and this. */
.legend {
  flex: 0 0 auto;
  display: flex; flex-wrap: wrap; gap: 2px 8px; margin-top: 6px;
  font-size: 10.5px; color: var(--ink-dim); font-family: var(--mono);
}
.legend i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 3px; vertical-align: -1px; }
.sw.party { background: var(--party); } .sw.enemy { background: var(--enemy); }
.sw.neutral { background: var(--neutral); } .sw.diff { background: var(--difficult); }
.sw.wall { background: var(--wall); }

/* ---------- controls ---------- */
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
.btn {
  font: inherit; font-size: 14px;
  min-height: 38px; padding: 7px 13px;
  color: var(--ink); background: var(--bg-raise);
  border: 1px solid var(--line-strong); border-radius: 6px;
  cursor: pointer; -webkit-appearance: none; appearance: none;
  touch-action: manipulation;
}
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn:active:not(:disabled) { background: var(--bg-active); }
/* Disabled is said with the face and the border, not with opacity: fading the
   whole button takes its label down with it, and a control that cannot be
   pressed still has to be readable to explain why the row is greyed. */
.btn:disabled {
  color: var(--ink-faint); background: transparent;
  border-color: var(--line-soft); cursor: default;
}
.btn-primary {
  background: var(--accent-fill); border-color: var(--accent-fill);
  color: var(--on-accent); font-weight: 600;
}
.btn-primary:hover:not(:disabled) { color: var(--on-accent); border-color: var(--accent); }
.btn-danger { border-color: var(--danger); color: var(--danger); }
.btn-danger:hover:not(:disabled) { color: var(--danger); border-color: var(--danger); }
.btn-theme { font-family: var(--mono); text-transform: lowercase; min-width: 62px; }
/* The form is a column of full-width fields, and the card it sits in is now
   the width of the page: capped, or a two-line note gets a 1400px box and the
   Send button stretches into a banner. `align-self` keeps the button the size
   of its label rather than the size of the field above it. */
.note-form { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; max-width: 560px; }
.note-form .btn { align-self: flex-start; }
.lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--ink-dim); }
.chk { font-size: 12px; color: var(--ink-dim); font-family: var(--mono); cursor: pointer; }

/* ---------- voice (top bar) ---------- */
.voice { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.voice[hidden] { display: none; }
.voice select {
  width: auto; font-size: 12px; font-family: var(--mono);
  padding: 3px 22px 3px 8px; min-height: 28px;
}
.btn-small { min-height: 28px; padding: 2px 10px; font-size: 12px; font-family: var(--mono); }
.voice.locked .chk { color: var(--accent); }
/* A solid surface, not a translucent wash: a wash lightens whatever it sits
   on by an amount that depends on the theme, which put the quietest text below
   its floor exactly while it was being read aloud. */
.speaking {
  background: var(--bg-speaking);
  box-shadow: -6px 0 0 var(--bg-speaking), 6px 0 0 var(--bg-speaking);
  border-radius: 3px;
}
.mech.speaking { color: var(--ink); }

/* What the narrator has already read is dimmed, so a spectator coming back to
   the tab can see where they were. Only while it is actually behind: caught up,
   every line is read and dimming them all would say nothing. app.js marks the
   nodes (`read`) as the playhead passes them. Dimmed text is still read text,
   so it goes to --ink-dim rather than to an opacity that would drop it under
   the contrast floor. */
.transcript.has-playhead .read.narration,
.transcript.has-playhead .read.dialogue,
.transcript.has-playhead .read.scene,
.transcript.has-playhead .read.dmnote,
.transcript.has-playhead .read.turn-head { color: var(--ink-dim); }
.transcript.voice-clickable .narration,
.transcript.voice-clickable .dialogue,
.transcript.voice-clickable .scene,
.transcript.voice-clickable .dmnote { cursor: pointer; }

/* ---------- narration transport ----------
   Under the transcript and the full width of it, so the row reads left to
   right as one sentence: what it is, the transport, the line being read, the
   settings. One line tall wherever the width allows — the padding is trimmed
   and nothing inside it is taller than a transport button — because every
   pixel of it comes off the transcript above.
   `display: flex` beats the UA stylesheet's `[hidden]`, so the hidden case is
   spelled out — without it a page with no narration shows an empty bar. */
#voice-panel {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 6px 16px;
  padding: 6px 11px;
}
#voice-panel[hidden] { display: none; }
#voice-panel .panel-title { margin: 0; flex: 0 0 auto; }
.transport { display: flex; gap: 6px; flex-wrap: wrap; flex: 0 0 auto; }
.btn-transport {
  min-width: 44px; padding: 7px 10px; font-size: 15px; line-height: 1;
  font-variant-emoji: text;
}
.btn-live { font-size: 12px; font-family: var(--mono); }
.btn-transport.on { border-color: var(--accent); color: var(--accent); }
.vt-now {
  flex: 1 1 170px; margin: 0; min-height: 0;
  display: flex; align-items: center;
  font-size: 12px; font-family: var(--mono); color: var(--ink-dim);
  overflow-wrap: anywhere;
  /* Nothing in here is longer than a few words any more, and this is what
     makes that a rule rather than a habit: whatever the panel says, it can
     never grow this row into a column and stand the transport on its end. */
  max-height: 6em; overflow: hidden;
}
/* `flex: 0 0 auto` and `nowrap` are load-bearing. As a shrinkable flex item
   beside a long anonymous text item, this span was squeezed to one character
   wide and "NARRATION" came out down the panel, a letter to a line. A label is
   never the thing that gives way. */
.vt-now .vt-tag {
  flex: 0 0 auto; white-space: nowrap;
  color: var(--ink-faint); text-transform: uppercase; letter-spacing: .07em; margin-right: 6px;
}
/* Which engine is reading: server-rendered voices, or this browser's own. */
/* Ordered last so that turning it on wraps a line of its own rather than
   pushing the settings off the bar. */
.vt-src {
  order: 2; flex: 0 0 100%; margin: 0;
  font-size: 11px; font-family: var(--mono); color: var(--ink-faint);
  overflow-wrap: anywhere;
}
.vt-src[hidden] { display: none; }
.vt-settings { order: 1; display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; }
/* A setting that is not doing anything says so, rather than sitting there
   ticked as though it were. `disabled` is "cannot apply to this game at all";
   `idle` is "on, but nothing to do just now" — still changeable. Titles differ.
   Both stay above the contrast floor: the cue is the italic label, not a fade
   into the background. */
.chk.disabled { color: var(--ink-faint); font-style: italic; cursor: default; }
.chk.idle { color: var(--ink-faint); }
.vt-settings select { width: auto; }
.pill-behind {
  font: inherit; font-size: 11px; font-family: var(--mono);
  cursor: pointer; border: 1px solid var(--accent); color: var(--accent);
  background: transparent; padding: 2px 8px; border-radius: 999px;
  min-height: 24px; -webkit-appearance: none; appearance: none;
  touch-action: manipulation;
}
.pill-behind[hidden] { display: none; }
.ctl-note { margin: 4px 0 8px; font-size: 12px; font-family: var(--mono); color: var(--ink-dim); }
.ctl-note.bad { color: var(--danger); }
.ctl-note[hidden] { display: none; }
/* The write half of the Controls panel. `.controls` and `.note-form` set
   `display: flex`, which beats the UA stylesheet's `[hidden]`, so the wrapper
   is what gets hidden — and it is a plain block for exactly that reason. */
#write-controls[hidden] { display: none; }
#write-locked[hidden] { display: none; }
#write-locked { margin: 0; }

input, select, textarea {
  font: inherit; font-size: 16px; /* 16px avoids iOS zoom-on-focus */
  width: 100%;
  color: var(--ink); background: var(--bg-sunken);
  border: 1px solid var(--line-strong); border-radius: 6px;
  padding: 8px 9px; -webkit-appearance: none; appearance: none;
}
select {
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
                    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 15px) calc(50% + 2px), calc(100% - 11px) calc(50% + 2px);
  background-size: 4px 4px; background-repeat: no-repeat;
  padding-right: 26px;
}
input[type="checkbox"] {
  width: auto; min-height: 0; padding: 0; margin: 0 5px 0 0;
  -webkit-appearance: checkbox; appearance: auto; accent-color: var(--accent-fill);
  vertical-align: -1px;
}
textarea { resize: vertical; }
input:focus-visible, select:focus-visible, textarea:focus-visible,
.btn:focus-visible, .transcript:focus-visible, summary:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.conn { font-family: var(--mono); font-size: 11px; margin: 10px 0 0; }
.conn-on  { color: var(--good); }
.conn-off { color: var(--ink-faint); }
.conn-warn { color: var(--accent); }

/* ---------- modal ---------- */
.modal {
  position: fixed; inset: 0; z-index: 50;
  background: var(--scrim);
  display: flex; align-items: flex-start; justify-content: center;
  overflow-y: auto; -webkit-overflow-scrolling: touch;
  padding: 24px 14px calc(24px + env(safe-area-inset-bottom));
}
.modal[hidden] { display: none; }
.modal-card {
  width: 100%; max-width: 560px;
  background: var(--bg-panel); border: 1px solid var(--line-strong);
  border-radius: 10px; padding: 18px;
}
.modal-card h2 { margin: 0 0 14px; font-family: var(--serif); font-size: 20px; color: var(--accent); }
.modal-card .lbl { display: block; margin: 12px 0 4px; }
.modal-card code { font-family: var(--mono); color: var(--ink); }
.row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.row-4 { grid-template-columns: repeat(4, 1fr); }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal-card-wide { max-width: 640px; }

/* ---------- voice lab ---------- */
.vl-rows { display: flex; flex-direction: column; gap: 10px; margin: 12px 0 4px; }
.vl-row {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-areas: "head head" "voice reset" "sliders sliders";
  gap: 6px 10px;
  padding: 10px; border: 1px solid var(--line-soft); border-radius: 8px;
  background: var(--bg-sunken);
}
.vl-head { grid-area: head; display: flex; align-items: center; gap: 8px; }
.vl-name { font-family: var(--mono); font-size: 13px; color: var(--ink); }
.vl-tag { font-size: 10px; }
.vl-head .btn { margin-left: auto; }
.vl-voice { grid-area: voice; font-family: var(--mono); font-size: 13px; }
.vl-auto { grid-area: reset; align-self: center; }
.vl-sliders { grid-area: sliders; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 14px; }
/* the treatment's own three, inside the fx block rather than the row grid */
.vl-fxsliders { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px 14px; }
.vl-slider { display: grid; grid-template-columns: 38px 1fr 44px; align-items: center; gap: 6px; }
.vl-slabel { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-faint); }
.vl-val { font-family: var(--mono); font-size: 11px; color: var(--ink-dim); text-align: right; }
/* the global input reset strips a range's track and thumb in WebKit */
.vl-slider input[type="range"] {
  width: 100%; min-height: 0; padding: 0; background: none; border: 0;
  -webkit-appearance: auto; appearance: auto; accent-color: var(--accent);
}
.vl-slider.vl-dead { opacity: .45; }
.vl-fx {
  grid-column: 1 / -1;
  display: grid; grid-template-columns: 58px 1fr; align-items: center; gap: 6px 10px;
  padding-top: 8px; border-top: 1px dashed var(--line-soft);
}
.vl-fxlabel {
  font-size: 10px; text-transform: uppercase; letter-spacing: .08em;
  color: var(--accent); font-family: var(--mono);
}
/* The treatment is seventeen knobs and the casting deals three, so the bench
   shows those three and folds the rest away. `.vl-fxbody` is the second column
   of `.vl-fx` — the label sits in the first — and holds the dealt grid and the
   disclosure under it, which is why it exists rather than the two being
   children of `.vl-fx` directly: at 58px the label column would have wrapped
   them into it. */
.vl-fxbody { display: grid; gap: 8px; min-width: 0; }
/* Same disclosure as `.turn-details`, and the same triangle: a summary that
   borrows `.vl-fxlabel` gets that class's colour and tracking, and needs the
   marker suppressed in both spellings or WebKit draws its own next to ours. */
.vl-more > summary {
  cursor: pointer; list-style: none; padding: 2px 0;
}
.vl-more > summary::-webkit-details-marker { display: none; }
.vl-more > summary::before { content: "\25B8 "; }
.vl-more[open] > summary::before { content: "\25BE "; }
.vl-more > .vl-fxsliders { padding-top: 8px; }
@media (max-width: 560px) {
  .vl-fx { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
  .vl-sliders, .vl-fxsliders { grid-template-columns: 1fr; }
}
.party-seats { display: grid; gap: 6px; }
/* Name, the two controls, and — on a line under all three — who that casting
   has actually dealt the seat. The cast spans the full width so a long voice
   line does not squeeze the name. */
.party-seat {
  display: grid; grid-template-columns: 1fr 130px 100px; gap: 4px 10px;
  align-items: center;
}
.party-seat > span:not(.party-cast) { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.party-seat select { margin: 0; min-width: 0; }
.party-cast {
  grid-column: 1 / -1;
  font-family: var(--mono); font-size: 11px; color: var(--ink-faint);
}
.party-cast:empty { display: none; }

/* ---------- responsive ----------
   The two-by-two board holds down to a tablet. Below that it becomes one
   column, and on a phone the transcript comes first: four fixed panels plus a
   narration bar before the first line of the story is not a story. */
@media (max-width: 900px) {
  .board { grid-template-columns: minmax(230px, min(var(--board-h), 55%)) minmax(0, 1fr); }
  .roster { columns: 260px 2; }
}
/* A laptop is short, not narrow: 768px of viewport is the common case, and at
   the desktop sizes the board and the transcript floor together came to more
   than it had, so the narration bar and the controls hung under the fold. */
@media (max-height: 820px) {
  /* A laptop is short, not narrow: the board takes a proportion of the stage
     rather than a length, so the transcript keeps a readable share of it. */
  :root { --board-h: clamp(240px, 36vh, 380px); }
  .board { grid-template-columns: minmax(230px, 22vw) minmax(0, 1fr); }
}
@media (max-width: 640px) {
  /* Map over roster, both full width, and the transcript above them: neither
     band may shrink here, because the page scrolls. With `flex: 1 1 auto`
     still on the feed it shrank to nothing under a board that no longer
     fitted, and the board drew straight over the transcript. */
  /* Source order, which is already story-first: the ordering above is what is
     dropped here, not added to. */
  .board {
    grid-template-columns: minmax(0, 1fr);
    order: 0; flex: 0 0 auto; min-height: 0;
  }
  .quad { height: var(--board-h); }
  .roster { columns: auto 1; }
  .feed { order: 0; flex: 0 0 auto; min-height: auto; }
  :root { --board-h: 300px; }
  .feed .panel-fill { flex: 0 0 auto; height: 66vh; min-height: 320px; }
  /* The stage stops being one screen: on a phone the whole page is a column
     and `.layout` scrolls it, so the stage is just its first two bands. */
  .stage { flex: 0 0 auto; }
  .row, .row-4 { grid-template-columns: 1fr; }
  /* three columns leave a phone about 90px for the name; stack instead */
  .party-seat { grid-template-columns: 1fr 1fr; }
  .party-seat span { grid-column: 1 / -1; }
  .topbar-right { width: 100%; }
  .cost-bar { width: 50px; }
}
/* ---------- landscape ----------
   A wide, short window is a different shape of problem, and the stacked
   layout answered it badly: the board's height is what sizes the map, so on a
   1373x857 tablet the map came out a 280px stamp while the roster spread
   itself over three columns of a panel a thousand pixels wide. Beside each
   other instead — board left, story right — the map gets the whole height of
   the stage to be square in, and the transcript becomes the tall column it
   always wanted to be. The gate is shape, not device: wide enough for two
   real columns, and half again as wide as it is tall — which is a browser
   window on a laptop, and a tablet held sideways once its chrome is off the
   height, and not a 4:3 window or anything held upright. */
@media (min-width: 1000px) and (min-aspect-ratio: 7/5) {
  .stage { flex-direction: row; }
  /* The board is one column of two rows now — map over table — rather than
     two panels side by side in a strip. */
  .board {
    order: 0; flex: 0 0 42%;
    grid-template-columns: minmax(0, 1fr);
    /* Three fifths to the map, two to the roster. A ratio rather than the
       map's own square, because a square panel in a column this wide leaves
       the row it sits in taller than itself and the roster starts 200px down
       the page; the canvas fits itself to whatever panel it is given. */
    grid-template-rows: minmax(160px, 3fr) minmax(120px, 2fr);
  }
  .feed { order: 1; flex: 1 1 auto; min-width: 0; }
  /* Roster columns are decided by the panel's width, and the panel is half of
     what it was: one column, so a card is not squeezed to a stub. */
  .roster { columns: 260px 2; }
}
@media (prefers-reduced-motion: reduce) {
  .hpbar i { transition: none; }
}

/* ---------- the score ----------
   A panel rather than a line in the transport, because the credits have to
   live somewhere a listener can actually read them: most of the pack is CC BY,
   and attribution is a condition of playing it at all, not a nicety. Closed by
   default — it is a list nobody needs open, but it must be one click away
   whenever the music is on. It sits in the card column under the narration
   bar, above the controls, and may be under the fold: a credit has to be
   reachable, not permanently on screen. */
#score-panel { display: flex; flex-direction: column; gap: 4px; }
#score-panel[hidden] { display: none; }
#score-now { flex: 0 0 auto; min-height: 0; }
#sound.locked .chk { color: var(--accent); }
.credits { font-size: 11px; font-family: var(--mono); color: var(--ink-faint); }
.credits summary { cursor: pointer; letter-spacing: .04em; }
.credits ul {
  margin: 6px 0 0; padding: 0 0 0 14px;
  max-height: 9em; overflow-y: auto; overflow-wrap: anywhere;
}
.credits li { margin: 0 0 4px; line-height: 1.35; }
