/* 5e Browser RPG – dark classic fantasy UI */

/*
 * The tokens moved out.
 *
 * Everything this file is built from — colour, type, space, radius,
 * elevation, layer, motion — now lives in tokens.css, which is the single
 * source and the only place a raw value belongs. The `:root` block that stood
 * here held colour and nothing else, which is why 63 font sizes, 17 radii, 43
 * shadows and 11 z-indexes accumulated below it with nowhere to be declared.
 *
 * Imported rather than linked because there is no build step here and eight
 * pages link this file with three different cache-busting strategies (and one
 * with none). One import is one place to be right; eight <link> edits are
 * eight places to drift. The cost is one serialised request, which is the
 * cheaper of the two mistakes.
 */
@import url('tokens.css');

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-ui);
  min-height: 100%;
}

/*
 * The serif is opt-in, and only for voice: what somebody says, what they are
 * called, and the headings that name a place. Everything else — buttons, tabs,
 * stat lines, tooltips, the log — is the interface talking about itself, and
 * gets the sans set on <body> above.
 */
h1, h2, h3,
.map-name, .party-name, .dlg-name, .dlg-line, .dlg-aside,
.sheet-name, .quest-title, .cbt-title, .hero p {
  font-family: var(--font-display);
}

/* Buttons and form controls inherit from <body>, so they need saying once. */
button, input, select, textarea { font-family: var(--font-ui); }

a { color: var(--gold); text-decoration: none; }
a:hover { text-decoration: underline; }

h1, h2, h3 { color: var(--gold); font-weight: var(--fw-semibold); letter-spacing: var(--ls-display); margin: 0 0 var(--sp-8); }

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: var(--sp-24);
}

/* Landing */
.hero {
  text-align: center;
  padding: var(--sp-48) var(--sp-16) var(--sp-32);
  background:
    radial-gradient(ellipse at 50% 0%, var(--bg-panel) 0%, transparent 60%),
    var(--bg);
  border-bottom: 1px solid var(--border);
}

.hero h1 {
  font-size: var(--fs-3xl);
  text-shadow: 0 0 24px rgba(212, 168, 75, 0.35);
}

.hero p { color: var(--text-dim); max-width: 560px; margin: var(--sp-12) auto var(--sp-24); line-height: var(--lh-normal); }

.btn {
  display: inline-block;
  background: linear-gradient(180deg, var(--border), var(--border-soft));
  color: var(--gold);
  border: 1px solid var(--gold-dim);
  padding: var(--sp-8) var(--sp-20);
  border-radius: var(--r-md);
  cursor: pointer;
  font-family: inherit;
  font-size: var(--fs-md);
  transition: background var(--dur), box-shadow var(--dur);
}

.btn:hover {
  background: linear-gradient(180deg, var(--gold-dim), var(--border));
  box-shadow: var(--sh-glow-sm);
  text-decoration: none;
}

.btn-primary {
  background: linear-gradient(180deg, var(--gold-dim), var(--gold-well));
  border-color: var(--gold);
  color: var(--gold-bright);
}

.btn-danger {
  background: linear-gradient(180deg, var(--danger-dim), var(--danger-well));
  border-color: var(--danger);
  color: var(--danger);
}

.btn-small { padding: var(--sp-4) var(--sp-12); font-size: var(--fs-sm); }
/* The one button on a landing page, which has a screen to itself and should
   not be the size of a toolbar control. */
.btn-lg { padding: var(--sp-12) var(--sp-24); font-size: var(--fs-md); letter-spacing: var(--ls-display); }
/* A glyph in front of a label. `.btn` is inline-block, so the two would sit on
   the text baseline with the icon hanging below it.

   The paint is on an opt-in class rather than on `.btn > svg`: the other
   icon-in-a-button on the site is a stroke-only symbol that sets its own
   `fill: none`, and it only survives a blanket `fill: currentColor` here
   because it happens to be declared later in this file. That is a coin toss to
   leave lying in a stylesheet. */
.btn:has(svg) { display: inline-flex; align-items: center; gap: var(--sp-8); }
.btn > svg { width: 0.85em; height: 0.85em; flex: 0 0 auto; }
.btn > .btn-glyph { fill: currentColor; stroke: none; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }

.panel {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-16) var(--sp-20);
  margin-bottom: var(--sp-16);
  box-shadow: var(--sh-bevel);
}

.panel h2 { font-size: var(--fs-lg); border-bottom: 1px solid var(--border); padding-bottom: var(--sp-6); margin-bottom: var(--sp-12); }

.grid-2 {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--sp-16);
}

@media (max-width: 800px) {
  .grid-2 { grid-template-columns: 1fr; }
}

/* Forms */

/* The `hidden` attribute has to keep working.
 *
 * A browser's own rule for it is `[hidden] { display: none }`, which is a
 * user-agent rule and therefore loses to any author rule that sets `display` —
 * including the one immediately below. So `<label hidden>` stayed visible, and
 * anything else built on `el.hidden = true` silently did nothing. Declared once
 * here rather than worked around at each call site. */
[hidden] { display: none !important; }

label { display: block; color: var(--text-dim); font-size: var(--fs-sm); margin-bottom: var(--sp-4); }
/* textarea belongs here too. It was left out, so every multi-line field fell
 * back to the browser default — a white box, sized to its `cols` rather than
 * its container, sitting beside its own label instead of under it. Nothing in
 * the game used one until the content editors did, which is why it went
 * unnoticed. */
input, select, textarea {
  width: 100%;
  background: var(--bg);
  border: 1px solid var(--border);
  color: var(--text);
  padding: var(--sp-8) var(--sp-10);
  border-radius: var(--r-md);
  font-family: inherit;
  margin-bottom: var(--sp-12);
}
textarea {
  display: block;
  resize: vertical;
  line-height: var(--lh-normal);
}
/* A checkbox is not a text field, and everything above was written for text
   fields. `width: 100%` on one makes it swallow its whole row and squeeze the
   caption beside it down to nothing — which is what happened to "roll these
   for me from now on" in the check prompt, where `flex: 0 0 auto` could not
   save it because a flex basis of `auto` reads the width property it is
   given. `.ce-check input` had already worked around this locally; that it
   needed to is the sign the correction belongs here. */
input[type="checkbox"],
input[type="radio"] {
  width: auto;
  padding: 0;
  margin-bottom: 0;
}
input:focus, select:focus, textarea:focus { outline: 1px solid var(--gold-dim); }

.ability-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--sp-8);
}
.ability-box {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-8);
  text-align: center;
}
.ability-box strong { display: block; color: var(--gold); font-size: var(--fs-lg); }
.ability-box span { font-size: var(--fs-2xs); color: var(--text-dim); text-transform: uppercase; }

/* What the class wants. Gold on the primary, because gold means "you can act
   on this" everywhere else in the game and this is the box to act on first;
   the secondary gets the same shape at part strength rather than a second
   colour, so the two read as one scale and not as two unrelated marks. */
.ability-box.is-primary { border-color: var(--gold-dim); background: var(--bg-panel-2); }
.ability-box.is-secondary { border-color: var(--ornament); }
.ability-box .ability-rank {
  display: block;
  font-size: var(--fs-2xs);
  font-style: normal;
  letter-spacing: 0.06em;
  color: var(--gold-dim);
}
.ability-box.is-secondary .ability-rank { color: var(--text-dim); }

/* The sentence over the grid. Sits where the method note sits and reads as
   part of the same running commentary, so it is styled to match rather than
   competing with it. */
.wiz-advice {
  margin: 0 0 var(--sp-8);
  font-size: var(--fs-sm);
  color: var(--gold-dim);
}
.wiz-advice:empty { display: none; }

.tabs {
  display: flex;
  gap: var(--sp-6);
  margin-bottom: var(--sp-16);
  flex-wrap: wrap;
}
.tab {
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  color: var(--text-dim);
  padding: var(--sp-6) var(--sp-12);
  border-radius: var(--r-md);
  cursor: pointer;
  font-family: inherit;
}
.tab.active {
  color: var(--gold);
  border-color: var(--gold-dim);
  background: var(--bg-panel);
}

/* Game layout — single viewport (desktop), no page scroll */
html:has(body.game-page) {
  height: 100%;
  overflow: hidden;
}

body.game-page {
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  background: var(--bg);
}

/* ====================================================================
   The shell: three columns of a text game
   ====================================================================
   Who you are, where you are, what has happened. The middle track is the only
   `1fr` — the rails are sized to their content and no wider, because a party
   card needs a face and a name and neither gets better with more room.

   This replaces a full-bleed map with a floating `pointer-events: none` HUD
   over it. That layout existed because the tile map wanted every pixel and
   everything else had to keep out of its way. Prose wants the opposite: a
   measure to read at, and panels that sit still. */
.game-shell {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) var(--rail-w, 390px);
  gap: var(--sp-16);
  height: 100vh;
  height: 100dvh;
  padding: var(--sp-12);
  overflow: hidden;
  /* Capped and centred rather than full-bleed. Left to fill a 2560px monitor
     the middle column becomes a 600px measure of text marooned in the middle
     of a 1600px void, which reads as a broken layout rather than a roomy one. */
  max-width: 1400px;
  margin: 0 auto;
}

.rail {
  display: flex;
  flex-direction: column;
  gap: var(--sp-10);
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
}

/* Two halves, top and bottom: what you are trying to do, and what has
   happened while you did it. An even split rather than sized-to-content
   because both are read at a glance and neither should jump about as its
   contents change — a log that grows must not push the tasks off the top. */
.rail-right {
  display: grid;
  grid-template-rows: 1fr 1fr;
  gap: var(--sp-10);
  overflow: hidden;
}
/* Width comes from the grid column, not from here — setting both is how the
   rail ended up 90px wider than the track holding it and ran off the screen. */
.rail-right { width: 100%; min-width: 0; }

/* Left rail: party on top, people in the scene below. Each pane is half the
   column height and scrolls inside itself, so neither list can steal the
   other's space. */
.rail-left {
  width: 200px;
  overflow: hidden;
  gap: var(--sp-8);
}
.rail-pane {
  flex: 1 1 50%;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--ornament-well);
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  padding: var(--sp-6) var(--sp-8) var(--sp-8);
}
.rail-pane-head {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  margin: 0 0 var(--sp-6);
  font-family: var(--font-ui, var(--font-display));
  font-size: var(--fs-2xs);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
}
.rail-pane .hud-party,
.rail-pane .hud-people {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
  width: 100%;
}

/* The middle column. `position: relative` because the combat board and the
   dialogue theatre are positioned against it — they are screens, not panels,
   and each covers the scene entirely while it is up. */
.scene {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--sp-10);
  min-height: 0;
  min-width: 0;
}

/* Where you are on the left, the menu on the right, one row.
   `space-between` rather than `flex-end`: the bar used to be the menu alone
   with 205px of nothing beside it, and the region moved into that nothing. */
.scene .topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-8);
  flex: 0 0 auto;
}

/* The region, as the eyebrow the deleted .loc-header used to carry.
   `min-width: 0` so a long region name yields to the icons instead of pushing
   them off the end — the menu is the thing you have to be able to reach. */
.topbar-where {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
  padding-left: var(--sp-4);
}

/* One of these is visible at a time. The scroll lives here rather than on the
   shell so the rails stay put while a long location is read. */
.screen {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
}

.is-busy .screen { cursor: progress; }

/* --- Shared panel treatment -----------------------------------------
   Smoked glass with a brass edge. `--ornament`, not gold: see the palette
   note. */
.hud-panel,
.hud-quest,
.hud-log,
.hud-icons {
  background: var(--ornament-well);
  -webkit-backdrop-filter: blur(7px) saturate(1.1);
  backdrop-filter: blur(7px) saturate(1.1);
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  box-shadow: var(--sh-bevel), var(--sh-2);
}

/* The filigree: a hairline inside the brass edge. Drawn with gradients rather
   than art so it costs nothing and scales. */
.hud-quest::before,
.hud-log::before,
.hud-icons::before {
  content: '';
  position: absolute;
  inset: 2px;
  border: 1px solid rgba(160, 131, 71, 0.22);
  border-radius: var(--r-sm);
  pointer-events: none;
}
.hud-quest, .hud-log, .hud-icons { position: relative; }

/* --- Icon bar -------------------------------------------------------- */
.hud-icons {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-4) var(--sp-6);
}

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: 1px solid transparent;
  border-radius: var(--r-md);
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  transition: color var(--dur-fast), background var(--dur-fast), border-color var(--dur-fast);
}
.icon-btn svg {
  width: 1.15rem;
  height: 1.15rem;
  /* The sprite is stroke-only on a 24 grid, so weight stays even at any size
     and `currentColor` means one hover rule lights the whole button. */
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.icon-btn:hover,
.icon-btn:focus-visible {
  color: var(--gold);
  background: var(--bg-raised);
  border-color: var(--ornament);
}
.icon-btn-sm { width: 1.5rem; height: 1.5rem; }
.icon-btn-sm svg { width: 0.9rem; height: 0.9rem; }

.icon-sep {
  width: 1px;
  height: 1.1rem;
  margin: 0 var(--sp-4);
  background: var(--ornament);
  opacity: 0.55;
}

/* The sprite itself is never drawn — only referenced by <use>. */
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
/* --- The party rail ---------------------------------------------------
   Portrait, name, hit points. It was a column of bare circles with the health
   drawn as a conic ring around each and the numbers only in a tooltip — a
   design for a rail floating over a tile map, where every pixel spent on
   chrome was a pixel off the world. There is no world underneath any more, so
   the rail can simply say what it knows. */
.hud-party {
  display: flex;
  flex-direction: column;
  gap: var(--sp-6);
  width: 100%;
}

/* People in the scene: same rail column as the party, tighter rows so a
   crowded street still fits half a screen. */
.hud-people {
  display: flex;
  flex-direction: column;
  gap: var(--sp-6);
  width: 100%;
}
.hud-people .people-list { gap: var(--sp-4); }
.hud-people .person {
  padding: var(--sp-4) var(--sp-4);
  gap: var(--sp-6);
  background: transparent;
}
.hud-people .person-face {
  width: 32px;
  height: 32px;
}
.hud-people .person-name { font-size: var(--fs-xs); }
.hud-people .person-role {
  font-size: var(--fs-2xs);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.hud-people .people-crowd h3 {
  margin: var(--sp-6) 0 var(--sp-4);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
  font-weight: var(--fw-semibold);
}
.hud-people .help-hint {
  margin: var(--sp-4) var(--sp-2);
  font-size: var(--fs-xs);
}

.hud-party .party-card {
  display: grid;
  grid-template-columns: var(--portrait) minmax(0, 1fr);
  grid-template-areas:
    "face name"
    "face hp"
    "face stat";
  align-items: center;
  gap: var(--sp-2) var(--sp-8);
  width: 100%;
  padding: var(--sp-6) var(--sp-8);
  text-align: left;
  background: var(--ornament-well);
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}
.hud-party .party-card:hover { border-color: var(--gold-dim); background: var(--bg-panel); }

/* Whose turn it is to be looked at: the active character carries the gold. */
.hud-party .party-card.is-active {
  border-color: var(--gold);
  box-shadow: var(--sh-ring);
}

.hud-party .party-face {
  grid-area: face;
  width: var(--portrait);
  height: var(--portrait);
  border-radius: var(--r-md);
  object-fit: cover;
  object-position: center top;
  border: 1px solid var(--ornament);
  background: var(--bg-inset);
}

.hud-party .party-name {
  grid-area: name;
  font-size: var(--fs-xs);
  font-weight: var(--fw-semibold);
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The bar the rail exists for. Thicker than the 4px sliver it replaces, and
   the numbers sit under it — colour alone is not a reading. */
.hud-party .party-card .hp-bar {
  grid-area: hp;
  height: 7px;
  margin: var(--sp-2) 0 var(--sp-2);
  border-radius: var(--r-md);
}

.hud-party .party-stats {
  grid-area: stat;
  display: flex;
  gap: var(--sp-6);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
}
.hud-party .party-hp { color: var(--text); }

/* Down: greyed, and it says so rather than only looking dim. */
.hud-party .party-card.is-down { border-color: var(--danger); }
.hud-party .party-card.is-down .party-face { filter: grayscale(1) brightness(0.5); }
.hud-party .party-downed {
  grid-area: stat;
  justify-self: end;
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--danger);
}

/* Conditions ride along the bottom of the portrait. */
.hud-party .cond-pips {
  grid-area: face;
  align-self: end;
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1);
  justify-content: center;
}
.hud-party .cond-pips:empty { display: none; }

/* Approval, as a dot.
   
   The word — "Neutral", "Devoted" — sat in the same grid cell as the name and
   ate the end of it, so half the party read as "Brother Al". The dot carries
   the same colour band, the word is in the tooltip the card already has, and
   the camp screen spells it out in full. */
/* The dot and, when it has moved, the direction it moved in. Both sit in the
   name cell, so they share one wrapper rather than both claiming the grid area
   and drawing on top of each other. */
.party-approval-row {
  grid-area: name;
  justify-self: end;
  align-self: center;
  display: flex;
  align-items: center;
  gap: var(--sp-4);
}
.hud-party .party-approval {
  width: 8px;
  height: 8px;
  padding: 0;
  border-radius: var(--r-circle);
  background: currentColor;
  /* Hides the label text without hiding the element from a screen reader. */
  /* Not type: this collapses the row to nothing when there is no drift to
     show. A height of zero written in the two properties that give a line
     box its height — neither is a leading or a size, and neither belongs
     on the type scale. */
  font-size: 0;
  line-height: 0;
  overflow: hidden;
  flex: 0 0 auto;
}
.hud-party .party-approval.ap-mid { color: var(--text-dim); }
.hud-party .party-approval.ap-high,
.hud-party .party-approval.ap-good { color: var(--success); }
.hud-party .party-approval.ap-low { color: var(--warn); }
.hud-party .party-approval.ap-risk { color: var(--warn); }
.hud-party .party-approval.ap-bad { color: var(--danger); }
/* A companion inside ten points of walking out gets the one animated thing on
   the rail. Everything else here is deliberately still, so this reads as an
   alarm rather than as decoration. */
.hud-party .party-approval.ap-risk,
.hud-party .party-approval.ap-bad { animation: ap-pulse var(--dur-pulse) var(--ease-in-out) infinite; }
@keyframes ap-pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
  .hud-party .party-approval.ap-risk,
  .hud-party .party-approval.ap-bad { animation: none; }
}

/* Leaves room for the dot so a long name ellipsises before it collides. */
.hud-party .party-name { padding-right: var(--sp-14); }

/* The XP bar is hidden here — levelling is not what a glance at the rail is
   for, and the character sheet shows it in full. */
.hud-party .xp-bar { display: none; }

.hud-party .party-add {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-6);
  width: 100%;
  min-height: 0;
  padding: var(--sp-6);
  border: 1px dashed var(--ornament);
  border-radius: var(--r-md);
  color: var(--text-faint);
  text-decoration: none;
  font-size: var(--fs-xs);
}
.hud-party .party-add-plus { font-size: 1rem; }
.hud-party .party-add:hover { color: var(--gold); border-color: var(--gold); }

/* The two halves. The second is itself a column — log above, actions below —
   so the buttons sit on the floor of the rail rather than wherever the log
   happens to end. */
.rail-right > .hud-quest { min-height: 0; display: flex; flex-direction: column; }
.rail-bottom {
  display: flex;
  flex-direction: column;
  gap: var(--sp-10);
  min-height: 0;
}
.rail-bottom .hud-log { flex: 1 1 auto; min-height: 0; }

/* Tasks scrolls inside its half rather than growing it. */
.hud-quest .track-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  padding-right: var(--sp-4);
}
.track-current { padding-bottom: var(--sp-4); }
.track-others { margin-top: var(--sp-12); border-top: 1px solid var(--ornament); padding-top: var(--sp-8); }
.track-others h3 {
  margin: 0 0 var(--sp-6);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
  font-weight: var(--fw-medium);
  opacity: 0.8;
}
.track-others ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--sp-4); }
.track-pick {
  display: flex;
  flex-direction: column;
  width: 100%;
  gap: var(--sp-1);
  padding: var(--sp-4) var(--sp-6);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--r-md);
  text-align: left;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.track-pick:hover { border-color: var(--ornament); background: var(--ornament-well); }
.track-pick-title { font-size: var(--fs-xs); color: var(--text); }
.track-pick-stage { font-size: var(--fs-2xs); color: var(--text-dim); }

/* --- Quest tracker ---------------------------------------------------- */
.hud-quest {
  /* Fills its half of the rail. The 190px was a leftover from the floating
     HUD, where the tracker was a small card sitting over the map and had to
     be sized so it did not cover it. The rail sizes it now. */
  width: 100%;
  padding: var(--sp-8) var(--sp-8);
  font-size: var(--fs-xs);
}
/* The tracker emits its own <h2>; the small-caps treatment used to come from a
   rule scoped to the context rail, which no longer exists. */
.hud-quest h2 {
  margin: 0 0 var(--sp-4);
  font-family: var(--font-ui);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
}

/* --- Log -------------------------------------------------------------- */
.hud-log {
  /* Fills the rail rather than carrying its own width: the rail is already
     sized, and a fixed width here is what pushed the panel off the edge of
     the shell. */
  width: 100%;
  padding: var(--sp-6) var(--sp-8) var(--sp-8);
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}
.hud-log-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-6);
}
.hud-log .log {
  max-height: 40vh;
  overflow-wrap: anywhere;
  overflow-y: auto;
  font-size: var(--fs-xs);
  /* Fades into the panel at the top rather than ending on a hard edge, so a
     scrolled log reads as continuing rather than as clipped. */
  mask-image: linear-gradient(to bottom, transparent, #000 14px);
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 14px);
}
.hud-log.collapsed .log { display: none; }
.hud-log.collapsed #btn-log-collapse svg { transform: rotate(180deg); }

/* --- Action bar -------------------------------------------------------
   Layout lives on `.action-bar`; this only places it. */
/* Combat draws its own bar inside the board, and renderActionBar() hides this
   one; the dialogue theatre covers the bottom of the screen, so hide it there
   too rather than letting the two overlap. */
body.in-dialogue .hud-actions { display: none; }

/* Combat takes the whole frame and draws its own portraits, initiative list
   and action economy. Leaving the exploration HUD on top of it would be two
   sets of the same information disagreeing, so it stands down — except the log,
   which is the one panel that is *more* useful in a fight. */
body.in-combat .rail-left,
body.in-combat .hud-quest,
body.in-combat .hud-actions { display: none; }

/* With the party rail stood down the board takes the room it was using.

   Placed by hand, because auto-placement cannot be trusted here. A
   `display: none` grid item is not a grid item at all, so the two survivors
   fall onto the FIRST two tracks rather than staying on their own: the scene
   landed in the collapsed left track at 0px wide and the log rail spread
   across the 1fr while the 390px track sat empty. Zeroing the left track was
   the first attempt at this and is what produced that, since the items still
   start from track one. Naming the columns is what makes it survive — hide
   whatever you like, these two do not move. */
body.in-combat .game-shell {
  grid-template-columns: minmax(0, 1fr) var(--rail-w, 390px);
  /* A row for the top line above the two columns. `auto` so it is exactly as
     tall as the header; the rail below is shorter by that much, which is the
     trade — the round and the turn want the whole width, and the log is the
     one thing that does not mind losing those pixels. */
  grid-template-rows: auto minmax(0, 1fr);
  /* The shell's 1rem is a gap between COLUMNS — panels standing side by side.
     Applied to the new row as well it put 16px between the combat header
     and the board it belongs to, which reads as two screens rather than one
     and is 16px of the thing that is short. Rows get a quarter of it; columns
     keep what they had. */
  gap: var(--sp-4) var(--sp-16);
  /* Wider than anywhere else in the game, and only here.

     The 1400px cap exists to keep a line of prose readable, and a tactical map
     is not prose: three columns with a sixteen-by-twelve board across the
     middle does not fit in the ~970px a 1400px shell leaves after the
     rail. This spends screen that only exists on the monitors that have it. */
  max-width: 1760px;
}
/* And the rail gives some back. In a fight it holds the log and nothing else —
   the party rail, the quest tracker and the action buttons are all hidden
   above — so 390px of it is 90px the battlefield could be using. */
body.in-combat { --rail-w: 300px; }
/* Placed by name for the reason the columns are: everything here is explicit so
   that hiding a panel cannot shuffle the survivors onto the wrong tracks. */
body.in-combat .scene { grid-column: 1; grid-row: 2; }
body.in-combat .rail-right { grid-column: 2; grid-row: 2; }
body.in-combat .cbt-top { grid-column: 1 / -1; grid-row: 1; }
/* The scene's own stack — the icon toolbar, then the board — closes up too.
   0.6rem is written for a page of prose with panels down it; here it is one
   more band between the header and the board, and the board is what is short. */
body.in-combat .scene { gap: var(--sp-4); }

/* And the log takes the whole of what is left. With the quest tracker hidden
   it is alone in a 300px column, and `max-height: 40vh` — a cap written for a
   log sharing that column with two other panels — left it stopping halfway
   down with the rest of the rail blank. In a fight the log is the transcript
   of the fight; there is nothing else the space could be for. */
body.in-combat .rail-right { display: flex; flex-direction: column; min-height: 0; }
body.in-combat .rail-right .rail-bottom { flex: 1 1 auto; min-height: 0; }
body.in-combat .rail-right .hud-log { flex: 1 1 auto; min-height: 0; }
body.in-combat .rail-right .hud-log .log { flex: 1 1 auto; min-height: 0; max-height: none; }

/* The phone stacks instead, so there it is the rows that need saying. Same
   failure otherwise: the scene takes the `auto` row and collapses to nothing
   under an absolutely-positioned board. */
@media (max-width: 760px) {
  body.in-combat .game-shell {
    grid-template-columns: minmax(0, 1fr);
    /* Three rows now: the top line, the board, the log strip. The top line is
       full width here for free — there is only one column to span. */
    grid-template-rows: auto 1fr auto;
  }
  body.in-combat .cbt-top { grid-column: 1; grid-row: 1; }
  body.in-combat .scene { grid-column: 1; grid-row: 2; }
  body.in-combat .rail-right { grid-column: 1; grid-row: 3; }
  /* And the rail is CAPPED, which is the difference between a board and no
     board. The rows are `auto 1fr auto`, and a `1fr` track carries an
     automatic minimum — so it yields to any `auto` track whose content is
     taller. The rail's content is the log plus (until the line above) the
     inspector: measured at 488px of a 740px phone, which left the scene 142,
     the topbar took 54 of that, and the battlefield was rendered 318x0. The
     log scrolls inside itself already; it only needed to be told how much of
     the screen it may have. */
  body.in-combat .rail-right { max-height: 16vh; overflow: hidden; }

  /* And the rosters. Same shape of fault one level down: `.cbt-field` is
     `minmax(0, 1fr) auto` — map above, the two rosters below — and an `auto`
     row sizes to its content, so six wrapped 88px cards took 184px of the
     field's 192 and left the board fourteen. Capped and scrolling, they are a
     strip you swipe rather than a wall.
   
     The whole screen is a budget on a phone and this is where it is spent:
     board 240 (a 16-wide grid needs that much height for square cells at
     360px), action column, initiative list, topbar 54, log 90.
     That is tight on 740, and the log is what gives — mid-fight it is the last
     four things that happened, and it still scrolls. */
  /* The rule that hid the inspector here was dead for as long as it existed.
     `body.in-combat .cbt-inspector { display: none }` inside a media query has
     exactly the specificity of `body.in-combat .cbt-inspector { display: flex }`
     4,100 lines further down, and a media query adds none — so the later one
     won and the inspector kept its 252px on a 740px phone. It is hidden beside
     its own definition now, where nothing can quietly out-order it. See the
     phone block after `.cbt-inspector`. */
}

/* ====================================================================
   The location screen
   ====================================================================
   Everything the tile renderer used to draw, as type.

   The measure is the whole design. Prose that runs the width of a 2560px
   monitor is unreadable however good it is, so `.loc-inner` is capped and
   centred, and every panel inside it shares that column. Nothing here is
   positioned absolutely and nothing overlaps: it is a page, and it scrolls. */
.loc-inner {
  max-width: 62ch;
  margin: 0 auto;
  padding: var(--sp-4) var(--sp-8) var(--sp-32);
  display: flex;
  flex-direction: column;
  gap: var(--sp-20);
}

/* ====================================================================
   Desktop: one screen, no page scroll
   ====================================================================
   A game screen, not a document. The window is the frame: the heading and the
   actions are always where you left them, the region chart holds the ways out,
   the left rail holds the party and the people, and the only thing that moves
   in the middle is the prose. */
@media (min-width: 1180px) {
  /* The scroll moves off the screen and into the panels that need it. */
  .screen { overflow: hidden; }

  /* One column now: the chart moved to the HUD rail, which is where a map
     belongs in a game and which gave the scene its full width back. */
  .loc-inner {
    display: block;
    max-width: none;
    height: 100%;
    min-height: 0;
    padding-bottom: var(--sp-4);
  }
  .loc-text { height: 100%; }

  /* Chart fills the middle, menus pinned to the bottom. The heading used to be
     the fixed thing at the top of this stack; it is in the topbar now, which is
     outside .screen and so is fixed by construction rather than by a rule. */
  .loc-text {
    display: flex;
    flex-direction: column;
    min-height: 0;
    gap: var(--sp-14);
  }
  .loc-foot { flex: 0 0 auto; }

  /* The chart fills the middle. It was the art and the prose scrolling here,
     with the map behind a keystroke; see renderLocation() for the swap. */
  .loc-map {
    flex: 1 1 auto;
    min-height: 0;
  }

  /* The bottom half holds the transcript and the action bar under it. */
  .rail-right .hud-log {
    min-height: 0;
  }
  .rail-right .hud-log .log {
    max-height: none;
    flex: 1 1 auto;
    min-height: 0;
  }

}


/* The parts of the column, on any width. `display: contents` on narrow screens
   makes the wrappers vanish from layout so everything simply flows in order. */
.loc-text, .loc-foot {
  display: flex;
  flex-direction: column;
  gap: var(--sp-20);
}
@media (max-width: 1179px) {
  .loc-text, .loc-foot { display: contents; }
}

/* --- Panels ----------------------------------------------------------
   Framing the actions so they read as a menu rather than free-floating
   buttons under the prose. Ways out live on the region chart; people live
   in the left rail under the party. */
.loc-foot {
  background: var(--ornament-well);
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  padding: var(--sp-10) var(--sp-12) var(--sp-12);
  display: flex;
  flex-direction: column;
  gap: var(--sp-8);
}
.loc-actions h2 { margin-bottom: var(--sp-6); }

/* --- The chart, in the scene -------------------------------------------
   It was a modal opened with M, and the space it now occupies held the
   establishing art and the prose. See renderLocation() for the trade: the
   thing consulted constantly is on screen, and the thing read once is behind
   the ⓘ in the corner.

   `position: relative` is load-bearing — the zoom cluster and the info button
   are placed against this box, and the chart underneath them is a full-bleed
   SVG that would otherwise be the positioning context for neither. */
.loc-map {
  position: relative;
  min-height: 14rem;
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  background: var(--bg-inset);
  overflow: hidden;
}
.loc-map .worldmap { display: block; width: 100%; height: 100%; }
.loc-map.is-empty {
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Both clusters sit ON the chart, quiet until wanted — the board's zoom keys
   do the same thing for the same reason: they are furniture beside the thing
   being looked at. */
.loc-map .map-zoom {
  position: absolute;
  top: 0.4rem;
  right: 0.4rem;
  margin-left: 0;
  padding: var(--sp-2);
  background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  opacity: 0.5;
  transition: opacity var(--dur-fast);
}
/* A letter in a ring, drawn rather than typed. ⓘ is a glyph in a decorative
   role — its ink sits differently inside its em box in every face, so it
   lands at a different size and weight depending on the machine. This is the
   same lesson the selected-target bullseye records. */
.map-info {
  position: absolute;
  right: 0.4rem;
  bottom: 0.4rem;
  width: 1.5rem;
  height: 1.5rem;
  font-family: var(--font-display, serif);
  font-style: italic;
  font-size: var(--fs-md);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-flush);
  color: var(--gold-dim);
  background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
  border: 1px solid var(--border);
  border-radius: var(--r-circle);
  opacity: 0.5;
  transition: opacity var(--dur-fast), color var(--dur-fast);
}
.loc-map:hover .map-zoom,
.loc-map:hover .map-info,
.loc-map:hover .map-look,
.loc-map .map-zoom:focus-within,
.map-look:focus-visible,
.map-info:focus-visible { opacity: 1; }
.map-info:hover { color: var(--gold); }

/* The way between the two views of a floor, in the corner opposite the zoom.
   A word rather than a glyph: "Look" and "Map" say which way it goes, and
   there is no icon for "walk this in first person" that anybody would read
   correctly the first time. */
.map-look {
  position: absolute;
  left: 0.4rem;
  bottom: 0.4rem;
  width: auto;
  padding: 0.1rem 0.5rem;
  font-size: var(--fs-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--gold-dim);
  background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  opacity: 0.5;
  transition: opacity var(--dur-fast), color var(--dur-fast);
}
.map-look:hover { color: var(--gold); }

/* ---- The first-person view ------------------------------------------
   The picture is the whole panel and the controls sit on it, exactly as the
   zoom cluster sits on the chart. It keeps the chart's box so that flipping
   between them does not move anything else on the page. */
/* The chart's own proportions — 118 by 87, its viewBox with the padding it
   draws place names into. Held here as well as in ui-firstperson.js's viewBox
   so that flipping between the two views does not move the panel, and so the
   picture fills its box without letterboxing or cropping. */
.loc-map.is-fp {
  aspect-ratio: 118 / 87;
  background: var(--fp-roof-5);
}
.fp-stage { display: block; width: 100%; height: 100%; }
.fp-view { display: block; width: 100%; height: 100%; }
.fp-void { fill: var(--fp-void); }
.fp-view polygon { stroke: var(--fp-edge); stroke-width: 0.5; stroke-linejoin: round; }
.fp-view .fp-wall { stroke-width: 0.7; }
/* Floor and ceiling are drawn a cell at a time, so an outline on them rules
   the ground with every quad's own edges — including the ones radiating out
   of the vanishing point, which read as cracks nothing in the level has. The
   depth bands alone are enough to say how far away the floor is. */
.fp-view .fp-floor,
.fp-view .fp-roof { stroke: none; }
.fp-leaf { fill: var(--fp-leaf); stroke: var(--fp-leaf-line); stroke-width: 1; }
.fp-leaf.is-stuck { stroke-dasharray: 4 3; }
.fp-leaf.is-locked { fill: color-mix(in srgb, var(--fp-leaf) 80%, #000); }
.fp-leaf.is-portcullis line { stroke: var(--fp-bars); stroke-width: 2.4; }
.fp-stair polygon { fill: var(--pg-stair); fill-opacity: 0.55; stroke: var(--fp-edge); }

/* Which way you are looking, over a ceiling that is nearly black — so it is
   given its own ground rather than trusted to a text-shadow, the same way
   every other mark on this panel is. */
.fp-compass {
  position: absolute;
  top: 0.4rem;
  left: 0.4rem;
  margin: 0;
  padding: 0.1rem 0.45rem;
  font-size: var(--fs-xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--gold-dim);
  background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  pointer-events: none;
}
.fp-walk {
  position: absolute;
  right: 0.4rem;
  bottom: 0.4rem;
  display: flex;
  gap: var(--sp-1);
  padding: var(--sp-2);
  background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
}
.fp-walk .fp-fwd { color: var(--gold); }

/* Walking into a wall. The only feedback there is, because a refused step
   makes no request and so has nothing to say in the log. */
.fp-stage.is-bump { animation: fp-bump var(--dur-fast, 140ms) ease-out; }
@keyframes fp-bump {
  0%   { transform: translateZ(0) scale(1); }
  40%  { transform: translateZ(0) scale(0.985); }
  100% { transform: translateZ(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .fp-stage.is-bump { animation: none; }
}

/* The description, opened from that corner. The measure lives here now, for
   the reason it lived on `.loc-prose`: prose the width of a wide modal is
   unreadable however good it is. */
.loc-desc-body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-14);
  max-width: 68ch;
}

/* --- Heading ----------------------------------------------------------
 *
 * There is no drawn heading any more. `.loc-header` was an <h1> with the
 * place's name and a rule under it, and the chart 90px below drew the same
 * name on the node the party pin sits on — `ui-map.js` labels every node,
 * `is-here` included. It cost 83px of a 578px column to repeat something
 * already on screen. The REGION, which the chart names nowhere, moved up to
 * `.topbar-where`.
 *
 * The <h1> survives as .loc-name, unpainted. A screen has to have a heading —
 * deleting the only <h1> leaves a document that cannot be navigated by one —
 * and "you are here" read aloud is exactly the thing a sighted player gets
 * from the pin on the chart and a blind one does not. So it carries the region
 * too, which is why .topbar-where is aria-hidden: between them they would say
 * Rivermark twice.
 *
 * Not `display: none` and not `.hidden`: both take it out of the accessibility
 * tree, which is the whole of what it is for.
 */
.loc-name {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Optional establishing painting. Generated separately and dropped in as
   assets/images/locations/<location_key>.png. Missing files remove the whole
   frame on error so a place with no art just shows prose.

   It leads the description popup now — the ⓘ in the chart's corner — rather
   than the scene, so nothing has to be scrolled off to reach the prose. */
.loc-art {
  margin: 0;
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  overflow: hidden;
  background: var(--bg-inset);
  max-width: 62ch;
}
.loc-art img {
  display: block;
  width: 100%;
  height: auto;
  /* ~28% of the viewport, not half the screen — leaves room for the opening
     paragraph so the player can tell the column scrolls. */
  max-height: min(28vh, 14rem);
  object-fit: cover;
  object-position: center 40%;
}
/* --- Prose ------------------------------------------------------------
   The description is the screen. It gets the largest comfortable size and a
   generous line height, because in a text game this paragraph is doing the
   work the tile art used to do. */
.loc-prose p {
  margin: 0 0 var(--sp-12);
  font-size: var(--fs-md);
  line-height: var(--lh-prose);
  color: var(--text);
}
.loc-prose p:last-child { margin-bottom: 0; }

/* The establishing shot, shown once. Set in italic behind a rule so it reads
   as a different register from the standing description — this is what the
   place was like when you first walked in, not what it is like. */
.loc-first {
  font-style: italic;
  color: var(--text);
  border-left: 2px solid var(--gold);
  padding-left: var(--sp-14);
}

/* One rotating line of flavour. Dimmer and smaller: it is true right now
   rather than true in general, and it must not be mistaken for a fact about
   the place. */
.loc-ambience {
  font-size: var(--fs-sm) !important;
  color: var(--text-dim);
  font-style: italic;
}

/* --- Section headings ------------------------------------------------- */
.loc-actions h2,
.loc-ways h2,
.loc-pit h2 {
  display: flex;
  align-items: center;
  font-family: var(--font-display);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-caps-wide);
  text-transform: uppercase;
  color: var(--text-dim);
  margin: 0 0 var(--sp-8);
  font-weight: var(--fw-semibold);
}

/* --- People -----------------------------------------------------------
   Drawn in the left rail under the party. A list, which is the whole point
   of the pivot: the crowd of a market square is legible here in a way two
   dozen overlapping sprites never were. */
.people-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}

/* How many are here, beside the heading. A count is worth having the moment a
   list can be scrolled out of sight. */
.loc-count {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  border: 1px solid var(--ornament);
  border-radius: var(--r-pill);
  padding: var(--sp-1) var(--sp-6);
  margin-left: var(--sp-6);
}

.person {
  display: flex;
  align-items: center;
  gap: var(--sp-10);
  width: 100%;
  padding: var(--sp-4) var(--sp-8);
  background: var(--ornament-well);
  border: 1px solid transparent;
  border-radius: var(--r-md);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
}
.person:hover,
.person:focus-visible {
  border-color: var(--ornament);
  background: var(--bg-panel);
  transform: translateX(2px);
}

/* Portraits survive the pivot and this is one of the two places they earn
   their keep. Square, small, and never the reason a row is tall. */
.person-face {
  width: 36px;
  height: 36px;
  flex: 0 0 auto;
  border-radius: var(--r-md);
  object-fit: cover;
  object-position: 50% 15%;
  background: var(--bg-inset);
  border: 1px solid var(--ornament);
}
/* A missing face must not leave a broken-image glyph in the list; the row is
   still perfectly usable as text. */
.person-face.is-missing { visibility: hidden; }

.person-text { display: flex; flex-direction: column; min-width: 0; }
.person-name {
  font-weight: var(--fw-semibold);
  color: var(--text);
  display: flex;
  align-items: center;
  gap: var(--sp-6);
}
.person-role {
  font-size: var(--fs-xs);
  color: var(--text-dim);
  line-height: var(--lh-snug);
}

/* Tags earn their place by changing what the player would do next. */
.person-tag {
  font-size: var(--fs-2xs);
  line-height: var(--lh-flush);
  padding: var(--sp-2) var(--sp-4);
  border-radius: var(--r-sm);
  border: 1px solid currentColor;
}
.person-tag.is-shop { color: var(--gold); }
.person-tag.is-quest { color: var(--accent); font-weight: var(--fw-bold); }

/* The crowd: present, talkable, and quieter than the authored cast. */
.people-crowd .person { background: transparent; padding: var(--sp-2) var(--sp-8); }
.people-list.is-crowd .person-face { width: 26px; height: 26px; }
.people-list.is-crowd .person-name { font-weight: var(--fw-medium); color: var(--text-dim); }

/* --- Actions ---------------------------------------------------------- */
/* A bar, not a column. See actionKey() in game.js for why the ones that name
   something keep their words while the verbs are glyphs. */
.action-list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-4);
}

/* The keys themselves are `.act-slot`, the combat bar's — same bevel, same
   size, same closed state — so a verb is the same kind of object in a fight
   and out of one. What is added here is the label some of them carry and the
   key hint in the corner. */
.loc-act { position: relative; }
.loc-act.has-label {
  width: auto;
  min-width: 34px;
  gap: var(--sp-6);
  /* The right padding reserves the corner the key hint sits in.
     It used to be 0.55rem, which was enough when the hint was 8.8px type in
     the corner of a 34px square. Raising the type floor to 12px put a legible
     letter into a space measured for an illegible one, and it landed on the
     end of the label — "Take Rusted Key" ran under its own L. The floor is
     right; the button simply has to make room for what it now draws. */
  padding: 0 var(--sp-20) 0 var(--sp-8);
  font-size: var(--fs-xs);
  color: var(--text);
}
.loc-act-label { white-space: nowrap; }
/* Danger reads on the edge, as it does on a combat key: this is the row that
   holds "Fight" and "Go down the stair" beside "Search". */
.loc-act.is-danger { border-color: color-mix(in srgb, var(--danger) 60%, var(--border)); }
.loc-act.is-danger:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); }
/* Down the stair and back up it are one glyph, turned over. */
.loc-act.is-up .act-icon { transform: rotate(180deg); }

/* The key hint. Rendering the shortcut on the control is what tells a player
   the keyboard is worth using — a menu-driven game where nobody discovers the
   letter keys is just a page of buttons. Number keys for travel live on the
   chart's way-out badges instead.
   In the corner now rather than in the row: the row is the icon and, sometimes,
   what it is about. */
.loc-act kbd {
  position: absolute;
  right: 3px;
  bottom: 1px;
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  line-height: var(--lh-flush);
  color: var(--text-faint);
  background: none;
  border: 0;
  text-shadow: var(--th-1);
  pointer-events: none;
}
.loc-act:hover kbd { color: var(--gold); }

/* The pit's card. Sits above the ways out with the same hairline between, so
   "what can I fight" and "where can I go" stay two separate reads. Each bout
   is two lines: the tier, and roughly what it will send. */
.loc-pit {
  margin-bottom: var(--sp-10);
  padding-bottom: var(--sp-10);
  border-bottom: 1px solid var(--ornament);
}
/* Two by two. The three tiers used to be a full-width column — three rows of a
   scene that is mostly chart — and there are exactly three of them, so the
   spare cell holds the readout. `minmax(0, 1fr)` rather than `1fr`: a long
   monster sample is a grid item that would otherwise refuse to shrink and push
   the second column off the card. */
.pit-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  /* Every row the same height as the tallest.

     The grid is two columns, so the last bout shares a row with the readout —
     and the readout is a heading plus one line per tier, which is taller than
     a two-line button. Left to itself the grid gave that row the extra height
     and the bout in it came out visibly bigger than the two above, which read
     as the hard match being a bigger button rather than a taller neighbour.

     `1fr` on the rows spends the height evenly instead, so the three bouts are
     one size whatever the readout has to say. */
  grid-auto-rows: 1fr;
  gap: var(--sp-4);
  align-items: stretch;
}

/* One column below the fold, where two 2-line buttons side by side leave no
   room for either to say what it is sending. */
@media (max-width: 30rem) {
  .pit-grid { grid-template-columns: minmax(0, 1fr); }
}

/* `display: flex` said out loud, and it is a fix rather than a tidy-up.
   `.btn` is `inline-block` and only `.btn:has(svg)` promotes it to
   `inline-flex` — a pit bout has no icon, so it never matched, and the
   `flex-direction: column` below has been inert since it was written: the
   tier and its sample ran together as one line of text, "A warm-up3 Giant
   Rats, or so". Two lines is what the two spans were always for. */
.pit-bout {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--sp-2);
  height: 100%;
  text-align: left;
}
.pit-label { font-weight: var(--fw-semibold); }
.pit-sample { font-size: var(--fs-xs); color: var(--text-dim); }
.pit-bout:hover .pit-sample { color: var(--text); }

/* The fourth cell: how many more bouts until somebody levels.
   A well, not a key — it is the only thing in this grid you cannot press, and
   drawing it with the buttons' bevel would say otherwise. */
.pit-level {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  justify-content: center;
  padding: var(--sp-8) var(--sp-10);
  background: var(--bg-inset);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
}
.pit-level-head {
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
}
.pit-level-counts {
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--fs-xs);
  line-height: var(--lh-snug);
  color: var(--text-dim);
}
/* The count is the number, so the number is the lit part. */
.pit-level-counts b {
  color: var(--gold);
  font-weight: var(--fw-semibold);
  font-variant-numeric: tabular-nums;
}

/* Ways the chart could not draw, listed in the scene instead. Sits above the
   actions with a hairline between, so travel and doing-things stay two
   separate reads even when they share a footer. Normally absent. */
.loc-ways {
  margin-bottom: var(--sp-10);
  padding-bottom: var(--sp-10);
  border-bottom: 1px solid var(--ornament);
}

/* A locked way is shown and refused, the same bargain the chart makes: you
   learn the door is there, and that it is not for you yet. */
.loc-act[disabled] {
  opacity: 0.55;
  cursor: not-allowed;
}
.loc-act[disabled]:hover kbd { color: var(--text-dim); border-color: var(--ornament); }

.loc-act.is-danger {
  border-color: var(--danger);
  color: var(--danger);
}
.loc-act.is-danger:hover {
  background: color-mix(in srgb, var(--danger) 14%, transparent);
}

/* ====================================================================
   The region chart
   ====================================================================
   An inline SVG built by ui-map.js from the same payload that draws the exit
   list. No image files, no library, nothing to bake — after a pivot away from
   162MB of tile and sprite art, the map is now the cheapest thing on the
   screen.

   Parchment: a warm ground, ink-brown strokes, and a soft vignette. The
   palette is hard-coded rather than themed because a chart is a drawn object
   and should look the same whatever the interface around it is doing. */
/* Inside the expanded view the parchment is a map again, not a button. */
.modal-map .map-body { cursor: default; }

.modal-map .map-body {
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  overflow: hidden;
  background:
    radial-gradient(ellipse at 50% 40%, var(--pg-paper-lit) 0%, var(--pg-paper) 55%, var(--pg-paper-dim) 100%);
  box-shadow: var(--sh-vignette);
}

.map-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-6);
  background: none;
  border: 0;
  padding: 0;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  cursor: pointer;
}
.map-toggle:hover { color: var(--gold); }

.map-expand {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-6);
  background: none;
  border: 0;
  padding: 0;
  color: var(--text-dim);
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  cursor: pointer;
  white-space: nowrap;
}
.map-expand:hover { color: var(--gold); }
.map-expand kbd {
  padding: var(--sp-1) var(--sp-4);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  border: 1px solid currentColor;
  border-radius: var(--r-sm);
  opacity: 0.75;
}
.map-toggle kbd {
  padding: var(--sp-1) var(--sp-4);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  border: 1px solid currentColor;
  border-radius: var(--r-sm);
  opacity: 0.7;
}

.worldmap {
  display: block;
  width: 100%;
  height: auto;
}

/* With a painted map behind them the roads stand down so the art reads. The
   names stay strong: where you are and where you can go are shown by the
   text, not by icons on the painting.

   `.is-bare` is added by the <image>'s own onerror, so a region with no
   painting keeps the ink roads at full weight and nothing has to know which
   regions have art. */
.worldmap .wm-art { pointer-events: none; }
.worldmap:not(.is-bare) .wm-edge { opacity: 0.22; }
.worldmap:not(.is-bare) .wm-edge.is-way { opacity: 0.55; }
.worldmap:not(.is-bare) .wm-label { stroke-width: 1.1; }

/* Fill the dialog rather than the column. The labels and glyphs are drawn in
   viewBox units, so they scale with it and stay in proportion — this is the
   whole reason the chart is SVG rather than a canvas. */
/* `height: 0` with `flex: 1 1 auto` is what makes the remaining space a
   DEFINITE height. Without it the SVG's `height: 100%` resolves against an
   indefinite parent, falls back to its own aspect ratio, and grows taller than
   the dialog — which cropped the bottom row of the town off the map. */
.modal-map .map-body {
  flex: 1 1 auto;
  height: 0;
  min-height: 0;
  display: flex;
}
.modal-map .worldmap { width: 100%; height: 100%; }

.modal-map-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--sp-8) var(--sp-16);
  margin-bottom: var(--sp-10);
}
.map-zoom {
  display: flex;
  gap: var(--sp-4);
  margin-left: auto;
}
.map-zoom .icon-btn {
  font-family: var(--mono);
  font-size: var(--fs-md);
  line-height: var(--lh-flush);
  min-width: 1.9rem;
}

/* Drag pans the chart (except on a place-name, which is travel). */
.worldmap { cursor: grab; touch-action: none; }
.worldmap.is-panning { cursor: grabbing; }
.worldmap .wm-node[data-map-node],
.worldmap .wm-neighbor[data-map-node] { cursor: pointer; }

.modal-map-head h2 {
  margin: 0;
  font-family: var(--font-display);
  color: var(--gold);
  font-size: var(--fs-lg);
}
.modal-map-hint { font-size: var(--fs-xs); color: var(--text-dim); }

/* Roads. Dashed and ink-brown; a road between two places you have both seen
   is drawn solid, so the chart fills in as the party learns the country. */
.wm-edge {
  stroke: var(--pg-road);
  stroke-width: 0.4;
  stroke-dasharray: 1.4 1.1;
  opacity: 0.55;
  fill: none;
}
.wm-edge.is-known {
  stroke-dasharray: none;
  stroke-width: 0.5;
  opacity: 0.85;
}
.wm-edge.is-out { stroke-dasharray: 1 1.4; }

/* A direct way out of the current scene — the map's stand-in for the old
   "Ways out" list. Brighter and slightly thicker so the eye finds "where can
   I go" before it finds the rest of the country. */
.wm-edge.is-way {
  stroke: var(--pg-road-open);
  stroke-width: 0.2;
  stroke-dasharray: none;
  opacity: 1;
}
/* The head on the end of it. A filled triangle drawn by ui-map.js rather than
   a marker-end, so it keeps its size while the shaft stays hairline — see
   arrowHead(). No stroke of its own: an outline at this size closes up into a
   blob, and the fill is already the road's own colour. */
.wm-edge-head {
  fill: var(--pg-road-open);
  stroke: none;
  pointer-events: none;
}
.wm-edge-head.is-locked {
  fill: var(--pg-road);
  opacity: 0.55;
}
.wm-edge.is-way.is-locked {
  stroke: var(--pg-road);
  stroke-dasharray: 1.2 1;
  opacity: 0.55;
}

/* ------------------------------------------------------------------ *
   A generated dungeon floor, drawn as an old-school plate.

   Only the Undervault's levels have one. Every authored region has a painted
   plate instead, and the two are mutually exclusive by construction — see
   `floorplan` in ui-map.js.

   The grammar is the one every printed module taught: near-black rock, rooms
   and corridors as paper cut out of it, graph-paper ruling over the open
   floor only, door glyphs across thresholds, stairs as a run of shortening
   ticks. The parchment .map-body still frames the plate — the dark field
   stops at the viewBox — so the map modal reads as a plate pasted into the
   same book as the painted regions.
 * ------------------------------------------------------------------ */

/* The rock the level is cut from. Warm near-black, not #000 — pure black
   beside the parchment frame reads as a dead screen region, not as stone. */
.wm-rock { fill: var(--pg-rock); pointer-events: none; }

.wm-rock-shadow { stroke: #d4d0c7; stroke-width: 1.05; stroke-linecap: round; }
.wm-hatch       { stroke: #1a1612; stroke-width: 0.18; stroke-linecap: butt; }
.wm-rock-edge   { stroke: #1a1612; stroke-width: 0.38; fill: none; stroke-linejoin: round; }

/* --- the rockwork -------------------------------------------------------

   Four paths, laid on the bare rock before any floor is painted over them.
   Each is ONE <path> holding every stroke of its kind on the level — see the
   note at the ruling for why that matters here more than it usually would. */

/* The soft grey the wall throws onto the page: the scribble again, one dash
   in three of it, pushed down and left and drawn underneath. A ragged echo
   rather than a blur filter — a filter over this chart is the one effect that
   reliably makes it stutter. */
.wm-rock-shadow {
  fill: none;
  stroke: var(--pg-rock-shade);
  stroke-width: 0.34;
  stroke-linecap: round;
  pointer-events: none;
}
/* The scribble filling the wall. Fatter than hatching wants to be, because it
   is colouring in rather than shading, and the dashes have to read as dashes
   at the size the chart is actually shown. */
.wm-hatch {
  fill: none;
  stroke: var(--pg-rock-line);
  stroke-width: 0.21;
  /* Butt caps, not round. Squarer ends are closer to the plate's own dashes,
     and on the deepest fully-walked level — five thousand of them — rounding
     every end cost about a third of the frame during a continuous zoom. */
  stroke-linecap: butt;
  pointer-events: none;
}
/* The wall's own line, on the floor side of the scribble. The one crisp edge
   on the plate: the fill is deliberately ragged, so if this is not clean the
   room has no shape at all. */
.wm-rock-edge {
  fill: none;
  stroke: var(--pg-rock-line);
  stroke-width: 0.38;
  stroke-linecap: round;
  stroke-linejoin: round;
  pointer-events: none;
}
/* Chips fallen off the wall. Pure decoration, and sparse enough — about one
   sample in forty — to read as litter rather than as texture. */
.wm-rubble {
  fill: var(--pg-paper);
  stroke: var(--pg-rock-line);
  stroke-width: 0.1;
  pointer-events: none;
}

/* The wall is drawn ONCE now, by the ink pass, from the outline of the floor
   as a whole. The per-shape strokes that used to do it are switched off here:
   a room's own rectangle drew a line straight across every doorway opening
   into it, and a passage's casing doubled the wall it shares with the room at
   each end. Both were invisible while each shape was outlined separately and
   both are obvious once there is a single continuous wall to compare them to.
   The strokes that survive are the ones carrying information rather than
   shape — where you are, the stair, the way in, and anything glimpsed. */
.worldmap.is-plan .wm-room:not(.is-here):not(.is-stair):not(.is-entrance):not(.is-glimpsed) {
  stroke: none;
}
.worldmap.is-plan .wm-hall-wall:not(.is-glimpsed) { stroke: none; }

/* A room: paper. The stroke is the wall, heavier than the corridor walls so
   the eye reads rooms first — the same hierarchy the old plates got from
   hand-inked outlines. Square corners; cut stone is not a rounded rectangle. */
.wm-room {
  fill: var(--pg-paper);
  stroke: var(--pg-ink);
  stroke-width: 0.55;
  pointer-events: none;   /* the NAME is the click target, here as everywhere */
}
/* The room the party is standing in: warmed, not recoloured — the party's
   own faces are standing on it, and they are most of the highlight. */
.wm-room.is-here {
  fill: var(--pg-paper-lit);
  stroke: var(--pg-road-open);
  stroke-width: 0.8;
}
/* On a plan the same 0.8 reads as a heavy brown frame — it was sized against
   a near-black ground where a warm line had to shout to be seen at all, and on
   paper it shouts. Thinner here, which is what the rule above always meant:
   the party's own faces are standing on the room, and they are most of the
   highlight. */
.worldmap.is-plan .wm-room.is-here { stroke-width: 0.32; }
/* Where you came in and where you go down. The stair ticks say it in glyph;
   the dashed wall says it at a glance from across the chart. */
/* The two rooms on a floor that go somewhere vertical, colour-coded to what
   they do rather than both drawn as "special".

   The stair DOWN is gold, like everything else that leads further in. The way
   OUT takes the colour the exit arrow and the exit pin already use, so the
   room, the stub leaving the chart and the mark on the end of it are one
   sentence: this is the way back to daylight.

   It matters more than it used to. Climbing out is no longer a button you can
   press anywhere on the floor — DelveEngine::status gates it on standing in
   this room — so a party deep in a level needs to be able to find it. */
.wm-room.is-stair { stroke: var(--pg-stair); stroke-dasharray: 1.6 0.8; }
.wm-room.is-entrance {
  stroke: var(--pg-road-open);
  stroke-dasharray: 1.6 0.8;
}
.wm-stair-ticks.is-down line { stroke: var(--pg-stair); }
/* Kept for a plan drawn by something that still asks for an up-flight; the
   dungeon chart marks its way out with .wm-waypin instead. */
.wm-stair-ticks.is-up line { stroke: var(--pg-road-open); }

/* The way out of the floor, drawn IN the room you can leave from. The same
   disc and arrow as the stub at the chart's border (.wm-exitpin) — one mark
   for one meaning — but inert: the room's own pin is the click target, and
   two things to click in one room is one too many. */
.wm-waypin { pointer-events: none; }
.wm-waypin-disc {
  fill: var(--pg-road-open);
  stroke: var(--pg-halo-dark);
  stroke-width: 0.3;
}
.wm-waypin-mark {
  fill: var(--pg-halo-dark);
  font-size: 2.8px;
  font-weight: var(--fw-bold);
  text-anchor: middle;
}

/* --- the fog ------------------------------------------------------------

   A shape the party has not walked into and cannot see is not in the markup at
   all — DelveEngine::fog() decides, and ui-map.js draws nothing for it. What
   these rules are for is the middle state: somewhere they have seen the way
   into and nothing more.

   Unfilled, and that is the whole idea. Paper means "we have been here and
   this is its floor"; a glimpsed room is a doorway and a guess at what is
   behind it, so it gets the wall and the ground stays showing through.

   That used to be said in pale cream, which was legible while the rock behind
   it was nearly black. On stone-coloured rock the same pale cream reads as
   floor — brighter than the paper it was meant to be a ghost of — so a room
   nobody had entered looked walked. It is thinned INK now: line only, no fill,
   with the rock showing where the floor would be if there were one. */
.wm-room.is-glimpsed {
  fill: none;
  stroke: var(--pg-glimpse);
  stroke-width: 0.4;
  stroke-dasharray: 1.2 1;
}
/* A glimpsed passage is drawn in the ground, not in paper — the casing below
   is the whole of it. The `.is-way` pairing is not redundant: a glimpsed run
   that is also a way out is highlighted further down the file, and without
   this it would come back as the brightest floor on the chart.  */
.wm-hall.is-glimpsed,
.wm-hall.is-glimpsed.is-way { stroke: var(--pg-rock); }
/* The wall under a glimpsed run goes with it: a black casing round a passage
   nobody has walked draws it more firmly than the room it leads to. It carries
   its own class rather than being selected through the run, because the walls
   are one group and the floors another — they are drawn in two passes so every
   casing sits under every floor, and no wall is a sibling of its own run. */
.wm-hall-wall.is-glimpsed { stroke: var(--pg-glimpse-wall); }

/* The ruling, one <path> of hairlines per chart. Faint: texture, not
   information. */
/* The ruling. 0.12 was set when a plan opened at 1.7 and the line landed
   around a pixel; a plan opens on its own grid now, at nearly four, and the
   same width came out close to three pixels — graph paper drawn in pencil
   rather than in print. */
.wm-grid-line {
  fill: none;
  stroke: var(--pg-rule);
  stroke-width: 0.09;
  pointer-events: none;
}

/* The stairs. Ink on paper, like the doors. */
.wm-stair-ticks line {
  stroke: var(--pg-label);
  stroke-width: 0.3;
  pointer-events: none;
}

/* A trap the party knows about: hollow where spotted, filled where it went
   off. Nothing is drawn for a trap they have not met — the map is the
   player's, not the GM's. */
.wm-trap { pointer-events: none; }
.wm-trap.is-found { fill: none; stroke: var(--pg-red); stroke-width: 0.3; }
.wm-trap.is-sprung { fill: var(--pg-red); stroke: none; }

/* A floor plan is denser than any authored region: eleven to fourteen rooms
   on a field the towns use for six or seven, every one of them named. At the
   chart's usual 3.1px the names overlapped into an unreadable stack, so down
   here the rooms answer to a pin instead (.wm-roompin below) and keep their
   drawn shape. What labels remain on a plan are the ways off the level, which
   sit out at the border with nothing to collide with — still shrunk, because
   they share the same crowded field. */
.worldmap.is-plan .wm-label { font-size: 2.5px; stroke-width: 0.7; }
.worldmap.is-plan .wm-hit { x: -7.5px; width: 15px; }

/* A room's name, folded into a pin.

   Sized to sit inside the smallest room on a plan rather than to be read at a
   glance — it is a thing you point at, and the tooltip does the reading. The
   disc is filled so it reads against floor, wall or party mark alike, and the
   whole mark brightens on hover and focus so the pointer and the keyboard get
   the same answer about what is live. */
.wm-roompin { pointer-events: none; }
.wm-roompin-disc {
  fill: var(--pg-halo-dark);
  stroke: var(--gold-dim);
  stroke-width: 0.35;
  transition: fill var(--dur-fast) var(--ease-out),
              stroke var(--dur-fast) var(--ease-out);
}
.wm-roompin-i {
  fill: var(--gold-dim);
  font-size: 2.6px;
  font-style: italic;
  font-weight: var(--fw-bold);
  text-anchor: middle;
  transition: fill var(--dur-fast) var(--ease-out);
}
.wm-node[data-map-node]:hover .wm-roompin-disc,
.wm-node[data-map-node]:focus-visible .wm-roompin-disc {
  fill: var(--gold-dim);
  stroke: var(--gold);
}
.wm-node[data-map-node]:hover .wm-roompin-i,
.wm-node[data-map-node]:focus-visible .wm-roompin-i {
  fill: var(--pg-halo-dark);
}
/* A doorway you can step through. The glyph is a hairline box a door's width
   across — far too little to aim at — so an invisible pad carries the pointer
   and the glyph itself stays inert. */
.wm-doorway .wm-door-hit {
  fill: transparent;
  stroke: none;
  pointer-events: none;
}
.wm-doorway.is-through .wm-door-hit { pointer-events: all; cursor: pointer; }
.wm-doorway.is-through:focus-visible { outline: none; }
.wm-doorway.is-through:hover .wm-doorbox,
.wm-doorway.is-through:focus-visible .wm-doorbox { fill: var(--pg-road-open); }
.wm-doorway.is-through:hover .wm-door,
.wm-doorway.is-through:hover .wm-portcullis,
.wm-doorway.is-through:focus-visible .wm-door,
.wm-doorway.is-through:focus-visible .wm-portcullis { stroke: var(--pg-road-open); }

/* The way out of the level, pinned. Same disc as a room so the two read as
   one vocabulary, with an arrow instead of an `i` — the difference between
   somewhere on this floor and the way off it. Filled rather than outlined,
   because it is the one mark on a plan that is worth finding in a hurry. */
/* The mark is inert and the pad under it takes the clicks. The caption this
   replaced was its own click target — text is clickable — and without a pad
   the only thing left to hit would be the hairline stub. */
.wm-exitpin { pointer-events: none; }
.wm-exitpin-hit {
  fill: transparent;
  stroke: none;
  pointer-events: all;
}
.wm-exitpin-disc {
  fill: var(--gold-dim);
  stroke: var(--pg-halo-dark);
  stroke-width: 0.3;
  transition: fill var(--dur-fast) var(--ease-out);
}
.wm-exitpin-mark {
  fill: var(--pg-halo-dark);
  font-size: 2.8px;
  font-weight: var(--fw-bold);
  text-anchor: middle;
}
.wm-neighbor[data-map-node]:hover .wm-exitpin-disc,
.wm-neighbor[data-map-node]:focus-visible .wm-exitpin-disc { fill: var(--gold); }
.worldmap.is-plan .wm-neighbor[data-map-node] { cursor: pointer; }

/* The pin is the room's hit target, so it wants the asking cursor the quest
   pin has — but it travels on click, so the pointer stays a pointer. */
.worldmap.is-plan .wm-node[data-map-node] .wm-hit { cursor: pointer; }
/* `x` and `width` are geometry properties here, so the plan's wide-label pad
   above outranks the narrow one the markup asks for and has to be undone
   rather than simply not applied. A pin wants a pad its own size — at the
   label's 15 units it would answer for the room next door. */
.worldmap.is-plan .wm-node[data-room-pin] .wm-hit { x: -3px; width: 6px; }

/* A passage: a run of floor with a wall down each side.

   Both are the SAME polyline stroked at two widths — the wall wide and dark
   underneath, the floor narrower and lighter on top — which is what gets a
   dogleg's corners right without offsetting two parallel lines around a
   mitre. The widths come from ui-map.js, because they are geometry rather
   than colour: the door tick has to match the passage it crosses.

   `butt` caps, not round. clipEnds() has already trimmed every run back to the
   wall it enters, so a flat cap lands exactly on the threshold and the passage
   reads as a doorway. A round cap puts half a stroke-width of floor past the
   wall and into the room — and the room fill is a tenth-opacity wash rather
   than paint, so it does not cover the overshoot: every passage came out
   looking like a capsule laid over the plan.

   `miter` joins, because a dogleg is a right angle and a rounded corner in a
   cut-stone passage looks like a mistake. */
.wm-hall-wall,
.wm-hall {
  fill: none;
  stroke-linejoin: miter;
  stroke-linecap: butt;
  pointer-events: none;
}
.wm-hall-wall { stroke: var(--pg-ink); }
.wm-hall { stroke: var(--pg-paper); }

/* The passage the party is standing in, lit like the room they would be in. */
.wm-hall.is-here { stroke: var(--pg-paper-lit); }
/* A passage they can step into from here. */
.wm-hall.is-way { stroke: var(--pg-line); }
.wm-hall.is-locked { stroke: var(--pg-paper-out); }
/* A secret way nobody has found: present, dim, and saying nothing. The glyph
   and the `s` are withheld server-side; this is only the run's own hush. */
/* `.wm-hall.is-unfound` lived here, for a secret passage drawn dimmed. There
   is no such run any more — one behind a door nobody has found is not drawn;
   see DelveEngine::fog(). */

/* The click target is the group, not the stroke: the stroke has
   `pointer-events: none` so that dragging across a passage still pans the
   chart, and this puts the hit area back on the run itself. */
.wm-hall-g[data-map-node] { cursor: pointer; }
.wm-hall-g[data-map-node] .wm-hall { pointer-events: stroke; }
.wm-hall-g:hover .wm-hall.is-way,
.wm-hall-g:focus-visible .wm-hall.is-way { stroke: var(--pg-line-on); }
.wm-hall-g:focus-visible { outline: none; }
/* ...and the casing goes brown, because the line above does nothing.
   --pg-line-on is two units of red and eight of blue from --pg-line: naming
   the ramp is what made that visible, and it means a corridor is a clickable
   thing whose keyboard focus could not be seen. Brightening it further is not
   available — a walkable corridor is already the brightest paper on the
   chart, which is the whole of how it says "walkable". So the focus lands on
   the WALL instead, which is near-black and has the entire ramp to move
   through. Same idiom as a focused place-name, same colour. */
.wm-hall-g:focus-visible .wm-hall-wall { stroke: var(--pg-focus); }

/* The door glyphs — the plate's legend, learned once:
   hollow square = a door · barred = locked · crossed = known-trapped ·
   dotted = portcullis · dashed seam + s = a found secret way. */
.wm-doorbox {
  fill: var(--pg-paper);
  stroke: var(--pg-ink);
  stroke-width: 0.3;
  pointer-events: none;
}
.wm-door-bar {
  stroke: var(--pg-ink);
  stroke-width: 0.28;
  pointer-events: none;
}
.wm-portcullis {
  stroke: var(--pg-ink);
  stroke-width: 0.5;
  stroke-dasharray: 0.35 0.45;
  pointer-events: none;
}
.wm-door {
  stroke: var(--pg-paper-dim);
  stroke-width: 0.55;
  pointer-events: none;
}
.wm-door.is-stuck {
  stroke: var(--pg-door);
  stroke-dasharray: 0.5 0.4;
}
.wm-secret-s {
  font-family: var(--font-display);
  font-size: 2.2px;
  fill: var(--pg-door);
  pointer-events: none;
}

/* Places are their names. No building glyphs, no rings — the painting is the
   map, and the text is the interface on top of it. */
.wm-node { cursor: pointer; }
.wm-node[data-map-node]:hover .wm-label,
.wm-node[data-map-node]:focus-visible .wm-label {
  fill: var(--pg-label-on);
  font-weight: var(--fw-bold);
  stroke: var(--pg-halo);
}
.wm-node:focus-visible { outline: none; }
.wm-node:focus-visible .wm-hit {
  fill: color-mix(in srgb, var(--pg-focus) 14%, transparent);
  stroke: var(--pg-focus);
  stroke-width: 0.25;
}

/* Invisible pad under each name so the click target is a finger, not a word. */
.wm-hit { fill: transparent; stroke: none; }

.wm-label {
  text-anchor: middle;
  font-family: var(--font-display, Georgia, serif);
  font-size: 3.1px;
  fill: var(--pg-label);
  paint-order: stroke;
  stroke: var(--pg-halo);
  stroke-width: 0.85;
}

/* Somewhere the party has not been but which is still on the chart is always a
   direct way out of this scene, so `.is-way` below carries its look. Only the
   unvisited ones further off use this — and they are not drawn at all. */
.wm-node.is-unknown .wm-label {
  opacity: 0.72;
  font-style: italic;
  fill: var(--pg-ink-soft);
}

/* A stage wants the party here. Yellow, above the place-name, and the tracked
   quest's mark breathes so that when several are open the eye finds the one
   the tracker is actually talking about. Drawn in the SVG rather than as a
   DOM pin, so it pans and zooms with the chart it belongs to. */
.wm-quest {
  fill: var(--gold-dim);
  font-size: 4.2px;
  font-weight: var(--fw-bold);
  text-anchor: middle;
  paint-order: stroke;
  stroke: var(--pg-halo-dark);
  stroke-width: 0.9;
  pointer-events: none;
}
/* The pin's own hover target. The glyph sits mostly above the node's hit
   rect, so without this the "?" was the one thing on the chart a mouse
   could not ask about. */
.wm-quest-hit {
  fill: transparent;
  stroke: none;
  pointer-events: all;
  cursor: help;
}
/* The pin's tooltip: the act-tip look, fixed-positioned at the pin by
   game.js so pan and zoom cannot strand it. */
.wm-tip {
  position: fixed;
  z-index: var(--z-tip);
  transform: translate(-50%, -100%);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  max-width: 300px;
  padding: var(--sp-6) var(--sp-8);
  background: var(--bg-panel);
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-md);
  box-shadow: var(--sh-3);
  pointer-events: none;
}
.wm-tip.is-below { transform: translate(-50%, 0); }
.wm-tip[hidden] { display: none; }
.wm-tip-name { color: var(--gold); font-size: var(--fs-xs); }
.wm-tip-why { color: var(--text-dim); font-size: var(--fs-2xs); }
.wm-quest.is-tracked {
  fill: var(--gold-bright);
  animation: wm-quest-pin var(--dur-pulse) var(--ease-in-out) infinite;
}
@keyframes wm-quest-pin {
  0%, 100% { transform: translateY(0); opacity: 1; }
  50%      { transform: translateY(-1px); opacity: 0.7; }
}
@media (prefers-reduced-motion: reduce) {
  .wm-quest.is-tracked { animation: none; }
}

/* You are here — the name carries it, and the party stand under it.

   The faces are the only mark on the chart that is not a place, and the only
   one that moves. They are drawn in the SVG, under the current node, so pan
   and zoom take them along the way they take the quest pins. */
.wm-party { pointer-events: none; }
/* ...except each face, which answers who it is on hover. Not a click target:
   travel is the place-names, and a token you could press would be a second
   way to do something the name above it already does. */
.wm-pc { pointer-events: all; cursor: default; }
.wm-pc-disc {
  fill: var(--pg-paper-lit);
  stroke: none;
}
.wm-pc-ring {
  fill: none;
  stroke: var(--pg-red);
  stroke-width: 0.1;
}
.wm-pc.is-active .wm-pc-ring {
  stroke: var(--gold-bright);
  stroke-width: 0.1;
}
/* Somebody at 0 hp is greyed and outlined dark: on a map at this size the ring
   colour is the only channel there is, so it says the one thing worth saying
   about a party member from across the region. */
.wm-pc.is-down { opacity: 0.55; }
.wm-pc.is-down .wm-pc-ring { stroke: var(--pg-ink-soft); }
.wm-pc.is-down .wm-pc-face { filter: grayscale(1); }
/* The letter under the portrait. Shows through only when the face fails to
   load and removes itself — a missing file on this host serves the homepage
   with a 200, so a broken <image> is the normal way that goes wrong. */
.wm-pc-initial {
  text-anchor: middle;
  font-family: var(--font-display, Georgia, serif);
  font-weight: var(--fw-bold);
  fill: var(--pg-way-ink);
}
.wm-pc.is-more .wm-pc-disc { fill: var(--pg-paper-dim); }

/* The people who are already here.

   Same token as a party mark so the chart has one visual idea of "somebody is
   standing there", and a different ring so it is never in doubt which of them
   is you: the party ring is red, theirs is the ink the roads are drawn in.

   Unlike a party mark this one is a control — it opens the conversation — so
   it takes a pointer and answers hover and focus the way the place names do. */
.wm-npc { pointer-events: all; cursor: pointer; }
.wm-npc-disc { fill: var(--pg-paper-lit); stroke: none; }
.wm-npc-ring {
  fill: none;
  stroke: var(--pg-way-ink);
  stroke-width: 0.26;
}
.wm-npc:hover .wm-npc-ring,
.wm-npc:focus-visible .wm-npc-ring {
  stroke: var(--gold-bright);
  stroke-width: 0.4;
}
.wm-npc:focus-visible { outline: none; }
.wm-npc-initial {
  text-anchor: middle;
  font-family: var(--font-display, Georgia, serif);
  font-weight: var(--fw-bold);
  fill: var(--pg-way-ink);
}
.wm-npc.is-more .wm-npc-disc { fill: var(--pg-paper-dim); }
.wm-npc.is-more { cursor: default; pointer-events: none; }

/* On the move. The transform is driven frame by frame from ui-map.js, so
   there is no CSS transition here to fight it — this is only the look. The
   tooltips are taken away for the duration: a hover that answers "who is
   that" about a face sliding out from under the cursor is noise. */
.wm-party.is-walking .wm-pc { pointer-events: none; }
.wm-party.is-walking .wm-pc-ring { stroke-width: 0.42; }
.wm-party.is-walking .wm-pc.is-active .wm-pc-ring { stroke-width: 0.55; }

.wm-node.is-here .wm-label {
  fill: var(--pg-red);
  font-weight: var(--fw-bold);
  font-size: 3.35px;
  stroke: var(--pg-halo);
  stroke-width: 1.05;
}

/* A place you can walk to from here. Gold weight on the name; the number key
   is already in the label text (see ui-map.js). */
.wm-node.is-way .wm-label {
  fill: var(--pg-way-ink);
  font-weight: var(--fw-bold);
  font-size: 3.25px;
  opacity: 1;
  font-style: normal;
  stroke: var(--pg-halo);
  stroke-width: 1;
}
.wm-node.is-way:hover .wm-label,
.wm-node.is-way:focus-visible .wm-label {
  fill: var(--pg-label);
}

/* A way out to somewhere we have not been. An empty ring: it has no name yet,
   and the ring is a place to click rather than a label pretending to be one.
   This used to be the exit's number key set as the whole label, which renumbered
   itself every time the party moved. */
.wm-unwalked {
  fill: color-mix(in srgb, var(--pg-paper-hi) 55%, transparent);
  stroke: var(--pg-way-ink);
  stroke-width: 0.5;
}
.wm-node.is-unnamed:hover .wm-unwalked,
.wm-node.is-unnamed:focus-visible .wm-unwalked {
  fill: var(--pg-halo);
  stroke: var(--pg-label);
  stroke-width: 0.7;
}
.wm-node.is-unnamed.is-locked .wm-unwalked {
  fill: none;
  stroke: var(--pg-ink-soft);
  stroke-dasharray: 0.6 0.5;
}

/* A shut direct exit: still named (knowing the road exists matters), but
   quieter so it does not look like the next click. */
.wm-node.is-way.is-locked .wm-label {
  opacity: 0.55;
  font-weight: var(--fw-medium);
  fill: var(--pg-ink-soft);
}

/* A road leaving the region, captioned with where it goes. An uncrossed one
   has no caption and no click, so it must not offer a hand either. */
.wm-neighbor { cursor: pointer; }
.wm-neighbor:not([data-map-node]) { cursor: default; }
.wm-out-label {
  font-family: var(--font-display, Georgia, serif);
  font-size: 2.7px;
  fill: var(--pg-ink-soft);
  font-style: italic;
  paint-order: stroke;
  stroke: var(--pg-halo);
  stroke-width: 0.7;
}
.wm-neighbor:hover .wm-out-label,
.wm-neighbor:focus-visible .wm-out-label { fill: var(--pg-label-on); font-weight: var(--fw-bold); }
.wm-neighbor:hover .wm-edge.is-out { opacity: 0.95; }
.wm-neighbor.is-way .wm-out-label {
  fill: var(--pg-way-ink);
  font-weight: var(--fw-bold);
  font-style: normal;
  font-size: 2.9px;
  stroke: var(--pg-halo);
  stroke-width: 0.95;
}
.wm-neighbor.is-way.is-locked .wm-out-label {
  opacity: 0.55;
  font-weight: var(--fw-medium);
  fill: var(--pg-ink-soft);
}

/* --- The tracker's "set off this way" button -------------------------- */
.track-go {
  display: inline-block;
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--gold);
  text-align: left;
  cursor: pointer;
  text-decoration: underline dotted;
  text-underline-offset: 2px;
}
.track-go:hover { color: var(--text); }

/* --- Narrow viewports -------------------------------------------------
   The rails go first: on a phone the scene is the game, and the party and the
   log are things you open rather than things you watch. */
@media (max-width: 1000px) {
  /* Narrow the TRACK, not the item. Setting a width on a grid child that is
     meant to fill its column is what pushed the rail off the edge of the
     screen once already. */
  .game-shell { --rail-w: 300px; }
  .loc-inner { max-width: none; }
}

@media (max-width: 760px) {
  .game-shell {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto 1fr auto;
    padding: var(--sp-8);
  }
  .rail-left {
    width: auto;
    flex-direction: row;
    overflow-x: auto;
    overflow-y: hidden;
    max-height: 28vh;
  }
  .rail-pane {
    flex: 1 1 50%;
    min-width: 11rem;
  }
  .rail-right { width: auto; }
  .hud-party { flex-direction: column; }
  .hud-log .log { max-height: 18vh; }
}

/* ---------------------------------------------------------------------------
   The creation wizard.

   This page used to be pinned to the viewport and refuse to scroll: the whole
   thing a flex column at `100dvh`, with `.wiz-body` the only part allowed to
   give. A step either fit or it wanted splitting, and that was the design
   rather than an oversight.

   The 3D creator ended that. It wants real height to be usable — its own
   pickers run down the side of the character — and no arrangement of a fixed
   viewport gives it that and the two lines of identity above it as well. The
   pinned version bought "no scrollbar on the window" at the price of a
   scrollbar inside the panel instead, which is the same scrollbar somewhere
   worse: it clipped the step's own furniture rather than the page's, and left
   the rail and the Next button floating above content that could not reach
   them.

   So the page scrolls, all of it, the way every other page here does. One
   scrollbar on the window instead of a second one nested in a panel. Nothing
   is pinned and nothing needs a `min-height: 0` to be allowed to shrink,
   because nothing is being asked to shrink any more.
   --------------------------------------------------------------------------- */
.wizard-page {
  /* No height and no overflow: the document is the scroller. */
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
}
.wizard {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 1100px;
  margin: 0 auto;
  padding: var(--sp-16) var(--sp-20) var(--sp-16);
  gap: var(--sp-12);
  flex: 1 1 auto;
}

.wiz-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-16); }
.wiz-titles h1 {
  margin: 0;
  font-size: var(--fs-2xl);
  color: var(--gold);
}
.wiz-titles p { margin: var(--sp-2) 0 0; color: var(--text-dim); font-size: var(--fs-sm); }

/* The step rail: progress and navigation in one. */
.wiz-rail {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
  gap: var(--sp-6);
}
.wiz-rail li { flex: 1 1 0; min-width: 0; }
.wiz-pip {
  width: 100%;
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  padding: var(--sp-6) var(--sp-8);
  font: inherit;
  font-size: var(--fs-xs);
  color: var(--text-dim);
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  cursor: pointer;
  text-align: left;
}
/* A step ahead of you is inert rather than absent: seeing that Review exists is
   the point of a rail, being able to jump to an empty one is not. */
.wiz-pip:disabled { cursor: default; opacity: 0.45; }
/* The step you are on is disabled too — clicking it would do nothing — but it
   must not be dimmed like the ones you cannot reach yet, or the current step
   reads as the one place you are not allowed to be. */
.wiz-pip.on:disabled { opacity: 1; }
.wiz-pip:not(:disabled):hover { border-color: var(--gold-dim); color: var(--text); }
.wiz-pip.on {
  color: var(--gold);
  border-color: var(--gold);
  background: var(--bg-raised);
}
.wiz-pip.done { color: var(--text); border-color: var(--gold-dim); }
.wiz-pip-n {
  flex: 0 0 auto;
  width: 1.25rem; height: 1.25rem;
  display: grid; place-items: center;
  border-radius: var(--r-circle);
  font-size: var(--fs-2xs);
  background: rgba(0, 0, 0, 0.35);
}
.wiz-pip.on .wiz-pip-n, .wiz-pip.done .wiz-pip-n { background: var(--gold-dim); color: var(--ink-on-gold); }
.wiz-pip-l { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.wiz-body {
  /* Fills the window on a short step and grows past it on a tall one.
     `flex-grow` takes the slack under a step that does not need it, so the
     panel is not a different height on every step; `flex-basis: auto` with no
     `min-height: 0` means a step taller than the window pushes the page down
     rather than being squeezed. The window scrolls; this never does. */
  flex: 1 1 auto;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: var(--sp-16) var(--sp-20);
}
/* Steps whose content is mostly form fields do not want to be 1100px wide —
   a text input that long is harder to read, not easier. */
.wiz-narrow { max-width: 560px; }
/* The name field and its dice. The button takes only the width of its word so
   the field keeps the full measure it had before — and `align-items: stretch`
   rather than a fixed height, so the two stay the same size if either's
   padding is ever changed. */
.wiz-name-row {
  display: flex;
  align-items: stretch;
  gap: var(--sp-8);
}
.wiz-name-row input { flex: 1 1 auto; min-width: 0; }
.wiz-name-row .btn { flex: 0 0 auto; }

.wiz-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-12); }

/* ---- The identity line ------------------------------------------------- */

/* Who they are, on one line: name, gender, race, subrace.

   A grid rather than flex, because these four want fixed proportions and not
   equal thirds — the name is the field somebody types a sentence into and the
   three selects hold one word each. `align-items: end` lines the controls up
   along their bottoms, which is what makes it read as one line rather than as
   four stacked label-and-field pairs that happen to be side by side. */
.wiz-idline {
  display: grid;
  grid-template-columns: minmax(14rem, 2.2fr) minmax(7rem, 1fr) minmax(8rem, 1fr) minmax(8rem, 1fr);
  gap: var(--sp-12);
  align-items: end;
}
.wiz-idfield { min-width: 0; }
.wiz-idfield select { width: 100%; }

/* Two rows before one column: at the width where four fields stop fitting, the
   name still deserves the full measure, and pairing gender with race keeps the
   line's reading order intact. Only below that does it become a stack. */
@media (max-width: 62rem) {
  .wiz-idline { grid-template-columns: 1fr 1fr; }
  .wiz-idfield-name { grid-column: 1 / -1; }
}
@media (max-width: 34rem) {
  .wiz-idline { grid-template-columns: 1fr; }
}

/* What the race grants — speed, ability bonuses, traits — under the line that
   chose it. One line of interface facts, so it is set in the UI face rather
   than the display one and kept quiet: it is a consequence of the selects
   above, not a thing to read before operating them. */
.wiz-benefits {
  margin: 0;
  color: var(--text-dim);
  font-size: var(--fs-sm);
  line-height: var(--lh-tight, 1.35);
}
.wiz-benefits:empty { display: none; }

/* ---- The creator ------------------------------------------------------- */

/* Sized for its contents, and the step scrolls if the window is short.

   This is the one step on this page allowed to do that. It was tried the other
   way — the creator taking whatever the two lines above did not use — and that
   guarantees no scrollbar at the price of the thing worth looking at: on a
   laptop it left about 340px, which is under what the creator's own tallest
   tab needs, so the panel silently clipped its last swatches instead.

   The height is measured rather than guessed. The panel's tallest tab is now
   Dress — two outfit pickers and eight attachment slots — at about 668 units of
   the creator's own 1440x900 reference space, ahead of Colour at 582. Its
   scroll viewport is the panel height less 188 for the title, tabs and footer,
   and because the canvas scales with `matchWidthOrHeight: 0.5` the viewport in
   reference units grows as sqrt(H * 1440 * 900 / W). At the 997px this box gets
   on a full window that puts 700px at a 766-unit viewport, which clears Dress
   with room for another slot or two. It was 640 before the attachment pickers
   were added, which cleared Colour and would have clipped Dress. */
[data-step="identity"]:not([hidden]) {
  display: flex;
  flex-direction: column;
  gap: var(--sp-12);
}

/* An iframe fills its box, and an iframe in a zero-height box is a blank page
   that looks exactly like a broken build — so this box is always given one. */
#look-model-well {
  height: 700px;
  border-radius: var(--r-md);
  overflow: hidden;
  background: #1e1a15;
  border: 1px solid var(--border);
}

.wiz-nav {
  display: flex;
  align-items: center;
  gap: var(--sp-16);
}
.wiz-nav #wiz-back { margin-right: auto; }
.wiz-reason {
  margin: 0;
  font-size: var(--fs-xs);
  color: var(--gold-dim);
  text-align: right;
}
.field-note { margin: var(--sp-4) 0 var(--sp-10); font-size: var(--fs-xs); min-height: 1.1em; color: var(--text-dim); }
.field-note.is-ok { color: var(--success-ink); }
.field-note.is-bad, .help-hint.is-bad { color: var(--danger-ink); }

/* Re-dressing an existing character is the look step with the wizard removed. */
.redress-mode .wiz-nav { justify-content: flex-end; }

/* Re-dressing is the creator and nothing else — no rail, no identity line, no
   other step — so it has a screen's worth of room the creation step spends on
   fields, and the box takes it.

   `max()` rather than a flex remainder now that the page scrolls: the creator
   fills a tall window, and on a short one it stops at the 700px its own tallest
   tab needs and lets the window scroll instead of clipping the last row of
   swatches. The 240px is the site bar, the title, the panel padding and the
   Save button underneath. */
.redress-mode #look-model-well { height: max(700px, calc(100dvh - 240px)); }

@media (max-width: 720px) {
  /* The page scrolls everywhere now, so the phone no longer needs telling. */
  .wiz-pip-l { display: none; }
  .wiz-pair { grid-template-columns: 1fr; }
}

/* ---------------------------------------------------------------------------
   The paperdoll creator.

   The preview is a stack of absolutely-positioned 128px frames. They line up
   because nothing in the sliced library is trimmed, so every layer shares the
   base body's coordinate space — the one rule the whole feature rests on.
   --------------------------------------------------------------------------- */
.pd-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: var(--sp-20); }

.pd-stage { text-align: center; }
.pd-preview {
  /* These are 128px frames of a 48px-tile world, so the walk view is zoomed —
     but it is the secondary view now that the portrait exists, and at 2x it was
     claiming 268px of the column and pushing the step off one screen.

     The two numbers below are related and must stay related: a scaled element
     keeps its *pre-scale* footprint in the layout, so the extra height has to be
     reserved by hand. Deriving the margin from the zoom is what stops the two
     drifting apart the next time either is touched. */
  --pd-cell: 128px;
  --pd-zoom: 1.5;

  position: relative;
  width: var(--pd-cell);
  height: var(--pd-cell);
  margin: 0 auto calc(var(--pd-cell) * (var(--pd-zoom) - 1));
  overflow: hidden;
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  transform: scale(var(--pd-zoom));
  transform-origin: top center;
}
/* 8 frames x 8 facings of 128px. Width doubled with the walk cycle. */
.pd-layer { position: absolute; width: 1024px; height: 1024px; image-rendering: auto; }

/* The portrait view. Same stacking trick as .pd-preview, but the frames are
   320px squares and there is no facing offset, so the layers can simply fill
   the box.

   No transform: scale() here — the box is sized directly instead. Scaling the
   walk preview was necessary because its frames are a fixed 128px cell, but the
   bust frames are big enough to shrink into whatever width the column has, and
   a scaled element leaves its pre-scale footprint in the layout (which is why
   .pd-preview needs that 140px bottom margin to reserve room for itself). */
.pd-bust {
  position: relative;
  width: 100%;
  max-width: 200px;
  aspect-ratio: 1 / 1;
  margin: 0 auto var(--sp-12);
  overflow: hidden;
  background: radial-gradient(ellipse at 50% 35%, var(--bg-raised), var(--bg-inset) 70%);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
}
.pd-bust-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* The busts are hand-drawn art rather than pixel art, so smooth them; the
     walk frames deliberately are not smoothed the same way. */
  image-rendering: auto;
}
.pd-stage-actions { display: flex; gap: var(--sp-8); justify-content: center; margin-bottom: var(--sp-6); }

/* Nine categories in one column ran off the bottom of any screen.

   `columns` rather than a grid because the groups are different heights — a grid
   would leave a ragged gap under every short one, whereas a column flow just
   packs the next group in. And a column *width* rather than a count, so the
   browser fits as many as the space allows: three on a desktop, two on a small
   laptop, one on a phone, with no breakpoints to keep in sync. */
.pd-pickers {
  columns: 230px;
  column-gap: var(--sp-20);
}
.pd-pickers .pd-group {
  break-inside: avoid;   /* a category split across the column break is unusable */
  margin-bottom: var(--sp-12);
}
.pd-group h3 {
  font-size: var(--fs-2xs); color: var(--text-dim); margin: 0 0 var(--sp-4);
  font-family: var(--font-ui); font-weight: var(--fw-medium);
}
.pd-opt { color: var(--text-faint); font-size: var(--fs-2xs); }
.pd-swatches { display: flex; gap: var(--sp-4); flex-wrap: wrap; }

.pd-chip {
  min-width: 30px; height: 30px; padding: 0 var(--sp-6);
  background: var(--bg-panel-2); border: 1px solid var(--border);
  color: var(--text-dim); border-radius: var(--r-md); cursor: pointer;
  font-family: var(--mono); font-size: var(--fs-2xs);
}
.pd-chip:hover { border-color: var(--border-strong); color: var(--text); }
.pd-chip.on { border-color: var(--gold); color: var(--gold); background: var(--gold-well); }

/* A colour swatch shows the layer itself, cropped to the head, because a hair
   colour is not describable and a word for it would be a guess. */
/* A flat colour disc, filled in by tintSwatches() from the artwork's own
   pixels. The background-position/size below only matter for the brief moment
   before that resolves, or if a canvas read is refused. */
.pd-colour {
  width: 30px; height: 30px; padding: 0;
  border-radius: var(--r-circle);
  background-repeat: no-repeat;
  background-size: 300%;
  background-position: -32px -18px;
  box-shadow: var(--sh-ring-dark);
}
/* The chosen one gets a ring outside its edge — a border would change the
   size of the disc and make the row twitch as you click along it.
   Outside, and so NOT --sh-ring: this is a colour swatch, and an inset ring
   would tint the very colour being chosen. The dark hairline stays because it
   is what keeps a pale swatch off the panel behind it. */
.pd-colour.on {
  border-color: transparent;
  box-shadow: var(--sh-ring-dark), 0 0 0 2px var(--gold);
}
.pd-colours { margin-top: var(--sp-6); gap: var(--sp-6); }

@media (max-width: 720px) {
  .pd-layout { grid-template-columns: 1fr; }
}

/* ---------------------------------------------------------------------------
   CSS 3D dice.

   Geometry — face transforms, the triangle clip-path, the .show-N resting
   orientations — is generated into dice.css by tools/gen_dice_css.py. This is
   only how they look and how long they take, which is why the palette is here
   and not in a file nobody should hand-edit.

   The perspective lives on the stage rather than the die, because a die that
   owns its own perspective gets a new vanishing point every time it moves.
   --------------------------------------------------------------------------- */
/* The stage's size comes from .stage20/.stage6 in dice.css, which is the widest
   the projected solid ever gets at rest — a face box is not the answer, because a
   d20 with 106px faces draws a solid 176px across. PERSPECTIVE in the generator
   must match the value below; the silhouette depends on it. */
.die-stage {
  display: grid;
  place-items: center;
  perspective: 900px;
  perspective-origin: 50% 45%;
}
.die {
  position: absolute;
  width: var(--die-box);
  height: var(--die-box);
  transform-style: preserve-3d;
  /* Overshoot slightly and settle, so it lands rather than arrives. */
  /* The only duration here that is not on the scale, and it should not be:
     dice3d.js sets --die-throw per throw, so 900ms is the value when no
     throw is in flight rather than a tier of this interface's motion. */
  transition: transform var(--die-throw, 900ms) var(--ease-throw);
  will-change: transform;
}
.die-face {
  position: absolute;
  inset: 0;
  margin: 0;
  display: grid;
  place-items: center;
  /* The far faces are culled rather than drawn behind the near ones, which is
     what makes the solid read as solid without any depth sorting. */
  backface-visibility: hidden;
  font-family: var(--mono);
  font-weight: var(--fw-bold);
  color: var(--ink-on-gold);
  background: linear-gradient(155deg, var(--gold), var(--gold-dim) 62%, var(--gold-well));
  /* A hairline of the page colour along each edge reads as the bevel between
     faces. Without it neighbouring faces of similar brightness merge and the
     silhouette is the only thing telling you it is a solid. */
  box-shadow: var(--sh-ring-dark);
}
.die20 .die-face { font-size: 2.1rem; }
.die6 .die-face { font-size: 1.55rem; border-radius: var(--r-md); }
/* The hit dice. Sized against the room each face shape actually leaves rather
   than against its box: a triangle wastes most of its corners, a pentagon
   almost none, so the d12's smaller box still carries the larger numeral for
   its size. Two digits have to fit — a d12 shows 12, a d10 shows 10. */
.die8 .die-face { font-size: 1.7rem; }
.die10 .die-face { font-size: 1.5rem; }
.die12 .die-face { font-size: 1.3rem; }

/* The dice the throw did not keep — 4d6 drop lowest, and the unkept die of an
   advantage pair. Dimmed and shrunk aside rather than removed, because the
   point of rolling four is seeing which three counted. */
.die-holder { transition: opacity var(--dur-fill) var(--ease), transform var(--dur-fill) var(--ease); }
.die-holder.is-dropped { opacity: 0.3; transform: scale(0.72); }
.die-holder.is-dropped .die-face { filter: grayscale(0.8); }

/* Reduced motion: land immediately. Somebody who asked their OS to stop things
   moving has not asked to be told the result more slowly. */
@media (prefers-reduced-motion: reduce) {
  .die { transition: none; }
  .die-holder { transition: none; }
}

/* --- the 4d6 trays on the Abilities step ---------------------------------

   One row per throw, and the row carries its own ability picker. The first
   version had six trays and then a six-box ability grid underneath showing the
   same numbers again, which both pushed the step off one screen and read as a
   contradiction — dice adding to 12 beside a score of 16. */
/* The row gap needs the same tumble allowance as the gap between dice in a row.
   With rows only a die-height apart, cubes from neighbouring rows passed through
   each other on the way down — the horizontal spacing was allowed for and the
   vertical was not, which is a mistake that only shows while things are moving. */
.roll-trays {
  display: grid;
  gap: calc((var(--die6-tumble) - var(--die6-rest)) / 2);
}
/* The first column holds the ability picker, and it is 6.5rem rather than 5
   because the longest option is "CON · 2nd" and not "CON" — the picker carries
   the class's ranking now. Widening the SELECT instead of its column is the
   version of this that looks fixed and is not: the select overflows its cell
   and lies across the first die of every throw. */
.roll-tray {
  display: grid;
  grid-template-columns: 6.5rem 1fr 2.6rem 2.4rem;
  align-items: center;
  gap: var(--sp-12);
}
.roll-tray-pick {
  margin: 0;
  width: 100%;
  padding: var(--sp-4) var(--sp-6);
  font-size: var(--fs-xs);
}
.roll-tray-mod {
  font-family: var(--mono);
  font-size: var(--fs-sm);
  color: var(--text-dim);
  text-align: center;
}
/* The gap is derived, not chosen: a cube at rest is one edge wide but swells to
   its diagonal mid-throw, and --die-tumble carries that number over from the
   generator. Half the difference on each side is exactly enough that a tumbling
   die reaches its neighbour and never crosses it. */
.roll-tray-dice {
  display: flex;
  gap: calc((var(--die6-tumble) - var(--die6-rest)) / 2);
}
.roll-tray-total {
  min-width: 2.4rem;
  text-align: center;
  font-family: var(--mono);
  font-size: var(--fs-lg);
  color: var(--gold);
}
.roll-tray-total.is-pending { color: var(--text-faint); }

/* ---------------------------------------------------------------------------
   The review step: the finished character before committing to them.
   --------------------------------------------------------------------------- */
.review-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: var(--sp-24); }
.review-portrait { text-align: center; }
.review-portrait h2 { margin: var(--sp-10) 0 0; font-size: var(--fs-lg); color: var(--gold); }
.review-facts { display: flex; flex-direction: column; gap: var(--sp-14); }

/* The racial bonus is called out rather than folded silently into the total,
   because the previous three steps showed the base score and a number that
   changed without explanation reads as a bug. */
.ability-bump {
  display: block;
  font-size: var(--fs-2xs);
  font-style: normal;
  color: var(--gold-dim);
}
.review-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-8); }
.review-stat {
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-8) var(--sp-8);
  text-align: center;
}
.review-stat span { display: block; font-size: var(--fs-2xs); color: var(--text-dim); }
.review-stat strong { font-size: var(--fs-lg); color: var(--gold); }

.review-list {
  margin: 0;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--sp-4) var(--sp-12);
  font-size: var(--fs-xs);
}
.review-list dt { color: var(--text-dim); }
.review-list dd { margin: 0; }

@media (max-width: 720px) {
  .review-layout { grid-template-columns: 1fr; }
  .review-stats { grid-template-columns: repeat(2, 1fr); }
}

/* The one stat chip you can act on, so it says so. */
.sheet-rank {
  cursor: pointer;
  font: inherit;
  text-align: center;
  border-color: var(--gold-dim);
}
.sheet-rank:hover:not(:disabled) { background: var(--bg-raised); border-color: var(--gold); }
.sheet-rank strong { text-transform: capitalize; }
.sheet-rank:disabled { opacity: 0.6; cursor: progress; }

/* Companions are not offered this: their look is authored content, resolved from
   the template, and re-dressing one would put a stale copy back on the row the
   template is meant to own. */
.sheet-redress {
  cursor: pointer;
  text-align: center;
  text-decoration: none;
  border-color: var(--gold-dim);
}
.sheet-redress:hover { background: var(--bg-raised); border-color: var(--gold); text-decoration: none; }

/* "My dice" and "roll these for me from now on" — the two ends of the same
   preference, one on the action bar and one inside the prompt. */
.cbt-manual, .check-automate {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-6);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  cursor: pointer;
  user-select: none;
}
.cbt-manual { margin-right: var(--sp-6); }
.cbt-manual input, .check-automate input { accent-color: var(--gold); margin: 0; }
.cbt-manual:hover, .check-automate:hover { color: var(--text); }
/* Its own row, hard-aligned left. `width: 100%` on an inline-flex label let the
   surrounding layout stretch it and fling the caption away from its box. */
.check-automate {
  display: flex;
  justify-content: flex-start;
  align-items: center;
  margin: var(--sp-12) 0 0;
  padding-top: var(--sp-10);
  border-top: 1px solid var(--border-soft);
}
.check-automate input { flex: 0 0 auto; }
.check-automate span { flex: 0 1 auto; text-align: left; }

/* Quest pins. Gold like everything else you can act on; the tracked one is
   brighter and slowly pulses so it is findable on a 48x44 town without being
   the loudest thing on screen. */
.map-marker.quest {
  color: var(--gold-dim);
  font-size: var(--fs-lg);
  text-shadow: var(--th-halo);
  pointer-events: none;
}
.map-marker.quest.tracked {
  color: var(--gold-bright);
  animation: quest-pin var(--dur-pulse) var(--ease-in-out) infinite;
}
@keyframes quest-pin {
  0%, 100% { transform: translateY(0); opacity: 1; }
  50%      { transform: translateY(-3px); opacity: 0.75; }
}

.track-where {
  margin-top: var(--sp-6);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}
.track-where.is-here { color: var(--success); }
/* What is in front of you, once you have arrived. Not dimmed like the route
   chain — it is the instruction, not the footnote. */
.track-here-what { color: var(--text); }
.track-here-what strong { color: var(--gold); font-weight: var(--fw-semibold); }
.track-via { color: var(--gold); }
.track-far { color: var(--text-faint); font-size: var(--fs-2xs); }

@media (prefers-reduced-motion: reduce) {
  .map-marker.quest.tracked { animation: none; }
}

/* Experience.
   Gold, because levelling is the one bar that is only ever good news — health
   is read by colour and shifts between three bands, so giving XP its own hue
   means a glance at the rail never confuses "nearly levelled" with "nearly
   dead". */
.xp-bar {
  display: block;
  height: 6px;
  background: var(--bg-inset);
  border-radius: var(--r-sm);
  overflow: hidden;
}
.xp-bar > span {
  display: block;
  height: 100%;
  background: linear-gradient(90deg, var(--gold-dim), var(--gold));
  transition: width var(--dur-fill) var(--ease);
}
.party-card .rail-xp { height: 3px; margin-top: var(--sp-4); }
.party-lv { color: var(--text-faint); }

/* A labelled bar: what it measures, the bar, and the numbers behind it. An
   unlabelled rectangle is decoration; "1150 / 2700" is information. */
.bar-row {
  display: grid;
  grid-template-columns: 5.5rem minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--sp-10);
  margin: var(--sp-6) 0;
}
.bar-row .bar-label { font-size: var(--fs-2xs); color: var(--text-dim); }
.bar-row .bar-value {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  white-space: nowrap;
}
.bar-row .hp-bar { margin-top: 0; }

/* Approval: a bar with zero in the middle.
 *
 * Every other bar on the sheet fills from the left because its floor is zero
 * and its ceiling is a maximum. Approval has neither — it runs negative, and
 * the interesting question is which side of nothing somebody is on. So the
 * track is symmetric, the fill grows out from the centre, and the line they
 * walk out on is drawn where it actually falls on their own scale. */
.ap-bar {
  position: relative;
  height: 8px;
  background: var(--bg-inset);
  border-radius: var(--r-sm);
  overflow: hidden;
}
/* Zero. Deliberately the brightest thing on the track. */
.ap-bar .ap-axis {
  position: absolute;
  left: 50%;
  top: -1px;
  bottom: -1px;
  width: 1px;
  margin-left: -0.5px;
  background: var(--text-dim);
  opacity: 0.85;
}
/* The line they leave on. */
.ap-bar .ap-leave {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  margin-left: -1px;
  background: var(--danger);
  opacity: 0.75;
}
.ap-bar .ap-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  border-radius: var(--r-sm);
  background: var(--gold-dim);
  transition: left var(--dur-fill) var(--ease), width var(--dur-fill) var(--ease);
}
.ap-bar.ap-high .ap-fill { background: linear-gradient(90deg, var(--gold-dim), var(--gold)); }
.ap-bar.ap-good .ap-fill { background: var(--success); }
.ap-bar.ap-mid  .ap-fill { background: var(--text-dim); }
.ap-bar.ap-low  .ap-fill { background: var(--meter-low); }
.ap-bar.ap-risk .ap-fill { background: var(--meter-risk); }
.ap-bar.ap-bad  .ap-fill { background: var(--danger); }
.bar-row .ap-arrow { font-style: normal; opacity: 0.85; }

@media (prefers-reduced-motion: reduce) {
  .ap-bar .ap-fill { transition: none; }
}

@media (prefers-reduced-motion: reduce) {
  .xp-bar > span { transition: none; }
}

/* The bar every screen draws: the party rail, the character sheet, the camp
   roster and the combat cards. */
.hp-bar {
  display: block;
  height: 8px;
  background: var(--bg-inset);
  border-radius: var(--r-md);
  overflow: hidden;
  margin-top: var(--sp-6);
}
.hp-bar > span {
  display: block;
  height: 100%;
  /* Bare fill is red on purpose: a bar drawn without one of the band classes
     below reads as somebody about to die, which is a bug that shows itself. */
  background: linear-gradient(90deg, var(--danger-dim), var(--danger));
  transition: width var(--dur-enter) var(--ease);
}

/* Health bands. Colour is not the only channel — the numeric HP sits beside
   every bar. */
.hp-bar > span.ok   { background: linear-gradient(90deg, var(--success-dim), var(--success)); }
.hp-bar > span.mid  { background: linear-gradient(90deg, var(--warn-dim), var(--warn)); }
.hp-bar > span.low  { background: linear-gradient(90deg, var(--danger-dim), var(--danger)); }

@media (prefers-reduced-motion: reduce) {
  .hp-bar > span { transition: none; }
}

.stat-row {
  display: flex;
  justify-content: space-between;
  font-size: var(--fs-sm);
  padding: var(--sp-2) 0;
  border-bottom: 1px solid rgba(61,74,99,0.4);
}

/* Modal */
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.72);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: var(--z-modal);
  padding: var(--sp-16);
}
.modal-backdrop.hidden { display: none; }

/**
 * Modals come in sizes, named at the call site.
 *
 * Every modal used to be capped at 520px with one hand-written exception for
 * the character sheet at 720, which is why a conversation with five replies
 * scrolled and an inventory of six items did not. How much room something needs
 * is a property of what is in it.
 */
.modal {
  background: var(--bg-panel);
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-lg);
  max-width: 560px;
  width: 100%;
  max-height: 86vh;
  overflow-y: auto;
  padding: var(--sp-20);
  box-shadow: var(--sh-4);
}
.modal.modal-wide { max-width: 780px; }
.modal.modal-tall { max-width: 620px; max-height: 92vh; }

/* The chart, opened up. Nearly the whole window, because the reason to open it
   at all is that the rail copy is too small to read place names on — and it is
   a drawing, so the more of it there is the better it works. */
.modal.modal-map {
  max-width: min(1200px, 94vw);
  height: 94vh;
  padding: var(--sp-14) var(--sp-16) var(--sp-16);
  display: flex;
  flex-direction: column;
  /* The chart letterboxes itself inside whatever box it is given, so there is
     never anything here to scroll to — and a scrollbar on a map that only
     looks cut off is worse than no scrollbar at all. */
  overflow: hidden;
}

/* Everything under the top of the stack is kept, not destroyed: the shop you
   opened from a conversation closes back onto the conversation. */
.modal.modal-buried { display: none; }

/* A modal's title bar: what this is, and the way out of it.
   Sticky, because the panel itself is what scrolls — on a journal with twenty
   quests in it, a close button that scrolls off the top is one you have to go
   looking for. The negative margins pull it out to the panel's own edges so it
   reads as a bar rather than a heading with a button beside it. */
/* The panel gives up its own top padding to the bar, which brings its own.
   Sticky is measured from the scrollport's PADDING edge, so leaving the
   padding in place parked the bar 20px down and let content scroll through
   the gap above it. */
.modal:has(.modal-head) { padding-top: 0; }
.modal-head {
  position: sticky;
  top: 0;
  z-index: 2;            /* local: over the modal body scrolling under it */
  display: flex;
  align-items: center;
  gap: var(--sp-16);
  margin: 0 calc(-1 * var(--sp-20)) var(--sp-14);
  padding: var(--sp-14) var(--sp-20) var(--sp-12);
  background: var(--bg-panel);
  border-bottom: 1px solid var(--ornament);
  border-radius: 8px 8px 0 0;
}
.modal-head h2 {
  margin: 0;
  font-family: var(--font-display);
  color: var(--gold);
  font-size: var(--fs-lg);
}
.modal-x {
  margin-left: auto;
  flex: 0 0 auto;
  font-size: var(--fs-lg);
  line-height: var(--lh-flush);
}

.modal-body { line-height: var(--lh-normal); color: var(--text-dim); }

/* What is waiting, in the ambush warning. Wraps rather than scrolls: a scaled
   roster can be eight or nine species deep and a row that slides sideways
   hides exactly the thing the dialog exists to show. The face crop matches
   .cbt-face so the goblin in the warning is the goblin on the card. */
.foe-lineup {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-8);
  list-style: none;
  margin: var(--sp-12) 0 0;
  padding: 0;
}
.foe-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-2);
  width: 74px;
  padding: var(--sp-4);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--bg-panel);
}
.foe-face {
  width: 100%;
  height: 52px;
  object-fit: cover;
  object-position: center 28%;
  border-radius: var(--r-sm);
  background: linear-gradient(180deg, var(--bg-panel), var(--bg));
}
.foe-name {
  font-size: var(--fs-2xs);
  line-height: var(--lh-tight);
  text-align: center;
  color: var(--text-dim);
}
.foe-count { font-size: var(--fs-2xs); color: var(--gold); }

.modal-actions {
  display: flex;
  gap: var(--sp-8);
  justify-content: flex-end;
  margin-top: var(--sp-16);
}

/* Character sheet */
.sheet-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--sp-16);
  margin-bottom: var(--sp-12);
  border-bottom: 1px solid var(--border);
  padding-bottom: var(--sp-12);
}
/* Avatar picker on the character sheet */
.avatar-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: var(--sp-8);
}
.avatar-option {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--sp-6) var(--sp-4) var(--sp-8);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  color: var(--text-dim);
  font-family: inherit;
  font-size: var(--fs-2xs);
  line-height: var(--lh-tight);
  text-align: center;
  cursor: pointer;
  transition: border-color var(--dur), box-shadow var(--dur), background var(--dur), color var(--dur);
}
.avatar-option img {
  width: 72px;
  height: 72px;
  object-fit: cover;
  object-position: center top;
  border-radius: var(--r-md);
  background: linear-gradient(180deg, var(--bg-panel), var(--bg));
}
.avatar-option:hover:not(:disabled) {
  border-color: var(--gold-dim);
  background: var(--bg-panel);
  color: var(--text);
}
.avatar-option.selected {
  border-color: var(--gold);
  color: var(--gold);
  background: var(--bg-panel);
  box-shadow: var(--sh-glow-sm);
}
.avatar-option:disabled { opacity: 0.5; cursor: progress; }

/* The gift step: origin feats as prose cards. Wider columns than the portrait
   grid because a feat is read, not glanced at. */
.gift-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.gift-card {
  display: flex;
  flex-direction: column;
  gap: var(--sp-6);
  padding: var(--sp-12) var(--sp-12);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  color: var(--text-dim);
  font-family: inherit;
  font-size: var(--fs-xs);
  line-height: var(--lh-snug);
  text-align: left;
  cursor: pointer;
  transition: border-color var(--dur), box-shadow var(--dur), background var(--dur), color var(--dur);
}
.gift-card strong { color: var(--text); font-size: var(--fs-sm); }
.gift-card em.help-hint { font-style: italic; font-size: var(--fs-2xs); }
.gift-card:hover {
  border-color: var(--gold-dim);
  background: var(--bg-panel);
  color: var(--text);
}
.gift-card.selected {
  border-color: var(--gold);
  background: var(--bg-panel);
  box-shadow: var(--sh-glow-sm);
}
.gift-card.selected strong { color: var(--gold); }

/* Romance: the heart on a camp seat, and the line under the sheet's approval
   bar. Small on purpose — it is a fact, not a fanfare. */
.camp-heart {
  color: var(--gold-bright);
  font-size: var(--fs-xs);
  line-height: var(--lh-flush);
}
.camp-heart.is-locked { color: var(--text-dim); }
.sheet-romance { color: var(--gold); font-size: var(--fs-xs); margin: var(--sp-2) 0 0; }
.sheet-romance.is-locked { color: var(--text-dim); }
.camp-talk { align-self: flex-start; }

/* Upcast chips under a spell row in the combat picker. */
.spell-upcast { display: flex; gap: var(--sp-6); margin: calc(-1 * var(--sp-4)) 0 var(--sp-6) var(--sp-20); flex-wrap: wrap; }
.spell-upcast-btn {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-pill);
  color: var(--text-dim);
  font: inherit;
  font-size: var(--fs-2xs);
  padding: var(--sp-2) var(--sp-10);
  cursor: pointer;
}
.spell-upcast-btn:hover { border-color: var(--gold-dim); color: var(--text); }

/* Casting from the sheet: one row under a healing spell. */
.sheet-cast-row { display: flex; gap: var(--sp-6); margin-top: var(--sp-6); align-items: center; }
.sheet-cast-row select {
  flex: 1 1 auto;
  min-width: 0;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-4) var(--sp-6);
  font: inherit;
  font-size: var(--fs-xs);
}

.sheet-bust {
  width: 150px;
  height: 190px;
  flex-shrink: 0;
  border-radius: var(--r-md);
  object-fit: cover;
  object-position: center top;
  border: 1px solid var(--gold-dim);
  box-shadow: var(--sh-2);
  background: linear-gradient(180deg, var(--bg-panel), var(--bg));
}
/* Title block sits between the bust and the header buttons */
.sheet-ident { flex: 1; min-width: 0; }
/* One row, top right. Stacked, these three made a column as tall as the
   portrait beside them and pushed the name block into a narrow gutter. */
.sheet-header-actions {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--sp-6);
  align-items: flex-start;
  justify-content: flex-end;
  align-self: flex-start;
}
.sheet-title {
  margin: 0 0 var(--sp-4);
  font-size: var(--fs-xl);
}
.sheet-subtitle {
  margin: var(--sp-2) 0;
  color: var(--text-dim);
  font-size: var(--fs-sm);
}
.sheet-combat-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-8);
  margin: var(--sp-12) 0 var(--sp-6);
}
.sheet-stat-pill {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-6) var(--sp-10);
  min-width: 4.5rem;
  text-align: center;
}
.sheet-stat-pill span {
  display: block;
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: var(--ls-wide);
}
.sheet-stat-pill strong {
  color: var(--gold);
  font-size: var(--fs-md);
}
.sheet-hp {
  margin-bottom: var(--sp-14);
  height: 10px;
}
.sheet-section {
  margin-top: var(--sp-16);
}
.sheet-section h3 {
  font-size: var(--fs-md);
  color: var(--gold);
  border-bottom: 1px solid var(--border);
  padding-bottom: var(--sp-4);
  margin: 0 0 var(--sp-10);
}
.sheet-abilities {
  grid-template-columns: repeat(6, 1fr);
}
@media (max-width: 640px) {
  .sheet-abilities { grid-template-columns: repeat(3, 1fr); }
}
.sheet-ab {
  padding: var(--sp-6) var(--sp-4);
}
.sheet-ab em {
  display: block;
  font-style: normal;
  color: var(--accent);
  font-size: var(--fs-xs);
}
.sheet-item-actions {
  display: flex;
  gap: var(--sp-4);
  flex-wrap: wrap;
  margin-top: var(--sp-6);
}
.sheet-spells .inv-item .badge {
  margin-left: var(--sp-6);
}

.modal h2 { margin-top: 0; }

.quest-item, .inv-item, .shop-item {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-10);
  margin-bottom: var(--sp-8);
}
.quest-item h3, .inv-item strong { color: var(--gold); font-size: var(--fs-md); margin: 0 0 var(--sp-4); }

/* A quest, shut. The summary is the whole entry until you ask for more: which
   quest, how it stands, and — while it is the one you are on — what to do
   next. Everything else is the playthrough, and there can be a lot of it. */
.quest-summary {
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  cursor: pointer;
  list-style: none;
}
/* Both spellings: the marker is ::marker in most engines and a WebKit
   pseudo-element in Safari, and a stray triangle beside our own is worse than
   no triangle at all. */
.quest-summary::marker { content: ''; }
.quest-summary::-webkit-details-marker { display: none; }
.quest-summary::before {
  content: '▸';
  flex: 0 0 auto;
  color: var(--gold-dim);
  transition: transform var(--dur-fast) var(--ease);
}
.quest-item[open] > .quest-summary::before { transform: rotate(90deg); }
.quest-summary:hover .quest-title { color: var(--gold); }

.quest-summary-text {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-width: 0;
}
.quest-title { color: var(--gold-dim); font-size: var(--fs-md); font-weight: var(--fw-semibold); }
/* One line while shut, so every entry is the same height and the list can be
   scanned; the full objective is inside anyway. */
.quest-gist {
  font-size: var(--fs-xs);
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.quest-item[open] .quest-gist { white-space: normal; }
.quest-summary .badge { flex: 0 0 auto; }

.quest-body {
  margin-top: var(--sp-8);
  padding-top: var(--sp-8);
  border-top: 1px solid var(--border);
}

@media (prefers-reduced-motion: reduce) {
  .quest-summary::before { transition: none; }
}
.quest-item p, .inv-item p { margin: 0; font-size: var(--fs-sm); color: var(--text-dim); }
.inv-desc { font-size: var(--fs-xs); color: var(--text-dim); }

/* --- The bag ------------------------------------------------------------

   A square per stack. `auto-fill` rather than a fixed count so the same grid
   works in the sheet's wide modal and on a phone, and a fixed row height so
   the empty slots that pad it out are the same size as the full ones — a bag
   with room left in it should look like one rather than stopping mid-row. */
.bag-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(4.6rem, 1fr));
  gap: var(--sp-6);
}
.bag-slot {
  position: relative;
  aspect-ratio: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: var(--sp-4) var(--sp-4);
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  color: var(--text-dim);
  font: inherit;
  cursor: pointer;
  overflow: hidden;
}
.bag-slot.is-empty {
  /* Dashed and dim: a slot you could fill, not a thing you can press. */
  border-style: dashed;
  opacity: 0.35;
  cursor: default;
}
.bag-slot:not(.is-empty):hover,
.bag-slot:not(.is-empty):focus-visible { border-color: var(--gold-dim); outline: none; }
.bag-slot.is-picked {
  border-color: var(--gold);
  box-shadow: var(--sh-ring-on);
}
/* Rarity on the tile's edge. Common says nothing — most of a bag is common,
   and a colour every slot wears is not a signal. */
.bag-slot.is-uncommon { border-color: var(--success-dim); }
.bag-slot.is-rare { border-color: var(--accent); }

.bag-glyph {
  width: 1.6rem;
  height: 1.6rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.bag-art { width: 2rem; height: 2rem; object-fit: contain; }
/* Two lines of name under the glyph. Enough to tell a Longsword from a Longbow
   without opening either; the full name is in the tooltip and the panel. */
.bag-label {
  font-size: var(--fs-2xs);
  line-height: var(--lh-display);
  text-align: center;
  color: var(--text-dim);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.bag-qty {
  position: absolute;
  right: 2px;
  bottom: 2px;
  padding: 0 var(--sp-2);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text);
  background: rgba(0, 0, 0, 0.72);
  border-radius: var(--r-sm);
}
.bag-worn {
  position: absolute;
  top: 2px;
  right: 3px;
  font-size: var(--fs-2xs);
  line-height: var(--lh-flush);
  color: var(--success);
}

/* One item, said in full. Holds its height whether something is picked or not,
   so choosing does not shove the rest of the sheet down the page. */
.bag-detail {
  margin-top: var(--sp-10);
  padding: var(--sp-10);
  min-height: 7.5rem;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
}
.bag-detail-head { display: flex; align-items: center; gap: var(--sp-6); }
.bag-detail-name { color: var(--gold); font-size: var(--fs-md); }
.bag-detail-name.is-uncommon { color: var(--success); }
.bag-detail-name.is-rare { color: var(--accent); }
.bag-detail-facts {
  margin: var(--sp-2) 0 var(--sp-6);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-faint);
}
.bag-give {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--sp-6);
  margin-top: var(--sp-8);
  padding-top: var(--sp-8);
  border-top: 1px solid var(--border);
}
.bag-give-label {
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-faint);
}
.bag-give-who { display: flex; flex-wrap: wrap; gap: var(--sp-4); }
.bag-qty-pick {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-4);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
}
.bag-qty-pick input {
  width: 3.4rem;
  padding: var(--sp-2) var(--sp-4);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text);
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
}
.quest-objective { margin-top: var(--sp-6); color: var(--text); }
.quest-meta { margin-top: var(--sp-6); }
.journal-entry {
  margin: var(--sp-6) 0 0;
  padding-left: var(--sp-10);
  border-left: 2px solid var(--border);
  font-style: italic;
  color: var(--text-dim);
}
.shop-list { display: flex; flex-direction: column; }

.game-page .panel {
  /* keep panels from forcing page growth */
  margin-bottom: 0;
}

.game-page .error-banner {
  flex-shrink: 0;
  margin-bottom: 0;
  padding: var(--sp-6) var(--sp-10);
  font-size: var(--fs-sm);
}

.footer-legal {
  text-align: center;
  color: var(--text-dim);
  font-size: var(--fs-xs);
  padding: var(--sp-24);
  border-top: 1px solid var(--border);
}

.legal-text {
  max-width: 800px;
  margin: 0 auto;
  line-height: var(--lh-normal);
  color: var(--text-dim);
  font-size: var(--fs-sm);
  white-space: pre-wrap;
}

.badge {
  display: inline-block;
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  color: var(--text-dim);
  font-size: var(--fs-2xs);
  padding: var(--sp-2) var(--sp-6);
  border-radius: var(--r-sm);
}
.badge.ok { color: var(--success); border-color: var(--success); }
.badge.done { color: var(--gold); }

.hidden { display: none !important; }

.error-banner {
  background: var(--danger-well);
  border: 1px solid var(--danger);
  color: var(--danger);
  padding: var(--sp-10) var(--sp-12);
  border-radius: var(--r-md);
  margin-bottom: var(--sp-12);
}
/* The same strip, for something that went right — a night's sleep, and whatever
   the fire had to say about it. One element and one modifier rather than two
   banners, so a message can never appear under an error that is still showing
   above it. */
.error-banner.is-good {
  background: var(--gold-well);
  border-color: var(--gold-dim);
  color: var(--gold);
}

.help-hint {
  font-size: var(--fs-xs);
  color: var(--text-dim);
  margin-top: var(--sp-8);
}

/* ====================================================================
   Shared vocabulary: pills and condition pips
   ==================================================================== */

/* A pill is a small labelled fact — a DC, a tag, a spell being held. It is the
   badge's louder cousin and shares its palette on purpose. */
.pill {
  display: inline-block;
  padding: var(--sp-1) var(--sp-6);
  border-radius: var(--r-pill);
  border: 1px solid var(--border);
  background: var(--bg-panel-2);
  color: var(--text-dim);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-wide);
  white-space: nowrap;
}
.pill-check {
  border-color: var(--gold-dim);
  color: var(--gold);
  background: var(--bg-panel);
}
.pill-tag { border-color: var(--accent); color: var(--accent); }
.pill-conc { border-color: var(--accent-dim); color: var(--accent); }
.pill-advantage { border-color: var(--success); color: var(--success); }
.pill-disadvantage { border-color: var(--danger); color: var(--danger); }

/* Two letters is enough to recognise a condition once and remember it, and it
   is the only thing that fits on a 132px card. The full name is in the title. */
.pip {
  display: inline-block;
  min-width: 1.35em;
  padding: 0 var(--sp-2);
  border-radius: var(--r-sm);
  border: 1px solid var(--danger);
  background: var(--danger-well);
  color: var(--danger);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  text-align: center;
  line-height: var(--lh-normal);
}
.pip-unconscious { border-color: var(--text-faint); background: var(--bg-panel-2); color: var(--text-dim); }
.pip-invisible { border-color: var(--accent-dim); background: var(--accent-well); color: var(--accent); }

/* ====================================================================
   The party rail
   ==================================================================== */

/* The party rail was a fixed 132px column with its own panel background. It is
   `.hud-party` now — a floating column of portraits with no chrome of its own,
   defined up with the HUD. The `.party-card` rules below still apply: only the
   card's outer shell changed, and `.hud-party .party-card` overrides that. */

.party-card {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--sp-2);
  padding: var(--sp-6) var(--sp-6) var(--sp-8);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  color: var(--text);
  font-family: inherit;
  font-size: var(--fs-2xs);
  text-align: center;
  cursor: pointer;
  transition: border-color var(--dur), box-shadow var(--dur), background var(--dur);
}
.party-card:hover,
.party-card:focus-visible {
  border-color: var(--gold);
  box-shadow: var(--sh-glow-sm);
  background: var(--bg-inset);
  outline: none;
}
.party-card.is-active {
  border-color: var(--gold);
  box-shadow: var(--sh-ring);
}

.party-face {
  width: 100%;
  height: 74px;
  object-fit: cover;
  object-position: center top;
  border-radius: var(--r-md);
  border: 1px solid var(--border);
  background: linear-gradient(180deg, var(--bg-panel), var(--bg));
}
.party-name {
  /* Names are the one place the rail still uses gold, so it reads as "this is
     a person" rather than as an accent competing with the check pills. */
  color: var(--text);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-2xs);
  line-height: var(--lh-tight);
  /* Two lines rather than an ellipsis: "Elara Stormwind" is an ordinary name
     and truncating it to "Elara Stormwind…" in a 118px column made the rail
     look broken. Three-word names still clip, which is the right trade. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* Gold marks the character you are currently playing, and nothing else in the
   rail. The class is `is-active`, set in renderPartyRail(). */
.party-card.is-active .party-name { color: var(--gold); }
.party-card .hp-bar { margin-top: var(--sp-2); height: 6px; }
.party-stats {
  display: flex;
  justify-content: space-between;
  color: var(--text-dim);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
}
.party-approval {
  border-radius: var(--r-sm);
  padding: var(--sp-1) var(--sp-4);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
}
.ap-high { background: var(--success-well); color: var(--success); }
.ap-good { background: var(--success-well); color: var(--success); }
.ap-mid  { background: var(--bg-panel); color: var(--text-dim); }
.ap-low  { background: var(--danger-well); color: var(--warn); }
.ap-risk { background: var(--danger-well); color: var(--warn); border: 1px solid var(--warn); }
.ap-bad  { background: var(--danger-well); color: var(--danger); }

/* Which way it last moved. The band is a standing; this is the feedback, and
   without it a player has no way to tell whether the last hour helped. */
.approval-drift {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  line-height: var(--lh-flush);
  letter-spacing: var(--ls-normal);
}
.approval-drift.is-up { color: var(--success); }
.approval-drift.is-down { color: var(--danger); }

/* The camp modal's face had no rule at all, so it drew at whatever the face
   PNG happened to be. That was 96px, and pinning it there keeps the modal
   looking exactly as it did while the art behind it moves to 144px -- which is
   precisely what a 96px box needs at this display's 1.5x device pixel ratio.
   Without this the faces would silently grow with the art.
   The row's stacked arrangement is left alone deliberately: it falls out of an
   <img> followed by a block <div>, and straightening it is a design change
   rather than part of making the art crisper. */
/* ====================================================================
   Camp
   ====================================================================
   The fire, and whoever is sitting at it. Companions are seats you can take
   rather than rows in a roster: the point of camping is the conversation, so
   the thing you click is the person. */
.camp-scene { display: flex; flex-direction: column; gap: var(--sp-14); }
.camp-head { margin: 0; }
.camp-intro { margin: 0; color: var(--text-dim); font-size: var(--fs-sm); font-style: italic; }

.camp-seats {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: var(--sp-10);
}

/* A seat and the one decision you can make about it. The wrapper is the grid
   item now, so the seats keep their column widths and the button hangs under
   the person it belongs to rather than in a row of its own. */
.camp-seat-wrap {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  min-width: 0;
}
.camp-march {
  align-self: flex-start;
  font-size: var(--fs-2xs);
  padding: var(--sp-4) var(--sp-8);
}
.camp-march:disabled { opacity: 0.45; cursor: not-allowed; }

.camp-seat {
  display: flex;
  align-items: stretch;
  gap: 0;
  padding: 0;
  overflow: hidden;
  text-align: left;
  background: var(--ornament-well);
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  color: inherit;
  font: inherit;
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
}
.camp-seat:hover:not(:disabled),
.camp-seat:focus-visible { border-color: var(--gold); transform: translateY(-1px); }
.camp-seat:disabled { opacity: 0.5; cursor: default; }

/* The generated campfire scene, cropped to a strip. Falls back to the
   companion's face when no camp art exists yet, which is why the fallback
   gets its own class rather than its own layout. */
.camp-seat-art {
  flex: 0 0 auto;
  width: 96px;
  align-self: stretch;
  object-fit: cover;
  object-position: center;
  background: var(--bg-inset);
  border-right: 1px solid var(--ornament);
}
.camp-seat-art.is-portrait { object-position: center top; }

.camp-seat-body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  padding: var(--sp-8) var(--sp-12);
  min-width: 0;
}
.camp-seat-name { font-weight: var(--fw-semibold); color: var(--text); }
.camp-seat-meta { display: flex; align-items: center; gap: var(--sp-6); flex-wrap: wrap; }
/* Said on the seat itself, because the seat is the thing that fixes it. */
.camp-seat-risk {
  display: block;
  margin-top: var(--sp-4);
  color: var(--warn);
  font-size: var(--fs-2xs);
  line-height: var(--lh-snug);
}
.camp-warning {
  margin: 0;
  padding: var(--sp-8) var(--sp-12);
  border-left: 3px solid var(--warn);
  background: var(--danger-well);
  color: var(--warn);
  font-size: var(--fs-sm);
}
.camp-waiting { font-size: var(--fs-2xs); color: var(--text-faint); font-style: italic; }

/* A row at the fire who is not a companion with controls: a party member, or
   somebody who has gone. Had no layout rule at all — the face and the name
   stacked, and the 96px portrait meant a "no longer with you" row was three
   inches tall for two words. */
.camp-row {
  display: flex;
  align-items: center;
  gap: var(--sp-12);
  padding: var(--sp-8) var(--sp-4);
  border-bottom: 1px solid var(--border-soft);
}
.camp-row:last-child { border-bottom: 0; }
.camp-row .camp-face {
  width: 44px;
  height: 44px;
  flex: 0 0 auto;
  border-radius: var(--r-md);
  object-fit: cover;
  object-position: center top;
  border: 1px solid var(--ornament);
}
.camp-ident { min-width: 0; }
.camp-ident strong { color: var(--text); font-weight: var(--fw-semibold); }
.camp-ident .sub { font-size: var(--fs-2xs); color: var(--text-dim); line-height: var(--lh-snug); }
.camp-row.is-gone { opacity: 0.6; }

/* The player's own characters, listed under the companions rather than among
   them: they are at the fire, but there is nothing to sit down and say. */
.camp-party h3 { margin-bottom: var(--sp-6); }
.camp-rest { border-top: 1px solid var(--ornament); padding-top: var(--sp-12); }
.camp-rest-actions .btn:disabled { opacity: 0.45; cursor: not-allowed; }
/* Only shown where neither kind of rest is possible, so it says where one is
   rather than leaving two dead buttons and no explanation. */
.camp-nowhere { margin-top: var(--sp-8); font-style: italic; }

/* The campfire behind a camp conversation. Sits under the panel's own
   background at low opacity — it is the room the scene happens in, not the
   thing being looked at, and the prose has to stay readable over it. */
/* `overflow-y: auto`, NOT `hidden`.
 *
 * This said `hidden`, to clip the fire to the panel's rounded corners — and in
 * doing so it overrode the `overflow-y: auto` every other conversation has, so
 * a camp scene could not be scrolled at all. Anything past the fold was simply
 * unreachable, replies included. `auto` clips just as well (a scroll container
 * is still clipped by its border-radius), and scrolls. */
.dlg-panel.is-camp { position: relative; overflow-y: auto; }
.dlg-camp-scene {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.28;
  filter: saturate(0.9);
  pointer-events: none;
  z-index: 0;            /* local: the painted backdrop, under the panel it fills */
}
/* Everything else in the panel rides above it. */
.dlg-panel.is-camp > *:not(.dlg-camp-scene) { position: relative; z-index: 1; }   /* local: over the backdrop */
/* A warm floor under the text so a bright fire cannot wash the line out. */
.dlg-panel.is-camp .dlg-script {
  background: linear-gradient(90deg, rgba(22, 17, 13, 0.82), rgba(22, 17, 13, 0.6));
  border-radius: var(--r-md);
  padding: var(--sp-10) var(--sp-12);
}

.camp-face {
  width: 96px;
  height: 96px;
}

.cond-pips {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--sp-2);
  min-height: 0.2rem;
}

/* A downed member stays on the rail — they are not gone, they are dying, and a
   heal has something to bring back. They just stop looking like a going concern. */
.party-card.is-down .party-face { filter: grayscale(1) brightness(0.55); }
.party-card.is-down { border-style: dashed; opacity: 0.85; }
.party-downed {
  color: var(--danger);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  font-size: var(--fs-2xs);
}

.party-add {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-4);
  min-height: 74px;
  border-style: dashed;
  color: var(--text-dim);
  text-decoration: none;
}
.party-add:hover { text-decoration: none; }
.party-add-plus { font-size: 1.6rem; line-height: var(--lh-flush); color: var(--gold-dim); }

/* ====================================================================
   Quest tracker and log
   ====================================================================
   These lived in a fixed 150px context rail. They float now — the placement and
   panel treatment are up with the rest of the HUD; what is left here is what
   they are made of rather than where they sit. */

.track-title { color: var(--gold); font-size: var(--fs-xs); line-height: var(--lh-tight); }
.track-stage { color: var(--accent); font-size: var(--fs-2xs); margin-top: var(--sp-2); }
.track-objective {
  margin: var(--sp-4) 0 0;
  font-size: var(--fs-2xs);
  line-height: var(--lh-snug);
  color: var(--text);
}
.track-where { margin-top: var(--sp-4); font-size: var(--fs-2xs); color: var(--text-dim); }

.log-filters { display: flex; gap: var(--sp-4); }
.chip {
  flex: 1 1 auto;
  padding: var(--sp-2) var(--sp-4);
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  color: var(--text-dim);
  font-family: inherit;
  font-size: var(--fs-2xs);
  cursor: pointer;
}
.chip.on { color: var(--gold); border-color: var(--gold-dim); background: var(--bg-panel); }

.roll-list { max-height: 60vh; overflow-y: auto; }
.roll-row {
  padding: var(--sp-6) var(--sp-6);
  margin-bottom: var(--sp-4);
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-left-width: 3px;
  border-radius: var(--r-sm);
  font-size: var(--fs-2xs);
}
.roll-row.outcome-critical { border-left-color: var(--gold); }
.roll-row.outcome-success  { border-left-color: var(--success); }
.roll-row.outcome-failure  { border-left-color: var(--danger); }
.roll-row.outcome-fumble   { border-left-color: var(--danger-dim); }
.roll-head { display: flex; justify-content: space-between; gap: var(--sp-4); }
.roll-head strong { color: var(--gold); }
.roll-verdict { color: var(--text-dim); text-transform: uppercase; font-size: var(--fs-2xs); }
.roll-sum {
  display: flex;
  align-items: baseline;
  gap: var(--sp-4);
  margin-top: var(--sp-2);
  font-family: var(--mono);
  color: var(--text-dim);
}
.roll-nat {
  color: var(--text);
  font-size: var(--fs-md);
  font-weight: var(--fw-bold);
}
.roll-who { color: var(--text-dim); font-size: var(--fs-2xs); margin-top: var(--sp-2); }

/* ====================================================================
   The action bar
   ==================================================================== */

/* Sits at the foot of the right-hand rail. It wraps, because the rail is
   narrower than four buttons in a row and a bar that overflows its column
   pushes its last action off the edge of the window. */
.action-bar {
  display: flex;
  flex-wrap: wrap;
  flex-shrink: 0;
  gap: var(--sp-6);
}
.action-btn { flex: 1 1 auto; min-width: 4.5rem; }

/* ====================================================================
   The dialogue theatre
   ====================================================================
   A panel over the bottom of the map frame, not a modal: a conversation
   happens somewhere, and greying out the somewhere throws that away. */

/* A conversation takes the screen.
 *
 * This was a strip anchored to the bottom of the map frame, on the reasoning
 * that a conversation happens somewhere and covering that somewhere throws it
 * away. That was right when the somewhere was a drawn map you could see behind
 * it. In a text game the "somewhere" is a paragraph the player has already
 * read, and the strip was competing with it — so the scene now dims and the
 * conversation is what you are looking at. */
.dialogue-theatre {
  position: fixed;
  inset: 0;
  z-index: var(--z-dialogue);
  display: grid;
  place-items: center;
  padding: var(--sp-24);
  background: rgba(8, 6, 5, 0.72);
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
}

.dlg-panel {
  position: relative;
  display: grid;
  grid-template-columns: 168px minmax(0, 1fr);
  gap: var(--sp-20);
  width: min(880px, 100%);
  /* 90% of the window, and no pixel ceiling.
   *
   * This was min(80vh, 640px), and on any screen taller than about 800px the
   * 640 won — so the panel stopped growing less than two thirds of the way down
   * a normal monitor while the space below it stayed empty. That was survivable
   * when a node was three sentences; the camp topics are long enough that the
   * reply buttons were being pushed out of sight.
   *
   * The calc() is the theatre's own 1.5rem of padding, top and bottom, so the
   * panel cannot be told to be taller than the space it is being centred in.
   * Identical to a flat 90vh on anything above about 480px tall. */
  max-height: min(90vh, calc(100vh - 3rem));
  padding: var(--sp-20) var(--sp-20);
  overflow-y: auto;
  background: linear-gradient(180deg, var(--dlg-panel-top), var(--dlg-panel-bottom));
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-lg);
  box-shadow: var(--sh-4);
}

.dialogue-theatre.hidden { display: none; }

/* The portrait rises a little as it changes, which is the whole of the
   staging: when the panel passes from the innkeeper to the companion cutting
   in, the new face arriving is what tells you the speaker changed. */
/* Sticky, so the face stays with the voice.
 *
 * The panel scrolls now, and a long camp topic is easily two screens — without
 * this the portrait scrolls off and you are reading unattributed prose, which
 * is precisely the staging the theatre exists to provide. */
.dlg-speaker { text-align: center; align-self: start; position: sticky; top: 0; }
.dlg-speaker.is-entering .dlg-bust { animation: dlg-enter var(--dur-enter) var(--ease-out); }
@keyframes dlg-enter {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .dlg-speaker.is-entering .dlg-bust { animation: none; }
}

.dlg-bust {
  width: 168px;
  height: 210px;
  object-fit: cover;
  object-position: center top;
  border-radius: var(--r-md);
  border: 1px solid var(--gold-dim);
  background: linear-gradient(180deg, var(--bg-panel), var(--bg));
  box-shadow: var(--sh-2);
}
/* Seeded NPCs with no painted bust fall back to their map sprite. */
.dlg-bust.is-sprite { object-fit: contain; object-position: center bottom; }
.dlg-name { margin-top: var(--sp-6); color: var(--gold); font-size: var(--fs-md); font-family: var(--font-display); }

/*
 * The theatre on a phone.
 *
 * `.dlg-panel` was `168px minmax(0, 1fr)` at EVERY width — the portrait column
 * a fixed 168px whatever the viewport, and the words given whatever was left.
 * At 360px what is left is 82 pixels: 360 less the theatre's 48 of padding,
 * the panel's 40, the 20 gap and the 168 portrait. About ten characters. One
 * word a line, for a screen whose entire purpose is a paragraph of speech.
 *
 * It survived because the theatre had no bench. Every other screen in this
 * game has a preview and a conversation needs an NPC, a quest state and a live
 * session to reach, so nobody had ever looked at one narrow. It is
 * tools/dialogue_preview.html now, and that page prints the character count.
 *
 * Below the fold the panel is one column and the speaker becomes a ROW: a
 * small bust beside the name, still sticky at the top of the scroll so you can
 * see who is talking, with the script full width beneath. Stacking the
 * portrait at its full 168x210 instead would have spent two thirds of a phone
 * screen before the first word — the same fault in a different direction.
 *
 * 760px is the shell's own fold, reused rather than tuned. The measure argues
 * for about 660 (below that the two-column cannot give 45 characters), but one
 * fold in an interface is easier to hold in the head than two, and the stacked
 * layout is no worse at 700px than the split one.
 */
@media (max-width: 760px) {
  /* The theatre's own padding is a frame around the panel, and on a phone it
     is 48px of the 360 spent on air outside the thing being read. Half of it
     back is four more characters a line. */
  .dialogue-theatre { padding: var(--sp-12); }

  .dlg-panel {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--sp-12);
    padding: var(--sp-12);
  }

  /* Sticky, and therefore opaque: the panel's own gradient starts here, so
     lines scrolling underneath do not show through the name. */
  .dlg-speaker {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas: "bust name" "bust role" "bust voice";
    align-items: center;
    gap: 0 var(--sp-10);
    text-align: left;
    padding: var(--sp-2) 4.25rem var(--sp-8) 0;   /* the right is .dlg-close's clearance */
    background: var(--dlg-panel-top);
    border-bottom: 1px solid var(--border-soft);
  }
  .dlg-bust { grid-area: bust; width: 56px; height: 70px; }
  .dlg-name { grid-area: name; margin-top: 0; }
  .dlg-role { grid-area: role; }
  .dlg-vo { grid-area: voice; margin-top: var(--sp-2); }

  /* The line no longer needs to dodge the close button: it is below the
     speaker row now, and that row is carrying the clearance. */
  .dlg-line { padding-right: 0; }
}
.dlg-role { color: var(--text-dim); font-size: var(--fs-2xs); }

.dlg-script { min-width: 0; display: flex; flex-direction: column; }

/* min-height keeps the panel from resizing line by line as the text types in,
   which would make the whole conversation twitch. */
.dlg-line {
  margin: 0;
  font-size: var(--fs-md);
  line-height: var(--lh-prose);
  min-height: 4.8em;
}

/* An aside is somebody leaning in: same panel, same portrait treatment, but
   set apart so it never reads as the NPC still talking. */
.dlg-line.is-aside {
  font-style: italic;
  color: var(--text-dim);
  border-left: 2px solid var(--accent);
  padding-left: var(--sp-14);
}
.dlg-line.is-aside.ap-bad,
.dlg-line.is-aside.ap-low { border-left-color: var(--danger); }
.dlg-line.is-aside.ap-high,
.dlg-line.is-aside.ap-good { border-left-color: var(--success); }

/* Next: the beat between one speaker and the next. */
.dlg-advance { margin-top: auto; padding-top: var(--sp-14); }
.dlg-advance:empty { padding-top: 0; }
.dlg-next {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-8);
  padding: var(--sp-6) var(--sp-16);
}
.dlg-next-hint {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: var(--sp-1) var(--sp-4);
}

/* A companion's aside: indented, italic, and hung off a rule, so it reads as
   somebody leaning in rather than as part of what the NPC said. */
.dlg-aside {
  margin: var(--sp-8) 0 0 var(--sp-20);
  padding-left: var(--sp-12);
  border-left: 2px solid var(--accent);
  color: var(--text-dim);
  font-style: italic;
  font-size: var(--fs-sm);
  line-height: var(--lh-snug);
}
.dlg-aside-who { color: var(--accent); font-style: normal; margin-right: var(--sp-6); }
.dlg-aside.ap-bad, .dlg-aside.ap-low { border-left-color: var(--danger); }
.dlg-aside.ap-high, .dlg-aside.ap-good { border-left-color: var(--success); }

.dlg-choices {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  margin-top: auto;
  padding-top: var(--sp-14);
}
.dlg-choices:empty { padding-top: 0; }
.dlg-choice {
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  text-align: left;
  padding: var(--sp-6) var(--sp-10);
  font-size: var(--fs-sm);
}
.dlg-key {
  flex: 0 0 auto;
  width: 1.3rem;
  height: 1.3rem;
  line-height: 1.3rem;   /* a fixed line BOX, sizing the key badge — not leading */
  text-align: center;
  border-radius: var(--r-sm);
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  color: var(--text-dim);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
}
.dlg-label { flex: 1 1 auto; min-width: 0; }

/* A check this party already failed. Listed, but plainly spent. */
.dlg-choice.is-spent {
  opacity: 0.45;
  cursor: not-allowed;
}
.dlg-choice.is-spent .dlg-label { text-decoration: line-through; }
.pill-spent {
  background: var(--danger-well);
  color: var(--danger);
  border-color: var(--danger);
}

/* Work that is ahead of you rather than behind you. Dimmed like a spent check,
   but deliberately NOT struck through and not in the danger colour — a line
   through it would read as "you tried this and lost", and this is the opposite:
   it is still there, and it is worth coming back for. */
.dlg-choice.is-locked {
  opacity: 0.5;
  cursor: not-allowed;
}
.pill-locked {
  background: var(--gold-well);
  color: var(--gold);
  border-color: var(--gold-dim);
}
/* The close button floats over the panel, so the prose has to be told to stop
   short of it. Without the reserved gutter the first two lines of every long
   speech run underneath the button and lose their last word — and only long
   speeches show it, so it survives any amount of testing on short ones. */
/* Read-aloud, under the portrait rather than up beside Esc.
 *
 * The top-right corner is spoken for: .dlg-close sits there absolutely and
 * .dlg-line carries 4.25rem of padding to clear it. A second control in that
 * corner would have to widen that clearance, and padding-right on the line
 * shortens EVERY line of the paragraph, not just the one beside the button.
 * Under the bust it costs nothing and it sits with the rest of the furniture
 * about who is talking, which is what it controls. */
.dlg-vo {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-6);
  margin-top: var(--sp-10);
  color: var(--text-dim);
  font-size: var(--fs-2xs);
  cursor: pointer;
  user-select: none;
}
.dlg-vo:hover { color: var(--text); }
.dlg-vo input { accent-color: var(--accent); cursor: pointer; margin: 0; }

.dlg-close { position: absolute; top: 0.5rem; right: 0.6rem; z-index: 2; }   /* local: over the line of dialogue */
/* Clearance, not rhythm: .dlg-close is absolutely positioned in this
   corner and this is its width plus air. It answers to the button, not to
   the spacing scale. */
.dlg-line { padding-right: 4.25rem; }

/* ====================================================================
   The d20 ceremony
   ==================================================================== */

.check-root {
  position: fixed;
  inset: 0;
  z-index: var(--z-check);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-16);
  background: rgba(6, 4, 2, 0.82);
}
.check-root.hidden { display: none; }

.check-panel {
  width: 100%;
  max-width: 620px;
  max-height: 92vh;
  overflow-y: auto;
  padding: var(--sp-16) var(--sp-20) var(--sp-20);
  background: var(--bg-panel);
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-lg);
  box-shadow: var(--sh-4);
}

.check-head {
  display: grid;
  grid-template-columns: 92px minmax(0, 1fr) auto;
  gap: var(--sp-14);
  align-items: center;
  padding-bottom: var(--sp-12);
  border-bottom: 1px solid var(--border);
}
.check-bust {
  width: 92px;
  height: 116px;
  object-fit: cover;
  object-position: center top;
  border-radius: var(--r-md);
  border: 1px solid var(--border);
  background: linear-gradient(180deg, var(--bg-panel), var(--bg));
}
.check-who { color: var(--text-dim); font-size: var(--fs-xs); }
.check-title h2 { margin: var(--sp-2) 0 var(--sp-4); font-size: var(--fs-xl); }
.check-mode {
  display: inline-block;
  padding: var(--sp-1) var(--sp-6);
  border-radius: var(--r-pill);
  border: 1px solid var(--border);
  color: var(--text-dim);
  font-size: var(--fs-2xs);
  text-transform: uppercase;
  letter-spacing: var(--ls-wide);
}
.check-mode.mode-advantage { border-color: var(--success); color: var(--success); }
.check-mode.mode-disadvantage { border-color: var(--danger); color: var(--danger); }
.check-reasons { margin: var(--sp-4) 0 0; color: var(--text-dim); font-size: var(--fs-2xs); }

.check-dc {
  text-align: center;
  padding: var(--sp-6) var(--sp-12);
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-md);
  background: var(--bg-inset);
}
.check-dc span { display: block; font-size: var(--fs-2xs); color: var(--text-dim); letter-spacing: var(--ls-caps); }
.check-dc strong { font-size: var(--fs-2xl); color: var(--gold); line-height: var(--lh-display); }

.check-body {
  display: grid;
  grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
  gap: var(--sp-16);
  margin-top: var(--sp-14);
}
.check-body h3, .check-boosts h3 {
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
  border-bottom: 1px solid var(--border);
  padding-bottom: var(--sp-4);
  margin: 0 0 var(--sp-6);
}
.mod-part {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-8);
  padding: var(--sp-2) 0;
  font-size: var(--fs-sm);
  color: var(--text-dim);
}
.mod-part em { font-style: normal; font-family: var(--mono); color: var(--text); }
.mod-total {
  margin-top: var(--sp-4);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--border);
  color: var(--text);
}
.mod-total em { color: var(--gold); font-weight: var(--fw-bold); }
.mod-dc em { color: var(--gold-dim); }

.check-chance-value { font-size: var(--fs-3xl); color: var(--gold); line-height: var(--lh-display); }
.check-chance-bar {
  height: 8px;
  border-radius: var(--r-md);
  background: var(--bg-inset);
  overflow: hidden;
}
.check-chance-bar > span {
  display: block;
  height: 100%;
  background: linear-gradient(90deg, var(--success), var(--success));
  transition: width var(--dur) var(--ease);
}

.check-boosts { margin-top: var(--sp-14); }
.boost-list { display: flex; flex-direction: column; gap: var(--sp-4); }
.boost-row {
  display: grid;
  grid-template-columns: minmax(6rem, auto) minmax(0, 1fr) auto auto;
  gap: var(--sp-8);
  align-items: center;
  padding: var(--sp-6) var(--sp-8);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  color: var(--text-dim);
  font-family: inherit;
  font-size: var(--fs-xs);
  text-align: left;
  cursor: pointer;
}
.boost-row:hover { border-color: var(--gold-dim); }
.boost-row.on {
  border-color: var(--gold);
  background: var(--bg-inset);
  color: var(--text);
  box-shadow: var(--sh-ring);
}
.boost-name { color: var(--gold); }
.boost-detail { font-size: var(--fs-2xs); }
.boost-adds { font-family: var(--mono); color: var(--accent); }
.boost-cost { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: var(--ls-wide); }
.cost-free { color: var(--success); }
.cost-use { color: var(--gold-dim); }
.cost-concentration { color: var(--warn); }

.check-actions {
  display: flex;
  gap: var(--sp-8);
  justify-content: flex-end;
  margin-top: var(--sp-16);
}

/* --- phase two --- */

.check-throw { text-align: center; padding: var(--sp-8) 0 0; }
.dice-row { display: flex; gap: var(--sp-16); justify-content: center; }

.d20 {
  position: relative;
  width: 104px;
  height: 104px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* A hexagon reads as a die at a glance without pretending to be a rendered
     icosahedron that would only look wrong. */
  clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%);
  background: linear-gradient(180deg, var(--border), var(--bg-inset));
  border: 0 solid transparent;
  box-shadow: var(--sh-3);
}
.d20-face {
  font-size: 2.6rem;
  font-weight: var(--fw-bold);
  color: var(--gold);
  font-family: var(--mono);
  line-height: var(--lh-flush);
}
.d20-tag {
  font-size: var(--fs-2xs);
  text-transform: uppercase;
  letter-spacing: var(--ls-caps);
  color: var(--text-dim);
}
.d20.landed { animation: die-land var(--dur-fill) var(--ease-out); }
@keyframes die-land {
  0% { transform: scale(1.18); }
  60% { transform: scale(0.95); }
  100% { transform: scale(1); }
}
/* On advantage or disadvantage both dice are shown and the one that counted is
   unmistakable — the whole point of rolling two is seeing which was kept. */
/* `is-kept` moved from the die to the holder when the 3D die arrived, so that
   one rule dims whichever kind of die is inside it. The hexagon keeps its own
   highlight because it has a flat background to change; the icosahedron does not
   need one — it is already the only lit object in the panel. */
.die-holder.is-kept .d20 { background: linear-gradient(180deg, var(--gold-dim), var(--gold-well)); }
.die-holder:not(.is-kept) { opacity: 0.4; filter: grayscale(0.7) blur(0.4px); }
.die-holder:not(.is-kept) .d20-face { color: var(--text-dim); }
/* The check panel stacks a die and its kept/dropped tag; the ability trays lay
   theirs out in a row. Only the former wants a column. */
.dice-row .die-holder { display: grid; justify-items: center; gap: var(--sp-4); }

.throw-breakdown {
  max-width: 320px;
  margin: var(--sp-16) auto 0;
  text-align: left;
}
.throw-verdict {
  margin-top: var(--sp-14);
  font-size: var(--fs-xl);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  text-shadow: var(--th-2);
}
.verdict-success { color: var(--success); }
.verdict-failure { color: var(--text-dim); }
.verdict-critical {
  color: var(--gold);
  font-size: var(--fs-3xl);
  text-shadow: 0 0 20px color-mix(in srgb, var(--gold) 60%, transparent), var(--th-2);
  animation: verdict-pop var(--dur-flourish) var(--ease-out);
}
.verdict-fumble {
  color: var(--danger);
  font-size: var(--fs-3xl);
  text-shadow: 0 0 18px color-mix(in srgb, var(--danger) 60%, transparent), var(--th-2);
  animation: verdict-shudder var(--dur-flourish) var(--ease-out);
}
@keyframes verdict-pop {
  from { transform: scale(0.6); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
@keyframes verdict-shudder {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-8px); }
  50% { transform: translateX(6px); }
  80% { transform: translateX(-3px); }
}

/* ====================================================================
   The combat board
   ==================================================================== */

.combat-screen {
  position: absolute;
  inset: 0;
  z-index: var(--z-combat);
  display: flex;
  flex-direction: column;
  gap: 0;
  padding: 0;
  overflow: hidden;
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
}
.combat-screen.hidden { display: none; }

/* The fight's top line. It sits in the shell markup and is empty out of
   combat, so it is hidden rather than left as a 0px grid item that still
   eats the row's gap. */
.cbt-top { display: none; }
body.in-combat .cbt-top {
  display: flex;
  align-items: center;
  gap: var(--sp-16);
  flex-shrink: 0;
  min-width: 0;
  min-height: 2.75rem;
  padding: 0 var(--sp-16);
  background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg) 100%);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
}
.cbt-brand {
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  flex: 0 0 auto;
  font-family: var(--font-display);
  font-weight: var(--fw-bold);
  font-size: var(--fs-md);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--gold);
}
.cbt-brand-icon {
  width: 1.15rem;
  height: 1.15rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* What gives way when the row is tight is the encounter's name: it is
   scene-setting, the log names the fight as well, and everything else on this
   line is a number you are playing off. */
.cbt-top .cbt-head { flex: 1 1 auto; min-width: 0; }
.cbt-head { white-space: nowrap; }
.cbt-head strong {
  display: block;
  min-width: 7rem;
  overflow: hidden;
  text-overflow: ellipsis;
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: var(--fw-semibold);
  color: var(--text);
}

.cbt-head {
  display: flex;
  gap: var(--sp-12);
  align-items: baseline;
  flex-shrink: 0;
  font-size: var(--fs-sm);
  color: var(--text-dim);
}
.cbt-meta {
  display: flex;
  align-items: center;
  gap: var(--sp-10);
  flex: 0 1 auto;
  flex-wrap: wrap;
  justify-content: flex-end;
}
.cbt-pill {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-6);
  padding: var(--sp-2) var(--sp-10);
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  font-size: var(--fs-xs);
  color: var(--text-dim);
}
.cbt-pill strong { color: var(--gold); font-weight: var(--fw-semibold); }
.cbt-turn { color: var(--text-dim); }
.cbt-turn strong { color: var(--text); }
.cbt-move { color: var(--gold); font-family: var(--mono); }
.cbt-move.spent { color: var(--text-dim); text-decoration: line-through; }

/* Three columns: the order, the battlefield, the verbs for this turn.
   Named tracks rather than `flex: 1` on everything, because the map wants the
   leftover and the two side panels want a width the cards and the keys already
   have.

   The board is 4:3 inside a wider column, so there is always slack beside it
   until height runs out. Give it a taller window and it becomes width-bound
   and spends every pixel of that middle track. The side panels are capped so
   they cannot eat that. */
.cbt-field {
  display: grid;
  grid-template-columns:
    minmax(13rem, 15.5rem) minmax(0, 1fr) minmax(14rem, 16.5rem);
  grid-template-areas: "init map panel";
  flex: 1 1 auto;
  min-height: 0;
  gap: 0;
}
/* Placed by name. The trap documented at `body.in-combat .game-shell` is that a
   `display: none` grid item stops being a grid item at all and the survivors
   fall onto the first tracks; naming the areas is what makes this survive
   somebody hiding a column. */
.cbt-init { grid-area: init; }
.cbt-map { grid-area: map; }
.cbt-panel { grid-area: panel; }

.cbt-init,
.cbt-panel {
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--bg-panel);
}
.cbt-init { border-right: 1px solid var(--border); }
.cbt-panel { border-left: 1px solid var(--border); }

.cbt-init > h3,
.cbt-board-fx > h3,
.cbt-act-label {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--gold);
  padding: var(--sp-10) var(--sp-12) var(--sp-6);
}

.cbt-init-list {
  flex: 1 1 0;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  padding: 0 var(--sp-8) var(--sp-8);
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}

.cbt-board-fx {
  flex: 0 0 auto;
  min-height: 0;
  max-height: 42%;
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--border);
}
.cbt-board-fx-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  margin: 0 var(--sp-8) var(--sp-8);
  padding: var(--sp-6);
  background: var(--bg-inset);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  line-height: var(--lh-snug);
}
.cbt-fx-empty { margin: 0; color: var(--text-dim); }
.cbt-fx-row {
  display: flex;
  align-items: flex-start;
  gap: var(--sp-6);
  width: 100%;
  margin: 0 0 var(--sp-4);
  padding: var(--sp-4) var(--sp-6);
  background: transparent;
  border: 0;
  border-radius: var(--r-md);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.cbt-fx-row:last-child { margin-bottom: 0; }
.cbt-fx-row:hover,
.cbt-fx-row:focus-visible,
.cbt-fx-row.is-on {
  background: var(--bg-panel);
  outline: none;
}
.cbt-fx-body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  min-width: 0;
}
.cbt-fx-who {
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.cbt-fx-line { color: var(--text); }
.cbt-fx-note { color: var(--text-dim); }
.cbt-fx-dot {
  width: 8px;
  height: 8px;
  border-radius: var(--r-circle);
  margin-top: 0.35em;
  flex-shrink: 0;
  background: var(--text-dim);
}
.cbt-fx-row.is-cond .cbt-fx-dot { background: var(--danger); }
.cbt-fx-row.is-boon .cbt-fx-dot { background: var(--success); }
.cbt-fx-row.is-conc .cbt-fx-dot { background: var(--accent); }

.rank-empty { color: var(--text-dim); padding: var(--sp-8) var(--sp-4); }

/* --- the battlefield --------------------------------------------------- */

.cbt-map {
  position: relative;
  min-width: 0;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-4);
  background:
    radial-gradient(ellipse at 50% 40%, var(--bg-panel) 0%, var(--bg-inset) 70%);
}
/* An SVG at `height: 100%` with no definite height on its parent falls back to
   its own aspect ratio and overflows — the same trap the world map's modal
   documents. Bounding both ways and letting the aspect ratio pick the smaller
   is what keeps sixteen-by-twelve square in any column. */
.battlemap {
  display: block;
  width: 100%;
  height: 100%;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  /* `pan-y`, not `none`.
   
     `none` was here so a two-finger drag would reach the board instead of
     scrolling the page — except the board never receives a touch drag at all.
     Panning is gated on `canPan: (e) => (e.button === 0 && spaceHeld) ||
     e.button === 1`, and a finger has no space bar and no middle button. So
     `none` was reserving the gesture for a capability that does not exist on
     a phone, and the whole of what it achieved was that a drag on the board
     did nothing while also refusing to scroll the page.
   
     `pan-y` gives the vertical drag back to the browser and keeps everything
     the board actually uses: taps are a click listener, and zoom is the
     button cluster in the corner and the wheel on a desktop. */
  touch-action: pan-y;
}
/* The cursor says which of the two things a drag will do. Default is a
   pointer, because a bare left-click on this board is a move order and never a
   pan; the grab hand appears only while space is down. */
body.bm-pan-ready .battlemap { cursor: grab; }
.battlemap.is-panning { cursor: grabbing; }

/* The zoom cluster, in the board's corner. It uses `.map-zoom` — the same
   group the region map's head carries, same glyphs, same `data-zoom` verbs —
   because a player who learned − ⌂ + on one map has learned it on both. What
   differs is only that this one floats over its map rather than sitting in a
   title bar, since the board has no title bar. */
.bm-zoom {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  z-index: 2;            /* local: over the board it zooms */
  margin-left: 0;
  padding: var(--sp-2);
  background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  /* Quiet until wanted: it is furniture beside a fight, and three lit keys in
     the corner of the board compete with the fight for attention. */
  opacity: 0.45;
  transition: opacity var(--dur-fast);
}
.cbt-map:hover .bm-zoom,
.bm-zoom:focus-within { opacity: 1; }

/* The board draws its own floor and its own border.

   It used to have neither, and inherited `.cbt-map`'s background instead — but
   the board is 4:3 and the column is not, so the panel is always wider than
   the drawing and the box you could see was never the box you could walk in.
   A floor a shade off the panel is the difference between the two. */
.bm-floor { fill: rgba(245, 236, 214, 0.035); }
.bm-prop { pointer-events: none; }
.bm-edge {
  fill: none;
  stroke: color-mix(in srgb, var(--border) 85%, var(--gold));
  stroke-width: 0.06;
}

.bm-cell.is-rough { fill: color-mix(in srgb, var(--grass) 28%, transparent); }
.bm-cell.is-water { fill: color-mix(in srgb, var(--water) 45%, transparent); }
.bm-cell.is-low   { fill: color-mix(in srgb, var(--building) 38%, transparent); stroke: var(--border); stroke-width: 0.03; }
.bm-cell.is-tall  { fill: color-mix(in srgb, var(--building) 50%, transparent); stroke: var(--wall); stroke-width: 0.05; }
.bm-cell.is-wall  { fill: var(--wall); }
.bm-cell.is-pit   { fill: var(--pit); }

/* A board that came with a picture.

   The terrain still exists — it is what the rules read and what the pointer
   hits — but it stops painting itself, because the walls and the furniture are
   already drawn and a flat fill over them only hides the drawing. What is left
   is the faintest wash, enough that a player who has zoomed out can still tell
   at a glance which squares are solid without having to read the picture.

   Deliberately not zero. An invisible obstacle is the oldest complaint in the
   genre, and "the art shows a table there" is not a rule anyone can see from
   across the board. */
.bm-terrain.is-drawn .bm-cell.is-rough,
.bm-terrain.is-drawn .bm-cell.is-water { fill: none; }
.bm-terrain.is-drawn .bm-cell.is-low,
.bm-terrain.is-drawn .bm-cell.is-tall,
.bm-terrain.is-drawn .bm-cell.is-wall {
  fill: color-mix(in srgb, var(--wall) 16%, transparent);
  stroke: color-mix(in srgb, var(--wall) 45%, transparent);
  stroke-width: 0.02;
}
.bm-terrain.is-drawn .bm-cell.is-pit { fill: color-mix(in srgb, var(--pit) 55%, transparent); }
/* The drawn map is the ground; nothing should be able to click it out of the
   way, and the cells above it keep every hit test they had. */
.bm-art { pointer-events: none; }

.bm-zone { fill: none; stroke-width: 0.06; stroke-dasharray: 0.3 0.2; opacity: 0.55; }
.bm-zone.is-party { stroke: var(--accent); }
.bm-zone.is-foe { stroke: var(--danger); }

/* Where you can walk. Gold is reserved throughout this file for "you can act
   on this", and a cell you may step into is exactly that. */
.bm-step {
  fill: color-mix(in srgb, var(--gold) 16%, transparent);
  stroke: color-mix(in srgb, var(--gold) 30%, transparent);
  stroke-width: 0.02;
}
.bm-step.is-provokes {
  fill: color-mix(in srgb, var(--danger) 14%, transparent);
  stroke: color-mix(in srgb, var(--danger) 45%, transparent);
  stroke-width: 0.05;
}
/* Enemy reach, hatched rather than tinted so it reads over the walk highlight
   instead of mixing with it into a third colour that means neither. */
.bm-danger { fill: url(#bm-hatch); opacity: 0.5; }
#bm-hatch line { stroke: var(--danger); }

/* Heavy enough to survive the reach highlight being switched off.
   At 7% these were legible only where `.bm-step` happened to be tinting the
   cell behind them, so spending your movement erased the board: the grid went
   with the highlight and the tokens were left floating on the panel. Every
   rule in this fight is a distance, and the squares are how a distance is
   read, so they hold their own weight now. */
.bm-grid line { stroke: rgba(245, 236, 214, 0.17); stroke-width: 0.018; }
.bm-grid { pointer-events: none; }

.bm-hit { fill: transparent; cursor: pointer; }
.bm-hit:focus-visible { fill: color-mix(in srgb, var(--gold) 25%, transparent); outline: none; }

.bm-route { fill: none; stroke: var(--gold); stroke-width: 0.09; stroke-linejoin: round; stroke-linecap: round; }
.bm-route.is-provokes { stroke: var(--danger); stroke-dasharray: 0.25 0.15; }
.bm-route-end { fill: var(--gold); opacity: 0.85; }
.bm-route-end.is-provokes { fill: var(--danger); }
.bm-route-cost {
  font-family: var(--mono);
  font-size: 0.34px;
  fill: var(--bg);
  text-anchor: middle;
  font-weight: var(--fw-bold);
  pointer-events: none;
}
.bm-path { pointer-events: none; }

.bm-blast { fill: color-mix(in srgb, var(--warn) 40%, transparent); stroke: var(--warn); stroke-width: 0.04; }
.bm-aim { fill: color-mix(in srgb, var(--warn) 18%, transparent); stroke: var(--warn); stroke-width: 0.05; }
.bm-aim-line { stroke: var(--warn); stroke-width: 0.06; stroke-dasharray: 0.2 0.15; }
.bm-aoe { pointer-events: none; }

/* --- tokens ------------------------------------------------------------
   One channel per fact, the same discipline the cards keep and for the same
   reason: `.bm-ring` is which side, `.is-selected` is what Attack will hit,
   `.bm-turn` is whose turn it is. SVG has no ::before, so the turn marker has
   to be a real element — the one place the card technique does not port. */
.bm-token { cursor: pointer; transition: transform var(--dur-tap) var(--ease-linear); }
.bm-hit-token { fill: transparent; }
.bm-ring { fill: var(--bg); stroke-width: 0.07; }
.bm-token.is-party .bm-ring { stroke: var(--accent); }
.bm-token.is-foe .bm-ring { stroke: var(--danger); }
.bm-token.in-reach .bm-ring { stroke-width: 0.1; }
.bm-turn { fill: none; stroke: none; }
.bm-token.is-actor .bm-turn { stroke: var(--gold); stroke-width: 0.08; }
.bm-token.is-selected .bm-ring { stroke: var(--gold); }
.bm-token.is-selected .bm-turn { stroke: var(--gold); stroke-width: 0.05; opacity: 0.6; }
.bm-token.is-downed { opacity: 0.5; filter: grayscale(0.8); }
/* A body on the floor is scenery, not a target.
   
   `pointer-events: none` rather than a cursor change, because the problem was
   never what the pointer LOOKED like: the click handler asks tokenOf() before
   cellOf() and returns on the first answer, so a corpse lying in a square
   swallowed every click meant for the square. You could not step into the
   space of something you had just killed — which is the one square you most
   want, having just fought your way to it.
   
   The server was already of this opinion. reachable() skips anything not
   alive, so a dead creature's cell is neither occupied nor blocked; only the
   drawing disagreed. Nothing is lost by making the body inert — it is already
   out of the initiative order, has no `role` or `tabindex`, and its name and
   the blow that finished it are in the log. */
.bm-token.is-dead {
  opacity: 0.28;
  filter: grayscale(1);
  pointer-events: none;
}
/* --- things that happen for a moment on the board ----------------------
   Every length below is a CELL, not a pixel. These elements live inside the
   `0 0 16 12` viewBox, where a CSS px is one user unit and one user unit is
   one five-foot square, so `translateY(-1.1px)` rises a little over one cell
   and scales with the board instead of fighting it. */

.bm-floats, .bm-shots { pointer-events: none; }

.bm-float text {
  font-family: var(--mono);
  font-size: 0.5px;
  font-weight: var(--fw-bold);
  text-anchor: middle;
  fill: var(--text);
  /* Legible over a portrait, a wall or bare floor without a panel behind it.
     `paint-order` puts the halo under the glyph rather than over it. */
  stroke: rgba(0, 0, 0, 0.85);
  stroke-width: 0.06px;
  paint-order: stroke fill;
  animation: bm-rise var(--dur-rise) var(--ease-out) forwards;
}
.bm-float.float-dmg text { fill: var(--danger); }
.bm-float.float-crit text { fill: var(--gold-bright); font-size: 0.72px; }
.bm-float.float-heal text { fill: var(--success); }
.bm-float.float-miss text { fill: var(--text-dim); font-size: 0.42px; }
.bm-float.float-cast text { fill: var(--accent); font-size: 0.42px; }
.bm-float.float-cond text { fill: var(--warn); font-size: 0.4px; }
.bm-float.float-boon text { fill: var(--success); font-size: 0.4px; }
.bm-float.float-save-ok text { fill: var(--success); font-size: 0.4px; }
.bm-float.float-save-bad text { fill: var(--danger); font-size: 0.4px; }

@keyframes bm-rise {
  0%   { transform: translateY(0.15px); opacity: 0; }
  12%  { opacity: 1; }
  70%  { opacity: 1; }
  100% { transform: translateY(-1.1px); opacity: 0; }
}

.bm-bolt {
  fill: var(--gold-bright);
  animation: bm-fly var(--dur-enter) var(--ease-linear) forwards;
}
.bm-trail {
  stroke: var(--gold);
  stroke-width: 0.035;
  opacity: 0;
  animation: bm-trail var(--dur-slow) var(--ease-out) forwards;
}
/* Aimed rather than thrown: a spell attack at range gets the accent colour the
   rest of the board uses for magic. */
.bm-shot.is-spell .bm-bolt { fill: var(--accent); }
.bm-shot.is-spell .bm-trail { stroke: var(--accent); }

@keyframes bm-fly {
  from { transform: translate(0, 0); }
  to   { transform: translate(var(--dx), var(--dy)); }
}
@keyframes bm-trail {
  0%   { opacity: 0; }
  30%  { opacity: 0.34; }
  100% { opacity: 0; }
}

/* A board that does not move at all still has to say what happened, so the
   floats keep their place and lose only the drift. The bolt is decoration and
   goes entirely. */
@media (prefers-reduced-motion: reduce) {
  .bm-float text { animation: bm-hold var(--dur-rise) steps(1) forwards; }
  .bm-bolt, .bm-trail { animation: none; opacity: 0; }
  @keyframes bm-hold { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
}

.bm-hp-back { fill: rgba(0, 0, 0, 0.65); }
.bm-hp.ok { fill: var(--success); }
.bm-hp.mid { fill: var(--warn); }
.bm-hp.low { fill: var(--danger); }

.cbt-placing {
  position: absolute;
  left: 50%;
  bottom: 0.5rem;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  padding: var(--sp-6) var(--sp-10);
  font-size: var(--fs-2xs);
  background: var(--bg-panel);
  border: 1px solid var(--warn);
  border-radius: var(--r-md);
  z-index: 3;            /* local: over the board and its zoom buttons */
}

/* The distance line on a card. Green is a clean shot, red is one you can take
   and will regret — the same reading as the inspector's, kept in step because
   they are looking at the same `ui.targets` entry. */
.cbt-dist { font-family: var(--mono); font-size: var(--fs-2xs); color: var(--text-dim); }
.cbt-dist.is-disadvantage { color: var(--danger); }
.cbt-dist.is-advantage { color: var(--gold); }
.econ.is-on { color: var(--success); border-color: var(--success); }

/* Three tracks need a narrower window than the shell does before they stop
   working. Below this the map goes above and the order and the verbs sit
   side by side under it, which is the only arrangement that keeps a
   sixteen-wide board legible. */
@media (max-width: 1180px) {
  .cbt-top { flex-wrap: wrap; }
  .cbt-top .cbt-head { flex: 1 1 10rem; }
  .cbt-field {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr) minmax(0, 40%);
    grid-template-areas:
      "map map"
      "init panel";
  }
  .cbt-init { border-right: 1px solid var(--border); border-top: 1px solid var(--border); }
  .cbt-panel { border-left: 0; border-top: 1px solid var(--border); }
  /* The inspector used to be hidden here, because it was an overlay and this is
     the width at which the board has no spare corner to put one in. It is in
     the rail now and takes nothing off the board, so it stays. */
}

/* The cards take the width the list gives them. Everything else about them is
   untouched on purpose: playClip(), flash(), float() and stepHp() all resolve
   `.cbt-card[data-cid]` and every animation the rank board had still plays. */
.cbt-init .cbt-card { width: 100%; }

/* Token left, everything about them right — BOTH SIDES. The party got this
   layout first and the enemy kept the old portrait-on-top plate, which read as
   two different kinds of thing looking at each other across the board; they are
   the same kind of thing, and the only difference worth drawing is whose they
   are. That is the ring's colour and the roster's own edge. */
.cbt-card {
  position: relative;
  flex: 0 0 auto;
  width: 88px;
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-8);
  padding: var(--sp-6);
  background: var(--bg);
  border: 1px solid transparent;
  border-radius: var(--r-md);
  color: var(--text);
  font-family: inherit;
  font-size: var(--fs-2xs);
  text-align: left;
  cursor: pointer;
  overflow: visible;
}
.cbt-init-num {
  flex: 0 0 auto;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: var(--r-circle);
  background: var(--bg-inset);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-semibold);
  color: var(--text-dim);
}
.cbt-card.is-actor .cbt-init-num {
  background: var(--gold);
  color: var(--ink-on-gold);
}
.cbt-klass {
  color: var(--text-dim);
  font-weight: var(--fw-normal);
}
.cbt-card:hover, .cbt-card:focus-visible { border-color: var(--gold-dim); outline: none; background: var(--bg-panel-2); }
.cbt-card.is-actor {
  background: var(--gold-well);
  border-color: var(--gold-dim);
}
/* Whose turn it is.
 *
 * Drawn as a pseudo-element rather than an inset box-shadow, because
 * `.is-selected` below sets `box-shadow` for its glow and comes later in the
 * file — so a card that was both the actor and the selected target lost this
 * ring entirely. Which is the one combination where it matters most: it
 * happens whenever you point at yourself, and it took the "it is your turn"
 * mark off the only card you were looking at. Two markers on one card need two
 * properties. */
.cbt-card.is-actor::before {
  content: '';
  position: absolute;
  inset: 0;
  border: 2px solid rgba(232, 197, 71, 0.55);
  /* One inside the card's own 5px, so the corners nest instead of cutting. */
  border-radius: var(--r-md);
  pointer-events: none;
}
.cbt-card.is-downed { opacity: 0.5; filter: grayscale(0.8); }
/* The dead stay on the board until the next action: seeing what you killed is
   most of how a fight reads, and the death clip needs something to play over. */
.cbt-card.is-dead { opacity: 0.3; filter: grayscale(1); cursor: default; }

/* Legality, shown rather than discovered: melee reaches the opposing front
   rank only, until nobody is left standing in it. */
.cbt-card.in-reach { border-color: var(--danger); }
.cbt-card.behind-line { opacity: 0.72; border-style: dashed; }

/* The one you have picked — what Attack will hit.
 *
 * Drawn as an OUTLINE and set after `.in-reach` deliberately. It used to be a
 * gold border, and every reachable foe already has a red one from the rule
 * above; that rule is later in the file, so it won the cascade and the only
 * thing left marking your target was a soft glow. With six of them on the
 * board that is not a mark, it is a guess. An outline sits outside the border
 * and cannot be cancelled by a border colour, so both facts stay readable at
 * once: red says you can reach it, gold says you are pointing at it. */
.cbt-card.is-selected {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
  box-shadow: var(--sh-glow);
}
/* And a mark on the card itself, for anyone who cannot pick gold from red.
 *
 * Drawn rather than typed. It was the character ◎, and a glyph in a decorative
 * role is at the mercy of whatever font resolves it: its ink sits differently
 * inside its em box in every face, so `top`/`right` were positioning a box
 * whose contents were somewhere else inside it, and the mark landed at a
 * slightly different place — and a slightly different weight — depending on
 * the machine. Two borders and a gradient are the same bullseye at a size and
 * a position this file actually controls.
 *
 * The dark ring around it is not decoration either: this sits on top of the
 * portrait, and gold on a pale face is gold on nothing. */
.cbt-card.is-selected::after {
  content: '';
  position: absolute;
  top: 4px;
  right: 4px;
  width: 11px;
  height: 11px;
  border: 2px solid var(--gold);
  border-radius: var(--r-circle);
  /* The centre pip, so the shape reads as a target and not as a bullet. */
  background: radial-gradient(circle at 50% 50%,
              var(--gold) 0 1.6px, transparent 1.7px);
  box-shadow: var(--sh-halo);
  pointer-events: none;
}

/* --- a token, not a poster ----------------------------------------------

   The cards carried a 150x66 plate, which at four or six of them down a 200px
   column was most of one side of the screen given over to pictures of people
   whose hit points were the thing being read. A round token instead, at
   roughly half the height: bigger than the counter on the board (~30px, and
   unreadable as a face) and small enough that what sits beside it — health,
   armour, the shot — is what the eye lands on. The same counter the initiative
   ribbon draws, so a face is the same object wherever it turns up.

   BOTH SIDES. The party had this first and the enemy kept the old plate, which
   read as two different kinds of thing looking at each other across the board.
   They are the same kind of thing; the only difference worth drawing is whose
   they are, and that is the ring's colour and the roster's own edge. */
.cbt-init .cbt-face {
  width: 2rem;
  height: 2rem;
}
.cbt-face.is-token {
  object-position: center;
  object-fit: contain;
  background: #0b0d10;
}
.cbt-face {
  width: 3.2rem;
  height: 3.2rem;
  flex: 0 0 auto;
  object-fit: cover;
  object-position: center 22%;
  border-radius: var(--r-circle);
  border: 2px solid var(--ornament);
  background: linear-gradient(180deg, var(--bg-panel), var(--bg));
  /* The turn ring on `.is-actor` is drawn round the card; this keeps the token
     itself legible against a dark board without competing with it. */
  box-shadow: var(--sh-1);
}
/* Whose it is, on the ring — the same channel the ribbon's tokens use. Dimmed
   toward the ornament rather than full danger red, which is spoken for: a card
   with a red BORDER is one you can reach this turn, and two reds saying
   different things is worse than no colour at all. */
.cbt-card.is-foe .cbt-face { border-color: color-mix(in srgb, var(--danger) 55%, var(--ornament)); }
.cbt-card.is-actor .cbt-face { border-color: var(--gold); }
/* `min-width: 0` is what lets the name ellipsis: a flex item defaults to its
   content's minimum width, and a long name would push the card wider than its
   column instead of being cut. */
.cbt-card .cbt-body { flex: 1 1 auto; min-width: 0; align-items: flex-start; }
/* Room kept for the selected-target bullseye, which is drawn in the card's
   top-right corner — that used to be empty plate above a portrait and is now
   the end of the name. Reserved on every card rather than added when one is
   picked, so selecting does not re-flow the word underneath the cursor. */
.cbt-card .cbt-name { padding-right: var(--sp-14); }
.cbt-card .cbt-slots { justify-content: flex-start; }
.cbt-card .hp-bar { align-self: stretch; }

/* Spell slots, a pip each, grouped by level — the row a player checks before
   deciding whether this is the turn to spend the last second-level.
   Wraps because a full caster at level five has three groups and nine pips,
   and a group broken across lines is better than a row clipped. */
.cbt-slots {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--sp-2) var(--sp-4);
  margin-top: var(--sp-2);
}
.slot-grp {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
}
.slot-grp > b {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-semibold);
  color: var(--text-faint);
}
/* Hollow is spent, filled is yours. Both are drawn: a level whose slots are
   gone still says how many it had, which is the difference between "I have
   nothing left" and "I never had any". */
.slot-pip {
  width: 6px;
  height: 6px;
  border-radius: var(--r-circle);
  border: 1px solid var(--accent-dim);
  background: transparent;
}
.slot-pip.on { background: var(--accent); border-color: var(--accent); }
.slot-grp.is-spent > b { opacity: 0.5; }
/* The half of a card that is not the portrait. A column in its own right, so
   the card itself only ever decides whether the two sit beside each other or
   one above the other. */
.cbt-body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  min-width: 0;
}
.cbt-name {
  color: var(--gold);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* Its own width in a column that may be `align-items: flex-start`, or the
     ellipsis has nothing to trigger against. */
  max-width: 100%;
}
.cbt-card .hp-bar { height: 5px; margin-top: var(--sp-2); }
.cbt-nums { font-family: var(--mono); color: var(--text-dim); font-size: var(--fs-2xs); }
.cbt-conds { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--sp-2); }

/* Death saves. Three held and three against, on the one card the player needs
   to look at before anything else on the board. */
.cbt-death {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  font-size: var(--fs-2xs);
  color: var(--danger);
  letter-spacing: var(--ls-wide);
}
.cbt-death.is-stable { color: var(--success); text-transform: uppercase; }
.cbt-death b { color: var(--text-dim); margin: 0 var(--sp-2); }
.death-dot {
  width: 6px;
  height: 6px;
  border-radius: var(--r-circle);
  border: 1px solid var(--border);
  background: transparent;
}
.death-dot.is-ok.on { background: var(--success); border-color: var(--success); }
.death-dot.is-bad.on { background: var(--danger); border-color: var(--danger); }

/* The foot of the right-hand rail, under the log — the page's furniture, not
   the board's. It hung over the battlefield until this: a readout that covered
   the ground it was describing, so pointing at somebody hid them and the cells
   around them behind their own card.

   It sits in the shell markup (`game.php`) and is drawn only while a fight is
   running, which is why the rule below is a `display: none` with `body.in-combat`
   turning it back on rather than a `hidden` attribute somebody has to remember
   to toggle. */
.cbt-inspector { display: none; }
body.in-combat .cbt-inspector {
  /* Fixed rather than sized to its contents: the log above it is pinned to its
     own bottom, so a panel that grew when you pointed at somebody would shove
     the last few lines of the fight up the rail every time the cursor moved. */
  flex: 0 0 auto;
  height: clamp(11rem, 34vh, 20rem);
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--sp-8);
  background: var(--ornament-well);
  border: 1px solid var(--ornament);
  border-radius: var(--r-md);
  text-align: center;
  overflow-y: auto;
  scrollbar-width: thin;
}
/* Nothing to say, so it says what it is for instead of vanishing. Space held
   open by an invisible box reads as a layout fault; the same box holding one
   line of grey text reads as a panel waiting for you. */
body.in-combat .cbt-inspector.is-idle { justify-content: center; color: var(--text-faint); }

/* On a phone the inspector does not get a third of the screen.
 *
 * This is the hide that used to live in the shell's own 760 block and never
 * applied: same specificity as the `display: flex` above, 4,100 lines earlier
 * in the file, and a media query does not break that tie. Here it is after the
 * rule it has to beat, which is the only thing that decides it.
 *
 * What it costs: the "40 feet — a clean shot" readout. That is a real loss and
 * the alternative was worse — it was taking 252px of a 740px screen and the
 * battlefield was getting none. Every fact in it is also on the card you
 * tapped. */
@media (max-width: 760px) {
  body.in-combat .cbt-inspector { display: none; }
}

/* Whatever is left after the name, the numbers and the shot — the art is the
   part worth shrinking, because everything under it is a fact you came here to
   read. `min-height: 0` because a flex item will not go below its content's
   height without it, and an image's content height is the image. */
.cbt-inspector .cbt-battler { flex: 0 1 auto; min-height: 0; }

.cbt-inspect-shot {
  align-self: stretch;
  padding: var(--sp-4) var(--sp-6);
  border-radius: var(--r-md);
  font-size: var(--fs-2xs);
  background: rgba(0, 0, 0, 0.25);
  border-left: 2px solid var(--success);
}
.cbt-inspect-shot.is-disadvantage { border-left-color: var(--danger); }
.cbt-inspect-shot.is-advantage { border-left-color: var(--gold); }
.cbt-inspect-shot em { display: block; color: var(--text-dim); font-size: var(--fs-2xs); }
.cbt-battler {
  max-width: 100%;
  max-height: 220px;
  object-fit: contain;
  filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.6));
}
.cbt-inspect-name { color: var(--gold); font-size: var(--fs-md); }
.cbt-inspect-line { color: var(--text-dim); font-size: var(--fs-2xs); }
.cbt-inspect-stats {
  display: flex;
  gap: var(--sp-8);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text);
}
/* The inspector's effect list: one row per thing riding on somebody, each
   saying what it is and what it does. Was a row of bare keys in boxes. */
.cbt-inspect-conds {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  text-align: left;
  margin-top: var(--sp-6);
}
.cbt-inspect-conds.help-hint { text-align: center; }
.cbt-inspect-conds .pip { font-size: var(--fs-2xs); min-width: 0; padding: 0 var(--sp-4); }

.cbt-effect {
  border-left: 2px solid var(--text-faint);
  padding: var(--sp-4) 0 var(--sp-4) var(--sp-8);
  background: rgba(0, 0, 0, 0.18);
  border-radius: 0 3px 3px 0;
}
.cbt-effect.is-boon { border-left-color: var(--success); }
.cbt-effect.is-cond { border-left-color: var(--danger); }
.cbt-effect.is-conc { border-left-color: var(--accent); }
.cbt-effect-head {
  display: flex;
  align-items: center;
  gap: var(--sp-6);
  flex-wrap: wrap;
}
.cbt-effect-head strong { font-size: var(--fs-2xs); }
.cbt-effect-rounds {
  margin-left: auto;
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  white-space: nowrap;
}
.cbt-effect p {
  margin: var(--sp-2) 0 0;
  font-size: var(--fs-2xs);
  line-height: var(--lh-snug);
  color: var(--text-dim);
}

/* A buff reads as help, not harm — the condition pips are all warnings. */
.pip-boon { border-color: var(--success); background: rgba(60, 140, 70, 0.18); color: var(--success); }
/* The round counter tucked into a pip, so a glance at the board carries it. */
.pip > em { font-style: normal; font-size: 0.85em; opacity: 0.8; margin-left: 0.1em; }

.cbt-bar {
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto auto;
  gap: var(--sp-8);
  padding: var(--sp-8);
  overflow: hidden;
  background: transparent;
  border: 0;
  border-radius: 0;
}
.cbt-economy {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-6);
  font-size: var(--fs-2xs);
  text-transform: uppercase;
  letter-spacing: var(--ls-wide);
}
/* In the header now. `align-self` because that row aligns on the baseline for
   its text and a row of pills has no useful one; `margin-left: 0` because the
   slot dots' `auto` was pushing them to the far end of a bar that no longer
   holds them. */
.cbt-meta .cbt-economy { align-self: center; }
.cbt-meta .econ-slots { margin-left: 0; }
.econ {
  padding: var(--sp-1) var(--sp-6);
  border: 1px solid var(--success);
  border-radius: var(--r-pill);
  color: var(--success);
}
.econ.spent { border-color: var(--border); color: var(--text-dim); text-decoration: line-through; }
.econ-slots { display: flex; gap: var(--sp-8); margin-left: auto; }
.slot-group { display: inline-flex; align-items: center; gap: var(--sp-2); color: var(--text-dim); }
.slot-group b { margin-right: var(--sp-2); color: var(--gold-dim); font-size: var(--fs-2xs); }
.slot-dot {
  width: 8px;
  height: 8px;
  border-radius: var(--r-circle);
  border: 1px solid var(--gold-dim);
  background: var(--gold);
}
.slot-dot.spent { background: transparent; opacity: 0.5; }

/* The same dots on the character sheet, with room to breathe and the count
   spelled out — the sheet is read at rest, where "2/4" is worth more than
   counting pips, while the combat bar has no room for it. */
.sheet-slots {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-6) var(--sp-16);
  padding: var(--sp-2) 0;
}
.sheet-slots .slot-group { gap: var(--sp-4); }
.sheet-slots .slot-dot { width: 10px; height: 10px; }
.slot-count { margin-left: var(--sp-4); font-size: var(--fs-2xs); color: var(--text-dim); }
.waiting { color: var(--text-dim); text-transform: none; letter-spacing: var(--ls-normal); }

/* --- The action bar ----------------------------------------------------
   Icon slots rather than a row of words, which is what every game with a
   combat bar settles on and for the reason they all settle on it: a player
   in a fight is reading the board, not the buttons, and after two turns the
   shapes are found by position and never read again. The names did not go
   away — they moved into `.act-tip`, which can say more than a label ever
   fitted and can say it in a colour that means something. */
.cbt-actions {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-4);
}
.cbt-actions.is-stack {
  flex-direction: column;
  flex-wrap: nowrap;
  align-items: stretch;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  gap: var(--sp-8);
}
.cbt-actions.is-stack .act-sep { display: none; }
.cbt-act-group {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}
.cbt-actions.is-stack .cbt-act-label { padding: var(--sp-4) var(--sp-4) 0; }
.cbt-actions.is-stack .cbt-manual { margin: 0 var(--sp-4); }
.cbt-actions.is-stack .act-tip { display: none; }

.act-text { display: none; }
.act-num {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.cbt-actions.is-stack .act-slot {
  width: auto;
  height: auto;
  justify-content: flex-start;
  gap: var(--sp-10);
  padding: var(--sp-8) var(--sp-10);
  text-align: left;
}
.cbt-actions.is-stack .act-num {
  flex: 0 0 auto;
  width: 1.4rem;
  height: 1.4rem;
  border-radius: var(--r-sm, var(--r-md));
  background: var(--bg-inset);
}
.cbt-actions.is-stack .act-slot.is-armed .act-num,
.cbt-actions.is-stack .act-slot:hover:not(:disabled) .act-num {
  background: var(--gold-well);
}
.cbt-actions.is-stack .act-text {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  min-width: 0;
  flex: 1 1 auto;
}
.cbt-actions.is-stack .act-name {
  font-size: var(--fs-xs);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  color: var(--text);
}
.cbt-actions.is-stack .act-desc {
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.cbt-actions.is-stack .act-slot:disabled .act-name { color: var(--text-dim); }
.cbt-actions.is-stack .act-slot:disabled .act-desc { color: var(--danger-ink); }

.act-slot {
  position: relative;
  flex: 0 0 auto;
  /* 34 and not 42. These are found by position rather than read, and the eight
     pixels are the board's — it is height-bound, so every one of them comes
     back as a pixel of board height and a third as much again of its width. */
  width: 34px;
  height: 34px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  color: var(--gold-dim);
  /* The bevel: a lit top edge and a dark floor, which is the whole of what
     makes a square read as a raised key rather than a coloured box. Drawn with
     inset shadows rather than a border image so it costs no asset. */
  background: linear-gradient(180deg, var(--key-top), var(--key-bottom));
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  box-shadow: var(--sh-key), var(--sh-1);
  cursor: pointer;
  transition: color var(--dur-fast), border-color var(--dur-fast), transform var(--dur-tap), box-shadow var(--dur-fast);
}
.act-icon {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.act-slot:hover:not(:disabled),
.act-slot:focus-visible {
  color: var(--gold-bright);
  border-color: var(--gold);
  outline: none;
  transform: translateY(-1px);
  box-shadow: var(--sh-key-lit), var(--sh-glow-sm);
}
.act-slot:active:not(:disabled) {
  transform: translateY(0);
  box-shadow: var(--sh-pressed);
}
/* Closed rather than absent. Which actions exist does not change from turn to
   turn, and a slot that vanishes when it is unavailable moves every slot after
   it — the position the player learned in the first fight has to keep meaning
   the same thing in the tenth. */
.act-slot:disabled {
  color: var(--text-dim);
  opacity: 0.4;
  filter: grayscale(1);
  cursor: not-allowed;
  transform: none;
}
/* A bonus action is the same manoeuvre bought with a different part of the
   turn — Cunning Action's Dash wears the Dash icon — so the part of the turn
   is what the border says. */
.act-slot.is-bonus { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
.act-slot.is-bonus:hover:not(:disabled),
.act-slot.is-bonus:focus-visible { border-color: var(--accent); }

/* Armed: clicked once, waiting to be clicked again before it spends anything.
   Louder than hover on purpose — hover means "this is what that does", and this
   means "I am about to". It has to be legible as a CHANGE while the pointer is
   still sitting on the slot, which is why it takes the lit key and adds a ring
   rather than only warming the border the way :hover does. Gold and not
   --danger: the action is not a mistake, it is simply not undoable. */
.act-slot.is-armed:not(:disabled) {
  color: var(--gold-bright);
  border-color: var(--gold);
  box-shadow: var(--sh-key-lit), var(--sh-glow-sm),
              0 0 0 2px color-mix(in srgb, var(--gold) 45%, transparent);
  animation: act-armed 1.1s ease-in-out infinite;
}
@keyframes act-armed { 50% { box-shadow: var(--sh-key-lit), var(--sh-glow-sm),
                                         0 0 0 4px color-mix(in srgb, var(--gold) 18%, transparent); } }
@media (prefers-reduced-motion: reduce) {
  .act-slot.is-armed:not(:disabled) { animation: none; }
}

.act-count {
  position: absolute;
  right: 2px;
  bottom: 1px;
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text);
  text-shadow: var(--th-1);
}

/* Flee, Parley and End turn are not attacks and are the ones a misclick costs
   most, so they sit past a gap rather than in the same run. */
.act-sep {
  flex: 0 0 auto;
  width: 1px;
  height: 22px;
  margin: 0 var(--sp-6);
  background: var(--border);
}

.cbt-move-box {
  flex: 0 0 auto;
  margin-top: auto;
  padding: var(--sp-8) var(--sp-10);
  background: var(--bg-inset);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  font-size: var(--fs-2xs);
}
.cbt-move-label {
  color: var(--text-dim);
  margin-bottom: var(--sp-4);
  font-family: var(--mono);
}
.cbt-move-bar {
  height: 6px;
  background: var(--bg);
  border-radius: var(--r-pill);
  overflow: hidden;
}
.cbt-move-bar > span {
  display: block;
  height: 100%;
  background: var(--gold);
}
.cbt-move-box.is-spent .cbt-move-bar > span { background: var(--border); }
.cbt-move-box.is-spent .cbt-move-label { text-decoration: line-through; }

.cbt-end-turn {
  width: 100%;
  flex: 0 0 auto;
  margin-top: var(--sp-4);
  padding: var(--sp-10) var(--sp-16);
  font-family: var(--font-display);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--gold);
  background: linear-gradient(180deg, var(--gold-well), var(--bg));
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-md);
  cursor: pointer;
}
.cbt-end-turn:hover,
.cbt-end-turn:focus-visible {
  color: var(--gold-bright);
  border-color: var(--gold);
  box-shadow: var(--sh-glow-sm);
  outline: none;
}

.act-tip {
  position: absolute;
  left: 0;
  bottom: calc(100% + 8px);
  z-index: 6;            /* local: over the action bar it belongs to */
  display: none;
  flex-direction: column;
  gap: var(--sp-2);
  max-width: 280px;
  padding: var(--sp-6) var(--sp-8);
  background: var(--bg-panel);
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-md);
  box-shadow: var(--sh-3);
  pointer-events: none;
}
.act-tip.is-on { display: flex; }
.act-tip-name { color: var(--gold); font-size: var(--fs-xs); }
.act-tip-why { color: var(--text-dim); font-size: var(--fs-2xs); }
/* Why you cannot, in the colour the rest of the board uses for cannot. */
.act-tip-why.is-blocked { color: var(--danger); }

.cbt-outcome {
  display: flex;
  align-items: center;
  gap: var(--sp-12);
}
.cbt-panel .cbt-outcome {
  flex-direction: column;
  align-items: stretch;
  text-align: center;
  padding: var(--sp-16) 0;
}
.cbt-outcome strong { font-size: var(--fs-xl); }
.cbt-outcome.outcome-victory strong { color: var(--gold); }
.cbt-outcome.outcome-defeat strong { color: var(--danger); }
.cbt-outcome .btn { margin-left: auto; }
.cbt-panel .cbt-outcome .btn { margin-left: 0; }

/* Spell and item pickers */
.spell-list { display: flex; flex-direction: column; gap: var(--sp-4); }
.spell-row {
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  padding: var(--sp-8) var(--sp-10);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  color: var(--text);
  font-family: inherit;
  font-size: var(--fs-sm);
  text-align: left;
  cursor: pointer;
}
.spell-row:hover:not(:disabled) { border-color: var(--gold-dim); }
.spell-row:disabled { opacity: 0.42; cursor: not-allowed; }
.spell-name { flex: 1 1 auto; color: var(--gold); }
.spell-cost { font-family: var(--mono); font-size: var(--fs-2xs); color: var(--accent); }
.spell-cost.gone { color: var(--danger); }
.spell-dice { font-family: var(--mono); font-size: var(--fs-2xs); color: var(--text-dim); }
.spell-note { font-size: var(--fs-2xs); color: var(--text-dim); }

/* --- combat feedback --- */

.cbt-float {
  position: absolute;
  left: 50%;
  top: 0;
  transform: translateX(-50%);
  z-index: 5;            /* local: over the card the number rises from */
  font-weight: var(--fw-bold);
  font-size: var(--fs-sm);
  white-space: nowrap;
  pointer-events: none;
  text-shadow: var(--th-halo);
  animation: float-up var(--dur-rise) var(--ease-out) forwards;
}
.cbt-float.float-dmg { color: var(--danger); }
.cbt-float.float-crit { color: var(--gold-bright); font-size: var(--fs-xl); }
.cbt-float.float-heal { color: var(--success); }
.cbt-float.float-miss { color: var(--text-dim); font-size: var(--fs-xs); }
.cbt-float.float-cast { color: var(--accent); font-size: var(--fs-xs); }
.cbt-float.float-cond { color: var(--warn); font-size: var(--fs-2xs); }
/* Green, matching .pip-boon: a boon and a condition read as opposites here. */
.cbt-float.float-boon { color: var(--success); font-size: var(--fs-2xs); }
.cbt-float.float-save-ok { color: var(--success); font-size: var(--fs-2xs); }
.cbt-float.float-save-bad { color: var(--danger); font-size: var(--fs-2xs); }

.cbt-card.hit-flash { animation: cbt-hit var(--dur-slow) var(--ease); }
.cbt-card.crit-flash { animation: cbt-crit var(--dur-flourish) var(--ease); }
.cbt-card.heal-flash { animation: cbt-heal var(--dur-flourish) var(--ease); }
.cbt-card.death-flash { animation: cbt-death var(--dur-flourish) var(--ease) forwards; }
@keyframes cbt-hit {
  0%, 100% { transform: translateX(0); filter: none; }
  25% { transform: translateX(4px); filter: brightness(1.9) saturate(0.6); }
  75% { transform: translateX(-4px); }
}
@keyframes cbt-crit {
  0%, 100% { transform: scale(1); filter: none; }
  30% { transform: scale(1.14); filter: brightness(2.4) saturate(1.4); }
  60% { transform: scale(0.96); }
}
@keyframes cbt-heal {
  0%, 100% { filter: none; }
  50% { filter: brightness(1.5) drop-shadow(0 0 10px var(--success)); }
}
@keyframes cbt-death {
  to { opacity: 0.28; filter: grayscale(1); }
}

/*
 * These two keyframes did not exist.
 *
 * `.cbt-float` has asked for `float-up` and `.combat-screen.screen-shake` for
 * `screen-shake` since they were written, and neither name was ever defined —
 * anywhere, in any stylesheet. An animation naming keyframes that do not exist
 * is not an error in CSS. It resolves to nothing and the element simply sits
 * there, so the damage number over a card appeared, held still for 1.1s and
 * vanished, and a killing blow shook nothing.
 *
 * Everything around them says they were meant to: ui-combat.js removes the
 * float after exactly 1100ms and strips `.screen-shake` after exactly 300ms,
 * which are --dur-rise and --dur-fill to the millisecond, and the
 * reduced-motion block below already suppresses both. Four facts in agreement
 * and no keyframes.
 *
 * They are written to match their nearest siblings rather than invented:
 * float-up takes bm-rise's shape, which is the battlefield's version of the
 * same idea, and screen-shake takes cbt-hit's displacement. float-up carries
 * `translateX(-50%)` through every stop because .cbt-float is centred with
 * it, and a keyframe that omitted it would throw the number to the left on
 * the first frame.
 */
@keyframes float-up {
  0%   { transform: translate(-50%, 0.25rem); opacity: 0; }
  12%  { transform: translate(-50%, 0); opacity: 1; }
  70%  { opacity: 1; }
  100% { transform: translate(-50%, -1.5rem); opacity: 0; }
}
@keyframes screen-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-3px); }
  45% { transform: translateX(3px); }
  70% { transform: translateX(-2px); }
}
.combat-screen.screen-shake { animation: screen-shake var(--dur-fill) var(--ease); }

/* One frame of a 3-frame side-view pose, played over the card. Same clip
   technique the animated scenery uses — see decorSpriteMarkup in game.js. */
.clip-window {
  position: absolute;
  left: 50%;
  bottom: 100%;
  transform: translateX(-50%);
  overflow: hidden;
  pointer-events: none;
  z-index: 6;            /* local: over the board it crops */
  filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.6));
}
.clip-window .clip-strip {
  position: absolute;
  left: 0;
  top: 0;
  max-width: none;
  animation-name: decor-frames;
  animation-iteration-count: 1;
  animation-fill-mode: forwards;
}

/* ====================================================================
   Reduced motion
   ====================================================================
   The stylesheet used to honour the setting in exactly one place — animated
   scenery — while the map faded, the camera glided, dice spun and cards were
   shaken. Everything decorative is switched off here in one block; the
   scripts skip their own timings separately, because a JS sleep is not
   something a media query can shorten. */

@media (prefers-reduced-motion: reduce) {
  .anim-clip .anim-strip,
  .clip-window .clip-strip { animation: none; }

  .combat-screen.screen-shake,
  .cbt-card.hit-flash,
  .cbt-card.crit-flash,
  .cbt-card.heal-flash,
  .d20.landed,
  .verdict-critical,
  .verdict-fumble {
    animation: none !important;
  }

  /*
   * death-flash is the fourth of those four card flashes and was the one left
   * out of the list above — tools/motion_probe.html found it, which is the
   * whole reason that bench exists.
   *
   * It cannot simply join them, though, and that is why it was worth finding
   * rather than just fixing. The other three flash and return; this one ends
   * on `forwards`, and what it leaves behind is the card greyed out to say
   * that character is down. `animation: none` would take the information away
   * with the motion.
   *
   * So it takes bm-hold's treatment, which is already the answer to this
   * question elsewhere in the file: same keyframes, same duration, stepped —
   * the card arrives at greyed without passing through anything. Reusing the
   * keyframes rather than restating `opacity: 0.28; filter: grayscale(1)`
   * here, because two copies of an end state is how they come to disagree.
   */
  .cbt-card.death-flash {
    animation: cbt-death var(--dur-flourish) steps(1) forwards;
  }

  #location-root.is-fading { transition: none; opacity: 1; }

  .cbt-float { animation: none; opacity: 1; }
  .check-chance-bar > span { transition: none; }
  .ribbon-slot.is-now { transform: none; }

  * { scroll-behavior: auto !important; }
}
/* ===========================================================================
   Level up
   ===========================================================================
   The one moment the game stops and asks the player something about their own
   character, so it is allowed to take the screen. Gold is used here as it is
   everywhere else in this stylesheet — only on the thing you can act on.
*/
.levelup-head {
  text-align: center;
  padding-bottom: var(--sp-14);
  margin-bottom: var(--sp-16);
  border-bottom: 1px solid var(--border-soft);
}
.levelup-eyebrow {
  margin: 0;
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-eyebrow);
  text-transform: uppercase;
  color: var(--gold);
}
.levelup-title { margin: var(--sp-4) 0 0; font-size: var(--fs-xl); }
.levelup-sub { margin: var(--sp-2) 0 0; color: var(--text-dim); font-size: var(--fs-sm); }

.levelup-step h3 { margin: 0 0 var(--sp-6); font-size: var(--fs-lg); }
.levelup-step h4 {
  margin: var(--sp-16) 0 var(--sp-6);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps-wide);
  text-transform: uppercase;
  color: var(--text-faint);
}
.levelup-note { margin: 0 0 var(--sp-16); color: var(--text-dim); font-size: var(--fs-sm); }
.levelup-error { margin: var(--sp-10) 0 0; color: var(--danger); font-size: var(--fs-sm); min-height: 1.2em; }
.levelup-actions { margin-top: var(--sp-20); display: flex; justify-content: flex-end; gap: var(--sp-10); }

/* --- the hit die ------------------------------------------------------- */
.levelup-dice { display: grid; place-items: center; min-height: 150px; }
.levelup-result { text-align: center; font-size: var(--fs-lg); min-height: 1.6em; margin: var(--sp-10) 0 0; }
.levelup-result strong { color: var(--gold-bright); }
.levelup-grim { color: var(--danger); font-size: var(--fs-sm); }

/* The die drawn when the 3D roller has no geometry for this many sides. Hit
   dice are d6, d8, d10 and d12 and only the d6 is a solid here, so this is what
   most characters actually roll on — it is the normal case, not a degraded one,
   and is styled as something worth watching rather than as a fallback. */
.flat-die {
  width: 104px;
  height: 104px;
  display: grid;
  place-items: center;
  border-radius: var(--r-lg);
  background: linear-gradient(160deg, var(--bg-raised), var(--bg-panel-2));
  border: 1px solid var(--border-strong);
  box-shadow: var(--sh-bevel), var(--sh-3);
  transition: transform var(--dur-enter) var(--ease), border-color var(--dur-enter) var(--ease);
}
.flat-die-face {
  font-size: 2.6rem;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}
.flat-die.is-landed {
  border-color: var(--gold);
  transform: scale(1.06);
}
.flat-die.is-landed .flat-die-face { color: var(--gold-bright); }

@media (prefers-reduced-motion: reduce) {
  .flat-die { transition: none; }
}

/* --- ability scores ---------------------------------------------------- */
.levelup-abilities {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: var(--sp-8);
}
.levelup-ability {
  display: grid;
  grid-template-columns: 1fr auto auto auto;
  align-items: center;
  gap: var(--sp-8);
  padding: var(--sp-8) var(--sp-10);
  background: var(--bg-inset);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
}
.levelup-ability.is-raised { border-color: var(--gold); cursor: pointer; }
.levelup-ability.is-capped { opacity: 0.5; }
.levelup-ability-name { font-size: var(--fs-sm); color: var(--text-dim); }
.levelup-ability-score { font-size: var(--fs-lg); font-variant-numeric: tabular-nums; }
.levelup-ability.is-raised .levelup-ability-score { color: var(--gold-bright); }
.levelup-ability-spent { color: var(--gold); font-size: var(--fs-xs); min-width: 1.6em; }
.levelup-remaining {
  margin: var(--sp-12) 0 0;
  text-align: right;
  font-size: var(--fs-xs);
  color: var(--text-faint);
}

/* --- feats and spells, both pickable rows ------------------------------ */
.levelup-feat,
.levelup-spell {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--sp-2) var(--sp-10);
  padding: var(--sp-10) var(--sp-12);
  margin-bottom: var(--sp-6);
  background: var(--bg-inset);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  cursor: pointer;
}
.levelup-feat:has(input:checked),
.levelup-spell:has(input:checked) { border-color: var(--gold); background: var(--gold-well); }
.levelup-feat.is-blocked,
.levelup-spell:has(input:disabled) { opacity: 0.45; cursor: not-allowed; }
.levelup-feat input,
.levelup-spell input { grid-row: 1 / span 3; align-self: start; margin-top: var(--sp-4); }
.levelup-feat-name,
.levelup-spell-name { font-weight: var(--fw-semibold); }
.levelup-feat-text,
.levelup-spell-text { grid-column: 2; font-size: var(--fs-xs); color: var(--text-dim); }
.levelup-spell-school { grid-column: 2; font-size: var(--fs-2xs); color: var(--text-faint); }
.levelup-feat-warn { grid-column: 2; font-size: var(--fs-xs); color: var(--danger); }
.levelup-feat-note { grid-column: 2; font-size: var(--fs-xs); color: var(--text-faint); font-style: italic; }
/* A feat nothing in the game reads yet says so louder than one that only
   half applies — it is the difference between "this does less than the book
   says" and "this does nothing at all". */
.levelup-feat-note.is-inert { color: var(--danger); font-style: normal; }
.levelup-feat-req {
  grid-column: 2;
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  color: var(--text-faint);
}

.levelup-spells,
.levelup-feats { max-height: 46vh; overflow-y: auto; padding-right: var(--sp-4); }
.levelup-spell-group { margin-bottom: var(--sp-10); }
.levelup-feat-group { margin-bottom: var(--sp-10); }
/* The filter sets `hidden` on rows and groups, and `hidden` loses to any
   explicit display — .levelup-feat is a grid — so it has to be said. Without
   this the filter narrows nothing and looks broken. */
.levelup-feat[hidden],
.levelup-feat-group[hidden] { display: none; }
.levelup-empty { color: var(--text-faint); font-size: var(--fs-sm); padding: var(--sp-10) var(--sp-4); }

/* The filter over a thirty-entry list. Deliberately plain: it is a way to
   find a name you already have in mind, not a second way to browse. */
.levelup-filter {
  width: 100%;
  margin-bottom: var(--sp-10);
  padding: var(--sp-8) var(--sp-10);
  background: var(--bg-inset);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  color: var(--text);
  font: inherit;
  font-size: var(--fs-sm);
}
.levelup-filter:focus { outline: none; border-color: var(--gold); }

/* The two points, revealed under the Ability Score Improvement when it is the
   entry selected. Inside its own <label>, so every control in here stops its
   click from re-toggling the radio above it. */
.levelup-asi-panel {
  grid-column: 2;
  margin-top: var(--sp-10);
  padding-top: var(--sp-10);
  border-top: 1px solid var(--border-soft);
}
.levelup-asi-panel[hidden] { display: none; }
.levelup-asi-panel .levelup-note { margin-bottom: var(--sp-10); }

/* --- the summary ------------------------------------------------------- */
.levelup-summary { margin: var(--sp-12) 0 0; padding-left: var(--sp-16); line-height: var(--lh-prose); }
.levelup-summary strong { color: var(--gold-bright); }

/* ===========================================================================
   Sign in, and account management
   ===========================================================================
   Two pages that are not the game: a centred card for signing in, and a plain
   table for administering accounts. Both use the same tokens as everything
   else, so they read as the same product rather than as scaffolding. */

.auth-page {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  padding: 0;
}
.auth-page .auth-card {
  margin: auto;
  width: min(26rem, calc(100% - 2 * var(--sp-24)));
}
.auth-card {
  width: min(26rem, 100%);
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: var(--sp-28);
  box-shadow: var(--sh-4);
}
.auth-head { text-align: center; margin-bottom: var(--sp-24); }
.auth-eyebrow {
  margin: 0;
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-eyebrow);
  text-transform: uppercase;
  color: var(--gold);
}
.auth-card h1 { margin: var(--sp-4) 0 0; font-size: var(--fs-xl); }
.auth-sub { margin: var(--sp-4) 0 0; color: var(--text-dim); font-size: var(--fs-sm); }

.auth-field { margin-bottom: var(--sp-14); }
.auth-field > span { display: block; margin-bottom: var(--sp-4); }
.auth-field em { color: var(--text-faint); font-style: normal; }

.auth-error { min-height: 1.3em; margin: var(--sp-4) 0 var(--sp-10); color: var(--danger); font-size: var(--fs-sm); }
.auth-submit { width: 100%; }

.auth-switch {
  margin: var(--sp-16) 0 0;
  text-align: center;
  font-size: var(--fs-sm);
  color: var(--text-dim);
}
.auth-closed { color: var(--text-faint); }
.auth-foot { margin: var(--sp-24) 0 0; text-align: center; font-size: var(--fs-xs); }
.auth-foot a { color: var(--text-faint); }

/* --- accounts ---------------------------------------------------------- */
.admin-page { padding: var(--sp-24); max-width: 62rem; margin: 0 auto; }
.admin-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--sp-16);
  flex-wrap: wrap;
  padding-bottom: var(--sp-16);
  margin-bottom: var(--sp-24);
  border-bottom: 1px solid var(--border-soft);
}
.admin-head h1 { margin: var(--sp-4) 0 0; }
.admin-nav { display: flex; gap: var(--sp-8); }

.admin-panel {
  background: var(--bg-panel);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-lg);
  padding: var(--sp-16) var(--sp-20);
  margin-bottom: var(--sp-20);
}
.admin-panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-16);
  margin-bottom: var(--sp-12);
}
.admin-panel h2 { margin: 0; font-size: var(--fs-md); }
.admin-note { margin: 0 0 var(--sp-12); color: var(--text-dim); font-size: var(--fs-sm); }
.admin-toggle { display: flex; align-items: center; gap: var(--sp-8); margin: 0; }
.admin-toggle input { width: auto; margin: 0; }

/* The table is the one thing here that can outgrow a narrow screen, so it
   scrolls inside its own box rather than pushing the page sideways. */
.admin-table-wrap { overflow-x: auto; }
.admin-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
.admin-table th {
  text-align: left;
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps-wide);
  text-transform: uppercase;
  color: var(--text-faint);
  padding: 0 var(--sp-10) var(--sp-8);
  white-space: nowrap;
}
.admin-table td {
  padding: var(--sp-10);
  border-top: 1px solid var(--border-soft);
  vertical-align: middle;
}
.admin-table tr.is-off { opacity: 0.5; }
.admin-table select { width: auto; min-width: 6rem; }
.admin-sub { font-size: var(--fs-xs); color: var(--text-faint); }
.admin-warn { font-size: var(--fs-xs); color: var(--danger); }
.admin-actions { display: flex; gap: var(--sp-6); justify-content: flex-end; }
.admin-error { min-height: 1.3em; color: var(--danger); font-size: var(--fs-sm); }
.hero-who { margin: var(--sp-14) 0 0; font-size: var(--fs-xs); color: var(--text-faint); }
.hero-who strong { color: var(--text-dim); font-weight: var(--fw-semibold); }

/* ===========================================================================
   Content editors
   =========================================================================== */
.ce-tabs { display: flex; gap: var(--sp-6); margin: 0 0 var(--sp-16); }
.ce-tab {
  background: var(--bg-panel);
  border: 1px solid var(--border-soft);
  border-radius: 7px 7px 0 0;
  color: var(--text-dim);
  padding: var(--sp-8) var(--sp-16);
  cursor: pointer;
  font: inherit;
}
.ce-tab.is-on { color: var(--gold-bright); border-color: var(--gold); background: var(--gold-well); }

.ce-main { display: grid; grid-template-columns: minmax(15rem, 22rem) 1fr; gap: var(--sp-20); align-items: start; }
@media (max-width: 60rem) { .ce-main { grid-template-columns: 1fr; } }

.ce-list {
  background: var(--bg-panel);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-lg);
  padding: var(--sp-6);
  max-height: 74vh;
  overflow-y: auto;
}
.ce-row {
  display: grid;
  width: 100%;
  text-align: left;
  gap: var(--sp-2);
  padding: var(--sp-8) var(--sp-10);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--r-md);
  color: inherit;
  cursor: pointer;
  font: inherit;
}
.ce-row:hover { background: var(--bg-raised); }
.ce-row.is-on { background: var(--gold-well); border-color: var(--gold); }
.ce-row-name { font-weight: var(--fw-semibold); }
.ce-row-sub { font-size: var(--fs-xs); color: var(--text-faint); }
.ce-row-tags { display: flex; gap: var(--sp-6); flex-wrap: wrap; }
.ce-row-tags em {
  font-style: normal;
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  color: var(--gold-dim);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-sm);
  padding: 0 var(--sp-4);
}

.ce-detail {
  background: var(--bg-panel);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-lg);
  padding: var(--sp-20);
  min-height: 20rem;
}
.ce-title { margin: 0 0 var(--sp-16); font-size: var(--fs-lg); }
.ce-title code { font-size: var(--fs-2xs); color: var(--text-faint); font-weight: var(--fw-normal); }
.ce-sub {
  margin: var(--sp-24) 0 var(--sp-10);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps-wide);
  text-transform: uppercase;
  color: var(--text-faint);
  border-top: 1px solid var(--border-soft);
  padding-top: var(--sp-14);
}
/* ===========================================================================
   The landing page — a shelf of books.

   Three things in a column: a hero that is mostly air, the module shelf, and
   the how-to. It used to be a hero of five identical grey buttons over a grid
   whose first cell was a flat list of every character on the account; the list
   went to characters.php and the buttons went to the bar at the top, and what
   is left is the one row that matters plus the one action that continues it.

   The palette rule holds here as everywhere: gold is for things you can act on
   (the two buttons on a card, the title of the game), brass ornament is for
   frames and rules, and the only warm thing with any saturation in it is the
   painting.
   =========================================================================== */

/* The footer sits on the fold on a tall screen rather than halfway up it. An
   install with one module and no how-to is a short page, and a rule across the
   middle of a black field reads as content that failed to arrive. */
body.home-page {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
/* The hero already ends in a rule and 3rem of air; the container's own 1.5rem
   on top of that puts the shelf heading right on the seam. */
body.home-page > main { flex: 1 0 auto; padding-top: var(--sp-32); }

/* --- The account bar --------------------------------------------------
   Housekeeping: who you are, the legal page, the admin doors, the way out.
   Quiet by construction — these are links, not buttons, because every one of
   them drawn as a button is a button competing with the one that starts a
   game. */
.site-bar {
  border-bottom: 1px solid var(--border-soft);
  background: var(--bg-inset);
  font-size: var(--fs-xs);
  flex: 0 0 auto;
  position: sticky;
  top: 0;
  z-index: 20;
}
.site-bar-in {
  max-width: 1200px;
  margin: 0 auto;
  padding: var(--sp-8) var(--sp-24);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-6) var(--sp-16);
  /* The name to the left, the doors to the right: `.site-who` takes the slack
     so nothing has to be told its own width. */
  justify-content: flex-end;
}
.site-who { color: var(--text-faint); margin-right: auto; }
.site-who strong { color: var(--text-dim); font-weight: var(--fw-semibold); }
.site-bar a { color: var(--text-dim); }
.site-bar a:hover { color: var(--gold); text-decoration: none; }
.site-bar a[aria-current="page"] { color: var(--text); }

/* Admin and studio pages pad and centre the body. The bar has to sit on the
   viewport, not in that column, or it would be a 62-rem strip with the page's
   padding showing above it. */
body.admin-page > .site-bar,
body.studio-page > .site-bar {
  width: 100vw;
  max-width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  margin-top: calc(-1 * var(--sp-24));
  margin-bottom: var(--sp-24);
}

/* --- The hero ---------------------------------------------------------
   A title, a rule, a sentence and a button. The lighting is three stacked
   gradients rather than an image: a low warm glow where the title sits, a cool
   wash off the top, and a vignette that closes the sides so the column of text
   is the only lit thing on the page above the shelf. */
.hero-home {
  padding: var(--sp-64) var(--sp-24) var(--sp-48);
  background:
    radial-gradient(ellipse 42rem 18rem at 50% 8%,
                    color-mix(in srgb, var(--gold) 7%, transparent) 0%, transparent 70%),
    radial-gradient(ellipse 60rem 26rem at 50% 0%, var(--bg-panel) 0%, transparent 65%),
    radial-gradient(ellipse 90rem 40rem at 50% 40%, transparent 40%, rgba(0, 0, 0, 0.45) 100%),
    var(--bg);
  border-bottom: 1px solid var(--border-soft);
}

/* Qualified, because `.hero p` sets a measure, a colour and a margin on every
   paragraph in here and would outrank a bare class. */
.hero-home .hero-eyebrow {
  font-family: var(--font-ui);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-eyebrow);
  text-transform: uppercase;
  color: var(--text-faint);
  margin: 0 0 var(--sp-14);
  max-width: none;
}

/* Struck metal: the fill runs bright at the top edge to dark at the foot, which
   is what a raised letter does under a light above it. `background-clip` needs
   a transparent colour to show through, so the glow moves to a drop-shadow —
   a text-shadow would be clipped away with the fill. */
.hero-home h1 {
  font-size: var(--fs-4xl);
  line-height: var(--lh-display);
  letter-spacing: var(--ls-display);
  margin: 0;
  background: linear-gradient(178deg, var(--gold-bright) 12%, var(--gold) 48%, var(--gold-dim) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  text-shadow: none;
  filter: drop-shadow(0 0 28px color-mix(in srgb, var(--gold) 22%, transparent));
}

.hero-home p {
  max-width: 44rem;
  color: var(--text-dim);
  font-size: var(--fs-md);
  line-height: var(--lh-prose);
  margin: 0 auto;
}

.hero-home .hero-actions { margin-top: var(--sp-28); }

/* A brass rule with a lozenge on it, in the ornament colour rather than gold:
   the palette spends gold on things you can press, and this is a divider. Two
   segments and a diamond, so it reads as a struck line rather than a border
   that lost its box. */
.rule-orn {
  --orn-w: 15rem;
  width: var(--orn-w);
  height: 0.5rem;
  margin: var(--sp-24) auto var(--sp-20);
  position: relative;
  background:
    linear-gradient(90deg, transparent, var(--ornament) 22%, var(--ornament) 78%, transparent);
  /* The line itself is a hairline in the middle of the box; the box is only
     tall enough to hold the lozenge. */
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: 50% 50%;
}
.rule-orn::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 0.42rem;
  height: 0.42rem;
  margin: calc(-1 * var(--sp-4)) 0 0 calc(-1 * var(--sp-4));
  transform: rotate(45deg);
  background: var(--ornament-bright);
  /* Not elevation and not a ring: a disc of the PAGE colour, punching a gap
     in the rule that passes behind the ornament. Left raw because it is a
     cut-out, and reading --sh-anything here would suggest a depth it has
     nothing to do with. */
  box-shadow: 0 0 0 0.28rem var(--bg);
}

/* --- What names a row -------------------------------------------------
   A heading with a rule running off it to the right, so the shelf reads as a
   labelled section and not as three cards adrift under the hero. */
.section-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--sp-4) var(--sp-14);
  margin: var(--sp-4) 0 var(--sp-16);
}
.section-head h2 {
  margin: 0;
  font-size: var(--fs-lg);
  letter-spacing: var(--ls-wide);
}
.section-hint {
  margin: 0;
  color: var(--text-faint);
  font-size: var(--fs-xs);
  flex: 1 1 12rem;
  /* The hairline runs from the end of the sentence to the edge of the row, so
     the heading sits on a line instead of floating above the cards. Drawn on
     the hint rather than as a third element because the hint is what has the
     slack: at a narrow width it wraps under the heading and takes the rule
     with it. */
  display: flex;
  align-items: center;
  gap: var(--sp-14);
}
.section-hint::after {
  content: "";
  flex: 1 1 auto;
  height: 1px;
  background: linear-gradient(90deg, var(--border), transparent);
}
/* Once the hint wraps to its own two lines there is no line left to finish —
   the rule comes out as a stub hanging off the last word. */
@media (max-width: 40rem) {
  .section-hint::after { display: none; }
}

.home-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--sp-16);
  /* Stretch, not start, so the frames end level. This used to be what made
     the buttons line up — they sat on the card's foot with `margin-top: auto`,
     which does nothing unless the card is taller than its content. The buttons
     are under the plate now and align on their own, but a row of cards ending
     at three different heights still reads as three different components
     rather than three of the same thing. */
  align-items: stretch;
}

/* The module cells are written into a wrapper by the script, but they have to
   sit in the PARENT grid or all three would share one column. `display:
   contents` removes the wrapper from the layout and leaves its children in
   place — which is exactly what it is for, and avoids the alternative of
   teaching the script where in the grid to splice each cell. */
.home-modules { display: contents; }

/* Two-up before one-up. The threshold is lower than it was at four columns,
   because three share the same width more generously and stay readable further
   down — dropping to two while there is still room for three wastes half the
   row on nothing. */
@media (max-width: 58rem) {
  .home-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 40rem) {
  .home-grid { grid-template-columns: minmax(0, 1fr); }
}

.home-cell { display: flex; flex-direction: column; }

/* The how-to, as five numbered steps.

   Columns rather than one list, because full width is a very long measure and a
   numbered list running the width of a desktop is hard to track back to the
   next line. The markers are drawn from a counter instead of the list's own:
   `columns` reflows the items between the two tracks, and a browser marker sits
   outside the padding box where the second column's edge cuts it off. */
.howto {
  color: var(--text-dim);
  line-height: var(--lh-prose);
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: howto;
  columns: 2;
  column-gap: var(--sp-40);
}
.howto li {
  break-inside: avoid;
  counter-increment: howto;
  position: relative;
  padding-left: 2.4rem;   /* clearance for the numbered disc, not rhythm */
  margin-bottom: var(--sp-14);
}
.howto li:last-child { margin-bottom: 0; }
/* A brass disc, aligned to the cap height of the first line rather than its
   box: 1.6 line-height puts the text a couple of pixels down inside it. */
.howto li::before {
  content: counter(howto);
  position: absolute;
  left: 0;
  top: 0.05rem;
  width: 1.6rem;
  height: 1.6rem;
  border-radius: var(--r-circle);
  border: 1px solid var(--ornament);
  background: var(--bg-inset);
  color: var(--ornament-bright);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: var(--fw-semibold);
  display: flex;
  align-items: center;
  justify-content: center;
}
.howto strong { color: var(--gold); }
@media (max-width: 52rem) { .howto { columns: 1; } }

/* The how-to sits under the shelf and is not part of its grid. Held to a
   reading measure and centred: five steps across 1200px is a line nobody
   finishes. */
.home-howto { margin-top: var(--sp-28); }

/* A saved character.

   Had no styling at all — three buttons and two divs, which held together at
   half the page width and does not at a quarter. The buttons wrap now instead
   of squeezing, and the sub-line is allowed to break. */
.char-card {
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  padding: var(--sp-10) var(--sp-12);
  margin-bottom: var(--sp-10);
  background: rgba(0, 0, 0, 0.12);
  /* Portrait beside the body. Flex rather than a two-column grid because the
     portrait is removed outright when the file is missing, and a grid would be
     left holding an empty column and its gap. */
  display: flex;
  gap: var(--sp-10);
  align-items: flex-start;
}
.char-card:last-child { margin-bottom: 0; }
.char-card .char-body { min-width: 0; flex: 1; }
.char-card .name { color: var(--gold); font-weight: var(--fw-semibold); line-height: var(--lh-snug); }
.char-card .sub {
  font-size: var(--fs-xs);
  color: var(--text-dim);
  line-height: var(--lh-snug);
  /* No bottom margin: it used to hold the buttons off the text below it, and
     they are in the corner now — left in, it is trailing space inside a card
     whose height is set by the portrait beside it. */
  margin: var(--sp-2) 0 0;
}

/* The cut face, at the size the party rail uses it. Fixed rather than fluid:
   these line up down the column and a portrait that resizes with its name
   makes a ragged left edge. */
.char-face {
  width: 3rem;
  height: 3rem;
  flex: 0 0 auto;
  border-radius: var(--r-md);
  object-fit: cover;
  object-position: center top;
  border: 1px solid var(--ornament);
  background: var(--bg-raised);
}
/* The card's own corner: portrait, then the text, then these, and the card is
   `align-items: flex-start` so they sit level with the top of the name. Column
   rather than a wrapping row because there are at most two and this is a
   quarter of the page — side by side they push the name into wrapping, and a
   name is what the card is for. */
.char-actions {
  display: flex;
  flex-direction: column;
  gap: var(--sp-6);
  flex: 0 0 auto;
}
/* Icon-only, so they need the border the transparent .icon-btn does not carry
   until hover — otherwise two glyphs float in the card with nothing to say
   they are pressable. */
.char-act {
  border-color: var(--border-soft);
  background: var(--bg-raised);
}

/* An unfilled module slot. Says what it is rather than being a hole. */
.module-slot-empty {
  border: 1px dashed var(--border-soft);
  border-radius: var(--r-lg);
  padding: var(--sp-16);
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 12rem;
  color: var(--text-faint);
  font-size: var(--fs-xs);
  line-height: var(--lh-normal);
  /* Faint enough that a shelf with one module on it does not read as a shelf
     with two things missing off it. */
  opacity: 0.55;
}

/* --- A module card ----------------------------------------------------
   The painting is the top two thirds of it with the name lying across the
   bottom of the picture; then the doors, then the blurb and the credit. The
   name sat in a line of text under the plate and the card read as a picture
   with a caption stuck below it — a book has its title on the cover. */
.module-card {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  /* The plate is flush to the frame, so the padding moves inside. */
  padding: 0;
  overflow: hidden;
  transition: border-color var(--dur), box-shadow var(--dur), transform var(--dur);
}
.module-card:hover {
  border-color: var(--ornament);
  box-shadow: var(--sh-3);
  transform: translateY(-2px);
}

/* The whole painting is the door. Sized by its own aspect-ratio rather than by
   the image, so a module with no cover on disk is a dark plate with a title on
   it and not a card half the height of the two beside it. */
.module-plate {
  position: relative;
  display: block;
  aspect-ratio: 3 / 2;
  overflow: hidden;
  background: var(--bg-inset);
  border-bottom: 1px solid var(--border);
  color: inherit;
}
.module-plate:hover { text-decoration: none; }
.module-cover {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--dur-fill) var(--ease);
}
.module-card:hover .module-cover { transform: scale(1.035); }

/* The scrim the title lies on. A gradient over the foot of the painting rather
   than a bar across it: the covers are painted with their interest in the
   middle and their ground at the bottom, so the darkening lands on dirt and
   sky rather than on somebody's face. */
.module-plate::after {
  content: "";
  position: absolute;
  inset: 40% 0 0;
  background: linear-gradient(180deg, transparent, rgba(8, 9, 11, 0.88) 82%);
  pointer-events: none;
}
/* No painting on disk: the plate stops reserving 3:2 of nothing and becomes a
   title bar. Holding the shape would draw a dark rectangle two thirds the
   height of the card with a caption at the bottom of it, which reads as a
   picture that failed rather than as a module nobody has painted yet — and the
   scrim would be a fade from nothing into black. */
.module-plate.no-art { aspect-ratio: auto; background: var(--bg-panel-2); }
.module-plate.no-art::after { display: none; }
.module-plate.no-art .module-cap { position: static; padding-top: var(--sp-16); }
.module-plate.no-art .module-name,
.module-plate.no-art .module-levels { text-shadow: none; }

.module-cap {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1;            /* local: the caption, over its own plate */
  padding: var(--sp-14) var(--sp-20) var(--sp-12);
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-12);
}
.module-name {
  font-family: var(--font-display);
  color: var(--gold);
  font-size: var(--fs-lg);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-tight);
  /* Legible over whatever the bottom of the painting happens to be, without a
     box behind it — same trick the map labels use. */
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95), 0 0 12px rgba(0, 0, 0, 0.8);
}
.module-card:hover .module-name { color: var(--gold-bright); }
.module-levels {
  font-family: var(--font-ui);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
  white-space: nowrap;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
}

/* How many of your parties are in here, in the corner of the painting. It was
   a line of body text under the blurb, which is a lot of prose for a number,
   and it only ever renders when the number is not zero — a card with no badge
   and a "Start a game here" button has already said so twice. */
.module-badge {
  position: absolute;
  z-index: 1;            /* local: the badge, over its own plate */
  top: 0.6rem;
  right: 0.6rem;
  padding: var(--sp-2) var(--sp-8);
  border-radius: var(--r-pill);
  border: 1px solid var(--gold-dim);
  background: color-mix(in srgb, var(--gold-well) 82%, transparent);
  backdrop-filter: blur(2px);
  color: var(--gold);
  font-family: var(--font-ui);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-wide);
}

/* Last in the card now that the doors have moved up, so it carries the foot.
   It keeps that padding when it is empty — a module with no blurb and no
   attribution is a card that ends below its buttons, not against them. */
.module-body { padding: var(--sp-14) var(--sp-20) var(--sp-20); }
.module-blurb {
  color: var(--text-dim);
  line-height: var(--lh-normal);
  margin: 0;
}
.module-credit {
  font-size: var(--fs-2xs);
  color: var(--text-faint);
  line-height: var(--lh-snug);
  margin: var(--sp-12) 0 0;
}

/* Directly under the plate, which is where the title is — the doors sit with
   the name of the thing they open rather than at the far end of a paragraph
   about it. No `margin-top: auto` any more: the plate's 3:2 is a fixed height,
   so a row of cards lines its buttons up without being asked, however long the
   blurbs below them run. */
.module-actions {
  padding: var(--sp-16) var(--sp-20) 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-6);
}


/* ------------------------------------------------------------------ *
   index.php — the picker: your characters, and whoever is open

   Two panes. The sheet is the page and the list is the index down its edge,
   so the sheet takes the room and the rail takes a fixed column — a list of
   names does not get more readable for being wider, and a sheet does.

   The rail is SECOND in the grid and FIRST in the source, which is the whole
   trick of this layout: on a phone there are no columns, source order wins,
   and you land on the list rather than on somebody the page picked for you.
 * ------------------------------------------------------------------ */
/* The header, held short. `.hero-slim` is already the passing-through hero that
   characters.php wears, and this page wants less again: the title is here to
   say which game you are in, and every pixel it spends is a pixel the list is
   pushed down by. The lozenge rule stays — it is the one thing that says the
   page has a top — on a shorter leash, and with nothing under it now that the
   eyebrow is gone. */
.picker-page .hero-slim { padding: var(--sp-16) var(--sp-16) var(--sp-12); }
.picker-page .hero-slim h1 { font-size: var(--fs-2xl); margin: 0; }
.picker-page .rule-orn { margin: var(--sp-10) auto 0; }

.picker {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 20rem;
  gap: var(--sp-20);
  align-items: start;
}
.picker-rail { grid-column: 2; grid-row: 1; }
.picker-detail { grid-column: 1; grid-row: 1; min-width: 0; }

/* One column, list first. The rail's own scroller goes with the columns: a
   pane that scrolls inside a page that scrolls is a trap on a touch screen,
   and there is no second pane beside it to keep level with. */
@media (max-width: 62rem) {
  .picker { grid-template-columns: minmax(0, 1fr); }
  .picker-rail, .picker-detail { grid-column: 1; grid-row: auto; }
  .rail-list { max-height: none; overflow: visible; }
}

.picker-rail {
  border: 1px solid var(--border-soft);
  border-radius: var(--r-lg);
  background: var(--bg-panel);
  padding: var(--sp-12);
  /* Follows you down a long sheet. `top` clears the site bar, which is not
     fixed — this only ever engages once the sheet is taller than the rail,
     which is most characters. */
  position: sticky;
  top: var(--sp-12);
}
.rail-head {
  display: flex;
  align-items: baseline;
  gap: var(--sp-10);
  border-bottom: 1px solid var(--border-soft);
  padding-bottom: var(--sp-8);
  margin-bottom: var(--sp-10);
}
.rail-head h2 {
  margin: 0;
  flex: 1;
  min-width: 0;
  border-bottom: none;
  padding-bottom: 0;
  font-size: var(--fs-md);
}
.rail-head .btn { align-self: center; }

/* The list scrolls inside the rail rather than running the page down past the
   sheet: twenty characters is a plausible account and the sheet beside them
   must stay reachable. Only in two columns — see the media query above. */
.rail-list {
  max-height: calc(100vh - 10rem);
  overflow-y: auto;
  /* Room for the scrollbar when there is one, so the cards do not shift
     sideways as the list grows past the fold. */
  padding-right: var(--sp-4);
}

.rail-group + .rail-group { margin-top: var(--sp-14); }
/* Which adventure this run of names is in. Quiet: it is a divider, and the
   loud thing in this column is a character's name. */
.rail-group-head {
  font-family: var(--font-ui);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
  margin: 0 0 var(--sp-8);
  border-bottom: none;
}

/* A character, as a button. `.char-card` gives it the frame characters.php
   uses; this makes it pressable — a button carries none of the font, colour
   or alignment of the text around it, and inherits a centred label. */
.rail-pick {
  width: 100%;
  text-align: left;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transition: border-color var(--dur), background var(--dur);
}
.rail-pick .name, .rail-pick .sub { display: block; }
.rail-pick:hover { border-color: var(--ornament); background: rgba(0, 0, 0, 0.24); }
.rail-pick:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
/* The one whose sheet is open. A left edge in gold rather than a filled card:
   the sheet beside it is already the loud answer to "who is this", and two
   golden things saying the same would compete. */
.rail-pick.is-active {
  border-color: var(--gold-dim);
  background: var(--gold-well);
  box-shadow: inset 3px 0 0 var(--gold);
}
.rail-party {
  color: var(--text-faint);
  font-style: italic;
}

/* The detail pane's other face: the module cards, when you are starting a new
   game rather than resuming one. Narrower than the old full-width shelf, so
   two across rather than three. */
.shelf-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-16);
  align-items: stretch;
}
@media (max-width: 46rem) {
  .shelf-grid { grid-template-columns: minmax(0, 1fr); }
}

/* The party, across the top of the sheet.

   Tabs rather than a second list: they name the four who march together, they
   sit directly above the buttons that act on all of them, and the one you are
   reading is lit. A row that wraps rather than scrolls — a party holds four and
   the pane is wide enough for four, but a long name should push the fourth onto
   a second line rather than off the edge. */
.party-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-8);
  margin-top: var(--sp-16);
}
/* The tab is a frame around two controls now: a tick that decides who walks
   out to a fight, and a button that opens the sheet. They are separate because
   they are separate questions — and because a checkbox inside a button is not
   valid HTML. */
.party-tab {
  display: flex;
  align-items: center;
  gap: var(--sp-6);
  flex: 1 1 10rem;
  min-width: 0;
  padding: var(--sp-6) var(--sp-10) var(--sp-6) var(--sp-8);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  background: rgba(0, 0, 0, 0.12);
  transition: border-color var(--dur), background var(--dur);
}
.party-tab:hover { border-color: var(--ornament); background: rgba(0, 0, 0, 0.24); }
.tab-open {
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  flex: 1;
  min-width: 0;
  text-align: left;
  font: inherit;
  color: inherit;
  cursor: pointer;
  background: none;
  border: 0;
  padding: 0;
}
.tab-open:focus-visible,
.tab-join:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
/* Gold, like everything else here that means "you can act on this". */
.tab-join {
  flex: 0 0 auto;
  width: 1rem;
  height: 1rem;
  accent-color: var(--gold);
  cursor: pointer;
}
.tab-join:disabled { cursor: not-allowed; }
/* Nought hit points: they cannot be taken to a fight, and the tab says so by
   going quiet rather than by disappearing — somebody who is down is still
   somebody whose sheet you may want to read. */
.party-tab.is-down { opacity: 0.6; border-style: dashed; }
/* The one on screen. Same gold edge the rail's own selection wears, so "this
   is the one you are reading" reads identically in both places. */
.party-tab.is-active {
  border-color: var(--gold-dim);
  background: var(--gold-well);
  box-shadow: inset 3px 0 0 var(--gold);
}
.tab-face {
  width: 2.1rem;
  height: 2.1rem;
  flex: 0 0 auto;
  border-radius: var(--r-md);
  object-fit: cover;
  object-position: center top;
  border: 1px solid var(--ornament);
  background: var(--bg-raised);
}
.tab-body { min-width: 0; }
.tab-name {
  display: block;
  color: var(--gold);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-snug);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tab-sub {
  display: block;
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  line-height: var(--lh-snug);
}
/* Somebody who travels with you rather than somebody you made. Said in a word
   on the tab rather than drawn as a different KIND of tab: they are a member of
   the party and the row is the party, and a companion's sheet opens the same
   way anyone else's does. What they are not is somebody you can play as, and
   nothing on this page offers that — Play sends the party and the server picks
   a lead, skipping them. */
.party-tab.is-companion .tab-name { color: var(--text); }

/* An armed Retire. The button says "Really retire?" and turns the colour of
   the thing it is about to do — the second press is the one that means it, and
   it should not look like the first. */
.retire-btn.is-armed {
  border-color: var(--danger);
  color: var(--danger);
  background: var(--danger-well);
}
.tab-comp {
  font-style: italic;
  color: var(--text-faint);
}

/* The bag and the store, which take the detail pane over rather than opening
   on top of it. Their own header, because the sheet's belongs to a character
   and this one belongs to a task: the way back, what you are looking at, and
   whose it is. */
.panel-head {
  display: flex;
  align-items: center;
  gap: var(--sp-12);
  border-bottom: 1px solid var(--border-soft);
  padding-bottom: var(--sp-10);
  margin-bottom: var(--sp-12);
}
.panel-head h2 { margin: 0; border-bottom: none; padding-bottom: 0; flex: 1; min-width: 0; }
.panel-who { color: var(--gold); font-size: var(--fs-sm); white-space: nowrap; }

.bag-list { list-style: none; margin: 0; padding: 0; }
/* Name, then what it is, then what you can do about it — the last column
   pushed right so the buttons line up down the list however long the names
   are. */
.bag-row {
  display: flex;
  align-items: baseline;
  gap: var(--sp-10);
  padding: var(--sp-6) 0;
  border-bottom: 1px solid var(--border-soft);
}
.bag-row:last-child { border-bottom: none; }
.bag-name { flex: 1; min-width: 0; color: var(--text); }
.bag-acts {
  display: flex;
  align-items: center;
  gap: var(--sp-6);
  flex: 0 0 auto;
}
/* Worn, not merely carried. Gold on the name rather than a badge: the list is
   long and a column of badges reads as noise. */
.bag-row.is-worn .bag-name { color: var(--gold); }
.bag-row.is-worn .bag-name::after {
  content: " · worn";
  font-size: var(--fs-2xs);
  color: var(--text-faint);
  font-style: italic;
}
.coin {
  font-variant-numeric: tabular-nums;
  color: var(--gold-dim);
  font-size: var(--fs-xs);
  white-space: nowrap;
}
/* Who the Give buttons are pointing at. Asked once for the panel rather than
   on every row: a party holds four and the answer is the same for the six
   things you are about to move. */
.hand-to {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-8);
  white-space: nowrap;   /* "Hand things to" is one phrase, not three lines */
  margin: var(--sp-12) 0;
  color: var(--text-dim);
  font-size: var(--fs-sm);
}
.hand-to select {
  background: var(--bg-inset);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-4) var(--sp-8);
  font: inherit;
}

.store-purse { margin: 0 0 var(--sp-12); color: var(--text-dim); }
.store-purse strong { color: var(--gold); }
.store-purse .stat-note { display: block; white-space: normal; }

.pick-sheet {
  border: 1px solid var(--border-soft);
  border-radius: var(--r-lg);
  background: var(--bg-panel);
  padding: var(--sp-20);
}
/* The Play button, and under it where pressing it takes you. Boxed and gold-lit
   because it is the one thing on a page of numbers that DOES something — the
   rest of the sheet is a reason to press it. */
.pick-play {
  margin: var(--sp-16) 0;
  padding: var(--sp-14) var(--sp-16);
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-md);
  background: var(--gold-well);
}
.pick-play .btn { display: inline-flex; align-items: center; gap: var(--sp-8); }
.pick-play .btn svg { width: 1em; height: 1em; fill: currentColor; }
/* The doors on one line, wrapping rather than shrinking. */
.play-doors { display: flex; flex-wrap: wrap; gap: var(--sp-10); align-items: center; }

/* The fights, on their own line under Play and at half its weight.
   Three buttons, one per tier, because a fight has a difficulty and picking it
   is the whole of the choice — offering one and deciding for the player which
   it was is the thing the pit's own card has never done. They are small and
   ungilded on purpose: resuming the game is why the page exists and a row of
   four gold buttons would say otherwise. */
.fight-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-8);
  margin-top: var(--sp-12);
  padding-top: var(--sp-12);
  border-top: 1px solid var(--gold-dim);
}
.fight-label {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-6);
  margin-right: var(--sp-2);
  font-family: var(--font-ui);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-dim);
}
.fight-label svg { width: 1em; height: 1em; fill: none; stroke: currentColor; }
/* Camp is pushed to the far end of the same line. It is the other thing you do
   between adventures — the two of them are a pair, and a fight you cannot sleep
   off is a fight you can only have once. */
.camp-btn, .camp-no { margin-left: auto; }
.camp-no {
  font-size: var(--fs-2xs);
  color: var(--text-faint);
  font-style: italic;
}
/* Nothing to press: a character with no party has no game to resume, and a
   gold-lit box saying so reads as a button that failed rather than as a
   sentence. Same frame, no light in it. */
.pick-play.is-idle { border-color: var(--border-soft); background: transparent; }
.pick-play.is-idle .help-hint { margin: 0; }
.play-where {
  margin: var(--sp-8) 0 0;
  color: var(--text-dim);
  font-size: var(--fs-xs);
}
/* The experience bar sits under the pills rather than among them: it is a
   measurement of progress and they are a readout of state, and it needs a
   whole line to be legible as either. */
.pick-xp { margin: var(--sp-10) 0 var(--sp-4); }

/* Two columns of boxes where there is room for two — saves beside skills,
   features beside proficiencies. The pane is already the narrower half of a
   split, so this folds early. */
.sheet-cols {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-20);
  align-items: start;
}
@media (max-width: 52rem) {
  .sheet-cols { grid-template-columns: minmax(0, 1fr); }
}

.sheet-list {
  list-style: none;
  margin: 0;
  padding: 0;
  color: var(--text-dim);
  font-size: var(--fs-sm);
}
.sheet-list > li { margin-bottom: var(--sp-6); line-height: var(--lh-snug); }
.sheet-list strong { color: var(--text); }
.sheet-list .inv-desc { margin: var(--sp-2) 0 0; }
/* Eighteen skills is a tall column on its own; it is the one list here worth
   splitting, and `break-inside` keeps a row off the fold. */
.skill-list { columns: 2; column-gap: var(--sp-16); }
.skill-list > li { break-inside: avoid; }
@media (max-width: 70rem) { .skill-list { columns: 1; } }
/* The spell list is the other one that runs long — a prepared caster at level
   six knows more lines than the whole features box holds — and it has the full
   width of the pane rather than half of it, so it splits later. */
.spell-list { columns: 2; column-gap: var(--sp-20); }
.spell-list > li { break-inside: avoid; }
@media (max-width: 46rem) { .spell-list { columns: 1; } }

/* A save or a skill: the bubble, the name, what it is rolled off, the number.
   The modifier is pushed right and tabular so the column reads down. */
.stat-line {
  display: flex;
  align-items: baseline;
  gap: var(--sp-6);
}
.stat-line .stat-name { flex: 1; min-width: 0; }
.stat-line.is-prof .stat-name { color: var(--text); }
.stat-mod {
  font-variant-numeric: tabular-nums;
  color: var(--text);
  font-weight: var(--fw-semibold);
}
.stat-note {
  font-size: var(--fs-2xs);
  color: var(--text-faint);
  white-space: nowrap;
}
/* Filled means proficient — the same mark the paper sheet uses, so a player
   holding both is reading one convention. Expertise is not a third state: a
   doubled skill is a proficient one, and the note beside it says ×2. */
.stat-dot {
  flex: 0 0 auto;
  width: 0.55rem;
  height: 0.55rem;
  border-radius: var(--r-circle);
  border: 1px solid var(--ornament);
  background: transparent;
}
.stat-line.is-prof .stat-dot { background: var(--gold); border-color: var(--gold); }

.sheet-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-sm);
  color: var(--text-dim);
}
.sheet-table th {
  text-align: left;
  font-family: var(--font-ui);
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-faint);
  font-weight: var(--fw-semibold);
  padding-bottom: var(--sp-4);
  border-bottom: 1px solid var(--border-soft);
}
.sheet-table td {
  padding: var(--sp-6) var(--sp-8) var(--sp-6) 0;
  border-bottom: 1px solid var(--border-soft);
  vertical-align: baseline;
}
.sheet-table tr:last-child td { border-bottom: none; }
.sheet-table tr.is-equipped td { color: var(--text); }

/* ------------------------------------------------------------------ *
   characters.php — one module's parties

   The saved-character list used to be a column on the home page and is now a
   page of its own, one module at a time and grouped by party. What it needs
   here is a heading that does not shout (the module already said what game
   this is) and a members grid that puts a party on one line where it fits.
 * ------------------------------------------------------------------ */

/* The hero, minus the full-height drama it has on the home page — this is a
   page you pass through on the way to a character, not one to sit and read. */
.hero-slim { padding: var(--sp-24) var(--sp-16) var(--sp-20); }
.hero-slim h1 { margin-bottom: var(--sp-4); }
.hero-sub {
  color: var(--text-dim);
  max-width: 46rem;
  margin: 0 auto;
  line-height: var(--lh-normal);
}
.hero-sub:empty { display: none; }   /* a module with no blurb leaves no gap */
.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-8);
  justify-content: center;
  margin-top: var(--sp-16);
}

.party-block { margin-bottom: var(--sp-16); }
/* The name and the headcount on one line, the count pushed right and quiet:
   it is the answer to "is anybody missing", not a label worth reading first. */
.party-head {
  display: flex;
  align-items: baseline;
  gap: var(--sp-10);
  border-bottom: 1px solid var(--border-soft);
  padding-bottom: var(--sp-8);
  margin-bottom: var(--sp-12);
}
/* No border of its own: `.panel h2` carries an underline, and inside a flex
   row that underline stops where the heading stops — so the rule ran to just
   short of the headcount and then the row's own border ran the rest of the
   way, at a different height. One line, on the row. */
.party-head h2 {
  margin: 0;
  flex: 1;
  min-width: 0;
  border-bottom: none;
  padding-bottom: 0;
}
.party-count {
  color: var(--text-faint);
  font-size: var(--fs-xs);
  white-space: nowrap;
}
/* The one Play on the page for this game — it belongs to the party, not to any
   member, so it sits on the party's own line and not in the grid of faces.
   `align-self` because the row aligns on the baseline for the heading and the
   headcount, and a button dropped into that sits low by its text's baseline. */
.party-play {
  align-self: center;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-6);
}
.party-play svg {
  width: 0.85rem;
  height: 0.85rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Auto-fill rather than a fixed count, because a party is one to six and the
   row should close up around whatever it holds. 17rem is the width at which the
   race/class/HP line stops wrapping: it was 15rem while the buttons sat under
   that line, and moving them into the corner takes their width out of it. */
.party-members {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
  gap: var(--sp-10);
}
/* The grid's gap spaces them; .char-card's own bottom margin would double it
   on every row but the last. */
.party-members > .char-card { margin-bottom: 0; }

/* Bloodied. The list is where you notice somebody needs a rest, and a number
   that is only ever grey does not tell you. */
.char-hp.is-hurt { color: var(--danger); font-weight: var(--fw-semibold); }

/* The grid's own gap does the spacing; .panel's bottom margin would add to it
   and only on some rows. */
.home-grid > .panel { margin-bottom: 0; }
/* The module cell's own rules — the plate, the title on it, the badge — are up
   with the rest of the landing page. They lived here, after it, which is the
   only reason a second `.module-name` further up would ever have been worth
   noticing: the later rule wins, and two copies of a card's styling in one
   stylesheet is one of them being fixed and the other not. */
.char-module { color: var(--gold); }

/* Which game the regions below belong to. Sits above .ce-sub and outranks it,
   because a module is a bigger division than a region — and it only ever
   renders when there is more than one module to tell apart. */
.ce-module {
  margin: var(--sp-32) 0 var(--sp-4);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-caps);
  color: var(--gold);
}
.ce-module + .ce-sub { border-top: 0; padding-top: 0; margin-top: var(--sp-6); }

.ce-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-12); }
.ce-grid-3 { grid-template-columns: 2fr 1fr 1fr; }
@media (max-width: 40rem) { .ce-grid, .ce-grid-3 { grid-template-columns: 1fr; } }

.ce-checks { display: flex; flex-wrap: wrap; gap: var(--sp-8) var(--sp-20); margin: var(--sp-12) 0; }
.ce-check { display: flex; align-items: center; gap: var(--sp-6); margin: 0; }
.ce-check input { width: auto; margin: 0; }
.ce-hint { font-size: var(--fs-xs); color: var(--text-faint); }
.ce-hint.is-ok { color: var(--success); }
.ce-hint.is-bad { color: var(--danger); }
.ce-actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--sp-10); margin-top: var(--sp-12); }
.ce-npc-art {
  display: flex;
  align-items: flex-end;
  gap: var(--sp-12);
  margin: var(--sp-12) 0 0;
}
.ce-bust {
  width: 5rem;
  height: 6.5rem;
  object-fit: cover;
  object-position: center 15%;
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  background: var(--bg-inset);
  flex: 0 0 auto;
}

/* The dialogue document. Monospace because it is JSON and the indentation is
   the only structure a reader has to go on. */
.ce-code {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  line-height: var(--lh-normal);
  white-space: pre;
  overflow-x: auto;
  tab-size: 2;
}

.ce-stage {
  background: var(--bg-inset);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  margin-bottom: var(--sp-8);
  padding: var(--sp-8) var(--sp-12);
}
.ce-stage summary { cursor: pointer; display: flex; gap: var(--sp-10); align-items: baseline; flex-wrap: wrap; }
.ce-stage summary code { font-size: var(--fs-2xs); color: var(--text-faint); }
.ce-terminal { font-style: normal; font-size: var(--fs-2xs); color: var(--gold-dim); }
.ce-stage-body { padding-top: var(--sp-12); }

/* ====================================================================
   The conversation graph
   ====================================================================
   A flowchart of one NPC's dialogue, drawn by assets/js/ui-dialog-graph.js.

   Unlike the region chart, which hard-codes a parchment palette because a map
   is a drawn object, this uses the semantic tokens: it is an interface for
   finding faults, and it should follow the theme around it. Every state below
   is a base/-dim/-well triple so a node reads at a glance — gold is a way in,
   red is broken, and the eye should go to the red. */

.dg-frame {
  position: relative;
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  background: var(--bg-inset);
  overflow: hidden;
  min-height: 22rem;
  height: 58vh;
  display: flex;
}

.dialoggraph {
  display: block;
  width: 100%;
  height: 100%;
  cursor: grab;
  touch-action: none;
}
.dialoggraph.is-panning { cursor: grabbing; }

/* Edges are drawn first and sit under the boxes. A curve rather than a straight
   line because a dialogue tree has far denser fan-out than the region map, and
   straight lines between stacked boxes become an unreadable hatch. */
.dg-edge {
  fill: none;
  stroke: var(--border-strong);
  stroke-width: 1.5;
}
/* A jump that goes back up the tree — a "Back." reply. Dimmed, because they are
   numerous and almost never the thing being looked for. */
.dg-edge.is-back { stroke: var(--border-soft); stroke-dasharray: 4 4; }
/* A link to a node that does not exist. This is the one the page is for. */
.dg-edge.is-dangling { stroke: var(--danger); stroke-width: 2; }
.dg-dangling-label {
  fill: var(--danger);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
}

.dg-node { cursor: pointer; }
.dg-box {
  fill: var(--bg-panel-2);
  stroke: var(--border);
  stroke-width: 1;
}
.dg-key {
  fill: var(--text);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
}
.dg-text {
  fill: var(--text-dim);
  font-family: var(--font-ui);
  font-size: var(--fs-2xs);
}
.dg-badge {
  fill: var(--text-faint);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  text-anchor: end;
}
.dg-badge.is-check { fill: var(--accent); }

/* A way in: `start`, `camp`, or a node an encounter's parley opens. */
.dg-node.is-root .dg-box { stroke: var(--gold); fill: var(--gold-well); }
.dg-node.is-root .dg-key { fill: var(--gold-bright); }

/* Ends the conversation — every reply is `end`. */
.dg-node.is-terminal .dg-box { stroke: var(--ornament); }

/* Forks on a die. */
.dg-node.is-check .dg-box { stroke: var(--accent-dim); }

/* Rotates a pool of interchangeable lines. */
.dg-node.is-pooled .dg-box { stroke: var(--warn-dim); }

/* Nothing reaches it, or the linter faulted it. Loud on purpose. */
.dg-node.is-orphan .dg-box,
.dg-node.is-broken .dg-box {
  stroke: var(--danger);
  stroke-width: 2;
  fill: var(--danger-well);
}
.dg-node.is-broken .dg-key { fill: var(--danger); }

.dg-node:hover .dg-box { stroke: var(--gold-bright); }
.dg-node:focus { outline: none; }
.dg-node:focus .dg-box { stroke: var(--gold-bright); stroke-width: 2; }
.dg-node.is-on .dg-box { stroke: var(--gold-bright); stroke-width: 2; }

.dg-zoom {
  position: absolute;
  right: 0.5rem;
  bottom: 0.5rem;
  display: flex;
  gap: var(--sp-4);
  z-index: 1;            /* local: over the chart it zooms */
}

/* The findings list under the chart. */
.dg-findings { margin-top: var(--sp-10); max-height: 12rem; overflow-y: auto; }
.dg-finding {
  display: flex;
  gap: var(--sp-8);
  align-items: baseline;
  padding: var(--sp-4) var(--sp-8);
  border-left: 2px solid var(--border);
  font-size: var(--fs-xs);
}
.dg-finding.is-error { border-left-color: var(--danger); background: var(--danger-well); }
.dg-finding.is-warning { border-left-color: var(--warn); }
.dg-finding code {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  cursor: pointer;
  text-decoration: underline dotted;
}
.dg-clean { color: var(--success); font-size: var(--fs-xs); }

/* ---------------------------------------------------------------------------
   Walking a conversation

   A reading panel, so it is sized for prose rather than for a form: the text
   column is capped near 62 characters because the authored lines run to a
   thousand and a paragraph the full width of a 1400px window is one the eye
   loses its place in. Wide enough for the replies to sit under it without
   wrapping, and no wider.
   --------------------------------------------------------------------------- */
.modal.modal-walk {
  max-width: min(1080px, 95vw);
  max-height: 92vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;          /* the body scrolls, so the head and actions stay put */
}

/* The route taken to get here, which is the one thing this view knows and the
   chart does not. Ellipsised from the left: the recent hops matter most and a
   long walk should not push the close button off the bar. */
.dw-trail {
  margin: 0;
  flex: 1 1 auto;
  min-width: 0;
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-faint);
  white-space: nowrap;
  overflow: hidden;
  direction: rtl;            /* clips the OLD end of the trail, not the new one */
  text-align: right;
}
.dw-trail > * { direction: ltr; unicode-bidi: embed; }
.dw-trail i { opacity: 0.5; padding: 0 var(--sp-4); font-style: normal; }
.dw-trail .is-here { color: var(--gold); }

.dw-body {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: var(--sp-4) var(--sp-2) var(--sp-6);
}

.dw-node { margin: 0 0 var(--sp-10); }
.dw-node code {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
}
.dw-node em {
  font-style: normal;
  font-size: var(--fs-2xs);
  margin-left: var(--sp-8);
  padding: var(--sp-1) var(--sp-6);
  border: 1px solid var(--gold-dim);
  border-radius: var(--r-pill);
  color: var(--gold);
}

.dw-pick { display: block; margin: 0 0 var(--sp-12); font-size: var(--fs-xs); color: var(--text-dim); }
.dw-pick select { width: 100%; margin: var(--sp-4) 0 var(--sp-2); font-family: var(--mono); font-size: var(--fs-2xs); }

.dw-chips { display: flex; flex-wrap: wrap; gap: var(--sp-6); margin-bottom: var(--sp-12); }
.dw-chip {
  font-size: var(--fs-2xs);
  font-family: var(--mono);
  padding: var(--sp-2) var(--sp-8);
  border: 1px solid var(--border);
  border-radius: var(--r-pill);
  color: var(--text-faint);
}
.dw-chip.is-effect { border-color: var(--accent-dim); color: var(--accent); }
.dw-chip.is-tag { border-color: var(--gold-dim); color: var(--gold); }

/* The line itself. This is what the panel is for, so it gets the room. */
.dw-text {
  max-width: 62ch;
  font-size: var(--fs-md);
  line-height: var(--lh-prose);
  color: var(--text);
}
.dw-text p { margin: 0 0 var(--sp-14); }

/* A companion cutting in. Set apart because it is a different mouth. */
.dw-aside {
  max-width: 62ch;
  margin: 0 0 var(--sp-14);
  padding: var(--sp-8) var(--sp-12);
  border-left: 2px solid var(--gold-dim);
  background: var(--bg-raised);
  font-size: var(--fs-sm);
}
.dw-aside strong { color: var(--gold); font-size: var(--fs-xs); }
.dw-aside p { margin: var(--sp-4) 0 0; }

.dw-choices { display: flex; flex-direction: column; gap: var(--sp-6); margin-top: var(--sp-16); }
.dw-choice { display: block; }
.dw-go {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--sp-2) var(--sp-10);
  width: 100%;
  text-align: left;
  padding: var(--sp-8) var(--sp-12);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--bg-raised);
  color: var(--text);
  cursor: pointer;
  font: inherit;
}
.dw-go:hover:not(:disabled) { border-color: var(--gold); background: var(--gold-well); }
.dw-go:disabled { cursor: not-allowed; opacity: 0.75; }
.dw-go.is-broken { border-color: var(--danger); background: var(--danger-well); }

/* The 1–9 shortcut, shown rather than documented — a key you have to be told
   about is one nobody uses. */
.dw-num {
  grid-row: 1 / 3;
  align-self: center;
  min-width: 1.35rem;
  height: 1.35rem;
  display: grid;
  place-items: center;
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-faint);
}
.dw-go:hover .dw-num { border-color: var(--gold); color: var(--gold); }

.dw-label { font-size: var(--fs-sm); }
.dw-meta {
  display: flex;
  align-items: center;
  gap: var(--sp-6);
  flex-wrap: wrap;
  font-size: var(--fs-2xs);
  font-family: var(--mono);
  color: var(--text-faint);
}

/* A reply that stops. Flat, and not a button, because it does not go anywhere. */
.dw-choice.is-end {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-2);
  /* The last value is clearance for the end-glyph in the corner; the other
     three are rhythm. */
  padding: var(--sp-8) var(--sp-12) var(--sp-8) 2.65rem;
  border: 1px dashed var(--border);
  border-radius: var(--r-md);
  color: var(--text-dim);
}

.dw-actions { flex: 0 0 auto; align-items: center; }
.dw-keys { margin-right: auto; font-size: var(--fs-2xs); }

@media (max-width: 640px) {
  .dw-trail { display: none; }
  .dw-keys { display: none; }
}

/* A reply whose outcome forks. The line is said once and the branches sit
   under it, indented so the group reads as one decision rather than as two
   replies that happen to share their wording. */
.dw-choice.is-fork {
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-8) var(--sp-8) var(--sp-8);
}
.dw-forkhead {
  display: flex;
  align-items: baseline;
  gap: var(--sp-8);
  flex-wrap: wrap;
  margin: 0 0 var(--sp-8);
  padding-left: var(--sp-2);
}
.dw-choice.is-fork .dw-go {
  margin-left: var(--sp-16);
  width: calc(100% - 1.1rem);
  padding: var(--sp-6) var(--sp-10);
}
.dw-choice.is-fork .dw-go + .dw-go { margin-top: var(--sp-4); }
.dw-choice.is-fork .dw-label { font-size: var(--fs-xs); color: var(--text-dim); }
.dw-forkhead .dw-label { font-size: var(--fs-sm); color: var(--text); }

/* The speaker beside their line.
   Sticky rather than scrolling away: on a variant that runs to a thousand
   characters the portrait is the thing telling you whose voice this is, and it
   is least useful at the top where you already know. */
.dw-scene {
  display: grid;
  grid-template-columns: 208px minmax(0, 1fr);
  gap: var(--sp-24);
  align-items: start;
}
.dw-scene.is-faceless { grid-template-columns: minmax(0, 1fr); }

.dw-portrait {
  position: sticky;
  top: 0;
  margin: 0;
}
.dw-portrait img {
  display: block;
  width: 208px;
  height: 277px;                  /* the busts are cut 384x512 — keep the ratio */
  object-fit: cover;
  object-position: center top;
  border-radius: var(--r-md);
  border: 1px solid var(--gold-dim);
  background: linear-gradient(180deg, var(--bg-panel), var(--bg));
  box-shadow: var(--sh-2);
}
.dw-portrait figcaption {
  margin-top: var(--sp-6);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-faint);
  text-align: center;
}

/* The small face in the title bar, so the speaker is still named when a long
   node has scrolled the portrait out from under it. */
.dw-face {
  width: 30px;
  height: 30px;
  flex: 0 0 auto;
  border-radius: var(--r-circle);
  object-fit: cover;
  object-position: center top;
  border: 1px solid var(--gold-dim);
}

/* The measure survives the portrait. Widening the panel to make room for a
   face gave the text 800px — about 95 characters, which is half again past
   where a line stops being comfortable to track back from. The portrait takes
   its column out of the panel, not out of the measure; the replies below are
   short and take the full width. */
.dw-scene .dw-text,
.dw-scene .dw-aside { max-width: 64ch; }

@media (max-width: 780px) {
  .dw-scene { grid-template-columns: minmax(0, 1fr); }
  .dw-portrait { position: static; justify-self: start; }
  .dw-portrait img { width: 132px; height: 176px; }
}

/* A person's row, with the way into their conversation beside it.
   The pair is the grid cell; the row keeps all the flexible width and Read
   takes only what it needs, so names of any length still line up. */
.ce-row-pair { display: flex; align-items: stretch; gap: var(--sp-4); }
.ce-row-pair .ce-row { flex: 1 1 auto; min-width: 0; }

.ce-row-walk {
  flex: 0 0 auto;
  align-self: center;
  padding: var(--sp-4) var(--sp-8);
  border: 1px solid var(--border-soft);
  border-radius: var(--r-md);
  background: none;
  color: var(--gold-dim);
  cursor: pointer;
  font: inherit;
  font-size: var(--fs-2xs);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  /* Held back until the row is under the pointer: on a list of sixty people a
     button on every line reads as sixty things to do. */
  opacity: 0;
  transition: opacity var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast), background var(--dur-fast);
}
.ce-row-pair:hover .ce-row-walk,
.ce-row-walk:focus-visible { opacity: 1; }
.ce-row-walk:hover { color: var(--gold-bright); border-color: var(--gold); background: var(--gold-well); }

/* Never hidden where hover does not exist — a touch device would have no way
   to reach it at all. */
@media (hover: none) {
  .ce-row-walk { opacity: 1; }
}

/* ===========================================================================
 * TOUCH SIZING — the 44px floor, below the fold only
 *
 * Every interactive control in this interface was drawn for a mouse and never
 * looked at again: tools/shell_probe.php measured `.icon-btn` at 32x32,
 * `.person` rows at 25px, `.party-add` at 15px and `.act-slot` at 34px — and
 * measured them IDENTICALLY at 1440px and at 360px. Nothing about the controls
 * responded to the viewport at all. 44px is the size a finger hits reliably.
 *
 * Width-conditional, and deliberately so. Applying the floor everywhere makes
 * the desktop rails and the action bar markedly chunkier to fix a problem
 * desktop does not have — a mouse hits 32px perfectly well. So this rides the
 * shell's own fold at 760px, where the three columns already become one: below
 * it you are on a phone holding a single column, and the controls should be
 * thumbable; above it nothing changes at all, which the style snapshot asserts.
 *
 * The honest caveat: width is a proxy for touch, not touch itself. A tablet in
 * landscape at 1024px with no mouse gets the mouse sizing. The fix for that is
 * `(pointer: coarse)` alongside the width — deliberately not added here,
 * because it would also catch a desktop with a touchscreen and make its UI
 * chunky for a pointer it is not being driven with.
 *
 * `--tap-min` is a token so the probe and the stylesheet cannot disagree about
 * what the floor is.
 * =========================================================================== */

@media (max-width: 760px) {
  .icon-btn,
  .icon-btn-sm {
    width: var(--tap-min);
    height: var(--tap-min);
  }
  /* The glyph does not grow with its target — a 44px button exists to be hit,
     not to shout. Kept at the size it reads best and simply given more room. */
  .icon-btn-sm svg { width: 1.1rem; height: 1.1rem; }

  /* Rows, which grow by their padding rather than by a height: the label has to
     stay vertically centred against a portrait that is not changing size. */
  .person,
  .action-btn,
  .act-slot,
  .loc-act {
    min-height: var(--tap-min);
  }
  .act-slot,
  .loc-act { width: auto; min-width: var(--tap-min); }

  /* min-WIDTH as well as min-height: the log's filters are "All", "Combat" and
     "Story", and padding alone left the short one at 41px — tall enough to hit
     and a finger's width too narrow. A target is a box, not a height. */
  .chip {
    min-height: var(--tap-min);
    min-width: var(--tap-min);
    padding: var(--sp-6) var(--sp-12);
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /* Beats `.hud-party .party-add { min-height: 0 }`, which is what flattened
     the recruit slot to fifteen pixels — a min-* floor from the old rail that
     the rewrite zeroed rather than removed. */
  .hud-party .party-add,
  .party-add {
    min-height: var(--tap-min);
  }
}

/* ===========================================================================
 * PHONE LAYOUT — the scene first, and the page allowed to scroll
 *
 * Below the fold the shell already collapsed to one column, but it collapsed
 * in DOM order onto `grid-template-rows: auto 1fr auto` — and the DOM order is
 * cbt-top, rail-left, scene, rail-right. So the 1fr, the one flexible row,
 * landed on the PARTY RAIL, and the scene took an `auto` row sized to whatever
 * its content happened to be. shell_probe.php measured the result at 430px:
 * party rail 241px, the game itself 240px, log and tracker 331px. The thing
 * you opened the game for was the smallest band on the screen, under two
 * pieces of furniture.
 *
 * That was an off-by-one in a row assignment, not a decision anybody made,
 * which is exactly the kind of thing a layout only confesses to when something
 * measures it.
 *
 * Two changes, and the second is the one that matters:
 *
 *   The rows are placed explicitly, so "which band is the game" is written
 *   down rather than inherited from the order the markup happens to be in.
 *
 *   The page scrolls. The desktop shell is a single viewport with
 *   `overflow: hidden` and `100dvh`, which is right for three columns side by
 *   side and wrong for four stacked: it meant everything below the fold had to
 *   fit in one screen, so every band was squeezed to make room for the others.
 *   Let it scroll and the scene can simply have the first screen.
 *
 * Reading order after the scene is tracker, actions, log, then party and the
 * people here — what you are doing, what you can do, what happened, who is
 * with you. The action bar is lifted above the log inside the rail rather than
 * left underneath it, because a log is a thing you consult and a button row is
 * a thing you reach for.
 * =========================================================================== */

@media (max-width: 760px) {
  html:has(body.game-page),
  body.game-page {
    height: auto;
    min-height: 100dvh;
    overflow: visible;
  }

  .game-shell {
    grid-template-columns: minmax(0, 1fr);
    /* Named rows so an added child cannot silently take the scene's place the
       way the party rail took the 1fr. */
    grid-template-rows: auto auto auto auto;
    height: auto;
    min-height: 100dvh;
    overflow: visible;
  }

  .cbt-top    { grid-row: 1; }
  .scene      { grid-row: 2; }
  .rail-right { grid-row: 3; }
  .rail-left  { grid-row: 4; }

  /* The first screenful is the game. Not 100dvh — a band that exactly fills
     the viewport gives no hint that anything follows it, and the rails below
     are then only found by accident. */
  .scene {
    min-height: 68vh;
    overflow: visible;
  }

  /* The rails stop being panes-that-scroll-inside-themselves and become
     sections of a page. Inner scrollers inside an outer scroller is the thing
     that makes a phone layout feel broken. */
  .rail,
  .rail-left,
  .rail-right {
    overflow: visible;
    max-height: none;
    height: auto;
  }
  .rail-right { display: flex; flex-direction: column; }
  .rail-left  { max-height: none; }
  .rail-pane  { overflow: visible; min-width: 0; }
  .hud-party  { flex-direction: row; flex-wrap: wrap; }

  /* Actions above the log, inside the rail. */
  .rail-bottom .action-bar { order: -1; }

  /* The log keeps a cap: it is the one band with unbounded content, and left
     to grow it would put the party rail an arbitrary distance down the page. */
  .hud-log .log { max-height: 40vh; }

  /* A fight USED to take the screen back: 100dvh, overflow hidden, on the
     argument that the board is the game at that moment and is the one thing
     here that genuinely wants a fixed viewport.
   
     The argument was right and the arithmetic was not. Pinned to one screen,
     everything on it competes for 740px — and the board lost, twice, badly
     enough to be reported as unusable. Even after the rail and the rosters
     were capped it came out 184px tall against the exploration map's 235 on
     the same phone, and the exploration map is the size that works.
   
     So the fight scrolls, like everything else below the fold. The board is
     given a real height rather than whatever is left over, and what falls
     under the fold is the log — which is what gives here, as it does in the
     budget above. */
  body.in-combat .game-shell {
    grid-template-rows: auto auto auto;
    height: auto;
    min-height: 100dvh;
    overflow: visible;
  }

  /* The board, sized rather than starved. 15rem is the exploration map's own
     floor plus the pixel or two its border costs — at 360px that is a 304-wide
     box 240 tall, and a 16x12 grid contained in it comes out at 19px a cell
     against the 15.4 it had. */
  body.in-combat .combat-screen {
    /* The desktop overlay is a single viewport. Below the fold the page
       scrolls, and an `inset: 0` box with `overflow: hidden` clips the
       initiative list and the verbs under the map. Relative lets the three
       bands stack and the log sit under them, which is the reading order. */
    position: relative;
    inset: auto;
    overflow: visible;
    height: auto;
  }
  body.in-combat .scene .topbar { display: none; }
  body.in-combat .cbt-map { min-height: 15rem; }

  body.in-combat .cbt-field {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto minmax(15rem, 38vh) auto;
    grid-template-areas:
      "init"
      "map"
      "panel";
  }
  body.in-combat .cbt-init,
  body.in-combat .cbt-panel {
    border-left: 0;
    border-right: 0;
    border-top: 0;
  }
  body.in-combat .cbt-init {
    max-height: none;
    border-bottom: 1px solid var(--border);
  }
  body.in-combat .cbt-init-list {
    flex: 0 0 auto;
    flex-direction: row;
    overflow-x: auto;
    overflow-y: hidden;
  }
  body.in-combat .cbt-init .cbt-card {
    width: 11rem;
    flex: 0 0 auto;
  }
  body.in-combat .cbt-init > h3 { display: none; }
  body.in-combat .cbt-board-fx {
    display: flex;
    flex: 0 0 auto;
    min-height: 0;
    max-height: 5.5rem;
    border-top: 1px solid var(--border);
  }
  body.in-combat .cbt-board-fx > h3 {
    padding: var(--sp-6) var(--sp-12) var(--sp-2);
  }
  body.in-combat .cbt-fx-note { display: none; }
  body.in-combat .cbt-fx-body {
    flex-direction: row;
    flex-wrap: nowrap;
    gap: var(--sp-4);
    align-items: baseline;
  }
  body.in-combat .cbt-fx-who { flex: 0 1 auto; }
  body.in-combat .cbt-fx-who::after { content: " ·"; }
  body.in-combat .cbt-fx-line {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  body.in-combat .cbt-fx-row { padding: var(--sp-2) var(--sp-4); margin-bottom: var(--sp-2); }
  body.in-combat .cbt-board-fx-list {
    margin: 0 var(--sp-8) var(--sp-6);
    padding: var(--sp-4) var(--sp-6);
  }

  /* The action column stays put while the log scrolls under it. It is the one
     band you reach for rather than read, and a fight where you scroll to find
     your own actions is worse than a fight with a short log. */
  body.in-combat .cbt-panel {
    position: sticky;
    bottom: 0;
    z-index: 1;            /* local: over the log sliding past beneath it */
    background: var(--bg-panel);
    max-height: 46vh;
    overflow: hidden;
  }
  body.in-combat .cbt-bar {
    background: var(--bg-panel);
  }
}

/* --- studio ------------------------------------------------------------ */
.studio-page { padding: var(--sp-24); max-width: 86rem; margin: 0 auto; }
.st-shelf { margin: 0 0 var(--sp-24); }
.st-card-actions { display: flex; gap: var(--sp-8); flex-wrap: wrap; }
.st-draft { font-size: var(--fs-xs); color: var(--gold); font-style: normal; margin-left: var(--sp-8); }
.st-new { max-width: 36rem; }
.st-levels { display: flex; align-items: center; gap: var(--sp-8); }
.st-levels input { width: 4.5rem; }
.st-meta { margin: 0 0 var(--sp-16); }
.st-desk {
  display: grid;
  grid-template-columns: minmax(18rem, 1.4fr) minmax(16rem, 1fr) minmax(14rem, 0.8fr);
  gap: var(--sp-16);
  align-items: start;
}
@media (max-width: 70rem) {
  .st-desk { grid-template-columns: 1fr; }
}
.st-pane {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--sp-16);
  min-width: 0;
}
.st-pane h2 {
  margin: 0 0 var(--sp-12);
  font-size: var(--fs-sm);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--gold-dim);
  border: none;
  padding: 0;
}
.st-map .loc-map { min-height: 16rem; }
.st-regions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-6);
  margin: 0 0 var(--sp-12);
}
.st-regions[hidden] { display: none; }
.st-region.is-on {
  border-color: var(--gold);
  color: var(--gold);
}

.st-plan-tools {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-8);
  margin: 0 0 var(--sp-8);
}
.st-plan-tool.is-on,
.st-plan-tools .is-on {
  border-color: var(--gold);
  color: var(--gold);
}
#st-plan-save.is-dirty { box-shadow: inset 0 0 0 1px var(--gold); }
.st-plan-hint { margin: 0 0 var(--sp-8); }
.st-plan-stage { min-height: 14rem; }
.st-plan-svg {
  display: block;
  width: 100%;
  height: auto;
  background: var(--pg-rock-dark);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  touch-action: none;
}
.st-plan-cell {
  fill: transparent;
  stroke: var(--pg-ink);
  stroke-width: 0.15;
  opacity: 0.35;
}
.st-plan-room rect {
  fill: var(--pg-paper);
  stroke: var(--pg-ink);
  stroke-width: 0.45;
}
.st-plan-room.is-selected rect { stroke: var(--gold); stroke-width: 0.7; }
.st-plan-room.is-from rect { stroke: var(--gold); stroke-dasharray: 0.8 0.5; }
.st-plan-room.is-draft rect { fill: transparent; stroke-dasharray: 0.7 0.5; }
.st-plan-room.is-bad rect { stroke: var(--danger); fill: color-mix(in srgb, var(--danger) 25%, transparent); }
.st-plan-label {
  fill: var(--pg-ink);
  font-size: 2.2px;
  text-anchor: middle;
  dominant-baseline: middle;
  pointer-events: none;
}
.st-plan-handle {
  fill: var(--gold);
  stroke: var(--pg-ink);
  stroke-width: 0.15;
  pointer-events: none;
}
.st-plan-hall {
  stroke: var(--pg-paper);
  stroke-width: 1.4;
  stroke-linecap: round;
}
.st-plan-hall.is-selected { stroke: var(--gold); }
.st-plan-hall.is-secret { stroke-dasharray: 1.4 0.9; }
.st-plan-inspect { margin-top: var(--sp-12); }
.st-plan-check {
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  padding-top: 1.4rem;
}

.st-cover {
  display: block;
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  border-radius: var(--r-sm);
  margin: 0 0 var(--sp-12);
  background: var(--bg-inset);
}
.st-cover.is-missing { display: none; }
.st-upload {
  display: block;
  font-size: var(--fs-sm);
  margin: 0 0 var(--sp-12);
}
.st-cover-up { max-width: 16rem; }
.st-list { list-style: none; padding: 0; margin: 0 0 var(--sp-8); }
.st-list li { margin: 0 0 var(--sp-4); }
.st-add { margin: 0 0 var(--sp-12); }
.st-add summary { cursor: pointer; color: var(--gold-dim); }
.st-beats { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--sp-8); }
.st-beat {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  width: 100%;
  text-align: left;
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: var(--sp-8);
  cursor: pointer;
}
.st-beat em {
  font-style: normal;
  font-size: var(--fs-xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--gold-dim);
}
.st-beat.is-fight em { color: var(--danger); }
.st-beat.is-end em { color: var(--gold); }
.st-card-node {
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: var(--sp-12);
  margin: 0 0 var(--sp-12);
  background: var(--bg-inset);
}
.st-card-node.is-start { border-color: var(--gold); }
.st-card-head {
  display: flex;
  align-items: center;
  gap: var(--sp-8);
  margin: 0 0 var(--sp-8);
}
.st-reply {
  margin: 0 0 var(--sp-12);
  padding: var(--sp-8) 0 0;
  border-top: 1px solid var(--border);
}
.st-reply-main, .st-pred {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-8);
  align-items: center;
  margin: 0 0 var(--sp-8);
}
.st-reply-main input[data-label] { flex: 1 1 10rem; min-width: 8rem; }
.st-reply-when, .st-reply-does { padding-left: var(--sp-8); }
.st-walk-fx { display: block; font-size: var(--fs-xs); color: var(--gold-dim); font-style: normal; }
@media (max-width: 50rem) {
  .st-reply { grid-template-columns: 1fr; }
}
.st-talk-graph {
  min-height: 8rem;
  margin: 0 0 var(--sp-12);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  overflow: hidden;
}
.st-talk-walk {
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: var(--sp-12);
  margin: var(--sp-12) 0;
  background: var(--bg-inset);
}
.st-walk-line { white-space: pre-wrap; }
.st-quest { margin: 0 0 var(--sp-12); }
.st-quest h4 { margin: 0 0 var(--sp-8); }
.st-stages { list-style: none; padding: 0; margin: 0 0 var(--sp-8); }
.st-stages li {
  display: grid;
  grid-template-columns: 1fr 8rem auto auto;
  gap: var(--sp-8);
  align-items: center;
  margin: 0 0 var(--sp-8);
}
.st-stages em { font-size: var(--fs-xs); color: var(--gold); }


/* --- the door menu ------------------------------------------------------
   What a party can do at a threshold, as a short list of verbs. Opening is
   always first and always present, so the common case — walk through — is the
   top item and one press of Enter. */
.door-menu {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  padding: var(--sp-4);
}
.door-opt {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  padding: var(--sp-4);
  text-align: left;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  color: inherit;
  cursor: pointer;
}
.door-opt:hover:not(:disabled),
.door-opt:focus-visible:not(:disabled) { border-color: var(--gold-dim); }
.door-opt:first-child .door-opt-label { color: var(--gold); }
.door-opt-label { font-weight: var(--fw-bold); }
.door-opt-hint { font-size: 0.85em; opacity: 0.75; }
/* A verb the party cannot reach — no tools for the lock, nothing to disarm.
   Shown rather than hidden, so the menu says why not instead of going quiet. */
.door-opt.is-off { opacity: 0.45; cursor: not-allowed; }
.door-tally {
  margin: var(--sp-2) 0 0;
  font-size: 0.85em;
  opacity: 0.8;
}
