/* ===================================================================
   Stream-Steuerung - shared stylesheet

   Layers, in order:
     1. tokens          design variables
     2. base            page chrome shared by every admin/streamer page
     3. controller      the viewer's clicker page (its own full-bleed layout)
     4. admin           tables, media grid, forms
     5. chat            chat page (the OBS chat overlay styles itself inline)
     6. deck             OBS deck editor + mobile control surface
     7. multi-stream     side window + channel switcher (docs/PLAN-PHASE2.md §C)
     8. narrow viewports the phone pass - must stay LAST, see its own note

   Plus a small icons layer up front (1b), because every later section draws
   them and they have to be sized and coloured before anything adds spacing
   around them.

   The OBS *media* overlay deliberately has no styles here - it ships its own
   minimal inline CSS so a stray rule in this file can never paint a background
   into a transparent Browser Source.
   =================================================================== */

/* ---------- 1. tokens ---------------------------------------------- */
:root {
  --bg:            #0f1115;
  --bg-panel:      #151924;
  --bg-panel-2:    #1b2130;
  --bg-input:      #0d1017;
  --border:        rgba(255, 255, 255, 0.10);
  --border-strong: rgba(255, 255, 255, 0.22);
  --text:          #e6e9f0;
  --text-dim:      #98a2b8;
  --accent:        #4c8dff;
  --accent-dim:    #2b5db8;
  --good:          #35c46b;
  --warn:          #e8b339;
  --bad:           #e8564a;
  --greenscreen:   #00b140;   /* broadcast-safe green, not #00ff00 */
  --radius:        0.6rem;
  --radius-lg:     1rem;
  --rail-w:        clamp(240px, 20vw, 340px);
}

/* ---------- 1b. icons ---------------------------------------------- */
/* The whole styling contract for app/templates/_icons.html. Kept up here,
   right after the tokens, because almost every later layer draws icons and
   they must already be sized and coloured by the time those rules add
   spacing around them.

   `.icon` is the <svg> root that each `icon()` call emits, and it is where
   fill/stroke/stroke-width live. That is not a style preference: <use>
   clones a <symbol> into a shadow tree, and document selectors do not match
   inside it - only inherited properties get through. So the symbols carry no
   painting of their own and inherit all of it from here. A rule like
   `.icon path { stroke: red }` would do nothing at all. */
.icon-sprite {
  /* The sprite is a real element in the document (the ids have to resolve),
     but must occupy no space and never paint. Not `display: none`: that is
     the one value that has historically broken <use> references into the
     hidden tree, and it buys nothing over this. */
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.icon {
  /* Sized in em, so an icon is always the size of the text it sits with and
     needs no per-site override - the deck's 1.5rem button icons and a
     .78rem nav link both come out right from this one rule. */
  width: 1em;
  height: 1em;
  /* Never let a flex parent stretch or squash an icon (the player tray, the
     chat form row and the side-window bar are all flex). */
  flex: 0 0 auto;
  /* Optical baseline: 1em box centred against lowercase text rather than
     sitting on the baseline, which reads as too high. */
  vertical-align: -.125em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Icon plus its text label as one unit: a real `gap` instead of a whitespace
   character, which would collapse, wrap between the two, or leave the icon
   stranded at the end of a line. */
.icon-label {
  display: inline-flex;
  align-items: center;
  gap: .4em;
}
/* Bootstrap's .dropdown-item and .nav-link are blocks with their own
   padding; an inline-flex child inside them keeps that intact, so the label
   wrapper only needs to fill the line. */
.dropdown-item .icon-label,
.nav-link .icon-label,
.btn .icon-label { vertical-align: middle; }


/* Icon-only controls: centre the glyph in the box instead of letting it sit
   on a text baseline in a line box that is taller than it is. Every one of
   these buttons now contains a single <svg> and no text.

   .stage-btn is listed here even though `.stage-btn-pane { display: none }`
   below relies on overriding it - that rule comes later in this file at the
   same specificity, and the fullscreen rule that re-shows a pane button is
   more specific still, so the cascade already sorts it out. Same pattern
   that block used before this rule existed. */
.stage-btn,
.side-stream-btn,
.poll-dismiss,
.deck-slot-move,
.chat-emote-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* These two hold EITHER one of our icons or a free-text symbol the streamer
   typed into the deck editor, so they centre their content whichever it is
   (`line-height: 1` alone leaves a 1em <svg> hanging below the box). */
.deck-slot-icon,
.deck-mobile-btn-icon {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ---------- 2. base ------------------------------------------------ */
html, body { height: 100%; }

body {
  background: var(--bg);
  color: var(--text);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

a { color: var(--accent); }
a:hover { color: #7aaeff; }

.card,
.list-group-item,
.modal-content,
.dropdown-menu {
  background: var(--bg-panel);
  border-color: var(--border);
  color: var(--text);
}

.table { --bs-table-color: var(--text); --bs-table-bg: transparent; }
.table > :not(caption) > * > * { border-color: var(--border); }
.table thead th {
  color: var(--text-dim);
  font-size: .8rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  font-weight: 600;
}

.form-control, .form-select {
  background: var(--bg-input);
  border-color: var(--border);
  color: var(--text);
}
.form-control:focus, .form-select:focus {
  background: var(--bg-input);
  border-color: var(--accent);
  color: var(--text);
  box-shadow: 0 0 0 .2rem rgba(76, 141, 255, .22);
}
.form-control::placeholder { color: #5c667c; }
.form-text { color: var(--text-dim); }

.form-check-input { background-color: var(--bg-input); border-color: var(--border-strong); }
.form-check-input:checked { background-color: var(--accent); border-color: var(--accent); }

.badge.bg-dark { background: #0b0e14 !important; }

/* A monospace pill for URLs the user has to copy into OBS. */
.url-pill {
  display: flex;
  align-items: center;
  gap: .5rem;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: .4rem .6rem;
  font: 13px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace;
  word-break: break-all;
}
.url-pill code { color: var(--text); background: none; padding: 0; }
.url-pill .btn { flex: 0 0 auto; }

.section-title {
  font-size: .78rem;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--text-dim);
  font-weight: 600;
  margin-bottom: .5rem;
}

/* ---------- 3. controller ------------------------------------------ */
/* Full-bleed: the clicker page owns the viewport, the navbar takes no layout
   space at all. Media buttons live in a fixed left rail, the stream fills
   the rest. */
body#page-controller {
  overflow: hidden;
}
/* The navbar used to be `display: none` here - a mouse-only viewer had no way
   back to the overview (the rail's own "Alle Streams" link, above, is the
   touch answer, since there is no hover on a phone). Now it is a fixed
   overlay, off-canvas by default, that controller.js slides in when the
   pointer nears the very top edge - never laid out in flow, so it can never
   change the rail/stage split that invariant 9's 16:9 maths depends on.
   `pointer-events: none` while off-canvas is load-bearing: the same top strip
   sits over .stage-capture, which owns click-to-place. */
body#page-controller .navbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 40;
  margin: 0;
  transform: translateY(-100%);
  transition: transform .16s ease;
  pointer-events: none;
}
body#page-controller.navbar-peek .navbar {
  transform: translateY(0);
  pointer-events: auto;
}
/* Belt-and-suspenders on top of the JS guard in controller.js: fullscreen
   must never show the navbar, even if a fullscreenchange fires without a
   preceding mousemove to clear the peek class first. `.navbar` is a sibling
   that comes BEFORE `.controller-shell` in the DOM (base.html renders the nav
   block ahead of the page body), so a sibling combinator off the shell can
   never reach it - controller.js mirrors `.is-fullscreen` onto `body` itself
   (`.controller-fullscreen`) purely so this selector has something to key
   off. */
body#page-controller.controller-fullscreen .navbar {
  transform: translateY(-100%) !important;
  pointer-events: none !important;
}
body#page-controller > .container-fluid { display: none; }  /* flash area */

.controller-shell {
  display: flex;
  /* dvh, not vh: Android Chrome's address bar resizes the viewport as it
     shows/hides, and the stage's height feeds invariant 9's 16:9 maths -
     100vh would leave a gap (or force a scroll) whenever the bar is out.
     iOS/Safari is explicitly out of scope, so no fallback is needed. */
  height: 100dvh;
  width: 100vw;
  overflow: hidden;
}

.controller-rail {
  width: var(--rail-w);
  flex: 0 0 var(--rail-w);
  background: var(--bg-panel);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  padding: .75rem .6rem;
  min-height: 0;
}

.rail-head { flex: 0 0 auto; }
/* Sits on its own line above the title/toggle row so it never competes with
   #rail-toggle for horizontal space on a narrow phone (invariant: the rail
   head is a sibling of .controller-stage, so this line's height never
   touches the stage's 16:9 box - see .controller-stage comment). */
.rail-home-link {
  display: inline-flex;
  align-items: center;
  color: var(--text-dim);
  font-size: .78rem;
  text-decoration: none;
  margin-bottom: .4rem;
}
.rail-home-link:hover, .rail-home-link:focus-visible { color: var(--text); text-decoration: underline; }
.points-row { display: flex; gap: .4rem; align-items: center; }

#points-value { font-variant-numeric: tabular-nums; }

[data-testid="points"] { transition: background .2s ease, transform .12s ease; }
[data-testid="points"].points-deny {
  background: var(--bad) !important;
  transform: scale(1.06);
}

.controller-rail .nav-pills { flex: 0 0 auto; gap: .2rem; }
.controller-rail .nav-link {
  color: var(--text-dim);
  text-align: left;
  padding: .35rem .6rem;
  font-size: .9rem;
  border-radius: var(--radius);
}
.controller-rail .nav-link.active { background: var(--accent-dim); color: #fff; }

.rail-scroll {
  flex: 1 1 auto;
  overflow-y: auto;
  min-height: 0;
  scrollbar-width: thin;
  scrollbar-color: rgba(255,255,255,.25) transparent;
}
.rail-scroll::-webkit-scrollbar { width: 7px; }
.rail-scroll::-webkit-scrollbar-thumb {
  background: rgba(255,255,255,.22);
  border-radius: 4px;
}

/* Emote search.
   While a query is active the tab strip is hidden and every pane is forced
   visible, so matches from all categories read as one list instead of the
   viewer having to guess which tab their emote was filed under. Bootstrap's
   .tab-pane hides with `display: none` plus a `.fade` opacity transition, so
   both have to be overridden - and the transition is disabled while searching
   so results appear at once rather than fading in on every keystroke. */
#rail-slide-emotes.is-searching #cat-tabs { display: none; }
#rail-slide-emotes.is-searching .tab-pane {
  display: block !important;
  opacity: 1 !important;
  transition: none !important;
}
/* Its own class rather than the `hidden` attribute: .media-btn is
   `display: flex`, which beats `[hidden]`'s `display: none` in the UA
   stylesheet unless the rule is more specific. */
.media-btn.is-filtered { display: none !important; }
/* An empty grid still claims its gap/padding, which reads as a stray band
   between results. */
#rail-slide-emotes.is-searching .btn-grid:not(:has(.media-btn:not(.is-filtered))) {
  display: none;
}

.btn-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(74px, 1fr));
  gap: .4rem;
  padding: .1rem;
}

.media-btn {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 66px;
  padding: .35rem;
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  cursor: pointer;
  transition: border-color .12s ease, transform .08s ease, opacity .15s ease;
  overflow: hidden;
}
.media-btn:hover { border-color: var(--border-strong); }
.media-btn:active { transform: scale(.96); }
.media-btn.is-armed {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px rgba(76,141,255,.45);
}
/* Pointer-drag in progress (controller.js) - past the movement threshold,
   before release. Distinct from .is-armed so the two gestures (tap-to-arm
   vs. press-and-drag) never look identical mid-interaction. */
.media-btn.is-dragging {
  opacity: .55;
  border-color: var(--accent);
}
/* Unaffordable is a *hint* only - the click is still sent and the server is
   still the one that refuses it. */
.media-btn.is-unaffordable { opacity: .38; }

.media-btn img,
.media-btn video {
  max-width: 100%;
  max-height: 52px;
  width: auto;
  height: auto;
  object-fit: contain;
  pointer-events: none;
}

.media-btn.is-sound {
  grid-column: span 2;
  min-height: 42px;
}
.media-btn .sound-label {
  font-size: .74rem;
  line-height: 1.15;
  text-align: center;
  word-break: break-word;
  pointer-events: none;
}

.cost-badge {
  position: absolute;
  right: 3px;
  bottom: 2px;
  background: rgba(0,0,0,.72);
  color: #fff;
  font-size: .66rem;
  padding: 1px 5px;
  border-radius: 7px;
  pointer-events: none;
  font-variant-numeric: tabular-nums;
}
.audio-dot {
  position: absolute;
  left: 4px;
  top: 1px;
  font-size: .7rem;
  color: var(--warn);
  pointer-events: none;
}

.controller-stage {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  padding: .6rem;
  gap: .4rem;
}

/* 16:9 box, centred, never taller or wider than the space available.
   
   This has to be *exactly* 16:9, not merely close. A click on .stage-capture is
   normalised by proportion into the virtual 1920x1080 stage, while the video
   inside letterboxes itself to its own 16:9. If the box is a different ratio the
   two disagree: with the previous `flex: 1 1 auto` the height was stretched by
   flex-grow (measured 1.486 instead of 1.778), which put a click near the top of
   the picture ~90px lower on the streamer's overlay than where the viewer aimed.
   
   `flex: 0 1 auto` stops the stretch; the width expression is a contain-fit -
   limited by the container's height when it is wide, and by 100% when it is
   tall - so the ratio holds in both directions. */
/* The size container is this wrapper, which holds nothing but the frame, so
   100cqh is precisely the height available to it. Measuring against
   .controller-stage instead included the footer and padding, which produced a
   too-wide box at short viewports (1.87 instead of 1.778 at 1920x600). */
.stage-fit {
  flex: 1 1 0;
  min-height: 0;
  min-width: 0;
  container-type: size;
  display: grid;
  place-items: center;
}

/* Phase C (docs/PLAN-PHASE2.md §C): this box holds no content of its own any
   more - it is purely the geometry reference that multistream.js measures
   with getBoundingClientRect() and copies onto #stream-box-a/b (whichever
   carries .is-main) and #stage-overlay, all `position: fixed` siblings
   living outside .stage-fit. Border/shadow/background stay here so the empty
   placeholder still looks right for the instant before JS runs, and so the
   fixed boxes rendering exactly on top of it look seamless once it has. */
.stage-frame {
  position: relative;
  aspect-ratio: 16 / 9;
  width: min(100%, calc(100cqh * 16 / 9));
  height: auto;
  max-width: 100%;
  max-height: 100%;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: #000;
  box-shadow: 0 10px 34px rgba(0,0,0,.45);
}

.stage-greenscreen {
  position: absolute;
  inset: 0;
  background: var(--greenscreen);
  display: grid;
  place-items: center;
  color: rgba(0,0,0,.65);
  font-weight: 600;
  text-align: center;
  padding: 1rem;
}

/* The capture layer must sit above the embed: an iframe swallows pointer
   events, so without this the viewer's clicks would never reach us. */
.stage-capture {
  position: absolute;
  inset: 0;
  z-index: 2;
  cursor: crosshair;
}
body.cursor-armed .stage-capture { cursor: none; }

/* The placement marker: "your click landed here", not a copy of the emote.
   It deliberately draws no artwork - on a low-latency stream the real
   placement appears in the same spot almost immediately, and rendering the
   emote here too showed it twice, stacked. A ring that expands once and
   vanishes carries the only information the stream does not already: that the
   click registered, and where. Never interactive - `.stage-capture` beneath it
   owns click-to-place. */
.echo-stamp {
  position: absolute;
  transform: translate(-50%, -50%);
  pointer-events: none;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, .9);
  /* A dark companion ring, so the marker survives a light stream frame. */
  box-shadow: 0 0 0 2px rgba(0, 0, 0, .45), inset 0 0 0 2px rgba(0, 0, 0, .25);
  animation: echo-ping 700ms ease-out forwards;
}
.echo-stamp.is-fullscreen {
  /* A fullscreen placement has no meaningful point - the server pins it to the
     stage centre, so the marker says "centred" rather than pointing nowhere. */
  left: 50%;
  top: 50%;
}
@keyframes echo-ping {
  from { opacity: .95; transform: translate(-50%, -50%) scale(.45); }
  to   { opacity: 0;   transform: translate(-50%, -50%) scale(1.7); }
}
@media (prefers-reduced-motion: reduce) {
  /* Still confirms the click, without the expanding ring. */
  .echo-stamp { animation: echo-fade 700ms linear forwards; }
  @keyframes echo-fade {
    from { opacity: .95; }
    to   { opacity: 0; }
  }
}

/* Two rows: the stream's own identity on top, housekeeping under it - see
   controller.html's comment on .stage-footer for why they were split.

   Within each row flex-wrap stays at its `nowrap` default deliberately -
   invariant 9: .stage-footer is a sibling of .stage-fit inside
   .controller-stage, so every line here grows the footer and leaves the 16:9
   box less height. Two lines is the budget; a third, from a row wrapping on
   its own, is not. Only .stage-title may give way (min-width:0 + ellipsis
   below); every other child keeps `flex: 0 0 auto` so it can never be
   squeezed into wrapping its own short text. */
.stage-footer { flex: 0 0 auto; display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
.stage-footer-main,
.stage-footer-meta { display: flex; align-items: center; gap: .5rem; min-width: 0; }
.stage-footer-main > *,
.stage-footer-meta > * { flex: 0 0 auto; white-space: nowrap; }

/* Row 1 is content, so it gets body colour and weight rather than the
   `.text-secondary small` the whole line used to share - the title being as
   dim as the connection status is the thing that was reported. */
.stage-footer .stage-title {
  /* The one child that may actually be long (a streamer-chosen title): it
     takes the row's spare width and degrades with an ellipsis rather than
     wrapping or pushing the viewer count out of the row. */
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: .98rem;
  font-weight: 600;
  line-height: 1.25;
  color: var(--text);
}
.stage-footer .stage-viewers {
  font-size: .9rem;
  font-weight: 600;
  color: var(--text);
}
#stage-viewers-value { font-variant-numeric: tabular-nums; }

/* Row 2 keeps the old dim treatment; `line-height` is pinned so the two rows
   together stay close to the height the single row used to have plus one
   small line, rather than inheriting whatever the body line-height is. */
.stage-footer-meta { line-height: 1.2; }

#ghost-cursor {
  position: fixed;
  z-index: 9999;
  pointer-events: none;
  transform: translate(-50%, -50%);
  line-height: 0;
  opacity: .9;
}
/* `img, video` both: a video asset's ghost and echo are <video>
   elements, and sizing only the <img> left them unconstrained. */
#ghost-cursor img,
#ghost-cursor video { max-width: 72px; max-height: 72px; }
body.cursor-armed { cursor: none; }

/* Coarse-pointer armed-state feedback (controller.js's showArmedHint) - the
   on-stage instruction that stands in for the ghost cursor a touch device
   cannot preview. Sits above .stage-capture (same z-index as .stage-controls)
   but is never itself interactive - a stray tap must always reach the stage
   underneath it, never this banner. */
.stage-armed-hint {
  position: absolute;
  left: 50%;
  bottom: .6rem;
  transform: translateX(-50%);
  z-index: 3;
  max-width: calc(100% - 1.6rem);
  pointer-events: none;
  background: rgba(0, 0, 0, .72);
  color: #fff;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: .4rem .7rem;
  font-size: .82rem;
  text-align: center;
  backdrop-filter: blur(4px);
}
.stage-armed-hint[hidden] { display: none; }

/* The "sound is off" chip (controller.html #stage-unmute). Deliberately more
   prominent than the small tray toggle: it is the first-load prompt that the
   stream is muted, so it sits centred near the top of the stage, does not
   fade with the control tray, and reads as a button. multistream.js shows it
   only while the stage is muted. pointer-events: auto because it sits in the
   pointer-events: none overlay and must be tappable (its own comment in the
   template). */
/* The pill lives on the container; the two inner buttons (the "tap for sound"
   action and the dismiss ×) are transparent and share it, separated by a
   hairline. A button cannot nest in a button, hence the div + two buttons. */
.stage-unmute {
  position: absolute;
  left: 50%;
  top: .6rem;
  transform: translateX(-50%);
  z-index: 4;
  display: inline-flex;
  align-items: stretch;
  max-width: calc(100% - 1.2rem);
  pointer-events: auto;
  color: #fff;
  background: rgba(0, 0, 0, .72);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  overflow: hidden;
  font-size: .8rem;
  line-height: 1.2;
  backdrop-filter: blur(4px);
}
.stage-unmute[hidden] { display: none; }
.stage-unmute .icon { width: 1.1em; height: 1.1em; flex: 0 0 auto; }

.stage-unmute-main,
.stage-unmute-dismiss {
  background: none;
  border: 0;
  color: inherit;
  cursor: pointer;
  font: inherit;
  transition: background .15s ease;
}
.stage-unmute-main {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  min-width: 0;
  padding: .4rem .6rem .4rem .8rem;
  /* Wrap to a second line on a narrow stage rather than truncating - the
     whole message ("Ton ist aus - zum Einschalten tippen") is the point, and
     a cut-off "...tipp" reads worse than two lines. One line where there is
     room (desktop), two where there is not (phone). */
  text-align: left;
}
.stage-unmute-dismiss {
  flex: 0 0 auto;
  padding: .4rem .7rem;
  border-left: 1px solid var(--border);
}
.stage-unmute-main:hover,
.stage-unmute-main:focus-visible,
.stage-unmute-dismiss:hover,
.stage-unmute-dismiss:focus-visible { background: rgba(255, 255, 255, .16); }

/* Bigger tap targets on touch, where this matters most (the whole reason it
   exists is that mobile needs a real control to tap - and the dismiss × must
   be comfortably tappable without hitting the unmute by accident). */
@media (pointer: coarse) {
  .stage-unmute { font-size: .85rem; }
  .stage-unmute-main { padding: .55rem .7rem .55rem 1rem; }
  .stage-unmute-dismiss { padding: .55rem .85rem; }
}

@media (max-width: 820px) {
  .controller-shell { flex-direction: column; }
  .controller-rail {
    width: 100%;
    /* Takes whatever the stage does not, instead of a fixed 46dvh slice.
       Stacked, the two used to split the screen by decree and both lose: the
       stage got a tall band it could not fill (its height is decided by the
       viewport's WIDTH, at 16:9) and sat centred in it with dead black above
       and below, while the emote grid above was clipped mid-row. Sizing the
       stage to what it actually needs (below) and giving the remainder to
       the rail turns that dead band into two more rows of emotes. */
    flex: 1 1 auto;
    min-height: 0;
    max-height: none;
    border-right: 0;
    border-bottom: 1px solid var(--border);
  }

  /* Invariant 9 lives here, so read this before changing it.
     `.stage-fit` must stay the `container-type: size` element and it must
     have a real height for `100cqh` to mean anything - that is precisely
     what broke when `container-type` was once put on `.controller-stage`
     instead. So the height is given explicitly rather than by `flex: 1 1 0`,
     and it is given as exactly the 16:9 height of the width the stage has:
     `.controller-stage`'s padding is .6rem a side, hence the 1.2rem. The
     frame's own `width: min(100%, 100cqh * 16 / 9)` then resolves to the
     full width, and the ratio is unchanged - the box simply stops being
     centred inside space it was never going to use. */
  .controller-stage { flex: 0 0 auto; }
  .stage-fit {
    flex: 0 0 auto;
    height: calc((100vw - 1.2rem) * 9 / 16);
  }
}

/* Coarse-pointer touch targets: the deck's mobile grid is the established
   >=48px reference (app.css §6) - the controller's small player-chrome
   buttons predate it and are well under that on a phone. Scoped to
   `pointer: coarse` only, so mouse/trackpad users on the "Desktop Chrome"
   baseline (and every existing screenshot/position assertion taken against
   it) see no change at all. */
@media (pointer: coarse) {
  .stage-btn,
  .side-stream-btn,
  .channel-switch-action,
  .chat-emote-toggle,
  .poll-dismiss,
  #rail-toggle,
  .rail-home-link {
    min-height: 44px;
    min-width: 44px;
    padding: .5rem .7rem;
  }
  .stage-volume-slider { min-height: 32px; }
  /* The side window is a SECOND live video decoder next to the main stage's
     own - a deliberate desktop convenience (glance at another channel while
     watching this one) that risks genuinely melting a mid-range phone if it
     is always available. Parking a channel there is hidden outright; "Als
     Hauptstream" (switching the main channel) still works, and
     multistream.js additionally tears the demoted box down on a coarse
     pointer instead of leaving it running as a small live preview - see its
     own comment on the `channel_switched` handler. */
  .channel-switch-action[data-action="side"] { display: none; }
}

/* ---------- 4. admin ---------------------------------------------- */
.admin-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: .8rem;
}

.stat-tile {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: .8rem 1rem;
}
.stat-tile .stat-value {
  font-size: 1.7rem;
  font-weight: 600;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}
.stat-tile .stat-label {
  font-size: .78rem;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: .05em;
}

/* Landing page: streamer + live viewer count under a channel's title. */
.channel-meta-sep { margin: 0 .35rem; opacity: .6; }

/* The admin section pills wrap by default on a normal screen - the class
   that used to say so lives in CSS now, see the narrow-viewport section for
   why it could not stay in the markup. */
.admin-nav .nav { flex-wrap: wrap; }

/* Landing page cards --------------------------------------------------- */
/* A page that uses the screen it is on, with a gutter that grows with it -
   Bootstrap's `.container` caps at 1320px, which left a three-across row of
   preview cards marooned in the middle of a wide monitor. The max-width is
   still there so a very wide screen does not stretch three cards to absurd
   widths; it is just much further out. */
.page-wide {
  width: 100%;
  max-width: 1680px;
  margin-inline: auto;
  padding-inline: clamp(1rem, 4vw, 2.5rem);
}

/* Three across. A fixed track count rather than `auto-fill`, so every card
   is the same width at any given viewport and the row of 16:9 previews lines
   up; `minmax(0, 1fr)` (not `1fr`) is what stops a long unbroken word in a
   title from widening its own column. Two up on a tablet, one on a phone. */
.channel-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1rem;
}
@media (max-width: 992px) {
  .channel-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) {
  .channel-grid { grid-template-columns: minmax(0, 1fr); gap: .75rem; }
}

/* Slightly tighter padding than a plain .stat-tile: the preview image runs
   edge to edge inside the card, so the padding that used to frame text now
   only frames the text under it. */
/* A column so the action row can be pushed to the bottom: grid already makes
   the cards in a row equal height, but a two-line title used to leave that
   card's buttons sitting lower than its neighbours', which reads as three
   cards that happen to be next to each other rather than one row. */
.channel-card {
  padding: .7rem .8rem .8rem;
  display: flex;
  flex-direction: column;
}
.channel-card .channel-actions { margin-top: auto; }

/* The preview still (app/services/preview.py). Fixed 16:9 whatever is (or is
   not) inside it, so a grid of cards keeps its rhythm as streams go live and
   images appear - a tile that only takes up space once an image loads would
   make the whole page jump. */
.channel-preview {
  position: relative;
  aspect-ratio: 16 / 9;
  width: 100%;
  margin-bottom: .55rem;
  border-radius: calc(var(--radius) - .15rem);
  overflow: hidden;
  background: var(--bg-input);
  border: 1px solid var(--border);
  display: grid;
  place-items: center;
}
.channel-preview-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* The reason there is no picture ("Gerade offline", "Kein Vorschaubild
   verfügbar"), shown only when the tile is empty - which is either how it
   rendered or what the image's own `onerror` turned it into. */
.channel-preview-note {
  display: none;
  padding: 0 .6rem;
  text-align: center;
  font-size: .74rem;
  color: var(--text-dim);
}
.channel-preview.is-empty .channel-preview-note { display: block; }

/* Overlaid on the still rather than sharing the title's line: it is the one
   thing a viewer scans a card for, and keeping it off that line is what lets
   the title below wrap without shoving it around. */
.channel-live-badge {
  position: absolute;
  top: .4rem;
  left: .4rem;
  display: inline-flex;
  align-items: center;
  padding: .1rem .4rem;
  border-radius: 999px;
  font-size: .7rem;
  line-height: 1.5;
  color: var(--text);
  background: rgba(0, 0, 0, .62);
  border: 1px solid var(--border);
  backdrop-filter: blur(3px);
}
.channel-live-badge.is-live { border-color: rgba(53, 196, 107, .5); }

/* Two lines, then an ellipsis - it used to be a single `text-truncate` line,
   which cut a real stream title after two to four words. `-webkit-` prefixed
   properties are not legacy here: the line-clamp mechanism is only specified
   in that form and every current engine implements it under those names, so
   the unprefixed `line-clamp` sits alongside for when that changes.
   `overflow-wrap: anywhere` covers the other half of a too-long title: one
   unbroken 40-character word would otherwise widen the card's grid track. */
.channel-title {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  overflow-wrap: anywhere;
  line-height: 1.25;
}

.media-table img.thumb,
.media-table video.thumb {
  max-width: 56px;
  max-height: 40px;
  object-fit: contain;
  background: #0b0e14;
  border-radius: 5px;
}


.live-dot {
  display: inline-block;
  width: 9px; height: 9px;
  border-radius: 50%;
  background: #444;
  margin-right: .35rem;
  vertical-align: middle;
}
.live-dot.is-live {
  background: var(--good);
  box-shadow: 0 0 0 3px rgba(53,196,107,.22);
}

.offset-hint {
  font-size: .74rem;
  color: var(--text-dim);
}

/* Emote grid for the 7TV import screen. */
.emote-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  gap: .6rem;
}
.emote-pick {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: .5rem;
  text-align: center;
  cursor: pointer;
  position: relative;
}
.emote-pick.is-imported { opacity: .45; }
.emote-pick img { max-width: 100%; max-height: 64px; object-fit: contain; }
.emote-pick .emote-name {
  display: block;
  font-size: .74rem;
  margin-top: .3rem;
  word-break: break-word;
  color: var(--text-dim);
}
.emote-pick input[type="checkbox"] { position: absolute; left: 6px; top: 6px; }

/* The streamer's live switch - the one card on the daily page that should
   be findable without reading, so it gets a left edge that changes colour
   with the state rather than relying on the dot alone. */
.live-card { border-left: 3px solid var(--border-strong); }
.live-card.is-live { border-left-color: var(--good); }

/* ---------- 5. chat page ------------------------------------------ */
.chat-shell {
  display: flex;
  flex-direction: column;
  /* dvh, not vh - see .controller-shell's own comment; the standalone chat
     page has the same navbar-plus-fixed-height layout and the same
     address-bar-resize problem on Android Chrome. */
  height: calc(100dvh - 56px);
  max-width: 780px;
  margin: 0 auto;
  padding: .5rem;
}
.chat-log {
  flex: 1 1 auto;
  overflow-y: auto;
  min-height: 0;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: .6rem .75rem;
  display: flex;
  flex-direction: column;
  gap: .3rem;
}
.chat-line { line-height: 1.35; word-wrap: break-word; }

/* Inline chat emotes (panel + standalone chat). Bound to the line box, not
   to the asset's own pixel size, so a row of emotes cannot change line
   rhythm - and `max-height` is a guard so a malformed/oversized asset cannot
   still blow one line out regardless of `em`. */
.chat-emote {
  height: 1.4em;
  width: auto;
  max-height: 48px;
  vertical-align: middle;
}
/* One colour per person, so a wall of chat is scannable.
   The server sends only a hue, derived from the identity rather than the name -
   two guests called "Gast" are still told apart, and one person keeps their
   colour across reloads and across every viewer's screen. Saturation and
   lightness are set here, per surface, so no hue can turn unreadable. */
.chat-line .chat-name {
  font-weight: 600;
  color: hsl(var(--name-hue, 210) 72% 68%);
}
.chat-line .chat-name::after { content: ":"; color: var(--text-dim); }
/* Your own messages keep the "this is you" marker, which beats colour identity. */
.chat-line.is-mine .chat-name { color: var(--good); }
.chat-form { flex: 0 0 auto; display: flex; gap: .4rem; padding-top: .5rem; }
.chat-status { min-height: 1.2em; font-size: .82rem; }

/* chat/streamer: name prompt, deleted-message styling, streamer chat log,
   home page channel cards. The OBS chat overlay styles itself inline and is
   deliberately untouched here. */
.name-prompt {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: .75rem .9rem;
}

.chat-line.is-deleted .chat-body,
.chat-log .chat-line.is-deleted { color: var(--text-dim); font-style: italic; }

.chat-line .chat-body { white-space: pre-wrap; }

tr.is-deleted { opacity: .55; }

.provider-field { transition: none; }

/* admin: sub-navigation ------------------------------------------------ */
/* The admin sections had no links between them, so /admin/ was a dead end
   and the media upload form was unreachable by clicking. */
.admin-nav {
  border-bottom: 1px solid var(--border);
  padding-bottom: .5rem;
}
.admin-nav .nav-link {
  color: var(--text-dim);
  padding: .35rem .7rem;
  border-radius: var(--radius);
  font-size: .92rem;
}
.admin-nav .nav-link:hover { color: var(--text); background: var(--bg-panel-2); }
.admin-nav .nav-link.active { background: var(--accent-dim); color: #fff; }

/* controller: sidebar carousel (emotes <-> chat) ----------------------- */
/* Two slides on one horizontal track. Adding .is-chat shifts the track by
   exactly one slide: the emote panel leaves to the left and the chat takes its
   place. One transform, so the animation is identical in both directions and
   the stream in the stage never moves.

   Two details that were bugs before, both of which let the emote panel show
   through while the chat was open:

   1. `overflow: clip`, not `hidden`. A `hidden` box is still *programmatically*
      scrollable, so calling .focus() on the off-screen chat input made the
      browser scroll the carousel to reveal it - displacing the track by a few
      hundred px on top of the transform and leaving the panes out of register.
      `clip` cannot be scrolled at all, which removes the whole class of bug.
      (`hidden` stays first as a fallback for anything that lacks `clip`.)

   2. Slides are 100% wide on a 100%-wide track and the shift is -100%, rather
      than 50% on a 200% track shifted -50%. The percentages then resolve
      against the carousel width directly instead of half of a doubled value,
      so a fractional rail width (267.812px here) cannot round into a 1px seam
      between the panes. */
.rail-carousel {
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
  overflow: hidden;
  overflow: clip;
}

.rail-track {
  display: flex;
  width: 100%;
  height: 100%;
  transform: translateX(0);
  transition: transform 320ms cubic-bezier(.22, .61, .36, 1);
  will-change: transform;
}

.rail-carousel.is-chat .rail-track { transform: translateX(-100%); }

.rail-slide {
  flex: 0 0 100%;
  width: 100%;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* An off-canvas slide stays laid out (the transition needs something to move)
   but must not be reachable by keyboard or announced by a screen reader. */
.rail-slide[inert] { pointer-events: none; }

/* Respect a viewer who asked for less motion: still swaps, just instantly. */
@media (prefers-reduced-motion: reduce) {
  .rail-track { transition: none; }
}

.chat-pane {
  display: flex;
  flex-direction: column;
  min-height: 0;
  height: 100%;
  padding: .25rem .1rem 0;
  gap: .35rem;
}

.chat-pane .chat-log {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: .45rem .55rem;
  font-size: .88rem;
  display: flex;
  flex-direction: column;
  gap: .25rem;
}

.chat-pane .chat-form { flex: 0 0 auto; display: flex; gap: .3rem; padding-top: 0; }
.chat-pane .chat-status { flex: 0 0 auto; min-height: 1.1em; }

/* chat: inline emote picker (controller sidebar + standalone /chat page) */
/* `position: fixed`, positioned in JS from the toggle button's own viewport
   rect - deliberately NOT in-flow. On the controller this markup is placed
   in the template outside .rail-track (which the carousel slides with a
   transform) and outside .controller-rail (itself translateX()'d in
   fullscreen), because a CSS transform on any ancestor turns `position:
   fixed` into something scoped to that ancestor instead of the viewport -
   which would silently break the JS positioning math and, worse, could
   grow that ancestor's box. See invariant 8 in CLAUDE.md: this is the exact
   surface that broke twice before over an in-flow addition. */
.chat-emote-toggle { flex: 0 0 auto; }

.chat-emote-picker {
  position: fixed;
  z-index: 1050;
  width: min(260px, 92vw);
  max-height: min(320px, 70vh);
  overflow-y: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 10px 28px rgba(0, 0, 0, .45);
  padding: .5rem;
  display: flex;
  flex-direction: column;
  gap: .4rem;
}
.chat-emote-picker[hidden] { display: none; }

.chat-emote-picker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
  gap: .3rem;
}

.chat-emote-btn {
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: .25rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .15rem;
  cursor: pointer;
}
.chat-emote-btn:hover,
.chat-emote-btn:focus-visible { border-color: var(--accent); }
.chat-emote-btn img { max-width: 100%; max-height: 32px; object-fit: contain; }
.chat-emote-btn-code {
  font-size: .62rem;
  color: var(--text-dim);
  word-break: break-word;
  text-align: center;
}
.chat-emote-btn[hidden] { display: none; }

/* controller: fullscreen + sliding panel ------------------------------- */
/* Player-style controls. They sit ABOVE .stage-capture (z-index 2) and outside
   it, so pressing one cannot also register as a placement. The container is
   pointer-transparent; only the buttons take clicks, leaving the rest of the
   stage clickable for placing emotes. */
.stage-controls {
  position: absolute;
  right: .55rem;
  top: .55rem;
  z-index: 3;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  max-width: calc(100% - 1.1rem);
  gap: .35rem;
  pointer-events: none;
  opacity: 1;
  transition: opacity .4s ease;
}

/* Fullscreen only (see controller.js's showStageControls/
   scheduleStageControlsIdle) - the class is only ever added while
   .is-fullscreen is set, so this never needs its own fullscreen scoping.
   .stage-btn keeps its own `pointer-events: auto` regardless of the
   container, so idle-hiding needs its own rule to actually stop clicks
   through the faded tray. */
.stage-controls.is-idle { opacity: 0; }
.stage-controls.is-idle .stage-btn,
.stage-controls.is-idle .stage-volume { pointer-events: none; }

@media (prefers-reduced-motion: reduce) {
  .stage-controls { transition: none; }
  /* Same reasoning as the tray fade above: the navbar still reveals and
     hides, it just does it instantly instead of sliding. */
  body#page-controller .navbar { transition: none; }
}

.stage-btn {
  pointer-events: auto;
  border: 1px solid rgba(255, 255, 255, .25);
  background: rgba(0, 0, 0, .55);
  color: #fff;
  border-radius: var(--radius);
  padding: .25rem .5rem;
  font-size: 1rem;
  line-height: 1.2;
  cursor: pointer;
  backdrop-filter: blur(4px);
  transition: background .15s ease, border-color .15s ease;
}
.stage-btn:hover { background: rgba(0, 0, 0, .78); border-color: var(--border-strong); }
.stage-btn[aria-pressed="true"] { background: var(--accent-dim); border-color: var(--accent); }

/* The pane shortcuts are only meaningful once the sidebar is off-canvas. */
.stage-btn-pane { display: none; }
.controller-shell.is-fullscreen .stage-btn-pane { display: inline-flex; }

/* The stream's own audio (app/static/js/multistream.js: setBoxMuted,
   refreshStageVolumeUI). Own pointer-events: auto because .stage-controls
   itself is pointer-events: none - the same reason .stage-btn sets it. */
.stage-volume {
  display: flex;
  align-items: center;
  gap: .3rem;
  pointer-events: auto;
}
.stage-volume[hidden] { display: none; }
.stage-volume-slider {
  width: 84px;
  max-width: 28vw;
  accent-color: var(--accent);
  cursor: pointer;
}
.stage-volume-slider[hidden] { display: none; }

/* Needed so the rail can be positioned against the shell in fullscreen. */
.controller-shell { position: relative; }

.controller-shell.is-fullscreen { background: #000; }

.controller-shell.is-fullscreen .controller-rail {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: min(var(--rail-w), 88vw);
  z-index: 5;
  transform: translateX(-100%);
  transition: transform 300ms cubic-bezier(.22, .61, .36, 1);
  box-shadow: 0 0 28px rgba(0, 0, 0, .6);
  border-right: 1px solid var(--border);
}

.controller-shell.is-fullscreen.is-panel-open .controller-rail {
  transform: translateX(0);
}

/* Give the stream the whole screen, and drop the framing that only makes sense
   in the windowed layout. */
.controller-shell.is-fullscreen .controller-stage { padding: 0; }
.controller-shell.is-fullscreen .stage-footer { display: none; }
.controller-shell.is-fullscreen .stage-frame {
  border: 0;
  border-radius: 0;
  box-shadow: none;
  /* aspect-ratio is deliberately NOT overridden. Placements are normalised
     against a virtual 1920x1080 stage, so stretching to fill a 21:9 monitor
     would land the emote somewhere other than where the viewer clicked over the
     picture. Letterboxing keeps click position and overlay position identical. */
}

@media (prefers-reduced-motion: reduce) {
  .controller-shell.is-fullscreen .controller-rail { transition: none; }
}

@media (max-width: 820px) {
  /* The rail is a stacked row in the windowed layout, but in fullscreen it has
     to be an overlay like everywhere else. */
  .controller-shell.is-fullscreen .controller-rail { max-height: none; }
}

/* admin: sortable table headers --------------------------------------- */
.sort-link {
  color: var(--text-dim);
  text-decoration: none;
  white-space: nowrap;
  display: inline-flex;
  align-items: center;
  gap: .25rem;
}
.sort-link:hover { color: var(--text); }
.media-table th.is-sorted .sort-link { color: var(--text); }
/* Sizes the icon inside it (`.icon` is 1em), so the sort indicator stays a
   hair smaller than the column label it follows. The margin is what the
   space in `label ▲` used to provide. */
.sort-arrow { font-size: .8em; opacity: .55; margin-left: .3em; }
.media-table th.is-sorted .sort-arrow { opacity: 1; color: var(--accent); }

/* betting: streamer polls page (/me/polls) + viewer popup (/c/<slug>) ---- */
/* Shared option-row look, used both for the streamer's live totals and for
   the viewer's bet options - percentages/points always arrive from the
   server, this only ever styles them. */
.poll-info-card { border-left: 3px solid var(--accent); }

.poll-option-row {
  display: block;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: .5rem .65rem;
  margin-bottom: .5rem;
  background: var(--bg-panel-2);
  cursor: default;
}
.poll-option-row.is-leader {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent) inset;
}
.poll-option-top {
  display: flex;
  align-items: center;
  gap: .4rem;
  font-weight: 600;
}
.poll-winner-tag {
  background: var(--good);
  color: #08210f;
  border-radius: .35rem;
  padding: .05rem .4rem;
  font-size: .72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .03em;
  margin-left: auto;
}
.poll-bar-track {
  display: block;
  height: .35rem;
  border-radius: .2rem;
  background: var(--bg-input);
  overflow: hidden;
  margin: .35rem 0;
}
.poll-bar-fill {
  display: block;
  height: 100%;
  background: var(--accent);
}
.poll-option-stats { margin: 0; }

/* streamer live view (streamer/polls.html) ------------------------------ */
.poll-live-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem 1rem; }
.poll-live-rule { border-left: 3px solid var(--warn); padding-left: .6rem; }
.poll-stale-banner {
  background: rgba(232, 179, 57, .14);
  border: 1px solid var(--warn);
  border-radius: var(--radius);
  padding: .5rem .75rem;
}

/* viewer popup on the controller stage ----------------------------------- */
/* Sits OUTSIDE .stage-capture and ABOVE it (z-index 2) and .stage-controls
   (z-index 3), same reasoning as .stage-controls: a click on a button here
   must never also register as a click on the stage underneath. */
.poll-panel {
  position: absolute;
  left: .55rem;
  top: .55rem;
  z-index: 4;
  width: min(320px, 84%);
  max-height: calc(100% - 1.1rem);
  overflow-y: auto;
  background: rgba(15, 17, 21, .92);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  padding: .65rem .75rem;
  backdrop-filter: blur(6px);
  font-size: .85rem;
  color: var(--text);
}
.poll-panel-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: .5rem;
}
.poll-question { font-weight: 700; }
.poll-dismiss {
  flex: 0 0 auto;
  border: 0;
  background: transparent;
  color: var(--text-dim);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
}
.poll-dismiss:hover { color: var(--text); }
.poll-meta {
  display: flex;
  justify-content: space-between;
  gap: .5rem;
  color: var(--text-dim);
  font-size: .78rem;
  margin: .3rem 0;
}
  margin: .3rem 0 .5rem;
  font-size: .75rem;
  color: var(--text-dim);
}
.poll-option-row .form-check-input { margin: 0; }
.poll-bet-row { display: flex; gap: .4rem; margin-top: .4rem; }
.poll-bet-row input { min-width: 0; flex: 1 1 auto; }
.poll-bet-status { min-height: 1.1em; color: var(--text-dim); font-size: .78rem; }
.poll-mine, .poll-result {
  margin-top: .5rem;
  padding: .4rem .5rem;
  border-radius: var(--radius);
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  font-size: .8rem;
}

.poll-badge {
  position: absolute;
  left: .55rem;
  top: .55rem;
  z-index: 4;
  border: 1px solid rgba(255, 255, 255, .25);
  background: rgba(0, 0, 0, .55);
  color: #fff;
  border-radius: var(--radius);
  padding: .3rem .6rem;
  font-size: .85rem;
  cursor: pointer;
  backdrop-filter: blur(4px);
}
.poll-badge:hover { background: rgba(0, 0, 0, .78); }

/* betting: viewer option rows are a label and a voter percentage, nothing else */
.poll-option-pct {
  margin-left: auto;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--text);
}

/* admin: viewer statistics table (/admin/) --------------------------- */
.people-table th.is-sorted .sort-link { color: var(--text); }
.people-table th.is-sorted .sort-arrow { opacity: 1; color: var(--accent); }
.people-id { user-select: all; }

/* streamer: chat moderation (/me/moderation, /me/chat) --------------- */
.mod-subject-id { user-select: all; }
.mod-sanction-row.is-banned { border-left: 3px solid var(--bs-danger, #dc3545); }
.mod-sanction-row.is-muted { border-left: 3px solid var(--bs-warning, #ffc107); }

/* moderation: the original text behind a filtered message ---------------- */
/* Collapsed by default: a moderator wants it on demand, not a wall of slurs
   every time they open the log. */
.raw-body { margin-top: .2rem; }
.raw-body summary { cursor: pointer; }
.raw-body-text {
  display: inline-block;
  margin-top: .2rem;
  padding: .15rem .4rem;
  border-radius: 4px;
  background: rgba(232, 86, 74, .12);
  border: 1px solid rgba(232, 86, 74, .3);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  word-break: break-word;
}

/* moderation: /help output inside the chat log --------------------------- */
.chat-help {
  border-left: 3px solid var(--accent);
  background: rgba(76, 141, 255, .08);
  border-radius: var(--radius);
  padding: .4rem .6rem;
  margin: .2rem 0;
}
.chat-help-line { font-size: .84rem; color: var(--text-dim); line-height: 1.4; }

/* chat: the same per-person colour in the moderation logs ----------------- */
/* So the person you are about to mute looks like the person you saw in chat. */
.chat-name-tint {
  font-weight: 600;
  color: hsl(var(--name-hue, 210) 70% 66%);
}

/* ---------- 6. OBS deck -------------------------------------------- */
/* streamer/deck_page.html - the grid editor (desktop dashboard) */
.deck-grid {
  display: grid;
  grid-template-columns: repeat(var(--deck-cols, 3), minmax(90px, 1fr));
  grid-template-rows: repeat(var(--deck-rows, 4), minmax(80px, 1fr));
  gap: .5rem;
}

.deck-slot {
  position: relative;
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  min-height: 80px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: .4rem;
  color: var(--text);
  text-align: center;
}

button.deck-slot {
  cursor: pointer;
}

.deck-slot.is-empty {
  border-style: dashed;
  color: var(--text-dim);
  cursor: pointer;
}
.deck-slot.is-empty:hover { border-color: var(--border-strong); color: var(--text); }
.deck-slot-add { font-size: 1.4rem; line-height: 1; }

.deck-slot.is-disabled { opacity: .45; }

.deck-slot-main {
  background: none;
  border: 0;
  color: inherit;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .1rem;
  width: 100%;
  cursor: pointer;
}
.deck-slot-icon { font-size: 1.4rem; line-height: 1; }
.deck-slot-label {
  font-size: .8rem;
  font-weight: 600;
  word-break: break-word;
}
.deck-slot-kind { font-size: .68rem; word-break: break-word; }

.deck-slot-move {
  position: absolute;
  right: .2rem;
  top: .2rem;
  background: rgba(0, 0, 0, .5);
  border: 1px solid var(--border);
  border-radius: 5px;
  color: #fff;
  font-size: .78rem;
  line-height: 1;
  padding: .15rem .3rem;
  cursor: pointer;
}
.deck-slot-move:hover { background: rgba(0, 0, 0, .75); }

.deck-grid.is-moving .deck-slot { border-color: var(--accent); cursor: pointer; }
.deck-slot.is-move-source { box-shadow: 0 0 0 2px var(--accent) inset; }

/* deck/mobile.html - phone control surface. Deliberately NOT based on
   base.html's normal chrome (navbar/flash container) - see the template's own
   comment for why. Fixed, non-scrolling layout; a page-level scrollbar or a
   navbar eating vertical space is exactly what a phone deck must not have. */
.deck-mobile-body {
  position: fixed;
  inset: 0;
  overflow: hidden;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  touch-action: manipulation;
}

.deck-mobile-header {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .5rem .6rem;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--border);
  min-height: 48px;
}
.deck-mobile-header .deck-mobile-title {
  flex: 1 1 auto;
  min-width: 0;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.deck-mobile-header .btn {
  min-height: 44px;
  min-width: 44px;
}

/* Three-state connection banner - never merged into one "nicht verbunden",
   see PLAN-PHASE2.md §B.5: the agent being up with OBS closed is the common
   case, not an edge case, and needs to read differently from no agent at all
   and from the server itself being unreachable. */
.deck-conn-banner {
  flex: 0 0 auto;
  padding: .3rem .6rem;
  font-size: .82rem;
  display: flex;
  align-items: center;
  gap: .4rem;
  flex-wrap: wrap;
}
.deck-mobile-body .deck-conn-banner {
  border-bottom: 1px solid var(--border);
}
.deck-conn-banner[data-level="down"] { background: rgba(232, 86, 74, .16); }
.deck-conn-banner[data-level="agent-offline"] { background: rgba(232, 86, 74, .10); }
.deck-conn-banner[data-level="obs-offline"] { background: rgba(232, 179, 57, .14); }
.deck-conn-banner[data-level="ok"] { background: rgba(53, 196, 107, .10); }

.deck-mobile-main {
  flex: 1 1 auto;
  min-height: 0;
  padding: .5rem;
  display: grid;
  grid-template-columns: repeat(var(--deck-cols, 3), 1fr);
  grid-template-rows: repeat(var(--deck-rows, 4), 1fr);
  gap: .5rem;
}

.deck-mobile-btn {
  min-height: 48px;
  min-width: 48px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-panel-2);
  color: var(--text);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: .2rem;
  padding: .3rem;
  text-align: center;
  overflow: hidden;
  transition: background .12s ease, border-color .12s ease, transform .08s ease;
}
.deck-mobile-btn:active { transform: scale(.96); }
.deck-mobile-btn .deck-mobile-btn-icon { font-size: 1.5rem; line-height: 1; }
.deck-mobile-btn .deck-mobile-btn-label {
  font-size: .78rem;
  font-weight: 600;
  word-break: break-word;
  max-height: 2.6em;
  overflow: hidden;
}
.deck-mobile-btn.is-disabled { opacity: .4; }
.deck-mobile-btn.is-empty { visibility: hidden; }
.deck-mobile-btn.is-confirm { border-color: var(--warn); }

/* Dropdown widgets: the same slot-filling box as an ordinary button (so the
   >=48px touch-target rule keeps holding for free), with a real <select>
   standing in for the tap. */
.deck-mobile-btn.is-dropdown { padding: .3rem .4rem; }
.deck-mobile-dropdown-select {
  width: 100%;
  min-height: 44px;
  margin-top: .2rem;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--bg-panel-2);
  color: var(--text);
  font-size: .78rem;
  text-align: center;
}
.deck-mobile-dropdown-select:disabled { opacity: .6; }

/* Slider widgets: a real <input type="range"> over ACTION_SPECS's own
   bounds - see deck/mobile.html and deck_mobile.js's "send on release" note. */
.deck-mobile-btn.is-slider { padding: .3rem .5rem; }
.deck-mobile-slider-input {
  width: 100%;
  margin-top: .3rem;
  accent-color: var(--accent);
}
.deck-mobile-slider-input:disabled { opacity: .5; }

/* Toggle widgets: a persistent on/off/unknown indicator, fed only by
   `deck_result`'s own `state` field - see PLAN-PHASE2.md's toggle design
   note on why "unknown" must read distinctly from "off" rather than
   defaulting to a confident wrong answer. */
.deck-mobile-toggle-state {
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .04em;
  padding: .05rem .4rem;
  border-radius: 999px;
  margin-top: .1rem;
}
.deck-mobile-btn.is-toggle-unknown .deck-mobile-toggle-state {
  background: rgba(255, 255, 255, .08);
  color: var(--text-dim);
}
.deck-mobile-btn.is-toggle-unknown .deck-mobile-toggle-state::after { content: "?"; }
.deck-mobile-btn.is-toggle-on { border-color: var(--good); }
.deck-mobile-btn.is-toggle-on .deck-mobile-toggle-state {
  background: rgba(53, 196, 107, .18);
  color: var(--good);
}
.deck-mobile-btn.is-toggle-on .deck-mobile-toggle-state::after { content: "AN"; }
.deck-mobile-btn.is-toggle-off .deck-mobile-toggle-state {
  background: rgba(255, 255, 255, .06);
  color: var(--text-dim);
}
.deck-mobile-btn.is-toggle-off .deck-mobile-toggle-state::after { content: "AUS"; }

/* page_link widgets: a real <a href> filling the same slot shape as every
   other widget, so the >=48px touch-target rule keeps holding for free.
   A disabled one (either explicitly, or because its target was deleted -
   see DeckWidgetSlot's docstring) is a <span>, not an <a>, so it has no href
   to begin with; pointer-events is a second layer for the enabled-<a>
   `is-disabled` case, which does not exist today but costs nothing to guard. */
.deck-mobile-btn.is-link { text-decoration: none; }
.deck-mobile-btn.is-link.is-disabled { pointer-events: none; }

/* Per-widget feedback from `deck_result` - a failed press must look visibly
   different from a successful one, not just be logged somewhere. */
.deck-mobile-btn.is-result-ok { border-color: var(--good); box-shadow: 0 0 0 2px rgba(53, 196, 107, .35); }
.deck-mobile-btn.is-result-error { border-color: var(--bad); box-shadow: 0 0 0 2px rgba(232, 86, 74, .35); }
.deck-mobile-btn-error-msg {
  font-size: .68rem;
  color: var(--bad);
  word-break: break-word;
}

/* Confirm sheet: a stray thumb must not be able to end a live stream. */
.deck-confirm-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, .55);
  z-index: 1040;
  display: flex;
  align-items: flex-end;
}
.deck-confirm-backdrop[hidden] { display: none; }
.deck-confirm-sheet {
  width: 100%;
  background: var(--bg-panel);
  border-top: 1px solid var(--border-strong);
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
}
.deck-confirm-title { font-weight: 700; margin-bottom: .3rem; }
.deck-confirm-actions { display: flex; gap: .5rem; margin-top: .8rem; }
.deck-confirm-actions .btn { flex: 1 1 0; min-height: 48px; }

/* Page menu: subpage navigation with a back affordance. */
.deck-page-menu-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, .45);
  z-index: 1030;
}
.deck-page-menu-backdrop[hidden] { display: none; }
.deck-page-menu {
  position: fixed;
  left: 0;
  top: 0;
  bottom: 0;
  width: min(280px, 84vw);
  background: var(--bg-panel);
  border-right: 1px solid var(--border);
  padding: .6rem;
  overflow-y: auto;
}
.deck-page-menu .list-group-item {
  min-height: 48px;
  display: flex;
  align-items: center;
}
.deck-page-menu .list-group-item.is-current { border-left: 3px solid var(--accent); }

/* ---------- 7. Phase C: side window + merged channel control -------- */
/* docs/PLAN-PHASE2.md §C. Two ALWAYS-mounted 16:9 boxes (#stream-box-a,
   #stream-box-b) plus two ALWAYS-mounted overlay anchors (#stage-overlay for
   the interactive layer, #side-stream for the small preview's chrome).
   multistream.js is the only thing that ever touches which box has
   .is-main/.is-side and where the two anchors sit - see the long comment on
   .stage-frame in controller.html for why this has to live outside
   .stage-fit's stacking context entirely.

   All four are `position: fixed`, positioned in *pixels* by JS (never by CSS
   layout), because their geometry has to track .stage-frame's own responsive
   16:9 box (invariant 9) rather than compute an independent one. */
.stream-box {
  position: fixed;
  overflow: hidden;
  background: #000;
  pointer-events: none;   /* never interactive on its own - see controller.html */
  visibility: hidden;     /* until multistream.js has measured a real rect */
}
.stream-box.is-positioned { visibility: visible; }
.stream-box.is-empty { display: none; }
.stream-box.is-main {
  z-index: 2;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: 0 10px 34px rgba(0, 0, 0, .45);
}
.stream-box.is-side {
  z-index: 3;
  border-radius: var(--radius);
}
/* Fullscreen drops the same framing .stage-frame used to drop - the visible
   chrome now lives on whichever box is main, not on the (contentless) frame
   underneath it. */
.controller-shell.is-fullscreen .stream-box.is-main {
  border: 0;
  border-radius: 0;
  box-shadow: none;
}
.stream-box iframe,
.stream-box .stream-box-oven,
.stream-box video {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}
.stream-box .stage-greenscreen { font-size: .85rem; }

/* The interactive layer: click capture, stage controls, the betting popup.
   Pixel-locked to whichever box carries .is-main - see .stage-capture,
   .stage-controls, .poll-panel etc. above, all unchanged, all still
   `position: absolute` against this as their containing block. */
.stage-overlay {
  position: fixed;
  z-index: 4;
  visibility: hidden;
}
.stage-overlay.is-positioned { visibility: visible; }

/* The side window's own chrome: border, drag bar, controls. Deliberately
   holds no video itself - .stream-box.is-side is a SEPARATE fixed element
   multistream.js keeps lined up exactly behind the hole below the bar, so
   dragging or resizing this box never touches (or reloads) the iframe under
   it. Not interactive beyond its own bar: the video area has no capture
   layer, so a viewer aiming at the small preview can never place anything on
   it by mistake. */
.side-stream {
  position: fixed;
  z-index: 5;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: 0 8px 24px rgba(0, 0, 0, .5);
  background: transparent;
  visibility: hidden;
}
.side-stream[hidden] { display: none; }
.side-stream.is-positioned { visibility: visible; }
.side-stream-bar {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: .25rem;
  padding: .2rem .3rem;
  background: rgba(0, 0, 0, .6);
  backdrop-filter: blur(3px);
  cursor: move;
  border-top-left-radius: calc(var(--radius) - 1px);
  border-top-right-radius: calc(var(--radius) - 1px);
  touch-action: none;
  pointer-events: auto;
}
.side-stream-title {
  flex: 1 1 auto;
  min-width: 0;
  font-size: .74rem;
  color: #fff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.side-stream-btn {
  pointer-events: auto;
  flex: 0 0 auto;
  border: 1px solid rgba(255, 255, 255, .3);
  background: rgba(0, 0, 0, .4);
  color: #fff;
  border-radius: 5px;
  font-size: .72rem;
  line-height: 1;
  padding: .2rem .38rem;
  cursor: pointer;
}
.side-stream-btn:hover { background: rgba(0, 0, 0, .7); }
.side-stream-btn[aria-pressed="true"] { background: var(--accent-dim); border-color: var(--accent); }

/* The merged channel control: one fixed popover, same pattern as
   .chat-emote-picker - positioned purely in JS against the toggle button's
   own viewport rect, so it never becomes part of any transformed/scrollable
   ancestor. It is a sibling of .controller-rail (see _side_stream.html), so
   invariant 8 simply does not apply here - there is no rail ancestor to be
   rescoped by. Replaces the old .stream-switcher (docs/KNOWN-ISSUES.md
   "merge Kanal wechseln and Nebenfenster into one control"); each row now
   carries two actions instead of being one big button, so the row itself is
   no longer clickable as a whole. */
.channel-switch-panel {
  position: fixed;
  z-index: 1050;
  width: min(300px, 92vw);
  max-height: min(360px, 70vh);
  overflow-y: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 10px 28px rgba(0, 0, 0, .45);
  padding: .5rem;
}
.channel-switch-panel[hidden] { display: none; }
.channel-switch-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-bottom: .4rem;
}
.channel-switch-hint { font-size: .68rem; color: var(--text-dim); }
.channel-switch-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .3rem;
}
.channel-switch-item {
  width: 100%;
  display: flex;
  align-items: center;
  gap: .4rem;
  background: var(--bg-panel-2);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: .35rem .5rem;
  color: var(--text);
}
.channel-switch-item-title {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: .85rem;
}
.channel-switch-action {
  flex: 0 0 auto;
  border: 1px solid var(--border-strong);
  background: var(--bg-panel);
  color: var(--text);
  border-radius: 5px;
  font-size: .72rem;
  line-height: 1;
  padding: .3rem .5rem;
  cursor: pointer;
}
.channel-switch-action:hover,
.channel-switch-action:focus-visible { border-color: var(--accent); }
.channel-switch-action[aria-pressed="true"] {
  background: var(--accent-dim);
  border-color: var(--accent);
  cursor: default;
}

/* ---------- 8. narrow viewports ----------------------------------- */
/* One place for "this site on a phone", rather than a `max-width` rule
   tacked onto each section as it was written. The complaint that produced
   it was not "things overlap" - the layouts already stack - it was that
   every page arrives as a wall of German explanatory prose, and on a 390px
   screen that prose IS the page.

   So the rules below do three things and nothing else: make explanatory
   text visually secondary rather than co-equal with the controls it
   explains, stop tables from folding every cell into four lines, and hand
   the space that frees up back to whatever the page is actually for. Copy
   that was simply too long is shortened in the templates themselves - CSS
   cannot fix a sentence. */

/* The "mehr" disclosure inside a long help block (templates/_hint.html).
   Styled to read as a link rather than as a control: it reveals a sentence,
   it does not do anything, and a button-shaped affordance in the middle of a
   form invites being pressed for the wrong reason. */
.hint-more { display: inline; }
.hint-more > summary {
  display: inline;
  cursor: pointer;
  color: var(--accent);
  /* The default disclosure triangle is a block-level marker and drags the
     summary onto its own line; the underline says "there is more here"
     without it. */
  list-style: none;
  text-decoration: underline dotted;
  text-underline-offset: .18em;
}
.hint-more > summary::-webkit-details-marker { display: none; }
.hint-more > summary::marker { content: ""; }
.hint-more[open] > summary { color: var(--text-dim); }
.hint-more-body { margin-top: .25rem; }

/* Which of the two controller hints applies. Keyed on the pointer, not the
   width: a 390px window on a desktop still has a right mouse button, and a
   1024px tablet does not. */
.hint-coarse { display: none; }
@media (pointer: coarse) {
  .hint-fine { display: none; }
  .hint-coarse { display: block; }
}

@media (max-width: 640px) {
  /* The unmute chip is centred at the top on a wide stage (where it looks
     right and there is room), but at phone width the centred chip's right
     edge runs into the top-right control tray (channel switch / volume /
     fullscreen). This is a space problem, not an input-device one, so it is
     keyed on width: shift the chip hard left and cap its width so it clears
     the controls, wrapping to two lines if it must. */
  .stage-unmute {
    left: .5rem;
    transform: none;
    max-width: calc(100% - 6rem);
  }

  /* Bootstrap's `.form-text` is the project's standard "what this field
     means" block and there are a lot of them, several running four or five
     lines. Kept - a first-timer needs them - but demoted, so a scan down the
     page hits labels and inputs rather than paragraphs. */
  .form-text {
    font-size: .72rem;
    line-height: 1.35;
    opacity: .78;
  }

  /* Same treatment for the hand-rolled equivalents: the short explanation
     that sits under a `.section-title` before the controls start. */
  .card-body > .text-secondary.small,
  .card-body > p.small.text-secondary { font-size: .74rem; opacity: .8; }

  /* `.table-responsive` only scrolls once its table actually overflows, and
     a table with no minimum width never does - it "fits" by wrapping every
     cell, so "Ein Sound mit Namen 0" became four stacked lines and a row
     became a paragraph. A floor makes the table overflow honestly and
     scroll sideways, which keeps a row readable as a row. */
  .table-responsive > .table { min-width: 32rem; }
  /* The media list has eight columns, so the generic floor above still left
     the name - the only cell with real text in it - narrow enough to stack
     into four lines. Give the widest table its own, wider floor rather than
     raising the generic one for tables that do not need it. */
  .table-responsive > .media-table { min-width: 42rem; }
  .table-responsive > .table td,
  .table-responsive > .table th { padding-top: .4rem; padding-bottom: .4rem; }

  /* The admin section pills wrapped onto three rows and ate the top third of
     every admin page. One row that scrolls sideways instead. (The template
     had to drop Bootstrap's `.flex-wrap` for this to apply at all - that
     utility is `!important`.) */
  .admin-nav .nav {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .admin-nav .nav::-webkit-scrollbar { display: none; }
  .admin-nav .nav-item { flex: 0 0 auto; }
  .admin-nav .nav-link { white-space: nowrap; padding: .3rem .55rem; font-size: .82rem; }
  /* "Mein Stream →" is a shortcut, not a section: it can go to the end of
     the scroll rather than being pushed there by `ms-auto`. */
  .admin-nav .nav-item.ms-auto { margin-left: 0 !important; }

  /* Cards carry the page's structure here, and their default padding plus
     the gap between them was more air than a 390px screen can spare. */
  .card-body { padding: .8rem; }
  .container, .container-fluid { padding-inline: .8rem; }

  /* Section headings are doing navigation work on a long stacked page, so
     they stay legible - but the h1 above them does not need to be large
     twice. */
  h1.h4 { font-size: 1.1rem; }
}

/* The controller's own rail: on a phone it gets ~46dvh for everything -
   points, hint, search, tabs AND the emote buttons - so every line of chrome
   above the grid costs a row of emotes. */
@media (max-width: 820px) {
  .controller-rail .rail-head { font-size: .92rem; }
  .controller-rail .rail-head .points-row { margin-top: .25rem !important; }
}
