:root {
  color-scheme: dark;
  --bg: #16161d;
  --fg: #e8e6e3;
  --accent: #7aa2f7;
  --warn: #e0af68;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: system-ui, sans-serif;
  /* a dice expression, table slug, file name or username is one unbreakable
     token, and in a 26rem sidebar an unbreakable token doesn't wrap — it
     overflows and takes its container's scroll width with it. break-word
     breaks only a word that can't fit a line alone, so prose is untouched.
     deliberately not `anywhere`. */
  overflow-wrap: break-word;
}

/* convention: a flex child holding free text needs `min-width: 0`. the
   default is `min-width: auto` — "never shrink below my longest word" —
   which is the commonest cause of "overflows instead of wrapping".

   AND ITS COST, which is the other half of the convention: the rule above
   makes an overflowing token break mid-word, so a text child with the floor
   removed AND a zero-basis `flex: 1` can be squeezed toward nothing and
   break ONE CHARACTER PER LINE. it needs a row of several inflexible
   siblings to get there — the handout folder heading, with six controls
   beside the name, is where it actually happened.

   THERE ARE TWO VALID GUARDS, and which applies depends on where the
   shortfall comes from:

   1. one text child can absorb it — give that child the truncation trio
      (`white-space: nowrap` + `overflow: hidden` + `text-overflow: ellipsis`)
      and `min-width: 0`. the text yields, the controls keep their width.
      `.room-header` and `.tile-control-row` work this way.
   2. the INFLEXIBLE SIBLINGS ALONE exceed the container — truncation cannot
      help however far the text yields, and only `flex-wrap: wrap` will do.
      `.folder-heading`, `.wb-group` and `.wb-board` are this case, and
      `.wb-board` proves the distinction: it already had the truncation trio
      and still overflowed its rail by 92px under the touch floor.

   a text child in a wrapping row bases at `auto` (`flex: 1 1 auto`), never
   at zero, or the row cannot tell it needs to wrap. and an `ellipsis`
   declaration without `white-space: nowrap` does nothing at all. */

.connection-banner {
  position: sticky;
  top: 0;
  padding: 0.4rem 1rem;
  background: var(--warn);
  color: #1a1a1a;
  font-size: 0.9rem;
  text-align: center;
}

.placeholder {
  max-width: 40rem;
  margin: 4rem auto;
  padding: 0 1rem;
}

.placeholder h1 {
  color: var(--accent);
}

.error {
  color: #f7768e;
}

.hint {
  color: #9aa0a6;
  font-size: 0.85rem;
}

.badge {
  background: var(--accent);
  color: #1a1a1a;
  border-radius: 3px;
  padding: 0 0.35rem;
  margin-left: 0.4rem;
  font-size: 0.75rem;
}

.badge.muted {
  background: #565f89;
}

button {
  background: #2a2a37;
  color: var(--fg);
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  padding: 0.3rem 0.7rem;
  cursor: pointer;
}

button:hover:not(:disabled) {
  border-color: var(--accent);
}

button:disabled {
  opacity: 0.45;
  cursor: default;
}

a {
  color: var(--accent);
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

input,
select,
textarea {
  background: #1f1f29;
  color: var(--fg);
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  padding: 0.35rem 0.5rem;
}

/* login + setup */
.login-page {
  max-width: 26rem;
  margin: 4rem auto;
  padding: 0 1rem;
}

.login-page h1 {
  color: var(--accent);
}

.roster {
  list-style: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.roster button {
  position: relative;
  width: 100%;
  padding: 0.7rem;
  font-size: 1rem;
  display: flex;
  align-items: center;
}

/* badge is out of flow so it can't shift the name; symmetric padding keeps
   the name centred and clear of it */
.roster .roster-name {
  flex: 1;
  min-width: 0;
  padding: 0 3rem;
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.roster button .badge {
  position: absolute;
  right: 0.7rem;
  top: 50%;
  transform: translateY(-50%);
  margin-left: 0;
}

.password-form,
.setup-form,
.admin-login {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  margin-top: 1rem;
}

/* tables + admin */
.tables-page,
.admin-page {
  max-width: 46rem;
  margin: 2rem auto;
  padding: 0 1rem;
}

.table-list {
  list-style: none;
  padding: 0;
}

.table-list li {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.5rem;
  border-bottom: 1px solid #2a2a37;
}

.table-list li.active-table {
  border-left: 3px solid var(--accent);
}

.table-name {
  font-weight: 600;
}

.table-name-input {
  font-weight: 600;
  width: 14rem;
}

.member-count {
  color: #9aa0a6;
  font-size: 0.85rem;
}

.table-list li {
  flex-wrap: wrap;
}

.table-members {
  flex-basis: 100%;
  padding: 0.4rem 0 0.2rem 1rem;
  border-left: 2px solid #2a2a37;
}

/* name | username | badges | role | password | suspend | remove | hint —
   rows are display:contents, so cells align in shared columns and buttons
   in the same column come out equal-width. column count must match the
   cells a row renders exactly, or auto-placement drifts */
.member-list {
  list-style: none;
  padding: 0;
  margin: 0 0 0.5rem;
  display: grid;
  grid-template-columns: repeat(8, max-content) 1fr;
  column-gap: 0.75rem;
  row-gap: 0.35rem;
  align-items: center;
}

.member-list li {
  display: contents;
}

/* the admin view adds the erase column before the hint */
.member-list.admin {
  grid-template-columns: repeat(9, max-content) 1fr;
}

.member-username {
  color: #9aa0a6;
  font-size: 0.85rem;
  /* a username has no spaces to break at */
  overflow-wrap: anywhere;
  min-width: 0;
}

.member-badges {
  display: flex;
  gap: 0.3rem;
}

.member-add {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.member-hint {
  color: #9aa0a6;
  font-size: 0.8rem;
  font-style: italic;
}

.admin-page table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 0.75rem;
}

.admin-page th,
.admin-page td {
  text-align: left;
  padding: 0.35rem 0.5rem;
  border-bottom: 1px solid #2a2a37;
}

tr.disabled-user {
  opacity: 0.5;
}

.inline-form,
.player-row {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin: 0.4rem 0;
  flex-wrap: wrap;
}

.create-table {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  align-items: flex-start;
}

/* play window */
.play {
  display: flex;
  /* vh first as the fallback; dvh tracks the visual viewport so the on-screen
     keyboard shrinks the box rather than pushing chrome off the bottom */
  height: calc(100vh - 0px);
  height: 100dvh;
  position: relative;
}

/* ---- tabbed right sidebar (specs/09, specs/11) ---- */

.sidebar {
  width: 26rem;
  max-width: 90vw;
  border-left: 1px solid #2a2a37;
  background: #1d1d26;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.sidebar.collapsed {
  width: 3rem;
}

.sidebar-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.8rem;
  border-bottom: 1px solid #2a2a37;
}

.sidebar-header h2 {
  margin: 0;
  font-size: 1rem;
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.sidebar.collapsed .sidebar-header {
  justify-content: center;
  padding: 0.5rem 0.2rem;
}

.sidebar.collapsed .sidebar-header h2,
.sidebar.collapsed .tab-text,
.sidebar.collapsed .sidebar-body {
  display: none;
}

.sidebar-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  padding: 0.4rem;
  border-bottom: 1px solid #2a2a37;
}

.sidebar.collapsed .sidebar-tabs {
  flex-direction: column;
  align-items: stretch;
}

.sidebar-tab {
  position: relative;
  font-size: 0.85rem;
  padding: 0.25rem 0.5rem;
}

.sidebar-tab.active {
  outline: 1px solid var(--accent);
}

.tab-unread {
  display: none;
}

.tab-unread.on {
  display: inline-block;
  width: 0.45rem;
  height: 0.45rem;
  border-radius: 50%;
  background: var(--warn);
  position: absolute;
  top: 2px;
  right: 2px;
}

.sidebar-body {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.sidebar-chat {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.sidebar-chat.hidden {
  display: none;
}

.sidebar-tab.hidden {
  display: none;
}

.wb-png.hidden {
  display: none;
}

/* ---- presence dock (lower-left over the scene) ---- */

.presence-dock {
  position: absolute;
  left: 0.75rem;
  bottom: 0.75rem;
  z-index: 8;
}

.presence-dock .dock-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  background: rgba(10, 10, 16, 0.85);
  border: 1px solid #2a2a37;
  border-radius: 6px;
  padding: 0.25rem 0.5rem;
}

/* collapsed, only the handle is left in the scene's lower-left corner, so
   presented content (pdf page controls especially) reaches its bottom edge.
   the row is hidden rather than unmounted — it holds the ptt listeners */
.dock-collapsible {
  display: contents;
}

.dock-collapsible.hidden {
  display: none;
}

.dock-collapse {
  background: none;
  border: none;
  padding: 0.1rem 0.25rem;
  font-size: 0.8rem;
  line-height: 1.3;
  color: #9aa0a6;
}

.dock-collapse:hover {
  color: #e6e6f0;
}

.dock-toggle {
  display: flex;
  align-items: center;
  gap: 0.2rem;
  background: none;
  border: none;
  padding: 0.1rem;
}

.dock-member {
  position: relative;
  display: inline-flex;
}

.dock-member .avatar,
.dock-member .initials {
  width: 1.7rem;
  height: 1.7rem;
  border-radius: 50%;
  object-fit: cover;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: #3b3b4d;
  font-size: 0.7rem;
  font-weight: bold;
}

.dock-member.speaking .avatar,
.dock-member.speaking .initials {
  outline: 2px solid #9ece6a;
}

.dock-member .presence {
  position: absolute;
  right: -2px;
  bottom: -2px;
  width: 0.5rem;
  height: 0.5rem;
  border: 2px solid #101017;
}

.dock-more {
  font-size: 0.75rem;
  color: #9aa0a6;
  margin-left: 0.2rem;
}

.presence-popover {
  position: absolute;
  bottom: 100%;
  left: 0;
  margin-bottom: 0.4rem;
  width: 17rem;
  max-height: 55vh;
  overflow-y: auto;
  background: #1d1d26;
  border: 1px solid #33334a;
  border-radius: 6px;
  padding: 0.4rem 0.8rem;
}

.presence-dock .voice-controls {
  font-size: 0.85rem;
  padding: 0;
  border-bottom: none;
  margin-bottom: 0;
}

.presence-dock .voice-buttons {
  flex-wrap: nowrap;
}

.presence-dock .voice-buttons button {
  font-size: 0.75rem;
  padding: 0.2rem 0.4rem;
  white-space: nowrap;
}

/* single line so the dock never grows over the focus controls above it */
.presence-dock .voice-controls .hint,
.presence-dock .voice-controls .error {
  margin: 0;
  font-size: 0.72rem;
  max-width: 13rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.members {
  list-style: none;
  padding: 0;
}

.members li {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.25rem 0;
}

.presence {
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 50%;
  display: inline-block;
}

.presence.active { background: #9ece6a; }
.presence.reconnecting { background: var(--warn); }
.presence.offline { background: #3b3b4d; }

.chat {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.chat-header {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 1rem;
  border-bottom: 1px solid #2a2a37;
}

.chat-header h2 {
  margin: 0;
  font-size: 1rem;
  flex: 1;
  min-width: 0;
}

.chat-header .clear-chat {
  font-size: 0.75rem;
  padding: 0.15rem 0.45rem;
  color: #d98a8a;
}

.chat-header .clear-chat:hover {
  background: #b03a3a;
  color: #fff;
}

.timeline {
  flex: 1;
  overflow-y: auto;
  padding: 0.5rem 1rem;
}

.chat-entry {
  padding: 0.25rem 0.4rem;
  border-radius: 4px;
}

.chat-entry .author {
  font-weight: 600;
  color: var(--accent);
  margin-right: 0.4rem;
}

.chat-entry .body p {
  margin: 0.15rem 0;
}

.chat-entry.whisper {
  background: #232336;
  border-left: 2px solid #bb9af7;
}

.whisper-tag {
  color: #bb9af7;
  font-size: 0.8rem;
  margin-right: 0.4rem;
}

.chat-entry.mention {
  background: #2b2b1f;
}

.chat-entry.deleted {
  opacity: 0.55;
}

.chat-entry.pending {
  opacity: 0.6;
}

.chat-entry.pending.failed {
  opacity: 1;
  border-left: 2px solid #f7768e;
}

.edited-badge {
  color: #9aa0a6;
  font-size: 0.75rem;
  margin-right: 0.4rem;
}

.entry-actions {
  float: right;
  visibility: hidden;
  display: inline-flex;
  gap: 0.3rem;
}

.chat-entry:hover .entry-actions {
  visibility: visible;
}

.entry-actions button {
  font-size: 0.7rem;
  padding: 0.1rem 0.35rem;
}

.system-entry {
  color: #9aa0a6;
  font-size: 0.85rem;
  font-style: italic;
  padding: 0.2rem 0.4rem;
}

.chat-entry.emote {
  font-style: italic;
}

.unread-divider {
  text-align: center;
  color: var(--warn);
  font-size: 0.8rem;
  margin: 0.4rem 0;
  border-bottom: 1px dashed var(--warn);
}

.mention-menu {
  list-style: none;
  margin: 0;
  padding: 0.25rem;
  border-top: 1px solid #2a2a37;
}

.mention-menu button {
  width: 100%;
  text-align: left;
  border: none;
  background: none;
}

.mention-menu button:hover {
  background: #2a2a37;
}

.chat-input {
  margin: 0.6rem 1rem 1rem;
  min-height: 3rem;
  resize: vertical;
  font-family: inherit;
}

.chat-entry .body code,
.chat-entry .body pre {
  background: #101017;
  border-radius: 3px;
  padding: 0 0.25rem;
}

.chat-entry .body pre {
  padding: 0.5rem;
  overflow-x: auto;
}

.chat-entry .body blockquote {
  border-left: 3px solid #3b3b4d;
  margin: 0.2rem 0;
  padding-left: 0.6rem;
  color: #b5b3ae;
}

/* ---- voice (phase 2) ---- */

.voice-controls {
  padding: 0.4rem 0;
  border-bottom: 1px solid #2a2a37;
  margin-bottom: 0.5rem;
}

.voice-buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

.voice-buttons button.active {
  background: #3b3b4d;
}

.voice-buttons .leave-voice {
  color: #e07070;
}

.join-voice,
.unlock-audio {
  width: 100%;
  padding: 0.4rem;
}

.voice-error {
  font-size: 0.85rem;
}

.voice-badge {
  margin-left: 0.3rem;
  opacity: 0.6;
}

.voice-badge.speaking {
  opacity: 1;
  text-shadow: 0 0 6px #6dc56d;
}

.members .voice-only .member-name {
  font-style: italic;
}

.prejoin-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 20;
}

.prejoin {
  background: #1c1c26;
  border: 1px solid #3b3b4d;
  border-radius: 6px;
  padding: 1.2rem;
  width: min(26rem, 90vw);
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

.prejoin select {
  width: 100%;
  margin-top: 0.2rem;
}

/* checkbox rows hang their text off the box instead of tucking it under.
   `label.` is load-bearing: play.rs renders an unrelated <span class="check">
   for onboarding status that must not become a flex container. */
label.check,
label.gm-check {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  line-height: 1.35;
}

label.check > input[type="checkbox"],
label.gm-check > input[type="checkbox"] {
  flex: none;
  margin: 0.18em 0 0;
}

/* pre-existing: centres the box against a single-line label. kept, and it
   still wins on specificity over the base rule above — including the box's
   top margin, which would fight `align-items: center`. */
.prejoin .check {
  display: flex;
  gap: 0.4rem;
  align-items: center;
}

.prejoin label.check > input[type="checkbox"] {
  margin: 0;
}

.mic-meter {
  height: 6px;
  background: #101017;
  border-radius: 3px;
  overflow: hidden;
}

.mic-meter-fill {
  height: 100%;
  background: #6dc56d;
  transition: width 80ms linear;
}

.ptt-rebind {
  align-self: flex-start;
  font-size: 0.85rem;
  padding: 0.15rem 0.5rem;
}

.gm-defaults {
  border: 1px solid #2a2a37;
  border-radius: 4px;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  font-size: 0.9rem;
}

.prejoin-actions {
  display: flex;
  gap: 0.5rem;
  justify-content: flex-end;
}

.voice-tiles {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  padding: 0.5rem 1rem 0;
}

.voice-tiles.empty {
  display: none;
}

.tile {
  position: relative;
  width: 10rem;
  border: 2px solid #2a2a37;
  border-radius: 6px;
  overflow: hidden;
  background: #101017;
}

.tile.speaking {
  border-color: #6dc56d;
}

.tile video {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  display: block;
}

.tile-avatar {
  aspect-ratio: 16 / 9;
  display: flex;
  align-items: center;
  justify-content: center;
}

.tile-avatar .initials {
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  background: #3b3b4d;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
}

.tile-footer {
  display: flex;
  gap: 0.3rem;
  align-items: center;
  padding: 0.2rem 0.4rem;
  font-size: 0.8rem;
}

.elevate-self {
  margin-left: auto;
  font-size: 0.65rem;
  padding: 0 0.3rem;
  opacity: 0.7;
}

/* capture views must stay control-free */
.scene.capture .elevate-self {
  display: none;
}

.tile-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.quality-badge {
  color: #d5a94f;
}

.tile-controls {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  padding: 0.2rem 0.4rem 0.4rem;
  font-size: 0.75rem;
}

.tile-controls input[type="range"] {
  width: 100%;
}

.tile-controls .gm-mute {
  color: #e07070;
}

/* in the scene grid the per-tile controls overlay on hover — always-on
   sliders under every face is clutter at gallery size */
.scene-tiles .tile .tile-controls {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(10, 10, 16, 0.85);
  opacity: 0;
  transition: opacity 0.15s;
  pointer-events: none;
}

.scene-tiles .tile:hover .tile-controls,
.scene-tiles .tile:focus-within .tile-controls {
  opacity: 1;
  pointer-events: auto;
}

.content-slot {
  margin: 0.5rem 1rem 0;
  border: 1px solid #3b3b4d;
  border-radius: 6px;
  overflow: hidden;
}

.content-slot video {
  width: 100%;
  max-height: 55vh;
  object-fit: contain;
  display: block;
  background: #000;
}

.slot-header {
  padding: 0.3rem 0.6rem;
  font-size: 0.85rem;
  background: #1c1c26;
}

.diagnostics {
  margin-top: 0.5rem;
  font-size: 0.75rem;
}

.diag-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.diagnostics pre {
  background: #101017;
  padding: 0.4rem;
  border-radius: 4px;
  max-height: 14rem;
  overflow: auto;
  white-space: pre-wrap;
}

/* ---- handouts (phase 3, specs/07) ---- */

.handouts-panel {
  flex: 1;
  min-height: 0;
  padding: 0.8rem;
  overflow-y: auto;
}

.handouts-panel header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.new-handout {
  display: flex;
  gap: 0.4rem;
  flex-wrap: wrap;
  align-items: center;
  margin-bottom: 0.8rem;
}

.new-handout .folder-input {
  flex: 1;
  min-width: 7rem;
}

.new-text {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.new-text textarea {
  min-height: 6rem;
}

.file-button {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.25rem 0.6rem;
  background: #2a2a3a;
  border: 1px solid #44445a;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
}

.file-button input[type="file"] {
  display: none;
}

.file-button.primary {
  border-color: var(--accent);
}

/* the gm-only handout tree (specs/07). indentation is a css variable set per
   node so the nesting is one rule rather than a class per level */
.library-toolbar {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin: 0.4rem 0;
}

.folder-node {
  margin-left: calc(min(var(--folder-depth, 0), 6) * 0.75rem);
}

/* the actions wrap to their own line rather than crushing the name — with
   `body { overflow-wrap: break-word }` a squeezed name breaks per character */
.folder-heading {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem;
  row-gap: 0.1rem;
  margin: 0.6rem 0 0.1rem;
  border-bottom: 1px solid #2a2a3a;
}

/* a drag hovering a heading is about to file the handout here */
.folder-heading:hover {
  background: #23233a;
}

.folder-toggle {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  /* `flex: 1` would base at 0, so the row never wraps and the name is
     squeezed to its longest word instead. `auto` bases at content */
  flex: 1 1 auto;
  text-align: left;
  background: none;
  border: none;
  color: var(--accent);
  cursor: pointer;
  padding: 0.15rem 0.2rem;
}

.folder-name {
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.folder-count {
  font-size: 0.7rem;
  color: #8a8aa0;
}

.folder-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.2rem;
  align-items: center;
  margin-left: auto;
}

.folder-actions button,
.folder-actions select,
.row-folder {
  font-size: 0.72rem;
  padding: 0.1rem 0.3rem;
}

.folder-delete {
  color: #d66;
}

/* the unfiled area is also the drop target for "take it out of the folder" */
.folder-root {
  min-height: 1.2rem;
}

.handout-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.handout-row {
  position: relative;
  /* reserve room so the pinned × never overlaps a long title/reveal-state */
  padding: 0.4rem 1.6rem 0.4rem 0.2rem;
  border-bottom: 1px solid #2a2a3a;
  cursor: grab;
}

/* red × pinned top-right, same affordance as the background-image delete */
.handout-delete {
  position: absolute;
  top: 0.3rem;
  right: 0.3rem;
  width: 1.15rem;
  height: 1.15rem;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: #b03a3a;
  color: #fff;
  font-size: 0.8rem;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s;
}

.handout-row:hover .handout-delete,
.handout-delete:focus-visible {
  opacity: 1;
  pointer-events: auto;
}

/* touch/coarse-pointer devices have no hover: keep it visible */
@media (hover: none) {
  .handout-delete {
    opacity: 1;
    pointer-events: auto;
  }
}

.handout-row .handout-title {
  background: none;
  border: none;
  color: var(--fg);
  font-size: 0.95rem;
  cursor: pointer;
  padding: 0;
  /* an uploaded file name is one token: break it rather than widening the
     sidebar's scroll width */
  overflow-wrap: anywhere;
  text-align: left;
  min-width: 0;
}

/* the floating viewer's title had no rule at all; a long file name pushed
   the close/download controls off the header */
.viewer-title {
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
}

.handout-row .handout-title:hover {
  color: var(--accent);
}

.kind-icon {
  margin-right: 0.3rem;
}

.reveal-state {
  display: block;
  font-size: 0.75rem;
  color: #9a9ab0;
}

.row-actions {
  display: flex;
  gap: 0.25rem;
  flex-wrap: wrap;
  margin-top: 0.25rem;
}

.row-actions button,
.row-actions .file-button {
  font-size: 0.75rem;
  padding: 0.15rem 0.45rem;
}

.row-actions .active {
  outline: 1px solid var(--accent);
}

.reveal-picker {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  margin: 0.4rem 0;
  padding: 0.5rem;
  background: #22222e;
  border-radius: 4px;
}

.access-picker .picker-row {
  display: flex;
  gap: 0.4rem;
}

.access-picker .picker-row .done {
  margin-left: auto;
}

.access-picker label {
  display: flex;
  gap: 0.4rem;
  align-items: center;
}

/* collection */

.collection-tools {
  display: flex;
  gap: 0.4rem;
  margin-bottom: 0.6rem;
}

.collection-tools input {
  flex: 1;
  min-width: 0;
}

.collection-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 0.6rem;
}

.collection-item {
  position: relative;
  background: #22222e;
  border-radius: 6px;
  overflow: hidden;
}

.thumb-button {
  width: 100%;
  background: none;
  border: none;
  color: var(--fg);
  cursor: pointer;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.thumb-button .thumb {
  width: 100%;
  height: 6rem;
  object-fit: cover;
  display: block;
}

.thumb-fallback {
  width: 100%;
  height: 6rem;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2rem;
  background: #2a2a3a;
}

.thumb-button .title {
  padding: 0.3rem 0.4rem;
  font-size: 0.85rem;
  text-align: left;
}

.badge.updated {
  position: absolute;
  top: 0.3rem;
  left: 0.3rem;
  background: var(--warn);
  color: #1a1a1a;
  border-radius: 3px;
  padding: 0 0.3rem;
  font-size: 0.7rem;
}

.link-in-chat {
  position: absolute;
  top: 0.3rem;
  right: 0.3rem;
  font-size: 0.8rem;
  padding: 0.1rem 0.3rem;
}

.show-more {
  width: 100%;
  margin-top: 0.6rem;
}

/* floating viewers */

/* this had NO rule whatsoever, so it was an ordinary in-flow child of .play
   — a backdrop that isn't one. same shape as .prejoin-backdrop. NOTE: .play
   is a grid on the narrow shell, so an overlay added in flow silently pushes
   it past 100dvh. */
.viewer-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  box-sizing: border-box;
  overflow: auto;
}

/* the panel inside it — same chrome as .viewer-panel, which it never had
   either. DECLARED desktop change, part of the same inherited bug: a fixed
   backdrop over an unstyled child is text floating on a scrim. */
.viewer {
  box-sizing: border-box;
  min-width: 0;
  /* `margin: auto` rather than relying on the backdrop's centring alone:
     a flex-centred item taller than its container is clipped at the top and
     cannot be scrolled back to */
  margin: auto;
  display: flex;
  flex-direction: column;
  background: #1d1d26;
  border: 1px solid #44445a;
  border-radius: 8px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}

.viewer > .viewer-header {
  cursor: default;
}

.viewer-panel {
  position: fixed;
  z-index: 40;
  /* inline sizes are viewport-clamped in code; border-box keeps that
     arithmetic true of the rendered rect (1px borders included) */
  box-sizing: border-box;
  width: min(44rem, 90vw);
  max-height: 85vh;
  display: flex;
  flex-direction: column;
  background: #1d1d26;
  border: 1px solid #44445a;
  border-radius: 8px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}

/* user-resized: explicit inline width/height, body fills */
.viewer-panel.sized {
  max-height: none;
}

.viewer-panel.maximized {
  left: 2vw;
  top: 2vh;
  width: 96vw;
  height: 92vh;
  max-height: none;
}

.viewer-panel.sized .viewer-body,
.viewer-panel.maximized .viewer-body {
  flex: 1;
  min-height: 0;
}

.viewer-panel.sized .image-view,
.viewer-panel.maximized .image-view {
  max-height: none;
  height: 100%;
}

.viewer-panel.sized .image-view img,
.viewer-panel.maximized .image-view img {
  max-height: 100%;
}

/* fluid (blurry) preview during the resize gesture; crisp re-render
   happens on pointerup */
.viewer-panel.resizing .pdf-view canvas {
  width: 100%;
}

.viewer-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.4rem 0.7rem;
  background: #26263a;
  border-radius: 8px 8px 0 0;
  cursor: move;
  user-select: none;
  touch-action: none;
}

.viewer-controls {
  display: flex;
  align-items: center;
  gap: 0.35rem;
}

.viewer-controls a {
  color: inherit;
  text-decoration: none;
  padding: 0 0.15rem;
}

.viewer-controls a:focus-visible,
.viewer-controls button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.viewer-board,
.viewer-max {
  font-size: 0.85rem;
}

/* the board dialog is a plain form, narrower than the macro manager */
.macro-dialog.board-from-handout {
  width: min(26rem, 92vw);
}

.board-from-handout .board-name {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  margin: 0.6rem 0;
}

.board-from-handout .macro-dialog-actions {
  justify-content: flex-end;
}

.viewer-close {
  font-size: 1rem;
}

.viewer-body {
  overflow: auto;
  padding: 0.6rem;
  min-height: 8rem;
}

.viewer-resize {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 1.1rem;
  height: 1.1rem;
  cursor: nwse-resize;
  touch-action: none;
  background: linear-gradient(
    135deg,
    transparent 50%,
    #44445a 50%,
    #44445a 60%,
    transparent 60%,
    transparent 70%,
    #44445a 70%,
    #44445a 80%,
    transparent 80%
  );
  border-bottom-right-radius: 8px;
}

/* whiteboard pop-out (specs/08): plain flex body, no pdf letterboxing —
   the surface's own .whiteboard-* styles fill it edge to edge */
.viewer-panel.wb-window .viewer-body {
  padding: 0;
  overflow: hidden;
  flex: 1;
  min-height: 0;
  display: flex;
}

.viewer-panel.wb-window .wb-surface {
  flex: 1;
  min-height: 0;
  min-width: 0;
}

.wb-popout {
  font-size: 0.85rem;
}

.viewer-panel.maximized .viewer-resize {
  display: none;
}

.image-view {
  position: relative;
  overflow: hidden;
  min-height: 10rem;
  max-height: 70vh;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
}

.image-view img {
  max-width: 100%;
  max-height: 68vh;
  transition: transform 0.05s linear;
}

.image-view .zoom-hint {
  position: absolute;
  bottom: 0.3rem;
  right: 0.5rem;
  font-size: 0.7rem;
  color: #9a9ab0;
  pointer-events: none;
}

.text-view {
  max-width: 38rem;
  line-height: 1.5;
}

.pdf-view canvas {
  max-width: 100%;
  display: block;
  margin: 0 auto;
  background: #fff;
}

.pdf-nav {
  display: flex;
  justify-content: center;
  gap: 0.6rem;
  align-items: center;
  padding-top: 0.5rem;
}

/* presented handout + share pip (content-slot arbitration) */

.content-slot.presented {
  border: 1px solid var(--accent);
  border-radius: 6px;
  margin: 0.5rem;
  overflow: auto;
  max-height: 60vh;
}

.content-slot.presented .slot-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.3rem 0.6rem;
  background: #26263a;
}

.content-slot.pip {
  position: absolute;
  right: 1rem;
  bottom: 6rem;
  width: 16rem;
  z-index: 20;
  border: 1px solid #44445a;
  border-radius: 6px;
  overflow: hidden;
}

.content-slot.pip .slot-header {
  font-size: 0.7rem;
  padding: 0.15rem 0.4rem;
}

.presented-minimized {
  margin: 0.5rem;
  align-self: flex-start;
}

/* toast + notices */

.reveal-toast {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 50;
  display: flex;
  align-items: center;
  gap: 0.3rem;
  background: #26263a;
  border: 1px solid var(--accent);
  border-radius: 6px;
  padding: 0.4rem 0.6rem;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}

.reveal-toast .toast-body {
  background: none;
  border: none;
  color: var(--fg);
  cursor: pointer;
}

.reveal-toast.notice {
  border-color: var(--warn);
}

/* chat chips */

.handout-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.2rem;
  background: #2a2a3a;
  border: 1px solid var(--accent);
  border-radius: 999px;
  color: var(--fg);
  padding: 0.05rem 0.55rem;
  font-size: 0.85rem;
  cursor: pointer;
}

.handout-chip:hover {
  background: #33334a;
}

/* avatars */

.avatar {
  width: 1.4rem;
  height: 1.4rem;
  border-radius: 50%;
  object-fit: cover;
  vertical-align: middle;
}

.member-list.hidden,
.member-add.hidden {
  display: none;
}
.avatar.hidden {
  display: none;
}

.member-avatar .initials {
  display: inline-flex;
  width: 1.4rem;
  height: 1.4rem;
  border-radius: 50%;
  background: #3b3b4d;
  align-items: center;
  justify-content: center;
  font-size: 0.65rem;
  font-weight: bold;
}

.tile-avatar .avatar {
  width: 3rem;
  height: 3rem;
}

.self-avatar {
  margin-top: 0.5rem;
}

/* ---- dice (phase 4, specs/05) ---- */

.dice-bar {
  border-top: 1px solid #2a2a3d;
  padding: 0.4rem 1rem 0.6rem;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

/* chips sit inline with the die buttons and wrap as the library grows */
.macro-chips,
.dice-shortcuts {
  display: contents;
}

.macro-chip {
  display: inline-flex;
  align-items: center;
  background: #2a2a3d;
  border-radius: 999px;
  overflow: hidden;
}

.macro-chip .macro-roll {
  padding: 0.15rem 0.2rem 0.15rem 0.6rem;
  font-size: 0.8rem;
}

.macro-chip .macro-unpin {
  padding: 0.15rem 0.45rem 0.15rem 0.15rem;
  font-size: 0.8rem;
  opacity: 0.5;
}

.macro-chip .macro-unpin:hover {
  opacity: 1;
  color: var(--accent);
}

.macro-chip .macro-bind-edit {
  padding-inline: 0.35rem;
  border-left: 1px solid var(--line);
}

.macro-binding {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 0.45rem;
  width: 100%;
  padding: 0.55rem 0.65rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--panel);
}

.macro-binding label {
  display: grid;
  gap: 0.15rem;
  font-size: 0.75rem;
}

.macro-binding input {
  width: 6rem;
}

.macro-binding-name {
  align-self: center;
  font-weight: 650;
}

.macro-edit,
.macro-export {
  font-size: 0.75rem;
  padding: 0.15rem 0.5rem;
}

/* advanced dice (specs/16) */
.dice-mode {
  margin: 0;
  color: #9aa0a6;
  font-size: 0.75rem;
  font-style: italic;
}

.subtotal.tally,
.roll-total.tally,
.ticker-total.tally {
  font-variant-numeric: normal;
  font-weight: 600;
}

.tally-aggregate {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
  flex-wrap: wrap;
}

.die.scored {
  outline: 1px solid var(--accent);
}

.die.unmatched {
  opacity: 0.55;
}

.die-note {
  margin-left: 0.2rem;
  font-size: 0.7rem;
  color: #9aa0a6;
}

.unmatched-count {
  margin-left: 0.4rem;
  color: #9aa0a6;
  font-size: 0.8rem;
}

.buckets {
  display: inline-flex;
  gap: 0.4rem;
  margin-left: 0.5rem;
}

.bucket {
  display: inline-flex;
  gap: 0.2rem;
  padding: 0 0.3rem;
  background: #2a2a3d;
  border-radius: 3px;
  font-size: 0.8rem;
}

.bucket.empty {
  opacity: 0.4;
}

.bucket-count {
  font-weight: 600;
}

/* macro manager dialog */
.macro-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 60;
}

.macro-dialog {
  background: #16161f;
  border: 1px solid #2a2a37;
  border-radius: 8px;
  padding: 1rem;
  width: min(46rem, 92vw);
  max-height: 82vh;
  overflow-y: auto;
}

.macro-dialog-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}

.macro-dialog-actions {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  margin-left: auto;
}

.macro-dialog-header h3 {
  margin: 0;
}

/* one column per control: every row emits the same cells, so pinned and
   unpinned rows line up instead of shifting each other's buttons */
.macro-manager-list {
  list-style: none;
  padding: 0;
  margin: 0.5rem 0;
  display: grid;
  grid-template-columns:
    max-content minmax(8rem, 1fr) max-content
    repeat(5, max-content);
  column-gap: 0.6rem;
  row-gap: 0.4rem;
  align-items: center;
}

.macro-manager-row,
.macro-row-actions {
  display: contents;
}

.macro-manager-meta,
.macro-action-edit {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.macro-manager-list button {
  /* was nowrap with no ellipsis partner: the row just overflowed */
  white-space: normal;
}

/* eight columns don't fit a phone: stack one macro per block instead */
@media (max-width: 44rem) {
  .macro-manager-list {
    display: block;
  }

  .macro-manager-row {
    display: block;
    padding: 0.5rem 0;
    border-bottom: 1px solid #2a2a37;
  }

  .macro-row-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin-top: 0.3rem;
  }

  .macro-manager-expr,
  .macro-manager-meta {
    display: block;
    overflow-wrap: anywhere;
  }

  .macro-name-edit,
  .macro-expr-edit {
    width: 100%;
    max-width: none;
  }

  .macro-create {
    flex-wrap: wrap;
  }

  /* beats the wider `.macro-create-expr { flex: 1 }` further down */
  .macro-dialog .macro-create-expr {
    flex: 1 1 100%;
  }
}

.macro-manager-row.unpinned .macro-manager-name {
  opacity: 0.65;
}

.macro-trial {
  grid-column: 2 / -1;
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 0.4rem;
}

.macro-trial label {
  display: grid;
  gap: 0.1rem;
  font-size: 0.72rem;
}

.macro-trial input {
  width: 5.5rem;
}

.macro-manager-name {
  font-weight: 600;
}

.macro-manager-expr,
.macro-expr-edit {
  font-family: ui-monospace, monospace;
  font-size: 0.85rem;
  min-width: 0;
}

.macro-manager-expr {
  overflow-wrap: anywhere;
}

.macro-name-edit {
  font-size: 0.85rem;
  min-width: 0;
  max-width: 10rem;
}

.macro-import {
  margin-top: 0.8rem;
  border-top: 1px solid #2a2a37;
  padding-top: 0.6rem;
}

.macro-import summary {
  cursor: pointer;
  font-size: 0.85rem;
}

.macro-import textarea {
  width: 100%;
  font-family: ui-monospace, monospace;
  font-size: 0.85rem;
  margin: 0.4rem 0;
}

.import-note {
  margin-top: 0.4rem;
}

.macro-mode {
  color: #9aa0a6;
  font-size: 0.75rem;
  font-style: italic;
  margin: 0;
}

.macro-row-actions button {
  font-size: 0.75rem;
  padding: 0.15rem 0.4rem;
}

.macro-create {
  display: flex;
  gap: 0.4rem;
  margin-top: 0.6rem;
}

.macro-create-expr {
  flex: 1;
  font-family: ui-monospace, monospace;
}

.dice-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem;
}

.die-button {
  font-size: 0.8rem;
  padding: 0.2rem 0.5rem;
}

/* shift-click pool badge */
.die-button {
  position: relative;
}

.die-count {
  position: absolute;
  top: -0.45rem;
  right: -0.35rem;
  min-width: 1rem;
  padding: 0 0.2rem;
  border-radius: 0.5rem;
  background: var(--accent);
  color: #0e0e14;
  font-size: 0.65rem;
  font-weight: 700;
  line-height: 1rem;
}

.dice-input-row {
  display: flex;
  gap: 0.4rem;
  align-items: center;
}

/* the field keeps its own line; who-sees-it and Roll drop below it */
@media (max-width: 44rem) {
  .dice-input-row {
    flex-wrap: wrap;
    row-gap: 0.3rem;
  }

  .dice-input-row .dice-input-wrap {
    flex: 1 1 60%;
  }

  .roll-visibility {
    margin-left: auto;
  }
}

/* the (i) sits inside the field's right edge */
.dice-input-wrap {
  position: relative;
  display: flex;
  flex: 1;
}

.dice-input {
  flex: 1;
  font-family: monospace;
  padding-right: 1.9rem;
}

.dice-help {
  position: absolute;
  right: 0.35rem;
  top: 50%;
  transform: translateY(-50%);
  width: 1.1rem;
  height: 1.1rem;
  padding: 0;
  border: 1px solid #3b3b4d;
  border-radius: 999px;
  background: none;
  font-size: 0.7rem;
  font-style: italic;
  line-height: 1;
  color: #9aa0a6;
}

.dice-help:hover {
  color: var(--accent);
}

.dice-collapse {
  padding: 0.1rem 0.35rem;
  font-size: 0.7rem;
  color: #9aa0a6;
}

/* collapsed, the whole bar leaves the scene column — the scene grows to
   fill it, exactly as it does when the sidebar collapses — and this handle
   brings it back. it rides *in* the presence dock, which already owns the
   scene's lower-left corner, so it costs no layout row and can't overlap */
.dice-restore {
  padding: 0.2rem 0.5rem;
  border-radius: 999px;
  border: 1px solid #3b3b4d;
  background: #1b1b26;
  font-size: 0.9rem;
  line-height: 1.3;
}

.dice-restore:hover {
  border-color: var(--accent);
}

.dice-bar.hidden {
  display: none;
}

.dice-guide-table {
  border-collapse: collapse;
  width: 100%;
  font-size: 0.85rem;
}

.dice-guide-table td {
  padding: 0.2rem 0.5rem 0.2rem 0;
  vertical-align: top;
}

.dice-guide-table code {
  color: var(--accent);
  /* wrap long pool examples at spaces; never break a token like 4d6kh3 */
  white-space: normal;
  overflow-wrap: normal;
  word-break: keep-all;
}

.dice-guide h4 {
  margin: 1rem 0 0.3rem;
}

.dice-guide-note {
  margin: 0.8rem 0 0;
  color: #9aa0a6;
  font-size: 0.8rem;
}

.dice-input.invalid {
  border-color: #f7768e;
}

.dice-error {
  margin: 0;
  color: #f7768e;
  font-size: 0.8rem;
}

.roll-visibility {
  font-size: 0.8rem;
}

.roll-entry {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 0.4rem;
  padding: 0.25rem 0.4rem;
  border-radius: 4px;
  background: #1f2430;
  border-left: 2px solid var(--accent);
  margin: 0.15rem 0;
}

.roll-entry .author {
  font-weight: 600;
  color: var(--accent);
}

.roll-entry.hidden-roll {
  border-left-color: #bb9af7;
}

.visibility-tag {
  color: #bb9af7;
  font-size: 0.75rem;
}

.roll-notation {
  background: #2a2a3d;
  padding: 0 0.3rem;
  border-radius: 3px;
  /* `4d6kh3 where 5..6 -> hits` has no spaces the layout can use */
  overflow-wrap: anywhere;
  min-width: 0;
}

.roll-comment {
  font-style: italic;
  color: #9aa0a6;
}

.roll-total {
  margin-left: auto;
  font-weight: 700;
  font-size: 1rem;
  padding: 0.05rem 0.5rem;
  background: #2a2a37;
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  animation: roll-pop 0.45s ease-out;
}

@keyframes roll-pop {
  0% { transform: scale(1.6); color: var(--warn); }
  100% { transform: scale(1); }
}

.save-as-macro {
  font-size: 0.75rem;
  visibility: hidden;
}

.roll-entry:hover .save-as-macro {
  visibility: visible;
}

.roll-breakdown {
  font-family: monospace;
  font-size: 0.85rem;
  color: #c0caf5;
  overflow-wrap: anywhere;
  min-width: 0;
}

/* repeated rolls (3x d6) wrap to their own lines under the header */
.roll-breakdown.multi {
  flex-basis: 100%;
  padding: 0.15rem 0 0.1rem;
}

.roll-repeat {
  padding: 0.1rem 0;
}

.die {
  display: inline-block;
  padding: 0 0.25rem;
  margin: 0 0.1rem;
  background: #2a2a3d;
  border-radius: 3px;
}

.die.dropped {
  text-decoration: line-through;
  opacity: 0.5;
}

.die.nat20 {
  background: #1f3324;
  color: #9ece6a;
  font-weight: 700;
}

.die.nat1 {
  background: #3a2029;
  color: #f7768e;
  font-weight: 700;
}

.die.success {
  outline: 1px solid #9ece6a;
}

.subtotal {
  color: #9aa0a6;
}

.dice-error .error-span {
  margin-left: 0.5rem;
  background: #2a2a3d;
  padding: 0 0.3rem;
  border-radius: 3px;
  color: #c0caf5;
}

.dice-error mark {
  background: #f7768e;
  color: #1a1b26;
  border-radius: 2px;
}

/* ---- phase 5: notebook ---- */

.notebook-panel,
.whiteboard-panel {
  flex: 1;
  min-height: 0;
  padding: 0.8rem;
  display: flex;
  flex-direction: column;
}

.notebook-panel header,
.whiteboard-panel header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.notebook-docs ul {
  list-style: none;
  padding: 0;
  margin: 0.4rem 0;
  max-height: 10rem;
  overflow-y: auto;
}

.notebook-docs li {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem;
  padding: 0.15rem 0.3rem;
  border-radius: 4px;
}

.notebook-docs li.active {
  background: #2a2a3a;
}

.notebook-docs li .doc-open {
  flex: 1;
  text-align: left;
  background: none;
  border: none;
  cursor: pointer;
  padding: 0.2rem;
  min-width: 0;
}

.notebook-docs li .entry-actions {
  visibility: hidden;
}

.notebook-docs li:hover .entry-actions {
  visibility: visible;
}

.doc-create {
  display: flex;
  gap: 0.4rem;
  margin: 0.4rem 0;
}

.doc-create input {
  flex: 1;
  min-width: 0;
}

.deleted-docs {
  font-size: 0.85rem;
  color: #888;
}

.deleted-docs ul {
  list-style: none;
  padding-left: 0.6rem;
}

.deleted-docs li {
  display: flex;
  justify-content: space-between;
  padding: 0.1rem 0;
}

.notebook-editor {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  border-top: 1px solid #33334a;
  padding-top: 0.5rem;
}

.editor-toolbar {
  display: flex;
  gap: 0.4rem;
  margin-bottom: 0.4rem;
}

.editor-hint {
  margin-top: 1rem;
}

.editor-stack {
  position: relative;
  flex: 1;
  min-height: 0;
}

/* transparent textarea over the highlighted mirror — classic trick;
   both must share metrics exactly */
.editor-mirror,
.editor-input {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0.6rem;
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 0.9rem;
  line-height: 1.45;
  white-space: pre-wrap;
  word-break: break-word;
  overflow-wrap: break-word;
  overflow-y: auto;
  border: 1px solid #33334a;
  border-radius: 4px;
  box-sizing: border-box;
  tab-size: 4;
}

.editor-mirror {
  pointer-events: none;
  color: #d8d8e0;
  background: #14141b;
  overflow: hidden;
}

.editor-input {
  color: transparent;
  caret-color: #e6e6f0;
  background: transparent;
  resize: none;
}

.editor-input::selection {
  background: rgba(90, 120, 255, 0.35);
}

.md-heading { color: #7aa2f7; font-weight: bold; }
.md-quote { color: #8a8fa8; font-style: italic; }
.md-bullet { color: #e0af68; }
.md-code, .md-codespan { color: #9ece6a; }
.md-bold { color: #f7768e; font-weight: bold; }
.md-italic { font-style: italic; }

.nb-cursor {
  position: relative;
  display: inline-block;
  width: 0;
}

.nb-cursor::before {
  content: "";
  position: absolute;
  left: -1px;
  top: -0.1em;
  width: 2px;
  height: 1.3em;
  background: var(--cursor-color);
}

.nb-cursor::after {
  content: attr(data-name);
  position: absolute;
  left: -1px;
  top: -1.25em;
  background: var(--cursor-color);
  color: #fff;
  font-size: 0.62rem;
  line-height: 1.2;
  padding: 0 0.25em;
  border-radius: 2px;
  white-space: nowrap;
  pointer-events: none;
}

.notebook-preview {
  flex: 1;
  overflow-y: auto;
  border: 1px solid #33334a;
  border-radius: 4px;
  padding: 0.6rem;
  background: #14141b;
}

/* ---- phase 5: whiteboard ---- */

.wb-toolbar {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  flex-wrap: wrap;
  padding: 0.3rem 0;
}

.wb-toolbar button.active {
  outline: 1px solid var(--accent);
}

.wb-width {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.6rem;
  height: 1.6rem;
}

.wb-width-dot {
  display: inline-block;
  border-radius: 50%;
  background: #d8d8e0;
}

.wb-color {
  width: 1.3rem;
  height: 1.3rem;
  border-radius: 50%;
  border: 2px solid transparent;
  background: var(--swatch);
  cursor: pointer;
  padding: 0;
}

.wb-color.active {
  border-color: #fff;
}

/* colour popover (phase 14): one current-colour trigger opening a swatch menu */
.wb-color-popover {
  display: inline-flex;
}

.wb-color-trigger {
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 50%;
  border: 2px solid #55556a;
  background: var(--swatch);
  cursor: pointer;
  padding: 0;
}

.wb-popover-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
}

.wb-color-menu {
  /* pinned in viewport space by `pin_popover` — an absolute menu in the
     narrow sidebar hangs off the window. hidden until it is measured */
  position: fixed;
  top: 0;
  left: 0;
  visibility: hidden;
  max-width: calc(100vw - 1rem);
  overflow: auto;
  z-index: 41;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.35rem;
  padding: 0.5rem;
  background: #1c1c28;
  border: 1px solid #33334a;
  border-radius: 6px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
}

/* zoom readout (phase 14) */
.wb-zoom-readout {
  min-width: 3.2em;
  text-align: center;
  font-variant-numeric: tabular-nums;
  color: #9aa0a6;
  font-size: 0.85rem;
}

.wb-surface {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.wb-canvas-wrap {
  position: relative;
  flex: 1;
  min-height: 0;
  border: 1px solid #33334a;
  border-radius: 4px;
  overflow: hidden;
  background: #f5f5f0;
}

/* whiteboard as the content-slot occupant: fills the stage */
.wb-slot {
  display: flex;
  flex-direction: column;
}

.wb-presented-note {
  padding: 1rem 0.2rem;
  color: #9aa0a6;
}

.wb-presented-note button {
  margin-top: 0.5rem;
}

.wb-canvas {
  display: block;
  touch-action: none;
  cursor: crosshair;
}

.wb-canvas.pan-tool {
  cursor: grab;
}

.wb-loading {
  position: absolute;
  top: 0.5rem;
  left: 0.5rem;
  z-index: 1;
  color: #555;
}

.wb-label-input {
  position: absolute;
  z-index: 2;
  font-size: 0.9rem;
  padding: 0.1rem 0.3rem;
}

/* markers (phase 12, specs/08): a layer above the canvas, positioned in
   board space so a puck sits on the same spot under any pan/zoom */
.wb-markers {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}

/* the puck sits *on* its coordinate: the dot is centred on the point and the
   label hangs below it, rather than the pair being centred as a column — a
   scaled dot in normal flow would sit visibly off its own coordinate, and at
   four cells visibly is an understatement. `--puck-d` is the painted diameter
   and `--puck-grab` the concentric hit target, both written per puck by the
   marker layer (specs/08). */
.wb-marker {
  position: absolute;
  width: 0;
  height: 0;
  user-select: none;
  --puck-d: 18px;
  --puck-grab: 44px;
}

/* only your own puck takes pointer events — nobody moves anyone else's */
.wb-marker.mine .wb-marker-grab {
  pointer-events: auto;
  cursor: grab;
  touch-action: none;
}

/* a large puck must not swallow its owner's own strokes, and must never
   shield ink from the eraser: under a drawing tool the grab core goes with the
   paint (specs/08). */
.wb-marker.inert-paint .wb-marker-grab {
  pointer-events: none;
}

/* gm token tool (phase 18): the puck itself is painted on the canvas — the
   drag is a canvas gesture — so the DOM layer holds only the per-token delete
   affordance, offset clear of the disc so it cannot swallow that drag */
.wb-token-tools {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
}

/* the token tool's two per-token affordances (specs/08). the ✎ is the
   **keyboard route into the token editor** — the canvas takes a double-click
   and nothing else — and shares the delete button's chrome so the pair reads
   as one set. both inherit the coarse-pointer tap floor like any other
   icon-only button; neither is on that block's exception list, deliberately */
.wb-token-edit,
.wb-token-remove {
  position: absolute;
  transform: translate(-50%, -50%);
  pointer-events: auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  padding: 0;
  line-height: 1;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--surface);
  color: #e11d48;
  cursor: pointer;
}

/* after the shared rule, or it would lose on source order: editing is not a
   destructive action, so the glyph is not the delete red */
.wb-token-edit {
  color: var(--text);
}

/* the sizing circle of a token being drawn: local to this tab, sent to nobody */
.wb-token-draft {
  position: absolute;
  border-radius: 50%;
  border: 1.5px dashed var(--puck);
  background: color-mix(in srgb, var(--puck) 20%, transparent);
  pointer-events: none;
  z-index: 1;
}

/* board-image arrange overlay (phase 14, gm-only): boxes over the canvas in
   board space, with move-drag, corner handles, and per-image controls */
.wb-image-arrange {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
}

.wb-img-box {
  position: absolute;
  border: 1.5px dashed var(--accent);
  background: rgba(80, 120, 220, 0.05);
  pointer-events: auto;
  cursor: move;
  touch-action: none;
}

.wb-img-tools {
  position: absolute;
  top: -1.6rem;
  right: 0;
  display: flex;
  gap: 0.2rem;
}

.wb-img-tools button {
  width: 1.4rem;
  height: 1.4rem;
  padding: 0;
  line-height: 1;
  background: #1c1c28;
  border: 1px solid #33334a;
  border-radius: 3px;
  cursor: pointer;
}

.wb-img-remove {
  color: #e11d48;
}

.wb-img-handle {
  position: absolute;
  width: 12px;
  height: 12px;
  background: #fff;
  border: 1.5px solid var(--accent);
  border-radius: 2px;
  touch-action: none;
}

.wb-img-handle.nw { top: -6px; left: -6px; cursor: nwse-resize; }
.wb-img-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; }
.wb-img-handle.sw { bottom: -6px; left: -6px; cursor: nesw-resize; }
.wb-img-handle.se { bottom: -6px; right: -6px; cursor: nwse-resize; }

/* add-image picker modal (phase 14) */
.wb-picker-backdrop {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(0, 0, 0, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
}

.wb-picker {
  width: min(640px, 90vw);
  max-height: 80vh;
  overflow: auto;
  background: #1c1c28;
  border: 1px solid #33334a;
  border-radius: 8px;
  padding: 1rem;
}

.wb-picker header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.75rem;
}

.wb-picker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: 0.6rem;
}

.wb-picker-item {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.4rem;
  background: #14141e;
  border: 1px solid #33334a;
  border-radius: 6px;
  cursor: pointer;
}

.wb-picker-item img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 4px;
}

.wb-picker-item span {
  font-size: 0.8rem;
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* the **class** keeps its fixed chrome size: its other two call sites are the
   tray's own buttons, which must not grow to four cells on a 512 grid. the
   board puck overrides it with the per-puck custom property below. */
.wb-marker-dot {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--puck);
  border: 2px solid #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

.wb-marker .wb-marker-dot {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--puck-d);
  height: var(--puck-d);
  transform: translate(-50%, -50%);
  /* the paint never takes the pointer; the grab core does */
  pointer-events: none;
  overflow: hidden;
  /* the border is inside the diameter, so `--puck-d` is the puck's real size
     rather than its size plus 4px. this stylesheet has no global
     `border-box`, so it is stated here */
  box-sizing: border-box;
  /* the ring stays a ring at every size — scaling it with the puck would turn
     a small one into a white blob */
  border-width: 2px;
}

/* **the identity colour survives as the ring** (specs/08). without a face the
   puck is exactly the former flat dot — `--puck` fill, white contrast ring —
   and the colour is the fill. with one, the image covers the fill, so the
   colour has to move to the border or it appears nowhere on the puck: a
   white-ringed thumbnail loses the one signal that is identical on every
   client, needs no storage and does not move when someone is assigned a
   character. the white contrast against the map comes from the box-shadow the
   base class already carries, widened here to keep the puck legible over ink */
.wb-marker .wb-marker-dot.has-face {
  border-color: var(--puck);
  box-shadow:
    0 0 0 1px rgba(255, 255, 255, 0.9),
    0 1px 3px rgba(0, 0, 0, 0.4);
}

/* the avatar fills the circle and the identity colour becomes its ring
   (specs/08). a user with no picture renders exactly today's flat dot: the
   img is removed by its own `on:error`, not hidden by a wire flag. */
.wb-marker-face {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 50%;
  display: block;
}

/* floored at the coarse-pointer tap target so a genuinely sub-pixel puck is
   still grabbable — `.wb-marker-dot` is exempt from that floor on purpose, and
   the exemption is right about the paint and silent about the grab. */
.wb-marker-grab {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--puck-grab);
  height: var(--puck-grab);
  transform: translate(-50%, -50%);
  border-radius: 50%;
}

/* roster text, not map furniture: it does not scale. it hangs half a puck
   below the point so a large dot does not cover it. */
.wb-marker-label {
  position: absolute;
  left: 0;
  top: calc(var(--puck-d) / 2 + 3px);
  transform: translateX(-50%);
  font-size: 0.7rem;
  line-height: 1;
  padding: 1px 4px;
  border-radius: 3px;
  background: rgba(30, 30, 40, 0.8);
  color: #eee;
  white-space: nowrap;
  pointer-events: none;
}

/* ---- pings (specs/08) ---- */

.wb-pings {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
}

/* the negative margin is what centres the ring on its point, so the box it is
   half of has to be the *border* box — without this the 3px border pushes the
   real box to 30px and a -12px margin lands the ring 3px down and right of the
   spot somebody is pointing at */
.wb-ping {
  position: absolute;
  box-sizing: border-box;
  width: 24px;
  height: 24px;
  margin: -12px 0 0 -12px;
  border-radius: 50%;
  border: 3px solid var(--puck);
  animation: wb-ping-pulse 3s ease-out forwards;
}

@keyframes wb-ping-pulse {
  0% {
    transform: scale(0.4);
    opacity: 1;
  }
  60% {
    transform: scale(2.6);
    opacity: 0.55;
  }
  100% {
    transform: scale(3.2);
    opacity: 0;
  }
}

/* an arrow on the nearest viewport edge, pointing at an off-screen ping. the
   rotation is the angle from the viewport centre, computed in `edge_arrow` —
   pure and unit-tested, because it is exactly the trigonometry that is right
   on one screen aspect and wrong on another. */
/* …and the same element becomes that arrow when the point leaves the viewport,
   rather than a second node: swapping *classes* on one stable span keeps the
   animation from restarting mid-pan, which recreating the element would do */
.wb-ping.offscreen {
  width: 0;
  height: 0;
  /* **centred by the transform alone.** the base rule's negative margin is
     reset here rather than replaced: the two mechanisms add, and a triangle
     centred twice sits ~9px up and left of the edge point `edge_arrow`
     computed — off the board entirely on the top and left edges */
  margin: 0;
  border-radius: 0;
  border-left: 12px solid var(--puck);
  border-top: 7px solid transparent;
  border-bottom: 7px solid transparent;
  border-right: 0;
  transform: translate(-50%, -50%) rotate(var(--ping-angle));
  animation: wb-ping-fade 3s ease-out forwards;
}

@keyframes wb-ping-fade {
  0%,
  70% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/* the reveal-sting precedent: a static ring that fades without the expansion */
@media (prefers-reduced-motion: reduce) {
  .wb-ping:not(.offscreen) {
    animation: wb-ping-fade 3s ease-out forwards;
    transform: scale(2.2);
  }
}

/* "the GM moved your view" — a chip, not a dialog: the board is not lost, it
   is under the pointer, so there is nothing to undo */
.wb-jump-chip {
  position: absolute;
  left: 50%;
  top: 12px;
  transform: translateX(-50%);
  z-index: 3;
  pointer-events: none;
  padding: 0.25rem 0.6rem;
  border-radius: 999px;
  font-size: 0.75rem;
  background: rgba(30, 30, 40, 0.85);
  color: #eee;
  animation: wb-ping-fade 3s ease-out forwards;
}

/* the ⌖ control greys itself for the rest of the hub's window after a press.
   the server *drops* an over-rate ping silently rather than answering it, so
   this is the only feedback there is — and it needs none of the server's
   permission to be right (specs/08) */
.wb-ping-slot.greyed button {
  opacity: 0.45;
  pointer-events: none;
}

/* the one size control is a sibling of the tray rather than a child
   (specs/08), and is laid out by the tray's own rules above */
.wb-size-row input[type="range"] {
  width: 6rem;
}

.wb-size-value {
  min-width: 3.4rem;
}

.wb-tray,
.wb-size-row {
  display: flex;
  align-items: center;
  gap: 0.3rem;
}

.wb-tray-label,
.wb-size-label,
.wb-size-value {
  font-size: 0.75rem;
  color: #9aa0a6;
}

.wb-tray-mine {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.wb-readonly {
  position: absolute;
  bottom: 0.5rem;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(30, 30, 40, 0.85);
  color: #eee;
  padding: 0.2rem 0.7rem;
  border-radius: 4px;
  font-size: 0.85rem;
}

/* ---- scene, music & streaming (phase 6, specs/09) ---- */

.scene-column {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* the layered surface: background → content slot → tiles → chrome.
   fills the column above the dice bar (specs/09: scene fills the viewport) */
.scene {
  position: relative;
  overflow: hidden;
  flex: 1;
  min-height: 200px;
  display: flex;
  flex-direction: column;
  border-bottom: 1px solid #2a2a37;
}

.scene-bg {
  position: absolute;
  inset: 0;
  z-index: 0;
}

.scene-bg-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.scene-content {
  /* flex items honor z-index without position; absolute overlays inside
     (share pip) must anchor to the scene, not this box — it can shrink.
     above the tiles layer (3) so pips stay visible and clickable; the
     chrome (4) is a later sibling, so it still paints on top */
  z-index: 4;
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.scene-content > * {
  pointer-events: auto;
}

.scene-content .content-slot {
  background: rgba(10, 10, 16, 0.85);
  max-width: min(92%, 1100px);
  max-height: 100%;
}

.scene-content .content-slot img {
  max-width: 100%;
  max-height: 70vh;
  display: block;
}

/* content on stage: the slot fills the content column edge to edge */
.scene.has-content .scene-content {
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  gap: 0.4rem;
  padding: 0.6rem;
  min-width: 0;
}

.scene.has-content .scene-content .content-slot:not(.pip) {
  flex: 1;
  min-height: 0;
  width: auto;
  max-width: none;
  max-height: none;
  margin: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* the occupant under the slot header takes the rest of the slot */
.scene.has-content .scene-content .content-slot:not(.pip) > :not(.slot-header) {
  flex: 1;
  min-height: 0;
  overflow: auto;
}

/* the stage renders a **bare** <img> into the slot, where the play window
   wraps one in `.image-view` — so on stage the flex item *is* the bitmap box
   and it stretches on both axes independently. contain-fit it in place. the
   selector is (0,3,1) deliberately: `.scene-content .content-slot img` above
   is (0,2,1) and its `max-height: 70vh` would otherwise cap the letterbox
   short. scoped to `.stage-slot` — the play window's slot is not broken, and
   `object-fit` rather than a re-wrap because the stage must not inherit a
   pan/zoom surface it can never drive */
.scene-content .content-slot.stage-slot > img {
  flex: 1;
  min-height: 0;
  width: 100%;
  max-width: 100%;
  max-height: none;
  object-fit: contain;
  object-position: center;
}

.scene.has-content .scene-content .content-slot .image-view {
  max-height: none;
  overflow: hidden;
}

.scene.has-content .scene-content .content-slot .image-view img {
  max-height: 100%;
}

.scene.has-content .scene-content .content-slot video {
  height: 100%;
  max-height: none;
}

.scene-tiles {
  position: relative;
  z-index: 3;
  transition:
    opacity 0.3s ease,
    width 0.3s ease;
  flex: 0 1 auto;
  max-height: 45%;
  overflow-y: auto;
}

.scene-tiles .voice-tiles {
  padding: 0.5rem;
}

/* nobody in voice: no strip, no reserved column — content gets the scene
   (tables running voice elsewhere still use the board and handouts).
   collapsed, not display:none, so the layout still crossfades (specs/09) */
.scene.no-tiles .scene-tiles {
  opacity: 0;
  overflow: hidden;
  pointer-events: none;
}

/* the side column collapses — this beats the layout rule below on
   specificity, whatever the source order. the gallery needs no collapse:
   an empty strip there holds a scene with nothing else in it */
.scene.has-content.no-tiles .scene-tiles {
  width: 0;
  padding: 0;
}

/* one adaptive layout (specs/09) — empty slot: faces fill the scene as a
   call grid, column count computed from container shape + tile count
   (--gallery-cols/--gallery-tile-w set by VoiceTiles) */
.scene:not(.has-content) .scene-tiles {
  flex: 1;
  max-height: none;
  min-height: 0;
}

/* an empty slot must not eat half the scene — the grid gets it all */
.scene:not(.has-content) .scene-content {
  flex: none;
}

.scene:not(.has-content) .voice-tiles {
  display: grid;
  grid-template-columns: repeat(var(--gallery-cols, 1), 1fr);
  gap: 0.75rem;
  align-content: center;
  align-items: center;
  justify-items: center;
  height: 100%;
  padding: 1rem;
}

.scene:not(.has-content) .voice-tiles .tile {
  width: 100%;
  max-width: var(--gallery-tile-w, 100%);
  /* the gm's tile scale lands here; layout changes crossfade rather than
     snap (specs/09), and a dragged control is the most visible case */
  transition: max-width 0.3s ease;
}

@media (prefers-reduced-motion: reduce) {
  .scene:not(.has-content) .voice-tiles .tile,
  .voice-tiles.spotlight .spotlight-stage .tile {
    transition: none;
  }
}

/* presented content takes the stage: content fills, faces move to a
   narrow side column */
.scene.has-content {
  flex-direction: row;
}

.scene.has-content .scene-tiles {
  flex: 0 0 auto;
  width: 13rem;
  max-height: none;
  overflow-y: auto;
}

.scene.has-content .voice-tiles {
  flex-direction: column;
  padding: 0.5rem 0.5rem 0.5rem 0;
}

.scene.has-content .scene-tiles .tile {
  width: 100%;
}

/* spotlight (specs/09): the tiles partition into the stage grid and the
   narrow side strip — structurally the presented-content layout with faces
   where the content goes, which is why it costs one layer and no new
   geometry. the empty-set path is untouched above: no rows, no rules here.

   the selectors carry `.scene` (and the state) on purpose: the plain-gallery
   rules above are `.scene:not(.has-content) .voice-tiles …`, so a bare
   `.voice-tiles.spotlight` would lose to them on specificity rather than on
   source order */
.scene:not(.has-content) .voice-tiles.spotlight,
.scene.has-content .voice-tiles.spotlight {
  display: flex;
  flex-direction: row;
  gap: 0.5rem;
  height: 100%;
  min-height: 0;
  padding: 0.5rem;
  align-items: stretch;
}

.scene .voice-tiles.spotlight .spotlight-stage {
  flex: 1;
  min-width: 0;
  display: grid;
  grid-template-columns: repeat(var(--gallery-cols, 1), 1fr);
  gap: 0.75rem;
  align-content: center;
  align-items: center;
  justify-items: center;
}

/* the spotlight grid *is* the gallery now, so the gm's tile scale lands on
   it — and crossfades rather than snapping, like every layout change */
.scene .voice-tiles.spotlight .spotlight-stage .tile {
  width: 100%;
  max-width: var(--gallery-tile-w, 100%);
  transition: max-width 0.3s ease;
}

.scene .voice-tiles.spotlight .spotlight-aside {
  flex: 0 0 auto;
  width: 13rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  overflow-y: auto;
  min-height: 0;
}

/* spotlighting everyone is legal and renders as the plain gallery: the strip
   is empty and collapses (the server does not normalize the set away) */
.scene .voice-tiles.spotlight .spotlight-aside:empty {
  display: none;
}

/* the strip is sized by its column, as it is under presented content */
.scene .voice-tiles.spotlight .spotlight-aside .tile {
  width: 100%;
  max-width: none;
}

/* content still owns the stage (specs/09): one column, spotlit first — dom
   order, so a screen reader reads the visual order — and the rest smaller */
.scene.has-content .voice-tiles.spotlight {
  flex-direction: column;
  padding: 0.5rem 0.5rem 0.5rem 0;
}

.scene.has-content .voice-tiles.spotlight .spotlight-stage {
  display: flex;
  flex-direction: column;
  flex: 0 0 auto;
  gap: 0.5rem;
}

.scene.has-content .voice-tiles.spotlight .spotlight-stage .tile {
  max-width: none;
}

.scene.has-content .voice-tiles.spotlight .spotlight-aside {
  width: 100%;
}

.scene.has-content .voice-tiles.spotlight .spotlight-aside .tile {
  width: 72%;
  opacity: 0.85;
}

/* the chip, not a ring: a colour alone survives neither stream compression
   nor a colour-blind viewer */
.spotlit-chip {
  color: #f0d48a;
}

/* the ⇄ menu and the ⌖ toggle sit side by side: `.tile-controls` is a
   column, so a button of its own stretches across the face — which is how
   the ⌖ came to cover ⬆ elevate */
.tile-control-row {
  display: flex;
  align-items: center;
  gap: 0.25rem;
}

.tile-control-row .spotlight-toggle {
  padding: 0 0.4rem;
  line-height: 1.4;
}

.tile-controls .spotlight-toggle.active {
  color: #f0d48a;
}

/* own face tucked away by default (local pref): a floating pip in the
   lower corner, not a grid cell — the others fill the grid without you.
   `:not(.spotlight-active)` belongs *in this selector*: the rule sets five
   properties, and an override from a later block would have to unset all
   five — which is how the next rule added here breaks the pip */
.scene:not(.has-content):not(.self-elevated):not(.spotlight-active)
  .voice-tiles
  .tile.mine {
  position: absolute;
  right: 1rem;
  /* clear of the voice-controls row and the music chip */
  bottom: 4rem;
  width: 14rem;
  max-width: 40%;
  z-index: 6;
  opacity: 0.95;
}

/* the tuck/elevate toggle only means something in the gallery. under a
   spotlight it still means something on *your* tile if you are the one on the
   stage — where it renders disabled and says so — but nothing at all in the
   side strip, so it goes */
.scene.has-content .elevate-self,
.scene.spotlight-active .voice-tiles .tile:not(.spotlit) .elevate-self {
  display: none;
}

/* theme chrome */
.scene.frame-square .tile,
.scene.frame-square .content-slot {
  border-radius: 0;
}

.scene .tile.speaking {
  border-color: var(--scene-accent, var(--accent));
}

.scene .content-slot.presented {
  border-color: var(--scene-accent, var(--accent));
}

.scene-chrome {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}

.scene-chrome > * {
  pointer-events: auto;
}

.scene-logo {
  position: absolute;
  top: 0.6rem;
  left: 0.6rem;
  max-width: 6rem;
  max-height: 3.5rem;
  object-fit: contain;
  opacity: 0.9;
}

/* dice ticker: recent public rolls fade in/out over the scene */
.dice-ticker {
  position: absolute;
  right: 0.6rem;
  top: 0.6rem;
  /* both insets, so a long composed tally can't push the left edge out */
  max-width: calc(100% - 1.2rem);
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  align-items: flex-end;
  pointer-events: none;
}

.ticker-roll {
  display: flex;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  gap: 0.5rem;
  align-items: baseline;
  background: rgba(10, 10, 16, 0.82);
  border-left: 3px solid var(--scene-accent, var(--accent));
  padding: 0.25rem 0.6rem;
  border-radius: 4px;
  animation: ticker-fade 9s ease forwards;
}

.ticker-roller {
  font-weight: 600;
  font-size: 0.85rem;
}

.ticker-expr {
  font-size: 0.8rem;
  opacity: 0.8;
}

.ticker-dies .die {
  font-size: 0.8rem;
}

/* high-contrast totals — stream compression eats subtle colors */
.ticker-total {
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 1.25rem;
  font-weight: 800;
  color: #ffffff;
  text-shadow: 0 0 4px #000;
}

.dice-ticker.minimal .ticker-roller,
.dice-ticker.minimal .ticker-expr,
.dice-ticker.minimal .ticker-dies {
  display: none;
}

@keyframes ticker-fade {
  0% { opacity: 0; transform: translateY(-4px); }
  6% { opacity: 1; transform: none; }
  85% { opacity: 1; }
  100% { opacity: 0; }
}

/* music player chrome */
.music-chip {
  position: absolute;
  bottom: 0.5rem;
  right: 0.6rem;
  display: flex;
  gap: 0.4rem;
  align-items: center;
  background: rgba(10, 10, 16, 0.82);
  border-radius: 999px;
  padding: 0.2rem 0.6rem;
  opacity: 0.5;
  transition: opacity 0.2s;
}

.music-chip:hover {
  opacity: 1;
}

.music-chip input[type="range"] {
  width: 6rem;
}

.music-mute {
  border: none;
  background: none;
  cursor: pointer;
}

.music-unlock {
  position: absolute;
  bottom: 3rem;
  right: 0.6rem;
  background: rgba(10, 10, 16, 0.92);
  border: 1px solid var(--warn);
  border-radius: 6px;
  color: var(--fg);
  padding: 0.3rem 0.7rem;
  cursor: pointer;
}

/* gm scene settings tab */
.scene-settings {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}

.scene-settings section {
  padding: 0.6rem 1rem;
  border-top: 1px solid #2a2a37;
}

.scene-settings h3 {
  margin: 0 0 0.4rem;
  font-size: 0.95rem;
}

.mode-buttons,
.bg-controls {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
}

.mode-buttons button.active {
  border-color: var(--accent);
  color: var(--accent);
}

.upload-picker {
  display: flex;
  gap: 0.4rem;
  flex-wrap: wrap;
  margin-top: 0.5rem;
}

.picker-thumb {
  padding: 0;
  border: 2px solid #2a2a37;
  border-radius: 4px;
  overflow: hidden;
  cursor: pointer;
  background: none;
}

.picker-thumb img {
  width: 5.5rem;
  height: 3.2rem;
  object-fit: cover;
  display: block;
}

.picker-thumb.active {
  border-color: var(--accent);
}

/* hover-revealed delete affordance on picker thumbs */
.picker-item {
  position: relative;
}

.picker-delete {
  position: absolute;
  top: -0.35rem;
  right: -0.35rem;
  width: 1.15rem;
  height: 1.15rem;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: #b03a3a;
  color: #fff;
  font-size: 0.8rem;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s;
}

.picker-item:hover .picker-delete,
.picker-delete:focus-visible {
  opacity: 1;
  pointer-events: auto;
}

/* touch/coarse-pointer devices have no hover: keep the affordance visible */
@media (hover: none) {
  .picker-delete {
    opacity: 1;
    pointer-events: auto;
  }
}

.music-library {
  list-style: none;
  padding: 0;
  margin: 0.5rem 0 0;
}

.music-library li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  /* three controls beside a name that can be 120 characters: the row wraps
     rather than pushing the buttons off a 320px screen (specs/11) */
  flex-wrap: wrap;
  gap: 0.5rem;
  padding: 0.2rem 0;
}

/* content basis, never a narrower min-width: removing the min-content floor
   is what lets break-word render a squeezed label one character per line
   (specs/11) */
.music-library .track-name {
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
  background: none;
  border: none;
  padding: 0.15rem 0;
  color: inherit;
  font: inherit;
  cursor: text;
}

.music-library .track-name:hover {
  text-decoration: underline dotted;
}

.music-library .track-name-edit {
  flex: 1 1 auto;
  min-width: 0;
}

.music-library .track-controls {
  display: flex;
  gap: 0.25rem;
  flex: 0 0 auto;
}

.music-library li.active .track-name {
  color: var(--accent);
}

.stage-url {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.stage-url code {
  /* a capability URL is one long opaque token: wrap it deliberately rather
     than hiding its tail behind a sideways scroll nobody finds */
  white-space: normal;
  overflow-wrap: anywhere;
  min-width: 0;
  max-width: 26rem;
  background: #101017;
  padding: 0.2rem 0.4rem;
  border-radius: 4px;
}

.hint.warn {
  color: var(--warn);
}

/* streamer mode: scene + chosen overlays only (specs/09) */
.play.streamer .sidebar,
.play.streamer .presence-dock,
.play.streamer .chat,
.play.streamer .dice-bar,
.play.streamer .dice-restore {
  display: none;
}

/* shift+, — the sidebar overlays the capture until dismissed */
.play.streamer.show-sidebar .sidebar,
.play.streamer.show-sidebar .sidebar .chat {
  display: flex;
}

.play.streamer .scene {
  height: 100vh;
  height: 100dvh;
  border: none;
}

.scene.hide-tiles .scene-tiles {
  display: none;
}

.scene.hide-rings .tile.speaking {
  border-color: #2a2a37;
}

.scene.hide-rings .voice-badge.speaking {
  text-shadow: none;
}

.streamer-controls {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  z-index: 7;
  display: flex;
  gap: 0.4rem;
  /* generous invisible hit zone: the bar reveals only when the pointer
     is actually on it at the top edge, not on any window hover */
  padding: 0.5rem 2.5rem 1rem;
  opacity: 0;
  transition: opacity 0.2s;
}

.streamer-controls:hover,
.streamer-controls:focus-within {
  opacity: 1;
}

.streamer-controls button {
  background: rgba(10, 10, 16, 0.9);
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  color: var(--fg);
  padding: 0.2rem 0.5rem;
  cursor: pointer;
  font-size: 0.8rem;
}

.streamer-keys {
  align-self: center;
  font-size: 0.7rem;
  color: #9aa0a6;
  background: rgba(10, 10, 16, 0.7);
  padding: 0.15rem 0.4rem;
  border-radius: 4px;
}

/* shift+c / shift+m: discrete right-side panels, invisible until invoked */
.streamer-menu {
  position: absolute;
  top: 3rem;
  right: 0.75rem;
  z-index: 9;
  width: 20rem;
  max-height: 70vh;
  overflow-y: auto;
  background: rgba(10, 10, 16, 0.92);
  border: 1px solid #3b3b4d;
  border-radius: 6px;
}

.streamer-present-list {
  list-style: none;
  margin: 0;
  padding: 0.4rem;
}

.streamer-present-list li {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.25rem 0.3rem;
}

.streamer-present-list .present-title {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.streamer-menu section {
  padding: 0 0.6rem 0.6rem;
}

/* esc: custom confirm — a native dialog would land in the capture */
.streamer-dice {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.6rem;
}

.streamer-dice-buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

.streamer-dice-pool {
  display: flex;
  gap: 0.4rem;
  align-items: center;
}

.streamer-dice-pool code {
  flex: 1;
  color: #9aa0a6;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.streamer-confirm {
  position: absolute;
  top: 40%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 10;
  background: rgba(10, 10, 16, 0.95);
  border: 1px solid #3b3b4d;
  border-radius: 6px;
  padding: 1rem 1.4rem;
  text-align: center;
}

.streamer-confirm .confirm-row {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
  margin-top: 0.6rem;
}

.streamer-hint-chip {
  position: absolute;
  top: 3rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 9;
  background: rgba(10, 10, 16, 0.9);
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  padding: 0.2rem 0.6rem;
  font-size: 0.8rem;
  color: #9aa0a6;
}

/* /stage: the whole viewport is the scene; nothing is interactive */
.stage {
  height: 100vh;
  height: 100dvh;
  background: #000;
}

.stage .scene,
.stage .stage-dead {
  height: 100vh;
  height: 100dvh;
  border: none;
}

.stage * {
  pointer-events: none;
}

.stage-slot {
  margin: 0;
}

.stage-slot canvas {
  max-width: 100%;
  display: block;
}

/* the presented board on stage (specs/09 phase 23): the wrapper is the box the
   camera is contain-fitted into, so it takes the whole slot and the canvas
   fills it exactly. no toolbar, no tray, no border chrome — the capture shows
   the board, not the application around it */
.stage-slot.stage-whiteboard {
  display: flex;
  flex-direction: column;
}

.stage-slot .stage-board {
  flex: 1;
  min-height: 0;
  border: none;
  border-radius: 0;
}

.stage-slot .stage-board canvas {
  max-width: none;
}

.stage-slot .handout-text {
  padding: 1rem 1.5rem;
  max-width: 60ch;
}

/* ---- phase 6 review fixes ---- */

/* capture cleanliness: interactive per-tile controls never render in
   streamer mode or on /stage (specs/09: scene + chosen overlays only) */
.scene.capture .tile-controls,
.stage .tile-controls {
  display: none;
}

.play.streamer .music-chip {
  display: none;
}

/* the stage's deliberate interactive exceptions: audio/music unlock
   (reachable via the obs browser-source "Interact" window) */
.stage .stage-unlock,
.stage .music-unlock {
  pointer-events: auto;
}

.stage-unlock {
  position: absolute;
  bottom: 0.6rem;
  left: 0.6rem;
  background: rgba(10, 10, 16, 0.92);
  border: 1px solid var(--warn);
  border-radius: 6px;
  color: var(--fg);
  padding: 0.3rem 0.7rem;
  cursor: pointer;
}

/* the pipped share + the gm's one-click slot switch under it: the wrap
   is the positioned box so the button can never anchor astray */
.share-pip-wrap {
  position: absolute;
  right: 0.5rem;
  bottom: 0.5rem;
  width: 14rem;
  z-index: 20;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  align-items: flex-end;
}

.share-pip-wrap .content-slot.pip {
  position: static;
  width: 100%;
  margin: 0;
}

.share-pip-wrap .switch-slot {
  background: rgba(10, 10, 16, 0.92);
  border: 1px solid var(--accent);
  border-radius: 4px;
  color: var(--fg);
  padding: 0.2rem 0.5rem;
  cursor: pointer;
  font-size: 0.8rem;
}

.stage-share video {
  width: 100%;
  max-height: 70vh;
  object-fit: contain;
  display: block;
  background: #000;
}

/* ---- keyboard shortcuts overlay (p7 a11y floor) ---- */

.shortcuts-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 40;
}

.shortcuts-overlay {
  background: #1c1c26;
  border: 1px solid #3b3b4d;
  border-radius: 6px;
  padding: 1.2rem 1.5rem;
  max-width: 26rem;
}

.shortcuts-overlay h3 {
  margin: 0 0 0.8rem;
}

.shortcuts-overlay table {
  border-collapse: collapse;
  width: 100%;
}

.shortcuts-overlay td {
  padding: 0.2rem 0.6rem 0.2rem 0;
}

.shortcuts-overlay kbd {
  background: #2a2a38;
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  padding: 0.05rem 0.4rem;
  font-size: 0.85em;
  white-space: nowrap;
}

.shortcuts-overlay button {
  margin-top: 0.8rem;
}

/* keyboard users get a visible focus ring everywhere */
:focus-visible {
  outline: 2px solid #7aa2ff;
  outline-offset: 1px;
}

/* admin p7 additions */
.admin-page .danger {
  color: #ff8484;
  border-color: #6e3a3a;
}

.admin-page .disk-usage {
  color: #9a9ab0;
  font-size: 0.85em;
  margin: 0 0.5rem;
}

.admin-page a.button-like {
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  padding: 0.15rem 0.5rem;
  text-decoration: none;
  font-size: 0.9em;
}

.admin-page .limits label {
  display: inline-flex;
  flex-direction: column;
  gap: 0.2rem;
  margin-right: 0.8rem;
}

/* ---- empty states & onboarding (specs/11, p7) ---- */

.empty-state {
  color: #9a9ab0;
  font-size: 0.9em;
  padding: 0.6rem 0.8rem;
  border: 1px dashed #3b3b4d;
  border-radius: 6px;
  margin: 0.6rem 0;
}

.onboarding-card {
  position: absolute;
  right: 1rem;
  bottom: 3.2rem;
  z-index: 12;
  background: rgba(22, 22, 32, 0.92);
  border: 1px solid #3b3b4d;
  border-radius: 8px;
  padding: 0.7rem 0.9rem;
  max-width: 17rem;
}

.onboarding-card header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.4rem;
}

.onboarding-card ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.onboarding-card li {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.15rem 0;
}

.onboarding-card li.done {
  opacity: 0.6;
}

.onboarding-card li.done .linkish {
  text-decoration: line-through;
}

button.linkish {
  background: none;
  border: none;
  color: #7aa2ff;
  cursor: pointer;
  padding: 0;
  font: inherit;
  text-decoration: underline;
}

/* ---- phase 8: session tooling (specs/14) ---- */

/* shared timer chip (overlay chrome) */
.timer-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.25rem 0.7rem;
  border-radius: 999px;
  background: rgba(20, 20, 30, 0.75);
  border: 1px solid var(--accent, #7aa2f7);
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}

.timer-chip .timer-label {
  opacity: 0.8;
  font-size: 0.85rem;
}

.timer-chip .timer-remaining {
  font-weight: bold;
  font-size: 1.05rem;
}

.timer-chip.paused {
  opacity: 0.7;
}

.timer-chip.final-minute {
  border-color: #d5734f;
}

.timer-chip.final-minute .timer-remaining {
  color: #ff9d6e;
  animation: timer-pulse 1s ease-in-out infinite;
}

.timer-chip.expired {
  border-color: #e05555;
  animation: timer-pulse 0.6s ease-in-out 4;
}

.timer-chip.no-motion,
.timer-chip.no-motion .timer-remaining {
  animation: none;
}

@keyframes timer-pulse {
  50% { transform: scale(1.06); }
}

@media (prefers-reduced-motion: reduce) {
  .timer-chip, .timer-chip .timer-remaining { animation: none; }
}

/* breakout room rail */
.room-rail {
  border-top: 1px solid #33334a;
  margin-top: 0.5rem;
  padding-top: 0.5rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.rooms-degraded {
  font-size: 0.8rem;
}

.room-group {
  border: 1px solid #2c2c3e;
  border-radius: 6px;
  padding: 0.3rem 0.45rem;
}

.room-group.here {
  border-color: var(--accent, #7aa2f7);
}

.room-header {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.room-name {
  font-weight: 600;
  font-size: 0.85rem;
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.room-join {
  font-size: 0.75rem;
  padding: 0.05rem 0.5rem;
}

.room-occupants {
  list-style: none;
  margin: 0.2rem 0 0;
  padding: 0;
}

.room-occupants li {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.1rem 0;
  font-size: 0.85rem;
}

.room-occupants .avatar,
.room-occupants .initials {
  width: 1.3rem;
  height: 1.3rem;
  border-radius: 50%;
  font-size: 0.6rem;
}

.room-occupants .member-name {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.room-move-menu {
  font-size: 0.7rem;
  max-width: 8rem;
}

.room-gm-controls {
  display: flex;
  gap: 0.3rem;
  flex-wrap: wrap;
}

.room-gm-controls button {
  font-size: 0.75rem;
}

.room-badge {
  font-size: 0.75rem;
  opacity: 0.85;
}

/* video-tile identity chip (specs/04) */
.tile-identity .avatar,
.tile-identity .initials {
  width: 1.4rem;
  height: 1.4rem;
  border-radius: 50%;
  font-size: 0.6rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* notebook grouping (specs/08 private docs) */
.doc-group {
  margin: 0.6rem 0 0.2rem;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  opacity: 0.7;
}

.doc-create-private {
  font-size: 0.8rem;
  align-self: flex-start;
}

/* timer gm controls */
.timer-section .timer-controls {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  flex-wrap: wrap;
  margin-bottom: 0.35rem;
}


/* ---- structured play (specs/15) ---- */
.game-panel { display: flex; flex-direction: column; gap: 0.75rem; overflow-y: auto; }
.game-runbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.game-status { font-weight: 600; }
.game-paused { color: var(--warn, #e0af68); }
.game-ap-row { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.ap-chip, .grant-chip {
  font-size: 0.8rem; padding: 0.1rem 0.5rem; border-radius: 999px;
  background: color-mix(in srgb, var(--accent, #7aa2f7) 18%, transparent);
}
.pile-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pile {
  display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
  padding: 0.5rem; border-radius: 8px; min-width: 5.5rem;
}
.pile.selected { outline: 2px solid var(--accent, #7aa2f7); }
.pile.disabled { opacity: 0.45; }
.pile-back { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; }
.pile-back.generic {
  font-size: 2.4rem; line-height: 1; display: flex;
  align-items: center; justify-content: center;
}
/* printed backs: the top card's title over the back visual. the wrap owns
   the card shape so a title-only back still gets a full card to fill */
.pile-back-wrap { position: relative; display: block; width: 3rem; height: 4.2rem; }
.pile-back-label {
  position: absolute; inset: 0.15rem; display: flex; align-items: center;
  justify-content: center; text-align: center; padding: 0.1rem;
  font-size: 0.68rem; line-height: 1.15; word-break: break-word;
  overflow: hidden; border-radius: 4px;
  background: rgba(10, 10, 16, 0.45);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.pile-count, .pile-top, .evidence-meta { font-size: 0.72rem; opacity: 0.75; }
.take-card { width: 100%; margin-top: 0.4rem; }
/* free-pick spread (specs/15): every undrawn card, backs only, one per
   realized slot. same card shape as a pile so the two read alike */
.pile-spread {
  display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.4rem;
}
/* roomier than a pile thumb: these carry a card *name* the player has to
   read and choose between, not just a "there is a pile here" marker */
.spread-card {
  position: relative; display: block; width: 4.8rem; height: 6.6rem;
  padding: 0; border-radius: 5px; border: 1px solid #2a2a37;
}
.spread-card:not(:disabled):hover { border-color: var(--accent, #7aa2f7); }
.spread-card:disabled { opacity: 0.45; cursor: default; }
.spread-card.armed {
  border-color: var(--accent, #7aa2f7);
  box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.35);
}
.spread-label {
  position: absolute; inset: 0.2rem; display: flex; align-items: center;
  justify-content: center; text-align: center; padding: 0.15rem;
  font-size: 0.8rem; line-height: 1.2;
  /* only break a word that can't fit on its own line — the old
     `word-break: break-word` split every name mid-syllable */
  overflow-wrap: break-word; hyphens: auto;
  overflow: hidden; border-radius: 4px;
  background: rgba(10, 10, 16, 0.55);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
/* an ap is spent the instant the pick lands, so say which card first */
.pick-confirm {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
  margin-top: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 6px;
  background: #1b1b26; border: 1px solid var(--accent, #7aa2f7);
}
.pick-question { font-size: 0.85rem; }
.pick-cost { font-size: 0.72rem; opacity: 0.75; }
.pick-take { margin-left: auto; }
.take-blocked { margin-top: 0.3rem; }
.card-back-label { width: 8rem; font-size: 0.75rem; }
.card-list { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.game-card {
  display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
  max-width: 7rem; font-size: 0.8rem; padding: 0.4rem; border-radius: 6px;
}
.game-card.shown { outline: 1px dashed var(--accent, #7aa2f7); }
.card-thumb { width: 3rem; height: 4.2rem; object-fit: cover; border-radius: 4px; }
.card-thumb.text { font-size: 2rem; display: grid; place-items: center; }
.game-card-viewer { max-width: min(90vw, 40rem); }
.game-card-viewer .card-image { max-width: 100%; max-height: 60vh; }
.game-card-viewer .card-frame { width: 100%; height: 60vh; border: 0; }
.card-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; padding-top: 0.5rem; }
.vote-options { display: flex; flex-direction: column; gap: 0.3rem; }
.vote-option.picked { outline: 2px solid var(--accent, #7aa2f7); }
.vote-matrix td { padding: 0.15rem 0.6rem; }
.game-gm-controls .gm-row { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.gm-ap-row { display: inline-flex; gap: 0.25rem; align-items: center; margin-right: 0.6rem; }
.gm-confirm { border: 1px solid var(--warn, #e0af68); border-radius: 6px; padding: 0.5rem; }
/* dealing (specs/15): one row per pile in the gm column, and the dialog it
   opens — which rides the shared .macro-dialog shell for scrim and scroll */
.gm-deal { display: flex; flex-direction: column; gap: 0.2rem; }
.gm-deal-row { display: flex; gap: 0.4rem; align-items: center; }
.gm-deal-pile { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
/* a pile can hold 64 cards: the list scrolls inside the dialog rather than
   pushing the recipient picker and the confirm button off the bottom */
.deal-cards {
  display: flex; flex-direction: column; gap: 0.15rem;
  max-height: 40vh; overflow-y: auto; margin: 0.5rem 0;
}
.deal-card { display: flex; gap: 0.4rem; align-items: center; }
.deal-card.taken { opacity: 0.55; }
.deal-to { display: block; margin: 0.5rem 0; }
.deal-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.act-preview { white-space: pre-wrap; font-size: 0.8rem; opacity: 0.9; }
.prep-row { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; margin: 0.25rem 0; }
.prep-block { border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); padding-top: 0.5rem; }
.prep-toggle { text-align: left; width: 100%; }
/* keep the reorder arrows on the title's line: the pile list is long and
   a wrapped second row per pile would double it */
.pile-row { flex-wrap: nowrap; }
.pile-row .prep-toggle { width: auto; flex: 1; min-width: 0; }
/* prepared polls: the question line keeps its reorder arrows, options are
   a plain editable list */
.poll-row { display: flex; flex-wrap: nowrap; gap: 0.35rem; align-items: flex-start; }
.poll-editor { flex: 1; min-width: 0; }
.poll-options { list-style: none; margin: 0.25rem 0; padding: 0; }
.poll-options li { display: flex; gap: 0.25rem; align-items: center; margin: 0.15rem 0; }
.poll-options li input { flex: 1; min-width: 0; }
.poll-problem { color: var(--warn, #e0af68); }
.gm-poll-list { list-style: none; margin: 0.25rem 0; padding: 0; }
.gm-poll-list li {
  display: flex; gap: 0.5rem; align-items: center; justify-content: space-between;
  margin: 0.15rem 0;
}
/* every ballot is in: the close lever is the next thing to press */
.gm-vote-ready { border-color: var(--ok, #9ece6a); }
.readiness.ready { color: var(--ok, #9ece6a); }
.card-titles { font-size: 0.8rem; margin: 0.25rem 0 0.25rem 1rem; }
.show-toast { position: fixed; bottom: 4.5rem; right: 1rem; z-index: 40; }
.reveal-banner {
  position: absolute; top: 0.5rem; left: 50%; transform: translateX(-50%);
  z-index: 5; padding: 0.35rem 0.9rem; border-radius: 999px;
}
/* gm prep workspace: large overlay — the sidebar column is too cramped
   for deck building */
.prep-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 35;
}

.prep-overlay {
  display: flex;
  flex-direction: column;
  width: min(94vw, 78rem);
  height: min(92vh, 60rem);
  background: #1d1d26;
  border: 1px solid #44445a;
  border-radius: 8px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}

.prep-header {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  padding: 0.5rem 0.9rem;
  background: #26263a;
  border-radius: 8px 8px 0 0;
}

.prep-header h3 { margin: 0; }
.prep-header .prep-close { margin-left: auto; font-size: 1rem; }

.prep-body {
  display: grid;
  grid-template-columns: minmax(20rem, 26rem) 1fr;
  gap: 1.2rem;
  padding: 0.9rem;
  overflow-y: auto;
  align-items: start;
}

.prep-side, .prep-main { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }

@media (max-width: 60rem) {
  .prep-body { grid-template-columns: 1fr; }
}

/* file inputs: compact, button-styled picker instead of a full-width bar */
.prep-overlay input[type="file"] { max-width: 16rem; }
.prep-overlay input[type="file"]::file-selector-button,
.game-panel input[type="file"]::file-selector-button {
  background: #2a2a38;
  color: inherit;
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  padding: 0.15rem 0.6rem;
  margin-right: 0.5rem;
  cursor: pointer;
}

/* upload labels: keep caption + picker on one line */
.pile-back-upload, .card-bulk-upload {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  flex-wrap: wrap;
}

.pile-fields > label { display: inline-flex; align-items: center; gap: 0.3rem; margin-right: 0.6rem; }

/* delete rides the identity row, pushed off Save when the line has room */
.pile-fields .pile-delete { margin-left: auto; }
.pile-fields .danger { color: var(--danger, #f7768e); }
.pile-fields .danger:disabled { color: inherit; opacity: 0.45; cursor: not-allowed; }
.prep-note { font-size: 0.8rem; opacity: 0.75; }
/* the mode notes own the gap to the checkbox row below them; scoped so the
   delete-blocked note stays centred in its flex row */
.pile-fields > .prep-note { display: block; margin-bottom: 0.5rem; }

/* card rows: name flexes, controls hug the right; the per-card back
   picker clips to just its button */
.card-titles li { display: flex; align-items: center; gap: 0.35rem; }
.card-titles .card-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.card-titles .card-rename { flex: 1; min-width: 0; }
/* prep back previews: pile-level next to the picker, card-level in rows */
.back-preview { width: 1.6rem; height: 2.2rem; object-fit: cover; border-radius: 3px; }
.back-preview.generic { font-size: 1.5rem; line-height: 1; opacity: 0.8; }
.card-back-thumb { width: 1.1rem; height: 1.5rem; object-fit: cover; border-radius: 2px; }
/* per-card back picker: the label is the button, native input hidden */
.card-back-upload input[type="file"] { display: none; }
.card-back-upload {
  background: #2a2a38;
  border: 1px solid #3b3b4d;
  border-radius: 4px;
  padding: 0.1rem 0.5rem;
  font-size: 0.75rem;
  cursor: pointer;
}

/* one channel for success and refusal text — keep it neutral */
.prep-status {
  margin: 0.25rem 0;
  font-size: 0.85rem;
  color: var(--accent, #7aa2f7);
}

/* planned rooms (prep) */
.planned-rooms { margin: 0.25rem 0; padding: 0; list-style: none; font-size: 0.85rem; }
.planned-rooms li { display: flex; align-items: center; gap: 0.35rem; }

/* come-join-me invites (specs/14) */
.room-invite-menu { max-width: 3.2rem; }
.invite-toast {
  position: fixed;
  right: 1rem;
  bottom: 8rem;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  background: #26263a;
  border: 1px solid var(--accent, #7aa2f7);
  border-radius: 6px;
  padding: 0.4rem 0.6rem;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
.invite-toast.outcome { bottom: 11.5rem; border-color: #3b3b4d; }

/* streamer rooms + evidence menu bodies */
.streamer-rooms, .streamer-evidence {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.5rem 0.7rem;
}
.streamer-evidence h5 { margin: 0.3rem 0 0.1rem; }
.streamer-room-row, .streamer-card-row {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}
.streamer-room-row.here { opacity: 0.85; }
.streamer-room-row > span:first-child, .streamer-card-row .card-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.game-reveal.flourish { animation: reveal-flourish 700ms ease-out; }
@keyframes reveal-flourish {
  from { transform: scale(0.92); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .game-reveal.flourish { animation: none; }
}
.dismiss-presentation { margin-left: auto; }

/* download policy (specs/07) — gm-only section above the library */
.download-settings {
  border-bottom: 1px solid #2a2a35;
  padding-bottom: 0.6rem;
  margin-bottom: 0.6rem;
}
.download-default {
  display: flex;
  align-items: center;
  gap: 0.35rem;
}
.download-default .check { flex: 1; min-width: 0; }
.info-toggle {
  background: none;
  border: none;
  color: #9aa0a6;
  cursor: pointer;
  font-size: 0.95rem;
  line-height: 1;
  padding: 0.15rem 0.3rem;
}
.info-toggle:hover, .info-toggle[aria-expanded="true"] { color: var(--accent); }
.download-guide { max-width: 32rem; }
.download-guide p { margin: 0 0 0.6rem; font-size: 0.85rem; line-height: 1.45; }
.download-guide p:last-child { margin-bottom: 0; }
.download-exceptions {
  list-style: none;
  margin: 0.5rem 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}
.download-exceptions li {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.82rem;
}
.download-exceptions .ex-title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.download-exceptions .ex-resolved { color: #9aa0a6; font-size: 0.75rem; }

/* ---- settings tab (sidebar) ---- */

.settings-panel {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  overflow-y: auto;
}

.settings-group {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.4rem;
}

.settings-group .switch-player,
.settings-group .streamer-toggle {
  font-size: 0.85rem;
}

.settings-group h3 {
  margin: 0;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: #9aa0a6;
}

.settings-group > .hint {
  margin: 0;
}

.settings-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.4rem;
}

/* the player's present toggle on a collection card (specs/07) */
.collection-item .present-toggle {
  padding: 0.1rem 0.3rem;
  background: none;
  border: none;
  font-size: 0.9rem;
}

.collection-item .present-toggle.active {
  color: var(--accent);
}

/* ---- presentation control (specs/09, specs/07, specs/06) ---- */

/* the gm's table-wide tile scale */
.tile-scale {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.85rem;
}

.tile-scale input[type="range"] {
  flex: 1;
  min-width: 6rem;
}

.tile-scale-value {
  min-width: 2.6rem;
  text-align: right;
  color: #9aa0a6;
  font-variant-numeric: tabular-nums;
}

/* the durable spotlight list (specs/09), on the scene tab */
.spotlight-settings {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  font-size: 0.85rem;
}

.spotlight-list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 12rem;
  overflow-y: auto;
}

.spotlight-actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* "take everyone here" + the chip that returns a viewer who paged away */
.pdf-nav .pdf-push,
.pdf-nav .pdf-following {
  font-size: 0.75rem;
  padding: 0.15rem 0.45rem;
}

.pdf-nav .pdf-following {
  border-color: var(--accent);
  color: var(--accent);
}

/* a revealed vote's frozen tally in the timeline */
.chat-entry.vote-result {
  border-left: 2px solid var(--accent);
  padding-left: 0.5rem;
}

.vote-result-summary {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 0.1rem 0;
  color: inherit;
  cursor: pointer;
}

.vote-result-rows {
  list-style: none;
  margin: 0.3rem 0 0;
  padding: 0;
  font-size: 0.85rem;
}

.vote-result-rows li {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.05rem 0;
}

.vote-result-rows .choice {
  color: #9aa0a6;
}

.vote-result-rows .abstained .voter,
.vote-result-rows .not-voted .voter {
  color: #9aa0a6;
}

/* ---- saved boards + grid (phase 17, specs/08) ---- */

/* the gm's board rail. players never render this — the board list is prep */
.wb-boards {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding: 0.4rem 0;
  border-bottom: 1px solid #33334a;
}

.wb-board-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* wraps for `.wb-group`'s reason, and it is the case truncation cannot save:
   the chip's controls alone exceed a 320px rail once the touch floor makes
   three of them 2.75rem, so no amount of yielding by the name closes it */
.wb-board {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.15rem;
  padding: 0.1rem 0.25rem;
  border: 1px solid #33334a;
  border-radius: 6px;
  background: #1c1c28;
}

/* two different states, two different marks: `live` is what the *table* sees,
   `viewing` is where this tab is pointed. blurring them is the one thing the
   rail must not do */
.wb-board.live {
  border-color: #6f6fd0;
}

.wb-board.viewing {
  background: #26263a;
}

.wb-board button {
  background: none;
  border: none;
  color: inherit;
  cursor: pointer;
  padding: 0.1rem 0.25rem;
  font-size: 0.85rem;
}

.wb-board-name {
  max-width: min(12em, 100%);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.wb-board-live {
  margin-left: 0.35rem;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: #a9a9ff;
}

.wb-board-actions {
  display: flex;
  gap: 0.4rem;
  align-items: center;
}

/* "you are not looking at the table's board" — persistent by design */
.wb-board-follow {
  border: 1px solid #6f6fd0;
  border-radius: 6px;
  background: #26263a;
  color: #d6d6ff;
  cursor: pointer;
  padding: 0.15rem 0.5rem;
}

.wb-grid-popover {
  display: inline-flex;
}

.wb-grid-trigger.active {
  color: #a9a9ff;
}

.wb-grid-menu {
  position: fixed;
  top: 0;
  left: 0;
  visibility: hidden;
  max-width: calc(100vw - 1rem);
  overflow: auto;
  z-index: 41;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  min-width: 12rem;
  padding: 0.5rem;
  background: #1c1c28;
  border: 1px solid #33334a;
  border-radius: 6px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
}

.wb-grid-kinds {
  display: flex;
  gap: 0.3rem;
}

.wb-grid-kind.active {
  color: #a9a9ff;
}

.wb-grid-menu label {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  font-size: 0.85rem;
}

.wb-grid-hint {
  margin: 0;
  font-size: 0.75rem;
}

/* ---- bulk zip import confirmation (specs/07) ---- */

.zip-import .zip-tree {
  list-style: none;
  margin: 0.5rem 0;
  padding: 0;
  /* the tree is the point of the dialog, but a hundred files must not push
     the buttons off screen */
  max-height: 40vh;
  overflow-y: auto;
  border: 1px solid #2a2a37;
  border-radius: 6px;
}

.zip-import .zip-tree li {
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.15rem 0.5rem;
}

.zip-import .zip-folder {
  font-weight: 600;
}

.zip-import .zip-merges,
.zip-import .zip-skipped ul {
  list-style: none;
  margin: 0.25rem 0;
  padding: 0;
}

.zip-import .zip-options {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem;
  margin: 0.5rem 0;
}

.zip-import .dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
  margin-top: 0.75rem;
}

/* present-choice dialog (specs/07): give a copy, or only show one */
.present-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 60;
}

.present-dialog {
  background: #16161f;
  border: 1px solid #2a2a37;
  border-radius: 8px;
  padding: 1rem;
  width: min(32rem, 92vw);
}

.present-choices {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  margin-top: 0.75rem;
}

.present-choices button {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.15rem;
  text-align: left;
  padding: 0.6rem 0.7rem;
}

.present-choices small {
  color: #9a9aab;
  font-weight: 400;
}

.present-choices .ghost {
  align-self: flex-end;
  background: none;
  border: none;
}

/* the content slot says a temporary presentation is not a copy */
.temporary-chip {
  font-size: 0.75rem;
  padding: 0.1rem 0.4rem;
  border: 1px solid #3a3a4a;
  border-radius: 999px;
  color: #b9b9c8;
}

/* ---- phase 24: what players may do to the board (specs/08) ---- */

/* the toolbar is four labelled groups plus a gear now, wrapping rather than
   overflowing so a narrow board keeps every control reachable */
/* wraps for the same reason `.wb-toolbar` does: the touch floor makes the
   Draw group eight 2.75rem controls, which is wider than a 360px phone. an
   inline-flex group that cannot wrap overflows the toolbar that can */
.wb-group {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem;
  padding: 0 0.4rem;
  border-left: 1px solid #33334a;
}

.wb-group:first-of-type {
  border-left: 0;
}

/* the fill slot rides inside the existing colour popover: one swatch control,
   two slots, with an explicit "none" so no fill is a choice */
.wb-fill-slot {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.3rem;
  padding-top: 0.45rem;
  margin-top: 0.35rem;
  border-top: 1px solid #33334a;
}

.wb-fill-label {
  color: #9aa0a6;
  font-size: 0.8rem;
  margin-right: 0.15rem;
}

.wb-fill {
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 3px;
  border: 2px solid transparent;
  background: var(--swatch);
  cursor: pointer;
  padding: 0;
}

.wb-fill.none {
  background: transparent;
  border-color: #55556a;
  color: #9aa0a6;
  font-size: 0.75rem;
  line-height: 1;
}

.wb-fill.active {
  border-color: #fff;
}

.wb-fill-opacity {
  flex: 1 1 100%;
  min-width: 6rem;
}

/* the slice eraser: a ring at the live radius, following the pointer. it is
   the whole size affordance for the tool, so it is never interactive */
.wb-erase-cursor {
  position: absolute;
  pointer-events: none;
  border: 1px solid rgba(255, 255, 255, 0.7);
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5) inset;
  z-index: 3;
}

.wb-erase-radius {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.15rem 0.3rem;
}

.wb-erase-ring {
  display: block;
  border: 1px solid currentColor;
  border-radius: 50%;
}

/* the ⚙ popover: the grid controls, the scroll mode and the declutter list —
   what is rarely touched and used to sit inline */
.wb-settings-popover {
  display: inline-flex;
}

.wb-settings-menu {
  position: fixed;
  top: 0;
  left: 0;
  visibility: hidden;
  z-index: 41;
  width: 19rem;
  max-width: calc(100vw - 1rem);
  /* the height cap is inline, from `pin_popover` — it knows the room below */
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  padding: 0.7rem;
  background: #1c1c28;
  border: 1px solid #33334a;
  border-radius: 6px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
  text-align: left;
}

.wb-settings-menu fieldset {
  border: 0;
  padding: 0;
  margin: 0;
}

.wb-settings-menu legend {
  padding: 0;
  color: #9aa0a6;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.wb-declutter {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.1rem 0.6rem;
}

.wb-declutter legend,
.wb-declutter .hint {
  grid-column: 1 / -1;
}

/* the five whiteboard player controls, in the sidebar Settings tab */
.wb-access-settings h4 {
  margin: 0.8rem 0 0.3rem;
  font-size: 0.95rem;
}

.wb-cap-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin-top: 0.4rem;
}

.wb-cap-label {
  min-width: 7rem;
  color: #c9ccd1;
}

.wb-cap-opt {
  padding: 0.15rem 0.5rem;
  border: 1px solid #33334a;
  border-radius: 4px;
  background: #22222f;
  color: #c9ccd1;
  cursor: pointer;
  font-size: 0.85rem;
}

.wb-cap-opt.active {
  border-color: #7aa2f7;
  color: #fff;
}


/* ---- narrow-width wrapping (phase 25 part 1) ----
   desktop-visible bugs, not mobile ones: every rule below fires in a 26rem
   sidebar as readily as on a phone. 44rem is the single shell breakpoint. */

@media (max-width: 44rem) {
  /* nine columns (ten for admin) with `li { display: contents }`, and a
     display:contents row is structurally incapable of wrapping. the buttons
     are self-labelling, so a card needs no label column. */
  .member-list,
  .member-list.admin {
    display: block;
    grid-template-columns: none;
  }

  .member-list li {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.35rem;
    padding: 0.5rem 0;
    border-bottom: 1px solid #2a2a37;
  }

  .member-list .member-name {
    flex: 1 1 100%;
    min-width: 0;
    font-weight: 600;
  }

  .member-list .member-username,
  .member-list .member-badges,
  .member-list .member-hint {
    flex: 0 1 auto;
    min-width: 0;
  }

  .member-add,
  .player-row,
  .inline-form,
  .doc-create,
  .editor-toolbar,
  .presence-dock .voice-buttons {
    flex-wrap: wrap;
  }

  .member-add input,
  .doc-create input {
    flex: 1 1 10rem;
    min-width: 0;
  }

  /* a fixed 14rem does not fit beside its buttons at 320px */
  .table-name-input {
    width: auto;
    flex: 1 1 10rem;
    min-width: 0;
  }

  .table-list li {
    flex-wrap: wrap;
  }

  /* the reorder arrows keep the title's line above the breakpoint; below it
     a second row is cheaper than an overflow */
  .pile-row,
  .poll-row {
    flex-wrap: wrap;
  }

  /* a 7rem first column leaves 13rem for three buttons in a 20rem sheet */
  .wb-cap-row {
    display: block;
  }

  .wb-cap-label {
    display: block;
    min-width: 0;
    margin-bottom: 0.2rem;
  }

  /* the two tables that render. the /stage vote matrix is a capture surface
     and is deliberately left alone. */
  .play .vote-matrix,
  .dice-guide-table {
    width: 100%;
    table-layout: fixed;
  }

  .play .vote-matrix td,
  .dice-guide-table td {
    overflow-wrap: break-word;
  }
}


/* narrow shell only (re-shown at `display: flex` in the 44rem block). a
   <header> defaults to `display: block`, so without this it renders on the
   desktop too, above the sidebar's own header. */
.sheet-header {
  display: none;
}

/* ---- the narrow shell (phase 25 part 2) ----
   44rem is the SINGLE shell breakpoint; the two pre-existing 44rem blocks
   are narrow-content rules for surfaces it owns, and 60rem stays a component
   breakpoint for the prep overlay's column count.

   NOTHING HERE IS REACTIVE. every breakpoint is css: no resize listener, no
   matchMedia bridged into a signal, no rust reading viewport width during
   render. the sole exception is DiceUi::new's one-shot init read. */

@media (max-width: 44rem) {
  /* an EXPLICIT tab-bar row: `1fr auto` collapses to zero, since the bar is
     absolutely positioned and the auto row has no in-flow content. a grid
     ROW, never `position: fixed` — a fixed bottom bar on iOS is laid out
     against the *layout* viewport and the keyboard covers it. `box-sizing`
     is not decoration: `.play` has no border-box rule, so 100dvh + a bottom
     inset overflows by the home indicator.
     THE HOME-INDICATOR INSET IS INSIDE `--tabbar-h`, never padding on
     `.play`. the bar is absolutely positioned inside `.sidebar`, and an
     absolute box resolves against its containing block's PADDING box — so
     bottom padding here would push the bar a full inset BELOW the row the
     grid reserved for it, leaving an unpainted band above it and its own
     bottom half under the indicator. everything keyed to `--tabbar-h` (the
     sidebar, the dice bar, the four toast offsets) then follows for free.
     CONSTRAINT: every overlay child of `.play` must be out of flow, or it is
     auto-placed into an implicit row and pushes the shell past 100dvh. */
  .play {
    --safe-b: env(safe-area-inset-bottom, 0px);
    --tabbar-h: calc(3.5rem + var(--safe-b));
    display: grid;
    grid-template-rows: minmax(0, 1fr) var(--tabbar-h);
    box-sizing: border-box;
  }

  .scene-column {
    min-height: 0;
  }

  /* the connection banner renders OUTSIDE the routes, sibling to `.play`,
     and is sticky: against a 100dvh play box the document grows taller than
     the viewport and the tab bar drops below the fold. a phone on a train
     shows it constantly. the body becomes the column that owns the viewport;
     the banner takes a row and `.play` gets the rest.
     SCOPED TO `.play` — and it must stay scoped. the play window is one of
     seven routes; `/`, `/t/:slug`, `/tables` and `/admin` are ordinary flow
     content with no scroll container of their own, and an unscoped
     `overflow: hidden` clips them at the viewport with no way to scroll.
     the whole suite missed that, because `scrollIntoViewIfNeeded` scrolls
     PROGRAMMATICALLY and `overflow: hidden` still permits it. real wheel
     input does catch it, and now does: see "the narrow shell does not trap
     the other six routes" in `mobile.spec.ts`. */
  body:has(.play) {
    display: flex;
    flex-direction: column;
    height: 100vh;
    height: 100dvh;
    overflow: hidden;
  }

  .connection-banner {
    position: static;
    flex: none;
    padding-top: max(0.4rem, env(safe-area-inset-top, 0));
  }

  .play {
    height: auto;
    flex: 1 1 auto;
    min-height: 0;
  }

  /* ---- the sidebar becomes a sheet ----
     `.sidebar.collapsed { display: none }` cannot work: the tab bar is a DOM
     child of the sidebar and would vanish with it. the parent stays rendered
     and non-intercepting; only the sheet body hides. */
  .sidebar,
  .sidebar.collapsed {
    position: absolute;
    inset: 0;
    width: auto;
    max-width: none;
    border: 0;
    background: transparent;
    /* the empty area passes taps through to the scene */
    pointer-events: none;
    /* the ladder, read off this stylesheet: scene chrome 4/6, dock 8,
       pre-join backdrop 20, prep overlay 35, viewers 40, toast 50. once
       `.sidebar` is positioned the scene's positioned children win by
       stacking order regardless of DOM order, so the dock would float over
       the sheet. 15 is floor AND ceiling: above the dock, below the pre-join
       modal — a sheet must never outrank a modal. */
    z-index: 15;
    box-sizing: border-box;
  }

  /* left visible it would sit inside the `pointer-events: none` parent:
     rendered but untappable. `.sheet-header` replaces it. */
  .sidebar-header {
    display: none;
  }

  .sidebar-body {
    position: absolute;
    inset: 0 0 var(--tabbar-h);
    background: #1d1d26;
    pointer-events: auto;
    padding-top: env(safe-area-inset-top, 0);
    padding-left: env(safe-area-inset-left, 0);
    padding-right: env(safe-area-inset-right, 0);
    box-sizing: border-box;
    /* THE SHEET SCROLLS, IT NEVER SPILLS. the bottom inset stops the box
       above the tab bar, but a box is not a clip: a child taller than the
       body escapes it and paints over the bar, and the bar is the only way
       out of a sheet — an intercepted tap there strands you in the panel.
       the whiteboard is what got there (a wrapped toolbar at the coarse
       floor is ~8 rows tall at 320px), and it reached the Chat tab.
       `auto`, not `hidden`: clipping the overflow would hide controls
       instead of covering navigation, which is the same bug facing the
       other way. every well-formed panel bases its own scroller on
       `min-height: 0` and never reaches this, so it engages exactly where
       the content genuinely does not fit. */
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* with the body scrollable, a `flex: 1; min-height: 0` canvas would happily
     resolve to nothing and leave a toolbar with no board under it. the floor
     keeps it usable and lets the sheet scroll instead.

     SCOPED TO THE SHEET, and it must stay scoped: `WhiteboardSurface` renders
     in three places, and the other two do not want this. the presented board
     (`panel.rs`'s `PresentedWhiteboard`) sits in a `.content-slot` whose
     occupant is `overflow: auto`, so on a 320x568 phone — where tiles and dock
     leave the slot well under the floor — an unscoped rule turns the presented
     board into a clipped scrollable box that a finger CANNOT scroll, because
     `.wb-canvas` is `touch-action: none`. it shrank to fit before the floor and
     must keep doing so. `/stage` is safe on its own (`.stage-slot
     .stage-board` is (0,2,0) and outranks this), but safe by specificity is
     not the same as meant.

     `dvh` because the shell owns the viewport in `dvh`; `vh` on iOS measures
     the URL-bar-hidden height and so overshoots while it is showing, in
     exactly the direction that forces the sheet to scroll. the 16rem cap
     keeps the floor from claiming more than a board needs on a tall phone —
     it is a floor, so flex still grows the canvas when there is room. */
  .sidebar-body .wb-canvas-wrap {
    min-height: min(40dvh, 16rem);
  }

  /* matches `.sidebar.collapsed .sidebar-body { display: none }` (the
     desktop rail rule) — restated so the sheet drops with the same signal */
  .sidebar.collapsed .sidebar-body {
    display: none;
  }

  .sheet-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.4rem 0.5rem 0.4rem 0.8rem;
    border-bottom: 1px solid #2a2a37;
    flex: none;
  }

  .sheet-header h2 {
    margin: 0;
    font-size: 1rem;
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* `flex-wrap: wrap`, `gap: 0.25rem` and `padding: 0.4rem` are inherited
     from the desktop rule and ALL THREE must go: with them, seven 2.75rem
     tabs need ~345px and would silently wrap to two rows, failing the 320px
     case. with gap 0 and no padding, seven tabs at 320px are 45.7px — 1.7px
     above the 44px floor.

     AT EIGHT TABS (specs/20's Cards) the bar stops fitting 320px — 40px a
     tab, under the floor — and the answer is that **the bar scrolls
     horizontally** rather than shrinking its targets. `flex: 1 0 2.75rem`
     is the whole mechanism: below eight tabs they grow to fill the width
     exactly as before, and past it they hold the floor and overflow into a
     scroller. scroll-snap keeps a tab from stopping half off-screen, and
     `sidebar.rs` scrolls the active one into view on change so a tab
     selected from the sheet is never left out of sight.

     Shrinking the targets, hiding a tab behind an overflow menu, and
     dropping one on mobile were each rejected: the first breaks the touch
     floor, the second hides a whole feature behind a second tap for a bar
     that is one item over, and the third makes "which tabs exist" depend on
     the device. */
  .sidebar-tabs,
  .sidebar.collapsed .sidebar-tabs {
    position: absolute;
    inset: auto 0 0;
    height: var(--tabbar-h);
    display: flex;
    flex-direction: row;
    align-items: stretch;
    justify-content: stretch;
    pointer-events: auto;
    flex-wrap: nowrap;
    gap: 0;
    padding: 0 env(safe-area-inset-right, 0) var(--safe-b)
      env(safe-area-inset-left, 0);
    background: #16161f;
    border-top: 1px solid #2a2a37;
    border-bottom: 0;
    box-sizing: border-box;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x proximity;
    scrollbar-width: none;
  }

  /* the scrollbar would eat into a bar that is already exactly `--tabbar-h` */
  .sidebar-tabs::-webkit-scrollbar,
  .sidebar.collapsed .sidebar-tabs::-webkit-scrollbar {
    display: none;
  }

  .sidebar .sidebar-tab,
  .sidebar.collapsed .sidebar-tab {
    /* grow to fill while they fit, hold the coarse-pointer floor once they
       do not — see the comment above the bar */
    flex: 1 0 2.75rem;
    min-width: 2.75rem;
    scroll-snap-align: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.1rem;
    padding: 0.2rem 0.1rem;
    border: 0;
    border-radius: 0;
    background: none;
  }

  .sidebar .sidebar-tab.active,
  .sidebar.collapsed .sidebar-tab.active {
    outline: none;
    background: #23233a;
    box-shadow: inset 0 2px 0 var(--accent);
  }

  .sidebar .tab-icon,
  .sidebar.collapsed .tab-icon {
    display: inline-flex;
    line-height: 1;
  }

  .sidebar .tab-icon .ico,
  .sidebar.collapsed .tab-icon .ico {
    width: 1.5rem;
    height: 1.5rem;
    vertical-align: 0;
  }

  /* beats `.sidebar.collapsed .tab-text { display: none }` (the desktop icon
     rail). the bar is icon-only by default at any height. */
  .sidebar .tab-text,
  .sidebar.collapsed .tab-text {
    display: none;
  }

  /* ---- the dice bar becomes a sheet with a d20 handle ----
     `.dice-restore` is already the handle and DiceUi's collapse state
     already models it: css plus the one-shot init default. */
  .dice-bar {
    position: absolute;
    inset: auto 0 var(--tabbar-h);
    z-index: 14;
    background: #1b1b26;
    border-top: 1px solid #2a2a3d;
    padding: 0.5rem 0.8rem 0.7rem;
    max-height: 60dvh;
    overflow-y: auto;
    padding-left: max(0.8rem, env(safe-area-inset-left, 0));
    padding-right: max(0.8rem, env(safe-area-inset-right, 0));
  }

  .die-button {
    font-size: 1rem;
    min-height: 2.75rem;
    min-width: 2.75rem;
  }

  /* the handle rides in the dock, which moves to the top edge here; it
     belongs in the thumb zone above the tab bar. `fixed`, not `absolute`:
     `.presence-dock` is positioned, so an absolute handle could not escape
     it. the objection to `fixed` is about bottom chrome tracking the
     visual viewport under the keyboard, and it APPLIES here: the chat
     composer has focus with the dice bar closed, which is the common case on
     a phone, so on ios the handle can float over the keyboard. `absolute` is
     not an available trade, and a handle over the keyboard is recoverable —
     dismiss the keyboard — where one trapped inside the dock is not.
     revisit if `interactive-widget` ever lands in webkit. */
  .dice-restore {
    position: fixed;
    right: max(0.75rem, env(safe-area-inset-right, 0));
    bottom: calc(var(--tabbar-h) + 0.75rem);
    z-index: 9;
    width: 3rem;
    height: 3rem;
    padding: 0;
    border-radius: 50%;
    font-size: 1.25rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /* ---- the dock moves to the scene's top edge ----
     voice controls in the thumb zone is worse than a thumb-stretch: a
     mis-tap there un-mutes you. */
  .presence-dock {
    top: env(safe-area-inset-top, 0);
    bottom: auto;
    left: max(0.5rem, env(safe-area-inset-left, 0));
    right: max(0.5rem, env(safe-area-inset-right, 0));
  }

  /* the desktop dock is shrink-to-fit; pinning left AND right gives it a
     real width, and content-box `.dock-row` then overflows by its padding.
     `fit-content` keeps that bound without spending it: expanded, max-content
     exceeds the line so the row takes the full width and wraps as before —
     collapsed, only the handle is left and the bar shrinks to it */
  .presence-dock .dock-row {
    flex-wrap: wrap;
    box-sizing: border-box;
    width: fit-content;
    max-width: 100%;
    min-width: 0;
  }

  /* expanded upward against the dock's old corner; from the top edge it
     expands downward, bounded by the viewport rather than a fixed 17rem */
  .presence-popover {
    top: 100%;
    bottom: auto;
    margin-top: 0.4rem;
    margin-bottom: 0;
    width: auto;
    left: 0;
    right: 0;
    max-width: none;
    max-height: 60dvh;
  }

  .presence-popover select {
    max-width: 100%;
  }

  /* the 13rem cap protected the controls *above* the dock; from the top
     edge there is nothing above it. these keep nowrap + ellipsis, so drop
     the cap but stay bound to the dock — `min-width: 0` is what lets the
     ellipsis engage inside a flex row. */
  .presence-dock .voice-controls .hint,
  .presence-dock .voice-controls .error {
    max-width: 100%;
    min-width: 0;
  }

  /* min-width: auto is what stops the row wrapping at 320px */
  .presence-dock .dock-row > *,
  .presence-dock .voice-controls,
  .presence-dock .voice-buttons {
    min-width: 0;
    max-width: 100%;
  }

  /* a fixed 13rem column beside the stage leaves ~7rem for the content it
     exists to flank; below the breakpoint the tiles run along the bottom */
  .scene.has-content {
    flex-direction: column;
  }

  .scene.has-content .scene-tiles {
    flex: 0 0 auto;
    width: auto;
    max-height: 22vh;
    overflow-x: auto;
    overflow-y: hidden;
  }

  .scene.has-content .voice-tiles {
    flex-direction: row;
    padding: 0.35rem;
  }

  .scene .voice-tiles.spotlight {
    flex-direction: column;
  }

  .scene .voice-tiles.spotlight .spotlight-aside {
    width: auto;
    flex-direction: row;
    overflow-x: auto;
    overflow-y: hidden;
    max-height: 22vh;
  }

  /* ---- floating viewers go full-screen ----
     `.viewer-panel` is positioned by an inline style attribute, and a
     stylesheet rule cannot beat one. `!important` is the correct tool and
     costs zero wasm versus plumbing a viewport signal into the component.
     DO NOT "clean this up". */
  .viewer-panel {
    inset: 0 !important;
    width: auto !important;
    height: auto !important;
    max-width: none !important;
    max-height: none !important;
    transform: none !important;
    border-radius: 0;
    border-width: 1px 0 0;
  }

  /* declared delta: maximize, resize and DRAGGING have no referent on a
     full-screen panel. the header stays visible — title, close, download,
     open, and make-a-board. */
  .viewer-resize,
  .viewer-max {
    display: none;
  }

  .viewer-header {
    cursor: default;
    /* the cursor alone is not enough: `touch-action: none` still swallows
       the gesture, so a title-bar drag would do nothing at all */
    touch-action: auto;
    border-radius: 0;
    padding-top: max(0.4rem, env(safe-area-inset-top, 0));
    padding-left: max(0.7rem, env(safe-area-inset-left, 0));
    padding-right: max(0.7rem, env(safe-area-inset-right, 0));
  }

  .viewer-body {
    padding-bottom: max(0.6rem, env(safe-area-inset-bottom, 0));
  }

  /* ---- the prep overlay goes full-screen too ---- */
  .prep-overlay {
    width: 100%;
    height: 100%;
    max-width: none;
    max-height: none;
    border-radius: 0;
    border-width: 0;
  }

  .prep-header {
    flex-wrap: wrap;
    gap: 0.4rem;
    border-radius: 0;
    padding-top: max(0.5rem, env(safe-area-inset-top, 0));
  }

  .prep-overlay input[type="file"] {
    max-width: 100%;
  }

  /* ---- toasts clear the tab bar ----
     `.reveal-toast` sat at `bottom: 1rem`, inside the bar zone. the whole
     stack shifts by `--tabbar-h`, keeping its relative spacing. */
  .reveal-toast {
    bottom: calc(var(--tabbar-h) + 0.5rem);
    left: 0.5rem;
    right: 0.5rem;
  }

  .show-toast {
    bottom: calc(var(--tabbar-h) + 4rem);
    left: 0.5rem;
    right: 0.5rem;
  }

  .invite-toast {
    bottom: calc(var(--tabbar-h) + 7.5rem);
    left: 0.5rem;
    right: 0.5rem;
  }

  .invite-toast.outcome {
    bottom: calc(var(--tabbar-h) + 11rem);
  }

  /* ---- voice pre-join: the first thing a phone player meets ---- */
  .prejoin {
    width: 100%;
    max-width: none;
    max-height: 100%;
    overflow-y: auto;
    box-sizing: border-box;
  }

  .prejoin-backdrop {
    padding: max(0.5rem, env(safe-area-inset-top, 0)) 0.5rem
      max(0.5rem, env(safe-area-inset-bottom, 0));
    box-sizing: border-box;
  }

  /* ---- streamer x mobile ----
     the bottom bar must not resurrect inside `.play.streamer`, and
     `.show-sidebar` must still raise the sheet. a cascade interaction, so
     it has a test rather than a reading. */
  .play.streamer .sidebar,
  .play.streamer .sidebar .sidebar-tabs,
  .play.streamer .sidebar.collapsed .sidebar-tabs {
    display: none;
  }

  .play.streamer.show-sidebar .sidebar {
    display: block;
  }

  .play.streamer.show-sidebar .sidebar .sidebar-tabs,
  .play.streamer.show-sidebar .sidebar.collapsed .sidebar-tabs {
    display: flex;
  }

  /* streamer mode gives the scene the whole viewport, so there is no
     tab-bar row to reserve — and no bottom inset either, or the 100dvh scene
     inside overflows its box by the home indicator */
  .play.streamer {
    grid-template-rows: minmax(0, 1fr);
  }
}

/* captions only where there is vertical room. a COMBINED query, not a nested
   one: at desktop widths this never matches, so it can never reveal text in
   the collapsed desktop icon rail. */
@media (max-width: 44rem) and (min-height: 40rem) {
  .sidebar .tab-text,
  .sidebar.collapsed .tab-text {
    display: block;
    font-size: 0.65rem;
    line-height: 1.1;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}

/* ---- the accessibility floor (phase 25 part 2) ----
   `(pointer: coarse)` keys on the PRIMARY POINTER TYPE, not on width or form
   factor, so a touch-primary large screen matches it at any width and
   correctly gets the floor. "the desktop is unchanged" means "unchanged for
   a FINE-pointer device" — it is not width-scoped. */

@media (pointer: coarse) {
  /* An accessibility FLOOR, not a preference: any control computing under
     16px makes iOS Safari zoom the page on focus, including every chat
     message. `!important` because the competing declarations are ordinary
     author rules that must lose, and a floor a later rule can reopen is not
     a floor.

     DO NOT enumerate the rules this beats. Three successive enumerations
     were each wrong — grepping selector *text* structurally cannot find a
     `<select>` styled by a class name, and a control can also inherit a
     small size from a compact parent. The floor catches both.

     The `:not()` chain MUST stay on one line: a line break between two
     compound selectors is a descendant combinator, which would ask for a
     matching descendant *of* the input. Non-text controls are excluded —
     they do not zoom, and growing them would relayout dense rows. */
  input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="color"]),
  select,
  textarea,
  /* NOT a form control, and that is why it is here: the notebook editor is
     a transparent textarea over a highlighted mirror div and both must share
     metrics exactly. Raising only the textarea drifts the caret off the
     highlighting under it. Any future overlay-mirror pair belongs here. */
  .editor-mirror {
    font-size: max(1rem, 1em) !important;
  }

  /* from an inventory of actual interactive elements: `<select>` and
     styled action links are interactive too, and a floor that knows only
     `button` leaves gaps. */
  button,
  select,
  [role="button"],
  summary,
  .viewer-controls a,
  .entry-actions button {
    min-height: 2.75rem;
  }

  /* an icon-only control is only as wide as its glyph — about 20px — so the
     height floor alone leaves a 20x44 target. the width floor goes ONLY to
     controls whose sole child is an icon; a text control keeps its intrinsic
     width, since forcing 2.75rem on those gaps out dense rows.
     `:where()` deliberately zeroes this to (0,1,0) so the exception list
     below, which is plain classes, can still beat it on source order. */
  :where(button, [role="button"], summary):has(> :where(.ico):only-child) {
    min-width: 2.75rem;
  }

  /* documented exceptions: decorative, or pinned inside another control's
     rect where 2.75rem would break the layout. each has a larger parent hit
     area or is not a primary path. */
  .dice-help,
  .macro-chip .macro-unpin,
  .tab-unread,
  .info-toggle,
  .handout-delete,
  .dock-collapse,
  .wb-marker-dot,
  .die-count {
    min-height: 0;
    min-width: 0;
  }

  input[type="checkbox"],
  input[type="radio"] {
    min-width: 1.15rem;
    min-height: 1.15rem;
    width: 1.15rem;
    height: 1.15rem;
  }

  /* removing the tap flash without replacing it is a downgrade, so these
     land together */
  * {
    -webkit-tap-highlight-color: transparent;
  }

  button:focus-visible,
  select:focus-visible,
  a:focus-visible,
  input:focus-visible,
  textarea:focus-visible,
  summary:focus-visible,
  [role="button"]:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
  }
}

/* hover-only controls are invisible controls on a touch screen. the two
   pre-existing `(hover: none)` blocks cover handout and picker deletes only;
   these are the ones they missed. */
@media (hover: none) {
  .notebook-docs li .entry-actions {
    visibility: visible;
  }

  .tile-controls,
  .roll-entry .save-as-macro {
    visibility: visible;
    opacity: 1;
  }
}


/* ---- one icon system (phase 25 part 4) ----
   24 viewBox, 1.5px stroke, round caps, `currentColor`. 1.25rem in ordinary
   controls, 1.5rem in the tab bar — one weight, one colour, one meaning. */
.ico {
  width: 1.25rem;
  height: 1.25rem;
  flex: none;
  vertical-align: -0.2em;
}

/* an icon sitting next to a visible label in the tab bar, where the caption
   is ellipsised and wants to shrink. DELIBERATELY NOT `.ico + *`: that
   selector reaches every icon-adjacent label, and removing the min-content
   floor is exactly what let a folder name break one character per line (see
   the `overflow-wrap` note at the top of this file). */
.ico + .tab-text {
  min-width: 0;
}

/* ---- table decks (specs/20) ------------------------------------------- */

/* faces are drawn from glyphs — no images, no uploads, crisp at any size */
.deck-card {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-width: 2rem;
  padding: 0.2rem 0.35rem;
  border: 1px solid #3a3a4d;
  border-radius: 0.3rem;
  background: #f4f4f7;
  color: #16161f;
  font-weight: 600;
  line-height: 1.05;
  font-variant-numeric: tabular-nums;
}

.deck-card.red {
  color: #b3123a;
}

.deck-card-rank {
  font-size: 0.95rem;
}

.deck-card-suit {
  font-size: 0.85rem;
}

.deck-panel {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  padding: 0.6rem;
  min-width: 0;
  overflow-y: auto;
}

.deck-picker {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

.deck-pick.active {
  background: #23233a;
  box-shadow: inset 0 -2px 0 var(--accent);
}

.deck-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.4rem;
  min-width: 0;
}

.deck-counts {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.deck-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

.deck-error {
  margin: 0;
  color: #ffb4b4;
}

.deck-hands {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* one row per **visible** holder, empty hands included: the list is a
   scannable table of the whole table, and a missing row would read as "not
   playing" rather than "no card yet" (specs/20) */
.deck-hand {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 2rem;
  padding: 0.25rem 0.4rem;
  border-radius: 0.3rem;
  background: #1c1c28;
  min-width: 0;
}

.deck-hand.mine {
  box-shadow: inset 2px 0 0 var(--accent);
}

.deck-hand.seat .deck-hand-name {
  font-style: italic;
}

.deck-hand-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: break-word;
}

.deck-cards {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  min-width: 0;
}

.deck-held {
  display: inline-flex;
  align-items: center;
  gap: 0.15rem;
}

.deck-card-controls,
.deck-seat-controls {
  display: inline-flex;
  align-items: center;
  gap: 0.15rem;
}

.deck-give {
  max-width: 6rem;
}

.deck-discard h4 {
  margin: 0 0 0.3rem;
  color: var(--muted);
  font-size: 0.85rem;
  font-weight: 600;
}

/* the deal dialog reuses the macro modal's chrome */
.deal-bulk {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-bottom: 0.5rem;
}

.deal-add-target {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  min-width: 0;
}

.deal-add-target.hidden,
.deal-bulk button.hidden {
  display: none;
}

.deal-add-target input {
  flex: 1 1 8rem;
  min-width: 0;
}

.deal-rows {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin: 0;
  padding: 0;
  list-style: none;
  max-height: 50vh;
  overflow-y: auto;
}

.deal-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

/* a pending count aimed at a holder the current view no longer contains: the
   gm must clear it by hand, and it blocks the confirm (specs/20) */
.deal-row.invalid {
  border: 1px solid #b3123a;
  border-radius: 0.3rem;
  padding: 0.15rem 0.3rem;
}

/* `min-width: 0` next to a non-shrinking sibling is what renders a long
   single-token name one character per line at 320px, so the tag wraps to its
   own line instead (the guard CLAUDE.md's phase-25 note names) */
.deal-name-wrap {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.35rem;
  flex: 1 1 auto;
  min-width: 0;
}

.deal-name {
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
  overflow-wrap: break-word;
}

/* the wrap owns the grow; inside it the name is content-sized. the orphan
   row has no wrap and keeps the rule above, so its label still fills the row */
.deal-name-wrap .deal-name {
  flex: 0 1 auto;
}

/* exactly the rows the top-of-round button skips */
.deal-tag {
  flex: 0 0 auto;
  color: var(--muted);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

.deal-held {
  display: flex;
  gap: 0.2rem;
  flex-wrap: wrap;
  min-width: 0;
}

.deal-count {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  flex: 0 0 auto;
}

.deal-count output {
  min-width: 1.5ch;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

.deal-footer {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin-top: 0.6rem;
}

.deck-settings-list {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  margin: 0.3rem 0;
  padding: 0;
  list-style: none;
}

.deck-settings-new.hidden {
  display: none;
}

.deck-settings-row,
.deck-settings-new {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem;
  min-width: 0;
}

.deck-settings-name,
.deck-settings-new input[type="text"],
.deck-settings-new input:not([type]) {
  flex: 1 1 8rem;
  min-width: 0;
}

/* the image picker's search (specs/08 token art). it rides the coarse-pointer
   16px form-control floor from specs/11 like every other input */
.wb-picker-search {
  width: 100%;
  margin-bottom: 0.75rem;
  padding: 0.35rem 0.5rem;
}

/* the inline token/label editor (specs/08 token art): the input plus, for a
   token, the picture and remove controls. the wrapper carries the board
   position the input used to, so the row moves as one */
.wb-label-editor {
  position: absolute;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 0.25rem;
}

.wb-label-editor .wb-label-input {
  position: static;
}

/* both are icon-only `<button>`s with one `.ico` child, so the coarse-pointer
   block's 2.75rem height AND its icon-only width floor already reach them —
   nothing here sets a min-height that would reopen either. neither is on the
   exception list, deliberately: they are a primary path, mid-session */
.wb-label-editor button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.15rem 0.3rem;
  line-height: 1;
}
