:root {
  /* Sampled from the app icon: a d20 drawn in navy ink on warm paper, with one
     gold face. Navy is the app; the paper is the ground the whole interface
     sits on, which is why the neutrals below are warm rather than gray.

     The gold stays in the mark and does not become a token. An amber already
     means something here -- it is the wash on an encounter -- and a second
     amber that meant "belongs to the app" would put two unrelated messages in
     one colour. */
  --accent: #1c3d63;
  --accent-dark: #0e253c;   /* the ink of the mark, exactly */
  --accent-soft: #b6c6d8;
  --accent-bg: #edf1f6;

  /* Urgency, which is not the brand. Before these were the same variable, so
     an overdue badge and a tag chip were the same color and the interface had
     only one thing it could say. Danger marks work that is late or blocked;
     accent marks what belongs to the app. */
  --danger: #b3283c;
  --danger-bg: #fbeaed;

  /* Warm neutrals: the icon's paper, carried through every panel and rule, so
     navy sits on them the way the ink sits on the card it was drawn on. */
  --gray-50: #faf7f0;
  --gray-100: #f3efe5;
  --gray-200: #e6e0d3;
  --gray-300: #d5cdbd;
  --gray-500: #6d665a;   /* 5.6:1 on --surface -- this carries most of the small metadata text */
  --gray-600: #5b544a;
  --gray-700: #47423a;
  --gray-900: #1f1c17;
  --border: var(--gray-200);

  /* What a card, a panel and an input sit on, and what the page sits on behind
     them. Named rather than written as #fff throughout, because the dark
     theme's whole job is to change these two. */
  --surface: #fffdf8;
  --page: #f6f2e9;

  /* Text sitting on the accent itself. The mark's paper rather than pure white,
     so a button and the brand are lit the same. On the dark theme the accent is
     the light colour, so this has to flip with it or every label disappears. */
  --on-accent: #fdfbf5;

  /* The header is its own pair rather than the accent, because on the dark
     theme the accent becomes the light colour -- and a bar that inverts while
     everything below it stays dark is the one thing the eye cannot ignore. */
  --header-bg: var(--accent-dark);
  --header-fg: #efe9db;

  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

/* There are two themes, and they are the palette above and the one below. The
   app inherited four more accent presets from the work-notes app it was forked
   from; they moved the brand colour to something the mark is not, which is a
   choice worth having only if the brand is arbitrary. This one is drawn in
   navy. */

/* ---- Dark ---------------------------------------------------------------
   Not a preset: a whole second palette, because this app is read in dim rooms
   at a table where the screen is the brightest thing in front of everyone.
   Every token is redefined here rather than a few patched, so nothing is left
   at a light-theme value and quietly glowing.

   Danger keeps its meaning and only its lightness moves -- "late" must not
   change what it says with the decor, but it does have to stay readable on a
   dark ground. */
[data-theme="dark"] {
  --on-accent: #101820;
  --header-bg: #101a26;
  --header-fg: #e8e2d3;

  --accent: #7fa9d6;
  --accent-dark: #a3c4e6;   /* on dark, the "stronger" accent is the lighter one */
  --accent-soft: #2f4560;
  --accent-bg: #1b2734;

  --danger: #f2879a;
  --danger-bg: #3a1e25;

  /* The neutrals turn cool here. Warm paper is a lit surface -- there is no
     such thing as a warm dark room -- so the dark theme keeps the ink and
     drops the page it was printed on. */
  --gray-50:  #14181e;
  --gray-100: #1b2029;
  --gray-200: #29303b;
  --gray-300: #3a424f;
  --gray-500: #949cab;      /* 5.9:1 on --surface -- still carries the small text */
  --gray-600: #a9b1bf;
  --gray-700: #c3c9d4;
  --gray-900: #e9ecf1;
  --border: var(--gray-200);

  --surface: #1b2029;
  --page: #14181e;

  color-scheme: dark;
}

/* ---- The three kinds of segment ------------------------------------------
   A wash, not a color scheme: enough to read the shape of a night at a glance
   -- where the fights are, how much talking stands between them -- without
   turning the list into bunting. Anything wearing data-kind gets a pair, and
   everything downstream reads --kind and --kind-bg without knowing which kind
   it got. Deliberately not the accent and not danger: those two already mean
   "belongs to the app" and "this is late", and a scene is neither.

   Dark repeats the pair rather than tinting the light one, for the reason the
   whole dark palette exists -- a pale wash on a dark row is a lamp. */
[data-kind="scene"]     { --kind: #4d6f9e; --kind-bg: #eef2f8; }
[data-kind="encounter"] { --kind: #96692a; --kind-bg: #faf3e6; }
[data-kind="combat"]    { --kind: #9c3a47; --kind-bg: #fbeef0; }
/* Not a segment kind -- the recap, which wears a segment's clothes. Grey on
   purpose: it is the one row above the night that is not part of it. */
[data-kind="recap"]     { --kind: #6d665a; --kind-bg: #f0efec; }

[data-theme="dark"] [data-kind="scene"]     { --kind: #9db6dd; --kind-bg: #232a35; }
[data-theme="dark"] [data-kind="encounter"] { --kind: #d8b070; --kind-bg: #2e2820; }
[data-theme="dark"] [data-kind="combat"]    { --kind: #e2919b; --kind-bg: #322128; }
[data-theme="dark"] [data-kind="recap"]     { --kind: #b8b0a2; --kind-bg: #26262a; }

/* Anything measured -- dates, times, counts, ages, due badges. Tabular figures
   line up in a column, and the shift in voice marks the difference between
   something written and something recorded. */
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* One focus style everywhere. There was none before. */
:where(a,
button,
input,
select,
textarea,
[tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-radius: 3px;
}

* { box-sizing: border-box; }

/* The hidden attribute has to beat a class that sets display. Without this a
   rule like `.segment-body { display: flex }` silently outranks the browser's
   own `[hidden] { display: none }`, and every panel the app hides by attribute
   is drawn anyway. */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--page);
  color: var(--gray-900);
}

/* ---- application header -------------------------------------------------
   A single bar wearing the icon's own background color, so the brand mark's
   square dissolves into it and leaves the flask. Everything is one row at a
   fixed height: the bar is chrome, and chrome that reflows is chrome you keep
   looking at. Links that used to sit here now live in the two menus. */
.app-header {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  height: 46px;
  padding: 0 0.85rem;
  background: var(--header-bg);
  color: var(--header-fg);
  position: sticky;
  top: 0;
  z-index: 40;
}
.brand {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  color: var(--header-fg);
  text-decoration: none;
  font-weight: 650;
  font-size: 0.95rem;
  letter-spacing: -0.01em;
  white-space: nowrap;
}
.brand img { display: block; border-radius: 3px; }

/* The line at the bottom of every page. Quiet by design: it is a statement of
   record, not something to read. */
.app-footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.15rem 0.6rem;
  padding: 1.75rem 1rem 1.25rem;
  border-top: 1px solid var(--border);
  color: var(--gray-500);
  font-size: 0.72rem;
}
.app-footer-build {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.navbtn {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  height: 28px;
  padding: 0 0.55rem;
  border: 0;
  border-radius: 5px;
  background: transparent;
  color: var(--header-fg);
  font: inherit;
  font-size: 0.85rem;
  cursor: pointer;
  white-space: nowrap;
}
.navbtn:hover,
.nav-menu.is-open .navbtn { background: rgba(255, 255, 255, 0.16); }
.navbtn .caret { font-size: 0.6rem; opacity: 0.7; }

.nav-menu { position: relative; }
/* Pushes a menu group to the far edge -- put this on the first item of the
   trailing group only; a second auto margin on the item after it would split
   the free space between both instead of collapsing the gap between them. */
.nav-menu-end { margin-left: auto; }
/* Right-edge menus open leftward from their button instead of the usual
   left-aligned drop, so they don't run off the side of the screen. Separate
   from .nav-menu-end because a menu can need this without also being the
   thing that pushes the group -- Links, sitting next to Tasks. */
.menu-right .menu { left: auto; right: 0; }

.iconbtn[hidden] { display: none; }
.iconbtn {
  /* inline-grid, not grid: these sit inside headings and rows beside words --
     "Prep" and its image button are one line, not two. */
  display: inline-grid;
  place-items: center;
  vertical-align: middle;
  width: 28px;
  height: 28px;
  border: 0;
  border-radius: 5px;
  background: transparent;
  /* Takes the ink of whatever it sits in. It was written for the header bar and
     said so in its colours, which made every icon button on a panel -- the
     segment delete, for one -- near-white on cream and effectively invisible. */
  color: inherit;
  opacity: 0.85;
  cursor: pointer;
}
.iconbtn:hover { background: color-mix(in srgb, currentColor 16%, transparent); opacity: 1; }
.iconbtn:disabled { opacity: 0.35; cursor: default; }
.iconbtn:disabled:hover { background: transparent; }

/* The sprite itself is markup, not a picture: it defines the shapes and draws
   nothing. Without this it reserves a line of the page above the header. */
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

/* One rule for every icon in the app. They are stroked rather than filled so a
   single line weight is what makes them look like a set, and they take their
   colour from the button around them so nothing has to be restyled per panel. */
.icon {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  pointer-events: none;   /* clicks belong to the button, not the drawing */
}

/* A scene that has been played wears its tick in the accent and at full
   strength: it is the one state in the row worth seeing from across the
   screen, and the same button un-plays it. */
.iconbtn.is-on { color: var(--accent); opacity: 1; }
.iconbtn.is-on:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.logout-form { display: flex; margin: 0; }

/* ---- header menus ---- */
.menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  z-index: 50;
  min-width: 13rem;
  padding: 0.3rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 7px;
  box-shadow: 0 10px 26px rgba(33, 29, 40, 0.18);
}
.menu[hidden] { display: none; }
.menu a,
.menu button {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  width: 100%;
  padding: 0.35rem 0.5rem;
  border: 0;
  border-radius: 4px;
  background: none;
  color: var(--gray-900);
  font: inherit;
  font-size: 0.83rem;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}
.menu a:hover,
.menu button:hover { background: var(--accent-bg); color: var(--accent-dark); }
/* A link that leads with its icon. The base rule spreads a label and a hint to
   opposite ends; here the icon sits with its words instead. */
.menu a.menu-link { justify-content: flex-start; align-items: center; gap: 0.6rem; }
.menu a.menu-link .icon { width: 15px; height: 15px; flex: none; color: var(--gray-500); }
.menu a.menu-link:hover .icon { color: inherit; }
.menu a[aria-current='page'] { background: var(--accent-bg); color: var(--accent-dark); font-weight: 600; }
.menu a[aria-current='page'] .icon { color: inherit; }
.menu-sep { height: 1px; margin: 0.3rem 0.2rem; background: var(--border); }
.menu .hint { color: var(--gray-500); font-family: var(--font-mono); font-size: 0.7rem; }
.menu-label {
  padding: 0.3rem 0.5rem 0.2rem;
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--gray-500);
}

/* ---- header search ---- */
.nav-search { position: relative; flex: 1 1 auto; max-width: 34rem; }
.nav-search-icon {
  position: absolute;
  left: 0.5rem;
  top: 50%;
  transform: translateY(-50%);
  color: var(--on-accent);
  opacity: 0.65;
  pointer-events: none;
}
.header-search-input {
  width: 100%;
  height: 28px;
  padding: 0 0.55rem 0 1.75rem;
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: 5px;
  background: rgba(255, 255, 255, 0.12);
  color: var(--on-accent);
  font: inherit;
  font-size: 0.85rem;
}
.header-search-input::placeholder { color: rgba(255, 255, 255, 0.68); }
.header-search-input:focus { background: rgba(255, 255, 255, 0.2); outline-color: var(--header-fg); }

/* ---- page title row ----
   The bar carries the app's name, so a page's own name goes here rather than
   competing with it. */
.page-title {
  padding: 0.55rem 1rem 0;
}
.page-title h1 {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--gray-900);
}

/* Generic button-styled link/button (export/backup actions, page controls). */
.btn,
.btn-action {
  display: inline-block;
  border: 1px solid var(--gray-300);
  background: var(--gray-100);
  color: var(--gray-700);
  border-radius: 6px;
  padding: 0.35rem 0.75rem;
  font-size: 0.82rem;
  font-family: inherit;
  text-decoration: none;
  cursor: pointer;
}
.btn:hover,
.btn-action:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }

/* For the one action in a panel that is the point of it -- the others stay
   quiet so the recommended one does not have to shout. */
.btn-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.btn-primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); color: var(--on-accent); }

main {
  padding: 0.75rem 0.75rem 3rem;
}

.panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.7rem 0.8rem;
  margin-bottom: 0.7rem;
}
/* A panel heading is a label, not a title: small, plum, and followed by a rule
   that runs to the panel's summary on the right. Costs a fifth of the vertical
   space the old shouting headings did, and carries a fact besides. */
.panel h2 {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0 0 0.55rem;
  font-size: 0.66rem;
  font-weight: 650;
  text-transform: uppercase;
  letter-spacing: 0.09em;
  color: var(--accent);
}
/* The first heading in a panel gets its air from the panel's own padding. A
   later one is starting a new group inside the panel and has to make its own,
   or it reads as a caption on the block above it rather than a heading for the
   block below. */
.panel h2:not(:first-child) { margin-top: 1.5rem; }

.panel h2::after {
  content: "";
  order: 1;
  flex: 1;
  height: 1px;
  background: var(--border);
}
.panel-summary {
  order: 2;
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 0.7rem;
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
  color: var(--gray-500);
}

/* #tag chips. Drawn over text that still reads "#tag" underneath (see
   tag-chips.js), so they stay inline and keep the surrounding line's size --
   a chip that changed the line height would reflow a note on every edit. */
.tag-chip {
  color: var(--accent);
  text-decoration: none;
  background: var(--accent-bg);
  border-radius: 3px;
  padding: 0 0.2rem;
  white-space: nowrap;
}
.tag-chip:hover { text-decoration: underline; }

/* Day stack */
#load-more-wrap { margin-bottom: 0.5rem; }
#load-more {
  background: var(--gray-100);
  border: 1px solid var(--gray-300);
  border-radius: 6px;
  padding: 0.4rem 0.75rem;
  cursor: pointer;
  font-size: 0.8rem;
}

.day { position: relative; }

.matrix-item {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  background: var(--surface);
  border: 1px solid var(--gray-200);
  border-radius: 4px;
  padding: 0.2rem 0.35rem;
  font-size: 0.78rem;
  cursor: grab;
}
.matrix-item.migrated { opacity: 0.5; }

.modal-actions .danger { color: var(--accent); }

/* Migration page */
.migrate-item {
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 0.75rem;
  margin: 0.75rem 0;
}
.migrate-item h3 { margin: 0 0 0.5rem; font-size: 0.9rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.migrate-item summary { font-size: 0.78rem; color: var(--gray-500); cursor: pointer; }
.migrate-sql,
.migrate-error {
  background: var(--gray-50);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.6rem;
  font-size: 0.72rem;
  line-height: 1.45;
  overflow-x: auto;
  white-space: pre-wrap;
  word-break: break-word;
}
.migrate-error { border-color: var(--accent); background: var(--accent-bg); color: var(--gray-900); }
.migrate-ok { font-size: 0.85rem; color: var(--gray-700); }
.migrate-run-form {
  display: flex;
  align-items: flex-end;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin-top: 0.6rem;
}
.migrate-run-form label {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  font-size: 0.75rem;
  color: var(--gray-700);
  flex: 1 1 20rem;
}
.migrate-run-form input[type="text"] {
  border: 1px solid var(--gray-300);
  border-radius: 4px;
  padding: 0.35rem 0.5rem;
  font-size: 0.82rem;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

#notes-overlay-slot { flex: 1; display: flex; min-height: 0; }

/* Modals */
dialog.modal {
  border: none;
  border-radius: 8px;
  padding: 1.25rem;
  width: 360px;
  max-width: 90vw;
}
dialog.modal::backdrop { background: rgba(0, 0, 0, 0.35); }
dialog.modal h3 { margin: 0 0 1rem; font-size: 1rem; }
dialog.modal h4 { margin: 1.25rem 0 0.5rem; font-size: 0.85rem; color: var(--gray-700); }
dialog.modal label {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  font-size: 0.8rem;
  color: var(--gray-700);
  margin-bottom: 0.75rem;
}
dialog.modal input[type="text"],
dialog.modal input[type="date"],
dialog.modal input[type="number"],
dialog.modal select {
  border: 1px solid var(--gray-300);
  border-radius: 4px;
  padding: 0.35rem 0.5rem;
  font-size: 0.85rem;
  font-family: inherit;
}
.modal-hint { font-size: 0.75rem; color: var(--gray-500); margin: 0 0 0.5rem; }

.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.5rem; }
.modal-actions button {
  border-radius: 4px;
  padding: 0.4rem 0.8rem;
  font-size: 0.8rem;
  cursor: pointer;
  border: 1px solid var(--gray-300);
  background: var(--gray-100);
}
.modal-actions .modal-primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }

/* A modal holding a document rather than a field. Wide because the thing being
   pasted is markdown with tables in it, and a 360px box makes a session's notes
   unreadable at exactly the moment the DM wants to check them. */
dialog.modal.modal-wide { width: 720px; }
.import-notes-tools { justify-content: space-between; align-items: center; margin: 0 0 0.5rem; }
.import-notes-file { display: flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; color: var(--gray-500); margin: 0; }
.import-notes-file input { font-size: 0.75rem; }
.import-notes-source {
  width: 100%;
  font-family: var(--mono, monospace);
  font-size: 0.8rem;
  line-height: 1.5;
  border: 1px solid var(--gray-300);
  border-radius: 4px;
  padding: 0.5rem;
  resize: vertical;
}
.import-notes-bad { color: var(--accent); }
.import-notes-instructions {
  max-height: 40vh;
  overflow: auto;
  white-space: pre-wrap;
  font-size: 0.75rem;
  background: var(--gray-100);
  border-radius: 4px;
  padding: 0.5rem;
}

/* Mobile: kanban board becomes a single vertical stack of columns instead of
   a grid, since a 2-up grid of 5 columns is still too cramped on a phone. */
@media (max-width: 600px) {
  /* The bar stays one row on a phone -- that is what collapsing eleven links
     into two menus bought. The brand's wordmark is the only thing that goes,
     since the icon beside it already says where you are. */
  .app-header { gap: 0.4rem; padding: 0 0.5rem; }
  .brand { font-size: 0; gap: 0; }
  .brand img { margin-right: 0.1rem; }
  .header-search-input::placeholder { color: transparent; }
  main { padding: 0.5rem 0.5rem 2rem; }
  
  
}

/* ---- People / per-person view ---- */

/* Generic button chrome for buttons outside the header nav (note skeletons,
   delete affordances). The .app-header nav rule is more specific, so the
   header keeps its own look. */
.btn-action {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.3rem 0.6rem;
  font: inherit;
  font-size: 0.78rem;
  color: var(--gray-700);
  cursor: pointer;
}
.btn-action:hover { border-color: var(--accent); color: var(--accent); }
.btn-action:disabled { opacity: 0.5; cursor: default; }

.note-template {
  border: 1px solid var(--border);
  border-radius: 6px;
  margin-bottom: 0.8rem;
  overflow: hidden;
}

/* Results dropdown for the header search field, which is styled up in the header block. */
.header-search-results {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  z-index: 30;
  max-height: 65vh;
  overflow-y: auto;
  padding: 0.25rem 0;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}
/* Results are ranked across kinds rather than grouped, so the kind is a badge
   on each row instead of a heading above a run of them. */
.header-search-kind {
  display: block;
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--gray-500);
}
.header-search-filters,
.header-search-notice {
  padding: 0.35rem 0.75rem;
  font-size: 0.7rem;
  color: var(--gray-500);
  border-bottom: 1px solid var(--border);
}
.header-search-filters { color: var(--accent); }
.header-search-result {
  display: block;
  margin: 0;
  padding: 0.35rem 0.75rem;
  border-left: none;
  text-decoration: none;
  color: var(--gray-900);
  font-size: 0.82rem;
}
.header-search-result:hover { background: var(--gray-100); text-decoration: none; color: var(--gray-900); }
.header-search-title { display: block; }
.header-search-title mark { background: var(--accent-bg); color: inherit; padding: 0 1px; border-radius: 2px; }

.header-search-snippet { display: block; font-size: 0.72rem; color: var(--gray-500); }
.header-search-empty { padding: 0.5rem 0.75rem; font-size: 0.8rem; color: var(--gray-500); }

/* Logout is a POST action but is visually part of the navigation. */

/* ---- Pages: the world as a tree of markdown documents ---------------------
   Two columns at a width worth designing for, one below it. The tree is the
   primary interface, so it keeps its width and the document takes the rest. */
.pages-layout {
  display: grid;
  grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
  gap: 1rem;
  align-items: start;
}
@media (max-width: 800px) { .pages-layout { grid-template-columns: 1fr; } }

.pages-tree-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.pages-tree-head h2 { margin: 0; }

.pages-filter { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.6rem 0 0.8rem; }
.pages-filter .chip {
  border: 1px solid var(--gray-300); border-radius: 999px; background: var(--surface);
  padding: 0.15rem 0.6rem; font: inherit; font-size: 0.75rem; cursor: pointer; color: var(--gray-600);
}
.pages-filter .chip:hover { border-color: var(--gray-500); }
.pages-filter .chip.is-active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }

.pages-tree-list,
.pages-tree-list ul { list-style: none; margin: 0; padding: 0; }
.pages-tree-list ul { margin-left: 0.8rem; border-left: 1px solid var(--gray-200); padding-left: 0.4rem; }
.page-node-row {
  display: flex; align-items: baseline; gap: 0.4rem; width: 100%;
  border: 0; background: none; text-align: left; font: inherit; font-size: 0.85rem;
  padding: 0.22rem 0.35rem; border-radius: 4px; cursor: pointer; color: inherit;
}
.page-node-row:hover { background: var(--gray-100); }
.page-node-row.is-current { background: var(--accent-bg); color: var(--accent-dark); font-weight: 600; }
.page-node-kind {
  font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--gray-500); flex: none; min-width: 3.6em;
}

.page-head { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
.page-head h2 { margin: 0; flex: 1 1 auto; cursor: text; }
.page-head h2:hover { text-decoration: underline dotted var(--gray-300); }

.page-source {
  width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.82rem; line-height: 1.55; border: 1px solid var(--accent-soft);
  border-radius: 6px; padding: 0.7rem; resize: vertical; background: var(--surface);
}
.page-body { min-height: 6rem; cursor: text; }
.page-status { min-height: 1.1rem; margin: 0.4rem 0 0; }

.page-backlinks { margin-top: 1.5rem; border-top: 1px solid var(--gray-200); padding-top: 0.8rem; }
.page-backlinks h3 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500); margin: 0 0 0.4rem; }
.page-backlinks ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; font-size: 0.85rem; }

/* ---- Rendered markdown ---------------------------------------------------
   A page link and a redlink differ by one border: a dotted underline says the
   page is owed rather than missing, which is the state a DM spends the week in. */
.pagelink { color: var(--accent-dark); text-decoration: none; border-bottom: 1px solid var(--accent-soft); }
.pagelink:hover { border-bottom-color: var(--accent); }
.pagelink.is-redlink { color: var(--gray-500); border-bottom: 1px dotted var(--gray-400); }
.pagelink.is-redlink:hover { color: var(--accent); border-bottom-color: var(--accent); }

.markdown { line-height: 1.6; }
.markdown > :first-child { margin-top: 0; }
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
  margin: 1.4em 0 0.4em; line-height: 1.25;
}
.markdown h1 { font-size: 1.35rem; }
.markdown h2 { font-size: 1.12rem; }
.markdown h3 { font-size: 0.98rem; }
.markdown h4,
.markdown h5,
.markdown h6 { font-size: 0.88rem; color: var(--gray-600); }
.markdown p { margin: 0 0 0.85em; }
.markdown ul,
.markdown ol { margin: 0 0 0.85em; padding-left: 1.4em; }
.markdown li { margin-bottom: 0.2em; }
.markdown blockquote {
  margin: 0 0 0.85em; padding: 0.1em 0 0.1em 0.9em;
  border-left: 3px solid var(--accent-soft); color: var(--gray-600);
}
.markdown code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.85em; background: var(--gray-100); padding: 0.1em 0.3em; border-radius: 3px;
}
.markdown pre {
  background: var(--gray-100); border: 1px solid var(--gray-200); border-radius: 6px;
  padding: 0.7rem; overflow-x: auto; margin: 0 0 0.85em;
}
.markdown pre code { background: none; padding: 0; font-size: 0.8rem; }
.markdown hr { border: 0; border-top: 1px solid var(--gray-200); margin: 1.4em 0; }
.markdown img { max-width: 100%; height: auto; border-radius: 6px; }
/* Wide tables scroll inside the document rather than widening the page. */
.markdown table { border-collapse: collapse; font-size: 0.85rem; margin: 0 0 0.85em; display: block; overflow-x: auto; }
.markdown th,
.markdown td { border: 1px solid var(--gray-200); padding: 0.35rem 0.55rem; text-align: left; }
.markdown thead th { background: var(--gray-100); }

/* ---- Games and segments --------------------------------------------------
   A game is its date plus an ordered list of segments. The list is the screen:
   everything else on the page is a header for it. */
.game-head { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.9rem; }
.game-head h2 { margin: 0; margin-right: auto; }
.game-field { display: flex; flex-direction: column; gap: 0.15rem; font-size: 0.72rem; color: var(--gray-500); }
.game-field input { font: inherit; font-size: 0.85rem; padding: 0.25rem 0.4rem; border: 1px solid var(--gray-300); border-radius: 4px; }
.game-field-wide input { min-width: 18rem; }
.game-status { min-width: 4rem; }

/* A night's notes, above the segments they were written beside. Quieter than a
   segment on purpose: this is a thing to read back, not a thing to work in. */
.game-notes { margin-bottom: 1rem; border-top: 1px solid var(--gray-200); padding-top: 0.7rem; }
.game-notes h3 {
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--gray-500); margin: 0 0 0.5rem;
}
.game-note-list { list-style: none; margin: 0; padding: 0; }
.game-note-who {
  font-size: 0.8rem; font-weight: 650; color: var(--gray-900);
  margin: 0.6rem 0 0.2rem;
}
.game-note-who:first-child { margin-top: 0; }
.game-note { margin: 0 0 0.5rem 0.9rem; border-left: 2px solid var(--gray-200); padding-left: 0.7rem; }
.game-note-title { display: block; font-size: 0.85rem; font-weight: 600; color: var(--accent); text-decoration: none; }
.game-note-title:hover { text-decoration: underline; }
.game-note-body { font-size: 0.85rem; }

.segment-add { display: flex; gap: 0.4rem; margin-bottom: 0.8rem; }
.segment-add input[type="text"] { flex: 1 1 auto; font: inherit; font-size: 0.9rem; padding: 0.35rem 0.5rem; border: 1px solid var(--gray-300); border-radius: 5px; }
.segment-add select { font: inherit; font-size: 0.85rem; border: 1px solid var(--gray-300); border-radius: 5px; }

.segment-list { list-style: none; margin: 0; padding: 0; }
/* Clipped, so the header's wash takes the row's corners with it whether the
   segment is open or shut -- otherwise a tinted bar squares off the top of a
   rounded row. */
.segment { border: 1px solid var(--gray-200); border-radius: 6px; overflow: hidden; margin-bottom: 0.5rem; background: var(--surface); }
/* The header carries the kind, so the list can be skimmed as a shape. Only the
   bar is washed -- the body underneath stays on the plain surface, because it is
   text to read rather than a thing to recognise. */
.segment-bar {
  display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.6rem; cursor: pointer;
  background: var(--kind-bg, transparent);
}
.segment-grip { color: var(--gray-300); cursor: grab; letter-spacing: -0.25em; }
.segment-kind {
  font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--kind, var(--accent-dark)); background: var(--surface);
  padding: 0.1rem 0.4rem; border-radius: 999px; flex: none;
}
.segment-title { font-weight: 600; font-size: 0.9rem; }
.segment-flags { font-size: 0.72rem; color: var(--gray-500); font-style: italic; }
.segment-actions { margin-left: auto; display: flex; align-items: center; gap: 0.3rem; }
.btn-quiet {
  border: 1px solid var(--gray-300); background: var(--surface); border-radius: 4px;
  font: inherit; font-size: 0.72rem; padding: 0.15rem 0.45rem; cursor: pointer; color: var(--gray-600);
}
.btn-quiet:hover { border-color: var(--accent); color: var(--accent-dark); }

/* A cut segment stays visible: the gap in the game is the point of keeping it. */
/* ---- the image manager ----
   Wider cards than the picker's tiles, because each one carries what points at
   it and that is the reason to be on this screen at all. */
.images-head { display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap; }
.images-head h2 { margin-right: auto; }
.images-upload { cursor: pointer; }
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.9rem; }
.image-card { margin: 0; display: flex; flex-direction: column; gap: 0.3rem; }
.image-card-tile { cursor: zoom-in; }
.image-card-meta { font-size: 0.72rem; color: var(--gray-500); }
.image-card-uses { font-size: 0.75rem; color: var(--gray-600); margin: 0; }
.image-card-uses.is-unused { color: var(--gray-500); font-style: italic; }

/* ---- an upload in flight ----
   Bottom right, out of the way of everything and on top of all of it, because
   the box the file is landing in may be collapsed or scrolled off. */
.upload-card {
  position: fixed; right: 1rem; bottom: 1rem; z-index: 60;
  display: flex; align-items: center; gap: 0.55rem;
  padding: 0.55rem 0.85rem;
  border: 1px solid var(--gray-200); border-radius: 8px;
  background: var(--surface); color: var(--gray-900);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
  font-size: 0.82rem;
}
.upload-card[hidden] { display: none; }
.upload-spinner {
  width: 15px; height: 15px; flex: none;
  border: 2px solid var(--gray-300); border-top-color: var(--accent);
  border-radius: 50%;
  animation: upload-spin 0.7s linear infinite;
}
@keyframes upload-spin { to { transform: rotate(360deg); } }
/* A reader who asked for less motion gets a pulse instead of a spin: the point
   is "something is happening", and that survives losing the rotation. */
@media (prefers-reduced-motion: reduce) {
  .upload-spinner { animation: upload-pulse 1.2s ease-in-out infinite; }
  @keyframes upload-pulse { 50% { opacity: 0.35; } }
}

/* ---- right-click on an image ----
   Copy what points at it, or delete it. Anchored where the click landed. */
/* The shadowbox: the picture, at whatever size the window allows, on a ground
   dark enough that the crop of the tile stops mattering. Sized to the image
   rather than to a box, so a tall portrait and a wide map both fit. */
.lightbox {
  border: none;
  padding: 0;
  background: transparent;
  max-width: 94vw;
  max-height: 94vh;
  overflow: visible;
  cursor: zoom-out;
}
/* The dialog takes focus so Escape closes it; the ring that focus draws would
   frame the picture in white, which is the one thing a shadowbox is for not
   doing. */
.lightbox:focus { outline: none; }
.lightbox::backdrop { background: rgba(0, 0, 0, 0.8); }
.lightbox img {
  display: block;
  max-width: 94vw;
  max-height: 86vh;
  width: auto;
  height: auto;
  border-radius: 6px;
}
.lightbox-name {
  margin: 0.5rem 0 0;
  text-align: center;
  font-size: 0.78rem;
  color: var(--gray-100);
}

.image-menu {
  position: fixed; z-index: 70; min-width: 168px;
  border: 1px solid var(--gray-200); border-radius: 6px;
  background: var(--surface); padding: 0.25rem;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.image-menu button {
  display: block; width: 100%; text-align: left;
  border: 0; background: transparent; border-radius: 4px;
  font: inherit; font-size: 0.82rem; color: var(--gray-900);
  padding: 0.35rem 0.55rem; cursor: pointer;
}
.image-menu button:hover { background: var(--accent-bg); color: var(--accent-dark); }
.image-menu .image-menu-danger { color: var(--accent); }
.image-menu-note { font-size: 0.72rem; color: var(--gray-500); padding: 0.3rem 0.55rem; }

/* ---- the image library ----
   A grid of what this campaign has already uploaded. Tiles are square and the
   image is cropped to fill one, because a map and a portrait side by side in
   their own proportions make a list nobody can scan. */
.image-picker { max-width: min(680px, 92vw); }
.image-picker-head { justify-content: flex-start; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; }
.image-picker-upload { cursor: pointer; }
.image-picker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: 0.6rem;
  max-height: 60vh;
  overflow-y: auto;
}
.image-tile {
  display: flex; flex-direction: column; gap: 0.25rem;
  border: 1px solid var(--gray-200); border-radius: 6px; background: var(--surface);
  padding: 0.3rem; font: inherit; cursor: pointer; text-align: left;
}
.image-tile:hover { border-color: var(--accent); }
.image-tile img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; display: block; }
.image-tile-name {
  font-size: 0.7rem; color: var(--gray-600);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* While a file is being dragged over it: a drop anywhere on the row puts the
   file in that segment's prep, so the whole row has to say so. */
.is-drop-target { outline: 2px dashed var(--accent); outline-offset: 2px; }

.segment.is-cut { opacity: 0.6; }
.segment.is-cut .segment-title { text-decoration: line-through; }
.segment.is-played { border-left: 3px solid var(--accent); }

.segment-body { display: flex; flex-wrap: wrap; gap: 1rem; padding: 0 0.7rem 0.7rem; }
.segment-half { flex: 1 1 260px; min-width: 0; }
/* The rail is hidden entirely when nothing belongs beside this segment, which
   flex handles without leaving an empty column behind. */
.segment-rail { flex: 1 1 220px; min-width: 0; border-left: 1px solid var(--gray-200); padding-left: 0.9rem; }
.segment-half h4 {
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--gray-500); margin: 0 0 0.3rem;
}
.segment-half .markdown { min-height: 3rem; cursor: text; font-size: 0.88rem; }
.segment-source {
  width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.8rem; line-height: 1.55; border: 1px solid var(--accent-soft);
  border-radius: 5px; padding: 0.5rem; resize: vertical;
}

/* The party, across the top of the game and of the running screen. A row that
   scrolls sideways rather than a grid that reflows: five characters at a table
   is five cards, and they should stay the same size whatever else is on screen. */
.party-bar { display: flex; gap: 0.5rem; overflow-x: auto; margin-bottom: 0.8rem; padding-bottom: 0.3rem; }

/* Levels, under the party they are about. One quiet row: it is written once at
   the end of a night and read when somebody asks when they levelled, so it must
   not compete with the party bar above it. */
.game-levels {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem;
  margin: -0.4rem 0 0.9rem; padding-bottom: 0.6rem;
  border-bottom: 1px solid var(--border);
  font-size: 0.78rem; color: var(--gray-600);
}
.game-levels-label { letter-spacing: 0.05em; text-transform: uppercase; font-size: 0.7rem; color: var(--gray-500); }
.game-level { display: inline-flex; align-items: center; gap: 0.35rem; }
.game-level-value {
  width: 3rem; font: inherit; font-family: var(--font-mono); padding: 0.15rem 0.3rem;
  border: 1px solid var(--gray-300); border-radius: 4px;
  background: var(--surface); color: var(--gray-900);
}
.game-level-value:focus { border-color: var(--accent); outline: none; }
.game-level-gain { color: var(--accent); font-weight: 600; }

/* Attendance, as a chip on the card rather than on it: the card is a link to the
   sheet, so the chip is a sibling and the two sit in one slot. An away card is
   dimmed and nothing more -- everything on it is still readable, still clickable
   and still in the initiative tracker, because a player who missed a night still
   has a character the DM looks things up on. */
.party-slot { position: relative; flex: 0 0 auto; }
.party-slot .party-pc { width: 13rem; }
.pc-dot { flex: none; width: 0.7rem; height: 0.7rem; border-radius: 50%; align-self: center; cursor: pointer; }
.party-colors {
  position: absolute; z-index: 5; top: 2rem; left: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.3rem; width: 7rem; padding: 0.4rem;
  background: var(--surface); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 6px 16px rgba(33, 29, 40, 0.18);
}
.party-colors[hidden] { display: none; }
.party-color { width: 1.1rem; height: 1.1rem; padding: 0; border: 1px solid rgba(0, 0, 0, 0.2); border-radius: 50%; cursor: pointer; }
/* The player's name sits top-right of a card and the chip lands on top of it,
   so the heading gives up that corner while a card is in a bar. */
.party-slot .party-pc h5 { padding-right: 2.8rem; }
.party-away {
  position: absolute; top: 0.3rem; right: 0.35rem;
  font: inherit; font-size: 0.6rem; letter-spacing: 0.04em; text-transform: uppercase;
  padding: 0.05rem 0.3rem; border-radius: 3px; cursor: pointer;
  border: 1px solid var(--gray-200); background: var(--surface); color: var(--gray-500);
}
.party-away:hover { border-color: var(--accent); color: var(--accent); }
.party-slot.is-away .party-pc { opacity: 0.5; }
.party-slot.is-away .party-away {
  opacity: 1; border-color: var(--danger); color: var(--danger); background: var(--danger-bg);
}

/* The top of the night. It wears a segment's clothes -- a washed bar that opens
   and shuts, in the grey no kind of segment uses -- because the night reads as
   one list of things that happen and this is the first of them. It is not in the
   segment list, because there is nothing here to reorder, carry or play. */
.recap {
  border: 1px solid var(--gray-200); border-radius: 6px; overflow: hidden;
  margin-bottom: 0.8rem; background: var(--surface);
}
.recap-bar {
  display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.6rem; cursor: pointer;
  background: var(--kind-bg, transparent);
}
.recap-chevron { color: var(--gray-500); font-size: 0.7rem; width: 0.7rem; flex: none; }
.recap-bar .segment-flags { margin-left: auto; font-style: normal; }
.recap-body { display: flex; flex-wrap: wrap; gap: 1rem; padding: 0.6rem 0.7rem 0.7rem; }
.recap-write { flex: 1 1 20rem; min-width: 0; }
.recap-rendered { min-height: 3rem; cursor: text; font-size: 0.88rem; }
.recap-rendered.is-empty { color: var(--gray-500); font-style: italic; }
.recap-previously {
  flex: 1 1 16rem; min-width: 0; align-self: flex-start;
  padding: 0.45rem 0.65rem; border-left: 1px solid var(--gray-200);
  font-size: 0.8rem;
}
.recap-previously h4 { margin: 0 0 0.2rem; font-size: 0.8rem; }
.recap-when { margin: 0 0 0.4rem; color: var(--gray-600); }
.recap-segments { list-style: none; margin: 0 0 0.4rem; padding: 0; }
.recap-segments li {
  padding-left: 0.45rem; border-left: 2px solid var(--kind, var(--gray-300)); margin-bottom: 0.15rem;
}
.recap-levels, .recap-absent { margin: 0.3rem 0 0; color: var(--gray-600); }
.recap-absent { color: var(--danger); }
.party-block {
  flex: 0 0 auto; width: 13rem; max-height: 9rem; overflow-y: auto;
  border: 1px solid var(--gray-200); border-radius: 6px;
  padding: 0.4rem 0.55rem; background: var(--surface);
}

.game-stack-list { list-style: none; margin: 0; padding: 0; }
.game-row + .game-row { border-top: 1px solid var(--gray-200); }
.game-row-link {
  display: flex; align-items: baseline; gap: 0.75rem; padding: 0.5rem 0.3rem;
  text-decoration: none; color: inherit;
}
.game-row-link:hover { background: var(--gray-100); }
.game-row-number { font-weight: 600; font-size: 0.88rem; flex: none; min-width: 5rem; }
.game-row-world { font-size: 0.82rem; color: var(--gray-600); font-style: italic; }
.game-row-facts { margin-left: auto; font-size: 0.75rem; color: var(--gray-500); }
/* The game being prepped is the one you came here for. */
.game-row.is-unplayed .game-row-number { color: var(--accent-dark); }
.game-row.is-unplayed .game-row-number::after { content: " · prepping"; font-weight: 400; font-size: 0.7rem; color: var(--gray-500); }
/* The campaign so far, folded into one line above the recent games. */
.game-row-earlier { padding: 0.3rem 0.3rem 0.45rem; }
.game-row-earlier button { width: 100%; text-align: left; border-style: dashed; }

/* ---- Info blocks ---------------------------------------------------------
   Reference, random tables and stat lines are one thing, so they get one list
   and one editor. A block's settings live with its contents rather than in a
   dialog: what it says and where it shows up are the same decision. */
.block-add { display: flex; gap: 0.4rem; margin-bottom: 0.8rem; }
.block-add input { flex: 1 1 auto; font: inherit; font-size: 0.9rem; padding: 0.35rem 0.5rem; border: 1px solid var(--gray-300); border-radius: 5px; }

.block-list { list-style: none; margin: 0; padding: 0; }
.block-item { border: 1px solid var(--gray-200); border-radius: 6px; margin-bottom: 0.5rem; background: var(--surface); }
.block-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.6rem; cursor: pointer; }
.block-title { font-weight: 600; font-size: 0.9rem; }
.block-views { font-size: 0.72rem; color: var(--gray-500); }
.block-actions { margin-left: auto; display: flex; align-items: center; gap: 0.3rem; }
.block-rolled,
.rail-rolled {
  margin: 0 0.6rem 0.5rem; padding: 0.4rem 0.6rem; background: var(--accent-bg);
  border-radius: 5px; font-size: 0.85rem; color: var(--accent-dark);
}
.block-editor { padding: 0 0.7rem 0.7rem; }
.block-source {
  width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.8rem; line-height: 1.55; border: 1px solid var(--accent-soft);
  border-radius: 5px; padding: 0.5rem; resize: vertical;
}
.block-rendered { min-height: 3rem; cursor: text; font-size: 0.88rem; }
.block-settings { display: flex; flex-wrap: wrap; gap: 0.4rem 1.1rem; margin-top: 0.6rem; font-size: 0.78rem; color: var(--gray-600); }
.block-settings label { display: flex; align-items: center; gap: 0.3rem; }

/* A block called for by name inside a body. The block itself is in the rail;
   this is what tells the writer the call landed. */
.blockref {
  font-size: 0.8rem; color: var(--accent-dark); background: var(--accent-bg);
  border-radius: 4px; padding: 0.05rem 0.35rem; text-decoration: none;
}
.blockref:hover { background: var(--accent-soft); }
.blockref.is-missing { color: var(--gray-500); background: var(--gray-100); border: 1px dashed var(--gray-300); }

.rail-block + .rail-block { margin-top: 0.9rem; }
.rail-block h5,
.party-block h5 {
  display: flex; align-items: center; gap: 0.4rem; margin: 0 0 0.25rem;
  font-size: 0.78rem; font-weight: 600;
}
.rail-block h5 .rail-roll,
.party-block h5 .rail-roll { margin-left: auto; }
.rail-block-body { font-size: 0.82rem; }
.rail-rolled { margin: 0 0 0.4rem; }

/* ---- Dice ----------------------------------------------------------------
   The tray sits over the corner of whatever page you are on, because rolling is
   something you do *during* something else — reading a scene, running a fight —
   and it must never be a place you go.

   The dice are flat polygons rather than anything pretending to be three
   dimensional. A real tumbling d20 needs a 3D engine, and this app has no build
   step to bring one through; a silhouette that changes its face fast and lands
   with a bounce reads as a thrown die at the size it is actually shown. */
.dice-tray {
  position: fixed; right: 1rem; bottom: 1rem; z-index: 50;
  width: 24rem; max-width: calc(100vw - 2rem);
  padding: 0.75rem 0.9rem 0.9rem;
  border: 1px solid var(--border); border-radius: 8px;
  background: var(--surface); color: var(--gray-900);
  box-shadow: 0 6px 24px rgb(0 0 0 / 0.18);
}
.dice-tray-head { display: flex; align-items: center; margin: 0 0 0.4rem; font-size: 0.78rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--gray-600); }
.dice-tray-head .dice-close { margin-left: auto; }

.dice-stage { min-height: 6.5rem; }
.dice-label { margin: 0 0 0.35rem; font-size: 0.9rem; }
.dice-sub { margin-left: 0.4rem; font-size: 0.72rem; color: var(--gray-500); }

.dice-pool { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.dice-set { display: flex; flex-wrap: wrap; gap: 0.25rem; }
/* The set that was thrown away under advantage stays on screen: seeing what you
   dodged is most of the reason for rolling twice. */
.dice-set.is-dropped { opacity: 0.4; }

.die { position: relative; display: inline-grid; place-items: center; width: 2.9rem; height: 2.9rem; }
.die svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.die polygon { fill: var(--accent-bg); stroke: var(--accent); stroke-width: 5; stroke-linejoin: round; }
.die.is-dropped polygon { fill: var(--gray-100); stroke: var(--gray-300); }
.die-face {
  position: relative; font-family: var(--font-mono); font-size: 1rem;
  font-variant-numeric: tabular-nums; color: var(--gray-900);
}
.die.is-rolling { animation: die-tumble 0.28s linear infinite; }
.die.is-landed { animation: die-land 0.3s ease-out 1; }

@keyframes die-tumble {
  0%   { transform: rotate(0deg) scale(1); }
  50%  { transform: rotate(180deg) scale(1.12); }
  100% { transform: rotate(360deg) scale(1); }
}
@keyframes die-land {
  0%   { transform: scale(1.25); }
  60%  { transform: scale(0.94); }
  100% { transform: scale(1); }
}
/* Somebody who has asked for less movement gets the number, immediately. */
@media (prefers-reduced-motion: reduce) {
  .die.is-rolling, .die.is-landed { animation: none; }
}

.dice-total { display: flex; align-items: baseline; gap: 0.5rem; margin: 0.5rem 0 0; }
.dice-sum { font-family: var(--font-mono); font-size: 2rem; line-height: 1; }
.dice-working { font-size: 0.72rem; color: var(--gray-500); }
.dice-crit { font-size: 0.72rem; font-weight: 600; }
.dice-crit.is-max { color: var(--accent); }
.dice-crit.is-min { color: var(--danger); }

.dice-history { list-style: none; margin: 0.7rem 0 0; padding: 0.55rem 0 0; border-top: 1px solid var(--border); font-size: 0.8rem; color: var(--gray-600); }
.dice-history li { display: flex; gap: 0.4rem; padding: 0.05rem 0; }
.dice-history b { min-width: 1.8rem; text-align: right; color: var(--gray-900); }
.dice-history li.is-max b { color: var(--accent); }
.dice-history li.is-min b { color: var(--danger); }

.dice-controls { margin-top: 0.6rem; padding-top: 0.5rem; border-top: 1px solid var(--border); }
/* The keyboard path in. Monospaced because what goes in it is notation, and
   full width because it is the first thing focus lands on. */
.dice-notation {
  width: 100%; box-sizing: border-box; margin-bottom: 0.5rem;
  font-family: var(--font-mono); font-size: 0.9rem; padding: 0.4rem 0.5rem;
  border: 1px solid var(--gray-300); border-radius: 4px;
  background: var(--surface); color: var(--gray-900);
}
.dice-notation:focus { border-color: var(--accent); outline: none; }
.dice-notation[aria-invalid] { border-color: var(--danger); }
.dice-complaint { margin: -0.3rem 0 0.5rem; font-size: 0.74rem; color: var(--danger); }
/* The die buttons are the mouse path and are sized to be hit without looking:
   a die is pressed mid-sentence with five people waiting, which is the same
   reason the tray is a tray and not a page. */
.dice-buttons { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.dice-pick {
  flex: 1 1 auto; min-width: 2.75rem; min-height: 2.75rem;
  font: inherit; font-size: 0.9rem; padding: 0.35rem 0.4rem;
  border: 1px solid var(--gray-300); border-radius: 4px;
  background: var(--surface); color: var(--gray-700); cursor: pointer;
}
.dice-pick:hover { border-color: var(--accent); color: var(--accent); }
/* Wraps because the DM's row carries one control more than a player's — the
   open-roll toggle — and a fixed row would push it off the tray's edge. */
.dice-fields { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-top: 0.5rem; font-size: 0.78rem; color: var(--gray-500); }
.dice-count, .dice-mod {
  width: 3.4rem; font: inherit; font-size: 0.88rem; padding: 0.3rem 0.35rem;
  border: 1px solid var(--gray-300); border-radius: 4px;
  background: var(--surface); color: var(--gray-900);
}
.dice-adv { display: flex; margin-left: auto; }
.dice-advantage {
  font: inherit; font-size: 0.8rem; padding: 0.3rem 0.6rem;
  border: 1px solid var(--gray-300); background: var(--surface); color: var(--gray-600); cursor: pointer;
}
.dice-advantage:first-child { border-radius: 4px 0 0 4px; }
.dice-advantage:last-child { border-radius: 0 4px 4px 0; }
.dice-advantage + .dice-advantage { border-left: none; }
.dice-advantage.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }

/* Anything on a sheet that can be thrown. It reads as a number until you point
   at it, because the sheet is mostly for reading. */
.roll {
  font: inherit; font-variant-numeric: tabular-nums; padding: 0 0.2rem;
  border: 1px solid transparent; border-radius: 4px;
  background: none; color: inherit; cursor: pointer;
}
.roll:hover { border-color: var(--accent); color: var(--accent); }

/* ---- The odds on a check -------------------------------------------------
   A computed block. The curve is the point: where it falls off is the DC worth
   setting, and that is a shape rather than a number, which is why it is drawn
   at all. Sized to sit in the rail beside a segment without scrolling. */
.party-odds { display: flex; flex-direction: column; gap: 0.35rem; }
.odds-check {
  font: inherit; font-size: 0.76rem; padding: 0.2rem 0.3rem;
  border: 1px solid var(--gray-300); border-radius: 4px;
  background: var(--surface); color: var(--gray-900);
}
.odds-figure { line-height: 0; }
.odds-chart { width: 100%; height: 5.5rem; overflow: visible; }
.odds-area { fill: var(--accent); opacity: 0.16; }
.odds-line { fill: none; stroke: var(--accent); stroke-width: 2; vector-effect: non-scaling-stroke; }
.odds-half, .odds-tick { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; }
.odds-tick-label { fill: var(--gray-500); font-size: 8px; text-anchor: middle; }
.odds-at { stroke: var(--gray-500); stroke-width: 1; stroke-dasharray: 2 2; vector-effect: non-scaling-stroke; }
.odds-at-dot { fill: var(--accent); }
.odds-answer { margin: 0; font-size: 0.76rem; color: var(--gray-600); }
.odds-answer b { font-size: 0.95rem; color: var(--gray-900); }
.odds-dc { width: 100%; accent-color: var(--accent); }
.odds-party { margin: 0; font-size: 0.68rem; color: var(--gray-500); }

/* ---- Recordings ----------------------------------------------------------
   The night's audio, under the party and above the scenes -- roughly where it
   sits in the evening. The native player is used as it comes: it already knows
   how to scrub, and a hand-built one would be a worse copy of something every
   browser ships. */
.recordings { margin-bottom: 0.8rem; }
.recording { padding: 0.4rem 0 0.5rem; border-bottom: 1px solid var(--border); }
.recording-head { display: flex; align-items: baseline; gap: 0.5rem; margin: 0 0 0.25rem; font-size: 0.84rem; }
.recording-meta { font-size: 0.72rem; color: var(--gray-500); }
.recording-head .recording-detach { margin-left: auto; }
.recording audio { width: 100%; height: 2.2rem; }
.recording-offer { margin: 0.5rem 0 0; }
.recording-attach { font-size: 0.74rem; }

/* ---- Quotes --------------------------------------------------------------
   A library of lines, so the line is what the eye lands on: the quote is set
   larger than anything around it and the attribution is small underneath, which
   is the shape a quotation has had for a very long time. */
.run-quote {
  display: flex; align-items: center; gap: 0.5rem; margin: 0.4rem 0 0;
  padding: 0.3rem 0.5rem; font-size: 0.78rem;
  border: 1px solid var(--accent-soft); border-radius: 5px; background: var(--accent-bg);
}
.run-quote-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.run-quote-speaker { font: inherit; font-size: 0.78rem; padding: 0.15rem 0.35rem; border: 1px solid var(--gray-300); border-radius: 4px; background: var(--surface); color: var(--gray-900); }

.quote-add { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
.quote-add input[name="quote"] { flex: 1 1 20rem; }
.quote-add input, .quote-add select {
  font: inherit; font-size: 0.9rem; padding: 0.35rem 0.5rem;
  border: 1px solid var(--gray-300); border-radius: 5px;
  background: var(--surface); color: var(--gray-900);
}
.quote-game + .quote-game { margin-top: 1.2rem; }
.quote-game h3 {
  display: flex; align-items: baseline; gap: 0.5rem; margin: 0 0 0.5rem;
  font-size: 0.78rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--gray-600);
}
.quote-date { font-size: 0.72rem; color: var(--gray-500); }
.quote-list { list-style: none; margin: 0; padding: 0; }
.quote { padding: 0.5rem 0 0.6rem; border-bottom: 1px solid var(--border); }
.quote-text {
  margin: 0; font-size: 1rem; line-height: 1.4; color: var(--gray-900);
  border-left: 2px solid var(--accent-soft); padding-left: 0.6rem;
}
.quote-by { display: flex; align-items: baseline; gap: 0.5rem; margin: 0.25rem 0 0 0.6rem; font-size: 0.78rem; color: var(--gray-600); }
.quote-speaker { cursor: text; }
.quote-speaker::before { content: "— "; }
.quote-speaker.is-empty { color: var(--gray-500); font-style: italic; }
.quote-speaker-input { font: inherit; font-size: 0.78rem; padding: 0.1rem 0.3rem; border: 1px solid var(--gray-300); border-radius: 4px; background: var(--surface); color: var(--gray-900); }
.quote-context { color: var(--gray-500); }
.quote-by .quote-delete { margin-left: auto; }

/* ---- Calendar ------------------------------------------------------------
   Months at a glance, all of them at once -- reading the cadence of a campaign
   is the only reason to draw a calendar rather than list dates.

   Three months to a row on a wide screen, so a year is four rows you can take
   in with one scroll and a day square is big enough to carry its game number.
   The column count is not written down: a minimum month width plus a capped
   grid gives three where there is room and two or one where there is not,
   without a ladder of breakpoints that would each have to be kept in step. */
.calendar-months {
  display: grid; gap: 1.1rem;
  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
  max-width: 68rem;
}
.cal-month h3 { margin: 0 0 0.4rem; font-size: 0.85rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--gray-600); }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; }
.cal-weekday { font-size: 0.66rem; text-align: center; color: var(--gray-500); padding-bottom: 0.15rem; }
.cal-day {
  position: relative; min-height: 2.6rem; padding: 0.2rem 0.3rem;
  font-size: 0.76rem; color: var(--gray-500);
  border: 1px solid var(--border); border-radius: 3px; background: var(--surface);
}
.cal-day.is-today { border-color: var(--gray-500); }
/* A session is the one thing on this screen worth seeing from across the room,
   so it takes the accent outright rather than a tint of it. */
.cal-day.is-played {
  display: block; text-decoration: none;
  background: var(--accent); border-color: var(--accent); color: var(--on-accent);
}
.cal-day.is-played:hover { background: var(--accent-dark); }
.cal-day b { display: block; font-size: 0.72rem; font-weight: 600; }
.run-search-all { flex: 0 0 auto; margin-left: auto; font-size: 0.7rem; text-decoration: none; }

.calendar-unplayed { margin-top: 0.9rem; font-size: 0.8rem; color: var(--gray-600); }

/* ---- Characters ----------------------------------------------------------
   A card is a glance and the sheet under it is the whole record. The two are
   the same numbers, so the card is deliberately the smallest arrangement of
   them that still answers what gets asked mid-sentence: AC, hit points, what
   they notice, and where they fall in the order.

   Modifiers are set in bold and the scores beneath them are not, because the
   modifier is the number a DM says out loud and the score is only where it came
   from. */
.character-board {
  display: grid; gap: 0.6rem;
  grid-template-columns: repeat(auto-fill, minmax(15.5rem, 1fr));
}
.pc-card, .party-pc {
  border: 1px solid var(--gray-200); border-radius: 6px;
  padding: 0.5rem 0.6rem; background: var(--surface);
}
.pc-card { cursor: pointer; }
.pc-card:hover { border-color: var(--gray-300); }
.pc-card.is-open { border-color: var(--accent); box-shadow: inset 2px 0 0 var(--accent); }
.pc-card.is-dragging { opacity: 0.45; }
.party-pc { flex: 0 0 auto; width: 13rem; display: block; color: inherit; text-decoration: none; }
.party-pc:hover { border-color: var(--accent); }

.pc-card h5, .party-pc h5 {
  display: flex; align-items: baseline; gap: 0.4rem; margin: 0;
  font-size: 0.9rem; font-weight: 600;
}
.pc-player { margin-left: auto; font-size: 0.7rem; font-weight: 400; color: var(--gray-500); }
.pc-subtitle { margin: 0.1rem 0 0.45rem; font-size: 0.76rem; color: var(--gray-600); }

.pc-stats { display: flex; gap: 0.75rem; margin-bottom: 0.45rem; }
.pc-stat { display: flex; flex-direction: column; line-height: 1.15; }
.pc-stat b { font-size: 0.92rem; }
.pc-stat span { font-size: 0.62rem; letter-spacing: 0.05em; color: var(--gray-500); }

.pc-abilities { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.15rem; }
.pc-ability {
  display: flex; flex-direction: column; align-items: center; line-height: 1.2;
  padding: 0.2rem 0; border-radius: 4px; background: var(--gray-100);
}
.pc-ability-name { font-size: 0.56rem; letter-spacing: 0.04em; color: var(--gray-500); }
.pc-ability-mod { font-size: 0.82rem; }
.pc-ability-score { font-size: 0.62rem; color: var(--gray-500); }

/* ---- The sheet -----------------------------------------------------------
   Everything a standard sheet carries, in the order it is asked for: who they
   are, what they can take and dish out, what they are good at, then the prose.
   The derived numbers sit next to the boxes they come from rather than in a
   summary somewhere else, so it is obvious which score moved which total. */
.character-sheet .sheet-identity,
.character-sheet .sheet-combat {
  display: grid; gap: 0.5rem 0.7rem; margin-bottom: 0.8rem;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}
.character-sheet label { display: flex; flex-direction: column; gap: 0.15rem; font-size: 0.74rem; color: var(--gray-600); }
.character-sheet label.wide { grid-column: span 2; }
.character-sheet input[type="text"],
.character-sheet input[type="number"],
.character-sheet select {
  font: inherit; font-size: 0.86rem; padding: 0.3rem 0.4rem;
  border: 1px solid var(--gray-300); border-radius: 5px;
  background: var(--surface); color: var(--gray-900);
}
.sheet-derived { align-self: end; font-size: 0.76rem; color: var(--gray-600); }
.sheet-derived b { color: var(--gray-900); }

.sheet-columns { display: grid; gap: 1.2rem 2rem; grid-template-columns: repeat(auto-fit, minmax(20rem, auto)); justify-content: start; margin-bottom: 1rem; }
.character-sheet h3 { margin: 0 0 0.4rem; font-size: 0.78rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--gray-600); }
.character-sheet h3 .modal-hint { text-transform: none; letter-spacing: 0; }

.ability-row { display: grid; grid-template-columns: 5.5rem 3.4rem 2rem auto; align-items: center; gap: 0.4rem; padding: 0.15rem 0; }
.ability-name { font-size: 0.8rem; }
.ability-mod { font-size: 0.9rem; }
/* Wins over the stacked label above, which is the shape every other field on
   the sheet wants and the only one this row does not. */
.character-sheet .ability-save { flex-direction: row; align-items: baseline; gap: 0.3rem; font-size: 0.72rem; }
.ability-row { padding: 0.08rem 0.25rem; border-radius: 4px; }

.skill-row { display: grid; grid-template-columns: 1.1rem 1.1rem minmax(0, 1fr) 2rem 2.2rem; align-items: center; gap: 0.3rem; padding: 0.08rem 0.25rem; font-size: 0.78rem; max-width: 24rem; border-radius: 4px; }
.skill-row.is-proficient .skill-name { font-weight: 600; }

/* The row is the roll, so the row is what lights up. Without this the only clue
   that a skill can be thrown is a modifier that does not look like a button --
   which is how this shipped, and why nobody found it. The two proficiency boxes
   keep their own cursor, because they are the one part of the row that is not a
   roll. */
.skill-row, .ability-row { cursor: pointer; }
.skill-row:hover, .ability-row:hover { background: var(--accent-soft); }
.skill-row input, .ability-row input, .ability-save { cursor: default; }
.skill-row input, .ability-save input { cursor: pointer; }
.skill-ability { font-size: 0.62rem; text-transform: uppercase; color: var(--gray-500); }
.skill-total { text-align: right; }

.sheet-prose { display: grid; gap: 0.9rem; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); }
.prose-rendered { min-height: 2.2rem; font-size: 0.85rem; cursor: text; }
.prose-source { width: 100%; font: inherit; font-size: 0.85rem; padding: 0.4rem; border: 1px solid var(--gray-300); border-radius: 5px; background: var(--surface); color: var(--gray-900); }
.sheet-footer { margin: 1rem 0 0; }
.btn-quiet.danger { color: var(--danger); }

/* ---- The running screen --------------------------------------------------
   Three rails, and the middle one is the whole point: what was prepped on the
   left, what is being written in the middle, what has to be reached for on the
   right. Full height, because at the table the page never scrolls to something
   else. */
body.running { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
.run-layout {
  flex: 1 1 auto; min-height: 0;
  display: grid;
  grid-template-columns: minmax(180px, 240px) minmax(0, 1fr) minmax(220px, 320px);
  gap: 0.9rem;
  padding: 0.9rem;
}
@media (max-width: 900px) {
  body.running { height: auto; overflow: visible; }
  .run-layout { grid-template-columns: 1fr; }
}
.run-rail { min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 1rem; }
.run-rail-head {
  display: flex; align-items: center; gap: 0.4rem; margin: 0 0 0.4rem;
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--gray-500);
}
.run-rail-actions { margin-left: auto; display: flex; gap: 0.25rem; }
/* A named game can be much longer than a numbered one, and the rail it sits in
   is narrow. It gives way rather than pushing "Tonight" off the row. */
.run-game-number { margin-left: auto; text-transform: none; letter-spacing: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Finding a name mid-play. Small, above the list, and never in the way of it. */
.run-search { display: flex; align-items: center; gap: 0.4rem; margin: 0 0 0.4rem; }
.run-search input {
  flex: 1 1 auto; min-width: 0; font: inherit; font-size: 0.8rem;
  padding: 0.25rem 0.4rem; border: 1px solid var(--gray-300); border-radius: 4px;
  background: var(--surface); color: inherit;
}
.run-search-count { font-size: 0.65rem; color: var(--gray-500); white-space: nowrap; }

.run-segments { list-style: none; margin: 0; padding: 0; }
/* While a search is running the segments holding the word are marked and the
   rest recede -- still readable, because the DM has to be able to keep their
   place in the night while looking something up. */
.run-segments.is-searching .run-segment:not(.is-match) { opacity: 0.35; }
.run-segments.is-searching .run-segment.is-match .run-segment-row {
  box-shadow: inset 2px 0 0 var(--accent);
  font-weight: 600;
}
/* The word itself, wherever it can be marked up. The log is a textarea and
   cannot be, so Enter selects it there instead. */
mark.find-hit { background: var(--accent-soft); color: inherit; border-radius: 2px; padding: 0 0.1em; }
.run-segment { display: flex; align-items: center; gap: 0.2rem; }
.run-segment-row {
  flex: 1 1 auto; display: flex; align-items: baseline; gap: 0.4rem; min-width: 0;
  border: 0; background: none; font: inherit; font-size: 0.85rem; text-align: left;
  padding: 0.3rem 0.35rem; border-radius: 4px; cursor: pointer; color: inherit;
}
.run-segment-row:hover { background: var(--gray-100); }
.run-segment.is-active .run-segment-row { background: var(--accent-bg); color: var(--accent-dark); font-weight: 600; }
.run-segment.is-played .run-segment-title { color: var(--gray-500); }
.run-segment.is-cut { opacity: 0.5; }
.run-segment.is-cut .run-segment-title { text-decoration: line-through; }
.run-segment-kind { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--kind, var(--gray-500)); flex: none; }
.run-segment-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The marks only matter for the segment being run, so they stay out of the way
   until the row is under the cursor or is the one in play. */
.run-segment-marks { display: none; gap: 0.2rem; }
.run-segment:hover .run-segment-marks,
.run-segment.is-active .run-segment-marks { display: flex; }
.run-keys { margin-top: auto; font-size: 0.68rem; line-height: 1.7; }
.run-keys kbd {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em;
  border: 1px solid var(--gray-300); border-bottom-width: 2px; border-radius: 3px; padding: 0 0.25rem;
}

.run-center { display: flex; flex-direction: column; min-height: 0; gap: 0.5rem; }
/* In the running column the party is a strip, not a panel: the log is what the
   height belongs to. */
.run-center > .party-bar { flex: 0 0 auto; margin-bottom: 0; }
.run-center .party-block { max-height: 6.5rem; }
.run-center-head { display: flex; align-items: baseline; gap: 0.5rem; }
.run-center-head h2 { margin: 0; font-size: 1.05rem; }
.run-kind {
  font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--kind, var(--accent-dark)); background: var(--kind-bg, var(--accent-bg));
  padding: 0.1rem 0.4rem; border-radius: 999px;
}
.run-status { margin-left: auto; }
.run-log {
  flex: 1 1 auto; min-height: 12rem; width: 100%; resize: none;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.9rem; line-height: 1.6; padding: 0.8rem;
  border: 1px solid var(--gray-300); border-radius: 6px; background: var(--surface);
}
.run-log:focus { border-color: var(--accent); outline: none; }
.run-prep { flex: 0 0 auto; max-height: 32vh; overflow-y: auto; font-size: 0.85rem; }
.run-prep summary {
  cursor: pointer; font-size: 0.72rem; text-transform: uppercase;
  letter-spacing: 0.06em; color: var(--gray-500);
}

.init-list { list-style: none; margin: 0 0 0.5rem; padding: 0; }
.init-row {
  display: flex; align-items: center; gap: 0.4rem;
  padding: 0.25rem 0.3rem; border-radius: 4px; font-size: 0.83rem;
}
.init-row.is-turn { background: var(--accent-bg); box-shadow: inset 2px 0 0 var(--accent); }
.init-row.is-pc .init-name { font-weight: 600; }
.init-row { flex-wrap: wrap; }
.init-row.is-down .init-name { text-decoration: line-through; color: var(--gray-500); }
.init-order {
  width: 1.9rem; text-align: right; font: inherit; font-size: 0.83rem; font-variant-numeric: tabular-nums;
  color: var(--gray-600); border: 1px solid transparent; background: transparent; border-radius: 3px; padding: 0 0.15rem;
}
.init-order:hover,
.init-order:focus { border-color: var(--gray-300); background: var(--surface); }
.init-round { font-size: 0.72rem; font-weight: 600; color: var(--accent-dark); text-transform: none; letter-spacing: 0; }
.init-setup, .init-running { display: flex; gap: 0.25rem; }
.init-setup[hidden], .init-running[hidden] { display: none; }
/* Statuses sit on a line of their own under the row, so a row with none is as
   short as it ever was. */
.init-conditions, .init-palette { flex: 0 0 100%; display: flex; flex-wrap: wrap; gap: 0.2rem; padding-left: 2.3rem; }
.init-conditions:empty { display: none; }
.init-palette[hidden] { display: none; }
.init-chip {
  font: inherit; font-size: 0.68rem; padding: 0 0.4rem; border-radius: 999px; cursor: pointer;
  border: 1px solid var(--gray-300); background: transparent; color: var(--gray-600);
}
.init-chip:hover { border-color: var(--accent); }
.init-chip.is-on { background: var(--accent-bg); border-color: var(--accent); color: var(--accent-dark); }
.init-conditions .init-chip::after { content: ' \00d7'; }
.init-marker { flex: none; display: inline-flex; padding: 0; border: 0; background: none; cursor: pointer; border-radius: 3px; }
.init-marker:hover { background: var(--accent-bg); }
/* A player's dot is chosen on their card in the bar, not here. */
.init-marker.is-fixed { cursor: default; }
.init-marker.is-fixed:hover { background: none; }
.init-shape { width: 14px; height: 14px; display: block; }
.init-marker-picker { flex: 0 0 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem; padding-left: 2.3rem; }
.init-marker-picker[hidden] { display: none; }
.init-pick { padding: 0; cursor: pointer; border: 1px solid transparent; background: none; border-radius: 4px; }
.init-pick:hover, .init-pick.is-on { border-color: var(--gray-500); }
.init-pick-shape { display: inline-flex; padding: 2px; }
.init-pick-color { width: 14px; height: 14px; border-radius: 50%; }
.init-pick-color.is-on { border-color: var(--gray-900, #222); box-shadow: 0 0 0 1px var(--surface); }
.init-row.is-down { opacity: 0.55; }
/* The name takes whatever width is left and gives it up first. Sized by its text
   it would push the row past the rail and wrap the remove button onto a line of
   its own; only the statuses are meant to sit on a second line. */
.init-row .iconbtn { width: 22px; height: 22px; flex: none; }
.init-name { flex: 1 1 0; min-width: 2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.init-hp { display: flex; align-items: baseline; gap: 0.1rem; font-variant-numeric: tabular-nums; }
.init-hp-current {
  width: 2.4rem; text-align: right; font: inherit; font-size: 0.83rem;
  border: 1px solid transparent; background: transparent; border-radius: 3px; padding: 0 0.15rem;
}
.init-hp-current:hover,
.init-hp-current:focus { border-color: var(--gray-300); background: var(--surface); }
.init-hp-max { color: var(--gray-500); font-size: 0.76rem; }
/* The - / + sit beside the hit points all the time but are only visible while
   the row is pointed at, so a long fight is not a column of buttons. Their
   space is kept either way, or the row would jump as the pointer crossed it. */
.init-hp { position: relative; }
.init-hp-adjust { position: relative; display: inline-flex; gap: 0.1rem; margin-left: 0.2rem; opacity: 0; transition: opacity 0.1s; }
.init-row:hover .init-hp-adjust,
.init-row:focus-within .init-hp-adjust,
.init-hp-adjust.is-typing { opacity: 1; }
@media (hover: none) { .init-hp-adjust { opacity: 1; } }
.init-hp-step {
  width: 1.1rem; height: 1.1rem; padding: 0; line-height: 1; font-size: 0.8rem; cursor: pointer;
  border: 1px solid var(--gray-300); border-radius: 3px; background: var(--surface); color: var(--gray-600);
}
.init-hp-step:hover { border-color: var(--accent); color: var(--accent-dark); }
/* The amount box covers the pair rather than pushing the row wider. */
.init-hp-amount {
  position: absolute; inset: 0 auto 0 0; width: 2.3rem; font: inherit; font-size: 0.8rem; text-align: center;
  border: 1px solid var(--accent); border-radius: 3px; background: var(--surface); padding: 0;
}
.init-hp-amount[hidden] { display: none; }
/* The three-second wait before a typed amount is applied, drawn as a fill that
   crosses the box left to right. Removing the class (leaving the box, Enter,
   Escape) is what stops it. */
.init-hp-amount.is-counting {
  background-image: linear-gradient(color-mix(in srgb, var(--accent) 40%, var(--surface)), color-mix(in srgb, var(--accent) 40%, var(--surface)));
  background-repeat: no-repeat; background-position: left; background-size: 0% 100%;
  animation: init-amount-wait var(--wait, 3s) linear forwards;
}
@keyframes init-amount-wait { to { background-size: 100% 100%; } }
.init-hp-adjust.is-typing .init-hp-step { visibility: hidden; }
.init-add { display: flex; gap: 0.25rem; }
.init-add input {
  font: inherit; font-size: 0.8rem; padding: 0.2rem 0.35rem;
  border: 1px solid var(--gray-300); border-radius: 4px; min-width: 0;
}
.init-add input[name="name"] { flex: 1 1 auto; }

/* ---- The plot board ------------------------------------------------------
   Four columns of storylines. A card is a title you can read at a glance and a
   body you open when you want the detail — the same rendered-then-source swap
   every other body in the app uses. */
.plot-columns { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.7rem; }
@media (max-width: 900px) { .plot-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .plot-columns { grid-template-columns: 1fr; } }

.plot-column h3 {
  display: flex; align-items: baseline; gap: 0.35rem; margin: 0 0 0.4rem;
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500);
}
.plot-count { font-variant-numeric: tabular-nums; color: var(--gray-400); }
.plot-add-input {
  width: 100%; font: inherit; font-size: 0.8rem; padding: 0.25rem 0.4rem; margin-bottom: 0.4rem;
  border: 1px solid var(--gray-200); border-radius: 4px; background: var(--gray-100);
}
.plot-add-input:focus { background: var(--surface); border-color: var(--accent); outline: none; }
.plot-cards { min-height: 2.5rem; display: flex; flex-direction: column; gap: 0.35rem; }

.plot-card {
  border: 1px solid var(--gray-200); border-radius: 5px; background: var(--surface);
  padding: 0.4rem 0.5rem; cursor: pointer;
}
.plot-card:hover { border-color: var(--gray-300); }
.plot-card-bar { display: flex; align-items: baseline; gap: 0.35rem; }
.plot-card-title { flex: 1 1 auto; font-size: 0.84rem; line-height: 1.35; }
.plot-card-expand { color: var(--gray-400); font-size: 0.8rem; }
.plot-card-delete { visibility: hidden; flex: none; }
.plot-card:hover .plot-card-delete { visibility: visible; }
/* The body is only on screen while the card is open, so a column of threads
   stays scannable. */
.plot-card-body,
.plot-card-source { display: none; }
.plot-card.is-open .plot-card-body:not([hidden]),
.plot-card .plot-card-source:not([hidden]) { display: block; }
.plot-card-body {
  margin-top: 0.4rem; padding-top: 0.4rem; border-top: 1px solid var(--gray-200);
  font-size: 0.8rem;
}
.plot-card-source {
  width: 100%; margin-top: 0.4rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.76rem; line-height: 1.5; border: 1px solid var(--accent-soft);
  border-radius: 4px; padding: 0.4rem; resize: vertical;
}

/* The pages this campaign owes itself: names written at the table that nobody
   has written a page for. Sits under the tree because it is the same list one
   step earlier — a page that does not exist yet. */
.pages-owed { margin-top: 1.2rem; border-top: 1px solid var(--gray-200); padding-top: 0.7rem; }
.pages-owed h3 {
  display: flex; align-items: baseline; gap: 0.35rem; margin: 0 0 0.4rem;
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500);
}
.owed-list { list-style: none; margin: 0; padding: 0; }
.owed-list li { display: flex; align-items: baseline; gap: 0.35rem; }
.owed-title {
  border: 0; background: none; font: inherit; font-size: 0.83rem; text-align: left;
  padding: 0.2rem 0.35rem; border-radius: 4px; cursor: pointer;
  color: var(--gray-500); border-bottom: 1px dotted var(--gray-300);
}
.owed-title:hover { color: var(--accent-dark); border-bottom-color: var(--accent); }
.owed-count { font-size: 0.7rem; color: var(--gray-300); font-variant-numeric: tabular-nums; }

/* ── Accounts, and the two surfaces they reach ──────────────────────────────
   Reskinning the tokens is how this app changes its look; these are new
   regions rather than restyled components, and they borrow the panel, the
   summary count and the quiet button that every other screen already uses. */

/* The DM's players screen. A row is a person: who they are, where they stand,
   and the two buttons that are the whole of account recovery in an app with no
   email. */
.player-row {
  display: flex; align-items: baseline; gap: 0.6rem;
  padding: 0.45rem 0; border-bottom: 1px solid var(--gray-200);
}
.player-row:last-child { border-bottom: 0; }
.player-who { display: flex; align-items: baseline; gap: 0.4rem; min-width: 12rem; }
.player-handle { font-size: 0.78rem; color: var(--gray-500); font-family: var(--mono, monospace); }
.player-state { flex: 1; font-size: 0.8rem; color: var(--gray-500); }
.player-state.is-pending { color: var(--accent-dark); }
.btn-quiet.is-danger { color: var(--danger); }

/* Shown once, when a link is minted. Deliberately loud: it is the only moment
   the DM can hand it over, and re-issuing invalidates whatever they missed. */
.invite-link {
  display: flex; align-items: flex-end; gap: 0.5rem;
  margin: 0.6rem 0 0.9rem; padding: 0.6rem 0.7rem;
  background: var(--accent-wash, var(--gray-100)); border-radius: 6px;
}
.invite-link label { flex: 1; display: block; font-size: 0.75rem; color: var(--gray-500); }
.invite-link input {
  width: 100%; margin-top: 0.2rem; padding: 0.35rem 0.45rem; font-size: 0.82rem;
  font-family: var(--mono, monospace); border: 1px solid var(--gray-300); border-radius: 4px;
  background: var(--surface); color: inherit;
}

/* Every player character across every campaign, drafts last. Two dropdowns per
   row, because whose it is and where it is are the two facts this screen sets. */
.assign-row {
  display: flex; align-items: center; gap: 0.6rem;
  padding: 0.45rem 0; border-bottom: 1px solid var(--gray-200);
}
.assign-row:last-child { border-bottom: 0; }
.assign-row.is-draft .assign-who strong { color: var(--gray-500); }
.assign-who { flex: 1; display: flex; align-items: baseline; gap: 0.4rem; }
.assign-line { font-size: 0.78rem; color: var(--gray-500); }
.assign-row select {
  padding: 0.3rem 0.4rem; border: 1px solid var(--gray-300); border-radius: 4px;
  font: inherit; font-size: 0.82rem; background: var(--surface); color: inherit; min-width: 9rem;
}

/* The hub. One bar with three things in it, because a player's surface has
   three things in it — see render_hub_header(). */
.hub-whoami { flex: 1; font-size: 0.85rem; color: var(--accent-soft); }
.hub-characters { display: flex; flex-wrap: wrap; gap: 0.7rem; }
.hub-character {
  flex: 1 1 14rem; padding: 0.7rem 0.8rem;
  border: 1px solid var(--gray-200); border-radius: 8px; background: var(--surface);
}
/* A draft is a character not in a world yet: shown, and visibly waiting. */
.hub-character.is-draft { border-style: dashed; }
.hub-character h3 { margin: 0 0 0.15rem; font-size: 0.95rem; }
.hub-character-line { margin: 0; font-size: 0.8rem; color: var(--gray-500); }
.hub-character-where { margin: 0.35rem 0 0; font-size: 0.75rem; color: var(--gray-500); }
.hub-account { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
.hub-account label { display: block; font-size: 0.75rem; color: var(--gray-500); }
.hub-account input {
  display: block; margin-top: 0.2rem; padding: 0.35rem 0.45rem; font: inherit; font-size: 0.85rem;
  border: 1px solid var(--gray-300); border-radius: 4px; background: var(--surface); color: inherit;
}
.hub-status { font-size: 0.8rem; color: var(--gray-500); }
.hub-status.is-error { color: var(--danger); }
.hub-password { margin-top: 1rem; }
.hub-password summary { font-size: 0.82rem; color: var(--gray-500); cursor: pointer; margin-bottom: 0.5rem; }

/* ── The shared sheet's two additions ───────────────────────────────────────
   The sheet itself was already styled; these are the parts that arrived with
   it being shared between the DM and its player. */

/* Who plays this character: a box while nobody owns it, the owner's name as
   text once somebody does. Both live in the same label so the field keeps its
   place in the identity grid either way. */
.player-owner { display: block; padding: 0.35rem 0.1rem; font-size: 0.85rem; }

/* What moved on this sheet, and who moved it. Collapsed by default: it is the
   answer to a question you only sometimes have, and an open log would push the
   prose sections off the screen. */
/* The notes beside a sheet. Built from the segment list's parts on purpose --
   a folded row with a bar you click and a body underneath -- because it is the
   same gesture, and a reader who has learnt it on the game page should not have
   to learn it twice. */
/* The advantage menu, at the cursor. Fixed rather than absolute because it is
   placed from clientX/clientY, which are viewport coordinates -- and because a
   menu that scrolls away from the number it belongs to is a menu you have to
   chase. */
.roll-menu {
  position: fixed; z-index: 60; min-width: 11rem;
  background: var(--surface); border: 1px solid var(--gray-300); border-radius: 6px;
  box-shadow: 0 10px 26px rgba(33, 29, 40, 0.22); padding: 0.25rem;
}
.roll-menu-what {
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--gray-500); padding: 0.3rem 0.5rem 0.35rem;
  border-bottom: 1px solid var(--gray-200); margin-bottom: 0.2rem;
}
.roll-menu-pick {
  display: block; width: 100%; text-align: left; font: inherit; font-size: 0.85rem;
  padding: 0.35rem 0.5rem; border: none; border-radius: 4px;
  background: none; color: var(--gray-900); cursor: pointer;
}
.roll-menu-pick:hover, .roll-menu-pick:focus { background: var(--accent-soft); outline: none; }

.sheet-notes { margin-top: 1.2rem; border-top: 1px solid var(--gray-200); padding-top: 0.7rem; }
.sheet-notes h3 { margin: 0 0 0.5rem; }
.sheet-notes-count { font-variant-numeric: tabular-nums; }
/* Stacked, so each control gets the whole width rather than competing for it:
   naming a note is one row across the bar, and the filter -- on a page that has
   not taken it into its header -- is the row above. */
.notes-bar { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 0.6rem; }
.notes-bar input {
  font: inherit; font-size: 0.85rem; padding: 0.3rem 0.5rem;
  border: 1px solid var(--gray-300); border-radius: 5px;
  background: var(--surface); color: var(--gray-900);
}
.notes-bar .note-filter { width: 100%; }
/* One row across the bar. The label wears the sheet's own field styling, which
   is why it is not restyled here. */
.note-new { display: flex; align-items: center; gap: 0.5rem; }
.note-new > label { flex: none; }
.note-new .note-name { flex: 1 1 auto; min-width: 0; }

/* The filter once the hub's header has taken it: the same input, wearing the
   bar's own search styling rather than the sheet's. */
.hub-notes-search .note-filter {
  width: 100%; height: 28px; padding: 0 0.55rem 0 1.75rem;
  border: 1px solid rgba(255, 255, 255, 0.28); border-radius: 5px;
  background: rgba(255, 255, 255, 0.12); color: var(--header-fg);
  font: inherit; font-size: 0.82rem;
}
.hub-notes-search .note-filter::placeholder { color: rgba(255, 255, 255, 0.68); }
.hub-notes-search .note-filter:focus { background: rgba(255, 255, 255, 0.2); outline-color: var(--header-fg); }

.note-list { list-style: none; margin: 0; padding: 0; }
.note { border: 1px solid var(--gray-200); border-radius: 6px; overflow: hidden; margin-bottom: 0.4rem; background: var(--surface); }
.note-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; cursor: pointer; }
.note-fold {
  flex: none; border: none; background: none; cursor: pointer; padding: 0 0.2rem;
  font-size: 0.9rem; line-height: 1; color: var(--gray-500);
}
.note-fold:hover { color: var(--accent-dark); }
/* The title is a box that does not look like one until it is wanted: a note's
   name is read far more often than it is changed, and a row of input frames
   would read as a form rather than as a list. */
/* Written as `.note-bar input.note-title` to out-specify the sheet's own rule
   for every text box on it, which would otherwise draw this one as a field. */
.note-bar input.note-title {
  flex: 1 1 auto; min-width: 0; font: inherit; font-weight: 600; font-size: 0.88rem;
  border: 1px solid transparent; border-radius: 4px; padding: 0.15rem 0.3rem;
  background: transparent; color: var(--gray-900); cursor: pointer;
}
.note-bar input.note-title:hover { border-color: var(--gray-200); }
.note-bar input.note-title:focus { border-color: var(--accent); background: var(--surface); cursor: text; }
/* Quiet, like the title beside it: which session a note is under is read far
   more often than it is changed, and a row of framed menus would read as a form
   rather than as a list. Same specificity trick as the title, for the same
   reason -- the sheet styles every select on it as a field. */
.note-bar select.note-game {
  flex: none; max-width: 11rem; font: inherit; font-size: 0.74rem;
  color: var(--gray-500); background: transparent;
  border: 1px solid transparent; border-radius: 4px; padding: 0.1rem 0.2rem; cursor: pointer;
}
.note-bar select.note-game:hover { border-color: var(--gray-200); color: var(--gray-600); }
.note-bar select.note-game:focus { border-color: var(--accent); background: var(--surface); }
.note-when { font-size: 0.72rem; color: var(--gray-500); font-variant-numeric: tabular-nums; flex: none; }
.note-actions { display: flex; align-items: center; gap: 0.3rem; flex: none; }
.note-body { padding: 0 0.6rem 0.6rem; }
.note-rendered { min-height: 2.2rem; cursor: text; font-size: 0.88rem; }
.note-source {
  width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.8rem; line-height: 1.55; border: 1px solid var(--accent-soft);
  border-radius: 5px; padding: 0.5rem; resize: vertical;
  background: var(--surface); color: var(--gray-900);
}
.note-empty { margin: 0.2rem 0 0; }

.sheet-audit { margin-top: 1.2rem; border-top: 1px solid var(--gray-200); padding-top: 0.7rem; }
.sheet-audit summary {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--gray-500); cursor: pointer;
}
.sheet-audit-count { font-variant-numeric: tabular-nums; }
.audit-list { list-style: none; margin: 0.6rem 0 0; padding: 0; }
.audit-entry {
  display: grid; grid-template-columns: 9rem 1fr auto auto; gap: 0.5rem;
  align-items: baseline; padding: 0.25rem 0; font-size: 0.8rem;
  border-bottom: 1px solid var(--gray-100, var(--gray-200));
}
.audit-entry:last-child { border-bottom: 0; }
.audit-what { color: var(--gray-500); }
.audit-move { font-family: var(--mono, monospace); font-size: 0.78rem; }
.audit-who { color: var(--gray-500); }
.audit-when { color: var(--gray-300); font-variant-numeric: tabular-nums; font-size: 0.75rem; }

/* On the hub a character's page says which campaign it is in, which is the
   whole of campaign scoping on that surface — there is no picker because a
   character belongs to one campaign and the URL names the character. */
.sheet-where { font-weight: 400; }

/* A hub card is a link to that character's sheet. */
a.hub-character { display: block; color: inherit; text-decoration: none; }
a.hub-character:hover { border-color: var(--accent); }

/* ── Import ─────────────────────────────────────────────────────────────────
   The only way a character is created, so the control is not tucked away —
   and a refusal has to be readable, because the parser refusing by name is
   only useful if somebody reads what it named. */
.sheet-import {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.6rem;
  margin-top: 1.2rem; padding-top: 0.7rem; border-top: 1px solid var(--gray-200);
}
.sheet-import-pick { display: flex; align-items: baseline; gap: 0.4rem; font-size: 0.8rem; color: var(--gray-500); }
.sheet-import-pick input[type="file"] { font: inherit; font-size: 0.78rem; max-width: 15rem; }
.sheet-import-kept { font-size: 0.78rem; }
.sheet-import-status { font-size: 0.8rem; color: var(--gray-500); }
/* Wraps rather than truncating: the refusal names the fields it could not find,
   and half of that sentence is no use to anybody. */
.sheet-import-status.is-error { color: var(--danger); flex-basis: 100%; }
.hub-import { margin-top: 0.9rem; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.6rem; }

/* ── Rolls as records ───────────────────────────────────────────────────────
   The tray keeps the last few; this is the whole record, grouped by the night
   each roll was thrown on. */
.run-live { display: flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: var(--gray-500); margin: 0 0 0.5rem; }

/* On the tray: whose roll, and whether the table can see it. */
.dice-who { color: var(--gray-500); font-size: 0.78rem; margin-left: 0.3rem; }
.dice-hidden {
  margin-left: 0.3rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--gray-500); border: 1px solid var(--gray-300); border-radius: 3px; padding: 0 0.25rem;
}
.dice-see-all { display: block; font-size: 0.75rem; margin: 0.2rem 0 0.6rem; }
/* Rolling this one in the open. Sits in the row with count and modifier because
   it is a setting for the next roll, and it clears itself the way none of the
   others do. */
.dice-open-roll { display: flex; align-items: center; gap: 0.25rem; font-size: 0.75rem; color: var(--gray-500); white-space: nowrap; }

.roll-filter { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.6rem; margin-bottom: 0.9rem; }
.roll-filter label { font-size: 0.75rem; color: var(--gray-500); }
.roll-filter select {
  margin-left: 0.3rem; padding: 0.25rem 0.35rem; font: inherit; font-size: 0.82rem;
  border: 1px solid var(--gray-300); border-radius: 4px; background: var(--surface); color: inherit;
}
.roll-game { margin-bottom: 1.2rem; }
.roll-game h3 {
  margin: 0 0 0.35rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--gray-500);
}
.roll-list { list-style: none; margin: 0; padding: 0; }
.roll-row {
  display: grid; grid-template-columns: 3rem 1fr 8rem 8rem 3rem auto; gap: 0.5rem;
  align-items: baseline; padding: 0.25rem 0; font-size: 0.83rem;
  border-bottom: 1px solid var(--gray-200);
}
.roll-row:last-child { border-bottom: 0; }
.roll-total { font-size: 0.95rem; text-align: right; }
.roll-who, .roll-dice, .roll-when { color: var(--gray-500); font-size: 0.78rem; }
.roll-dice { font-family: var(--mono, monospace); }
.roll-when { font-variant-numeric: tabular-nums; }
/* A roll the table has not seen. Marked rather than merely un-shown, because on
   this page the DM is deciding whether to show it. */
.roll-row.is-hidden-roll { background: var(--gray-100, transparent); }
.roll-row.is-max .roll-total { color: var(--accent-dark); }
.roll-row.is-min .roll-total { color: var(--danger); }

/* The pulse: a dot that ticks each time the poll comes back. It is the
   difference between "nobody has rolled" and "this screen stopped listening an
   hour ago", which otherwise look identical — and a live loop whose failure is
   indistinguishable from quiet is one nobody can trust mid-session. */
.dice-pulse {
  width: 6px; height: 6px; border-radius: 50%; margin-left: 0.45rem;
  background: var(--gray-300); flex: none;
}
.dice-pulse.is-beating { animation: dice-pulse-beat 900ms ease-out; }
/* Stopped listening. Not the danger token — nothing is wrong with the campaign,
   only with this tab's connection to it. */
.dice-pulse.is-stalled { background: var(--gray-500); box-shadow: 0 0 0 2px var(--gray-200); }
@keyframes dice-pulse-beat {
  0% { background: var(--accent); transform: scale(1.6); }
  100% { background: var(--gray-300); transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .dice-pulse.is-beating { animation: none; }
}
