/* MCR Operator Monitoring Panel.
 *
 * Builds on tokens.css tokens (--bg, --surface, --border, --text, --muted,
 * --accent, --accent-2, --warn, --danger, --radius...). Dark is the default via
 * theme.js. House rules: no left-edge accent strips anywhere; differentiate with
 * full borders, background tint, and heading colour only. Target wall is a
 * 2560x1440 monitor; the card grid is sized for up to six games.
 */

/* --- MCR-specific tokens, defined for both themes --- */
:root {
  --mcr-header-h: 48px;
  --mcr-gap: 12px;
  /* TSL UMD v5.0 tally palette: off / red / green / amber. The wall uses the
     same four colours as the multiviewer so operators read one colour language.
     Greens and ambers are damped slightly from pure tally values for a screen
     that is stared at for hours, but the mapping is one-to-one. */
  --lamp-ok: #2f6b46;        /* tally green  */
  --lamp-ok-text: #9fe7bd;
  --lamp-active: var(--danger);   /* tally red */
  --lamp-latched: var(--warn);    /* tally amber */
  --lamp-latched-text: #241a02;
  --lamp-off: #33405f;       /* tally off (unlit) */
  --ok: #16a34a;
  --live: #16a34a;
  --poll: #f59e0b;
  --downcol: #dc2626;
  --slate-ring: #5B9BFF;     /* on air from a file */
  /* Amber as small text on this panel's own surface, which is not the same
     colour as amber as a lamp. --warn is a tally value picked to be read as
     light; at 11.5px on the light theme's white card it comes out around
     2:1 against the background, which is a hedge an operator would have to
     lean in to read. The tally value stands wherever amber is a lamp or a
     fill (nothing below changes colour), and this is the reading of it for a
     row of text. Dark needs no adjustment, so it is --warn itself. */
  --warn-text: var(--warn);
  /* The ring on a leg whose input has no media arriving. Yellow and not the
     tally amber (--warn), because a take in flight already pulses amber and
     the two must not be confused. */
  --down-ring: #facc15;
}

:root:not([data-theme="dark"]) {
  --lamp-ok: #cfe9d8;
  --lamp-ok-text: #1a6b3d;
  --lamp-off: #dfe3ea;
  --warn-text: #8a5200;
  /* Darker on the light theme so the ring still reads against white. */
  --down-ring: #ca8a04;
}

* { box-sizing: border-box; }

html, body {
  height: 100%;
  margin: 0;
}
body {
  background: var(--bg);
  color: var(--text);
  font-family: "Fira Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
  font-size: 14px;
  line-height: 1.4;
  -webkit-font-smoothing: antialiased;
}

/* ===== Header strip ===== */
.mcr-header {
  height: var(--mcr-header-h);
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 0 16px;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  position: sticky;
  top: 0;
  z-index: 40;
}
.mcr-header .brand {
  font-weight: 700;
  letter-spacing: -0.01em;
  font-size: 15px;
  display: flex;
  align-items: center;
  gap: 8px;
  color: inherit;
  text-decoration: none;
}
.view-switcher {
  flex: 0 0 auto; width: auto; max-width: 200px;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-radius: 6px;
  font: inherit; font-size: 12.5px; font-weight: 600;
  padding: 3px 8px; margin-left: 4px; cursor: pointer;
}
.view-switcher:hover { border-color: var(--accent); }
.mcr-header .brand .mark {
  color: var(--accent);
  font-size: 18px;
  line-height: 1;
}
.mcr-header .spacer { flex: 1; }
.mcr-header .clock {
  font-variant-numeric: tabular-nums;
  color: var(--muted);
  font-size: 13px;
  white-space: nowrap;
}
.mcr-header .clock b { color: var(--text); font-weight: 600; }
.mcr-header a { color: var(--muted); text-decoration: none; }
.mcr-header a:hover { color: var(--accent); }

/* The wall's own alarm: one break open, on a card the operator is not looking
   at, on a tab they have not selected.
   Everything else this panel says about a break is inside a card, and six
   cards is more than one pair of eyes. The card frame closed the tab half of
   that; this closes the card half. It is in the header because the header is
   the only surface on this page that is not a card, is already a fixed 48px,
   and is on screen whatever the wall is doing. Nothing is inserted into the
   wall itself and nothing is overlaid on it, so no card loses a pixel of
   video to it: the header's own spacer is flex: 1 and gives up the width.
   The same red pill as the title row's unresolved chip, deliberately. An
   operator should read the header and the card as one alarm rather than as
   two facts that happen to be about the same break. It pulses on the ring's
   own period so a wall with one card ringed and this lit beats once.
   Raised only for a break that is overdue or unresolved. Ordinary firing and
   an ordinary owed in point are the panel working, six games do them all
   afternoon, and an alarm that is lit all afternoon is one an operator learns
   to stop seeing. */
.brk-alarm {
  font: inherit; font-size: 11.5px; font-weight: 700;
  letter-spacing: 0.02em;
  color: #fff; background: var(--danger); border: 1px solid var(--danger);
  border-radius: 999px; padding: 3px 12px;
  cursor: pointer; flex: 0 1 auto; min-width: 0;
  max-width: 42%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  animation: brkpulse 1s ease-in-out infinite;
}
.brk-alarm[hidden] { display: none; }
.brk-alarm:hover { filter: brightness(1.15); }

.hdr-user { display: flex; flex-direction: column; line-height: 1.15; }
.hdr-user .u { font-weight: 600; font-size: 13px; }
.hdr-user .r { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
/* The username is the way to the account page. */
a.hdr-user { color: inherit; text-decoration: none; }
a.hdr-user:hover .u { color: var(--accent); }
/* The account page borrows the login card, under the header. */
.account-wrap { display: flex; justify-content: center; padding: 32px 20px; }

/* status dot (WS link / instance health) */
.dot {
  width: 9px; height: 9px; border-radius: 50%;
  display: inline-block; flex: 0 0 auto;
  background: var(--muted);
}
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.down { background: var(--danger); }
.dot.pulse { animation: dotpulse 1.6s ease-in-out infinite; }
@keyframes dotpulse { 50% { opacity: 0.35; } }

/* transport badge */
.badge {
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em;
  padding: 2px 7px; border-radius: 999px;
  border: 1px solid var(--border); color: var(--muted);
  text-transform: uppercase;
  white-space: nowrap;
}
.badge.live { color: #fff; background: var(--live); border-color: var(--live); }
.badge.poll { color: #3a2c05; background: var(--poll); border-color: var(--poll); }
.badge.down { color: #fff; background: var(--downcol); border-color: var(--downcol); }
/* The connection is up and some of what should be coming down it is not.
   Amber, like POLL, because both say degraded rather than gone, and outlined
   rather than filled so the two are not the same object at a glance: POLL is
   a path the panel chose, STALE is telemetry that stopped. The tooltip names
   what has gone quiet. */
.badge.stale {
  color: var(--poll); background: transparent; border-color: var(--poll);
}

/* small icon button (theme toggle, header actions) */
.iconbtn {
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text);
  border-radius: 8px;
  padding: 5px 10px;
  font: inherit;
  font-size: 12px;
  cursor: pointer;
}
.iconbtn:hover { border-color: var(--accent); color: var(--accent); }

/* ===== Wall ===== */
.wall {
  /* One height for every row, whether the grid declared it or created it.
     grid-template-rows only sizes the rows it names, so two named rows and
     sixteen games left rows three to six sized by content: a row whose cards
     all had a preview open stood taller than the row beside it, and the wall
     lost the even ruled grid an operator scans across. grid-auto-rows sizes
     every row, so row six matches row one and the difference between six
     games and sixteen is only how far you scroll.

     Two rows fill the viewport exactly, which is the layout a six-game wall
     has always had: subtract the header and the three gaps a two-row grid
     spends (top padding, the gap between, bottom padding), then halve. */
  --mcr-row-h: calc((100dvh - var(--mcr-header-h) - var(--mcr-gap) * 3) / 2);
  /* min-height, not height: the grid has to be free to grow past the viewport
     so the page scrolls. The header is sticky and stays put while it does. */
  min-height: calc(100dvh - var(--mcr-header-h));
  padding: var(--mcr-gap);
  display: grid;
  gap: var(--mcr-gap);
  grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-auto-rows: var(--mcr-row-h);
}
/* 4-or-fewer games collapse to a tighter grid. These set the row height
   rather than grid-template-rows, so the single declaration above still sizes
   every row and there is one answer to how tall a row is. */
.wall[data-count="1"],
.wall[data-count="2"] {
  --mcr-row-h: calc(100dvh - var(--mcr-header-h) - var(--mcr-gap) * 2);
}
.wall[data-count="1"] { grid-template-columns: 1fr; }
.wall[data-count="2"] { grid-template-columns: repeat(2, 1fr); }
.wall[data-count="3"],
.wall[data-count="4"] { grid-template-columns: repeat(2, 1fr); }

/* ===== Game card ===== */
.game {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  position: relative;
  display: grid;
  /* title | video (absorbs slack) | sources+meters | lamps|scte | health */
  grid-template-rows: auto minmax(120px, 1fr) auto auto auto;
  min-height: 0;
  min-width: 0;
}
/* Break surround. The inner ring is drawn on a pseudo-element rather than as
   an inset box-shadow on the card: an inset shadow paints beneath child
   backgrounds, so the opaque health strip and title bar cut the ring at the
   top and bottom edges. This one closes all the way round.

   Four states now rather than one, keyed off data-break-state (see
   breakRingState in card.js for what each word means and why they are read
   loudest first). This base is the settled break, "onair", and it is byte for
   byte what .break-active was: a confirmed break on air must look today
   exactly as it looked yesterday, because everything else here is measured
   against it being the quiet one.

   Every escalation below is paid for out of the pseudo-element, never out of
   the border. border-width is in the box model and .game is a grid item in a
   fixed grid cell, so a 2px border would take a pixel of video off every
   side. The ring, the outline and the animation cost no layout at all, which
   is the whole reason the escalation can be this loud without spending any
   picture. */
/* On a slate: what is on air is a file off local disk, which is not a fault
   and not an alarm, so it gets a colour of its own rather than borrowing the
   break palette.

   The card's own border, and nothing else. The break states draw an inset ring
   on a ::after at z-index 3, which paints over the card's content - the tab row
   included - and at 2px on a rounded corner that reads as a pane of glass laid
   over the UI rather than a frame around it. That is tolerable for a break,
   which is transient and wants to be intrusive. A slate can sit for a whole
   pre-game, so it gets the quieter treatment: colour on the existing 1px
   border, no overlay, no extra layer, nothing over the tabs.

   Steady, not pulsing. The break rings pulse because something is in flight or
   overdue; a slate is a held state an operator chose and is not going to change
   on its own.

   Deliberately above the break rules, which are the same specificity and
   therefore win: a card can be on a slate during a break, the frame carries one
   colour, and the break is the more urgent news. */
.game[data-slate] { border-color: var(--slate-ring); }

.game[data-break-state] { border-color: var(--warn); }
.game[data-break-state]::after {
  content: ""; position: absolute; inset: 0; z-index: 3;
  border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px var(--warn);
}
.game.degraded { border-color: var(--danger); }
/* Below .game.degraded, and with the same specificity, so the later rule
   wins: a break that is unsettled or wrong outranks a degraded card on the
   frame, while a settled one still gives way to it. A card can be both and
   the frame carries one colour, so the order is the decision. */
.game[data-break-state="firing"],
.game[data-break-state="unconfirmed"] { border-color: var(--warn); }
.game[data-break-state="open"] { border-color: var(--danger); }

/* Transmitted, nothing back yet. The pulse is the source row's, at the source
   row's own period: srcpulse is 1s ease-in-out and so is this, so a card
   where a take and a break are both in flight beats once rather than twice.
   What differs is what moves. srcpulse dims a button with a filter; a filter
   on this card would dim the video with it, so the ring is faded on its own
   pseudo-element and the picture underneath is untouched. */
.game[data-break-state="firing"]::after {
  box-shadow: inset 0 0 0 2px var(--warn);
  animation: brkpulse 1s ease-in-out infinite;
}
/* Commanded, on air, and never echoed by the post SCTE parser. Dashed, which
   is what .src-btn.unconfirmed already means one row down, and still, because
   nothing is in flight: this is a break that is up and uncorroborated rather
   than one still being answered. Both edges dashed, so the mark survives at
   the card's own scale; no strip down one side. */
.game[data-break-state="unconfirmed"] { border-style: dashed; }
.game[data-break-state="unconfirmed"]::after {
  box-shadow: none;
  outline: 2px dashed var(--warn);
  outline-offset: -3px;
  border-radius: inherit;
}
/* Overdue, or older than the process that fired it. Red because this is not
   uncertainty any more: a break is open on air and nothing but an operator
   will close it. */
.game[data-break-state="open"]::after {
  box-shadow: inset 0 0 0 2px var(--danger);
  animation: brkpulse 1s ease-in-out infinite;
}
/* Opacity, on the ring's own element. Nothing here is inherited by the card,
   so no filter and no opacity ever reaches the video. */
@keyframes brkpulse { 50% { opacity: 0.28; } }

.game .g-title {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 10px;
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
  min-height: 32px;
  /* A grid item's automatic minimum size is its min-content width, and with
     .name now nowrap that is the whole name: without this the row grows past
     the card and .game's overflow: hidden takes the right-hand end of it,
     countdown and transport badge included, with no mark to say so. Zero
     lets the row be the card's width and pushes the decision down to .name,
     which ellipsises. */
  min-width: 0;
}
/* The name is the item in this row allowed to give way, and it says so with an
   ellipsis. It used to wrap instead, and this row is an "auto" track in
   .game's grid, so wrapping came straight out of the video: measured at
   1366x768 with six cards, a 69-character game name ran the title to three
   lines, 77.3px against 33.7px, and took the video from 163.63px to its 120px
   floor. Nothing had to break for that; an admin only had to type a long name.
   min-width: 0 replaces the flex default of auto, which would floor this item
   at its own min-content and put the overflow back. What sits to the right of
   it is the break countdown and the transport badge, and the last characters
   of a name the operator is looking straight at are worth less than either. */
.game .g-title .name {
  font-weight: 600; font-size: 13.5px;
  flex: 0 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The break countdown's slot. Empty for all but the seconds of an actual
   break, and an empty flex item would still take a gap on either side, so it
   is taken out of the row entirely when there is nothing to say. */
.game .g-title [data-break]:empty { display: none; }
.game .g-title .net {
  font-size: 10px; font-weight: 700; letter-spacing: 0.06em;
  color: var(--muted); border: 1px solid var(--border);
  padding: 1px 6px; border-radius: 4px; text-transform: uppercase;
}
.game .g-title .inst {
  font-size: 10px; font-weight: 600;
  color: var(--muted); border: 1px solid var(--border);
  background: var(--surface);
  padding: 1px 8px; border-radius: 999px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  max-width: 140px; min-width: 0;
  text-decoration: none;
}
.game .g-title .inst[href] { cursor: pointer; }
.game .g-title .inst[href]:hover { border-color: var(--accent); color: var(--accent); }
.game .g-title .spacer { flex: 1; }

/* video: fills the flexible grid row; contain keeps 16:9 letterboxed. An
   inline-size container, so the caption text over it is sized from the tile
   (cqi) rather than from the window: one rule for a one-up wall and a six-up
   one. Its width comes from the grid track and never from its content, which
   is what that containment needs. */
.g-video {
  position: relative;
  background: #000;
  min-height: 0;
  cursor: pointer;
  overflow: hidden;
  container-type: inline-size;
}
.g-video video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
.g-video .vph {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  color: var(--muted); font-size: 12px; gap: 6px; flex-direction: column;
  background: repeating-linear-gradient(45deg, #0b0b0f, #0b0b0f 12px, #101018 12px, #101018 24px);
}
.g-video .v-overlay {
  position: absolute; left: 6px; bottom: 6px; right: 6px;
  display: flex; gap: 6px; align-items: center; pointer-events: none;
}
.v-chip {
  font-size: 10.5px; font-weight: 600;
  background: rgba(0,0,0,0.55); color: #eaeaea;
  padding: 2px 6px; border-radius: 4px;
  font-variant-numeric: tabular-nums;
}
.v-chip.err { background: rgba(220,38,38,0.85); color: #fff; }
.v-chip.ok { background: rgba(22,163,74,0.8); color: #fff; }

/* Caption text: the boxes the words sit in, wherever they are drawn (over a
   card's preview, over the enlarge modal's video, on a /captions tile). White
   on a fixed near-black in both themes, like .v-chip, because on the wall it
   sits on video and video is dark in both. Each row is its own box, the way a
   608 receiver draws a caption, and a row too long for its box ellipsises
   rather than wrapping into a line the display never sent. Sized in em, so
   the padding and gaps scale with the text. Nothing animates: a roll-up
   arrives as the same rows shifted up by one and is drawn exactly so. */
.v-cc {
  display: flex; flex-direction: column; align-items: center; gap: 0.15em;
  line-height: 1.3; font-weight: 600;
}
.v-cc[hidden] { display: none; }
.v-cc-row {
  max-width: 100%; box-sizing: border-box;
  padding: 0.05em 0.4em; border-radius: 0.15em;
  background: rgba(0, 0, 0, 0.78); color: #fff;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.v-cc-tag { color: #c8c8c8; font-size: 0.8em; letter-spacing: 0.04em; margin-right: 0.45em; }
/* Over the card's preview: top-centre, at most 92% of the tile. The top and
   not the bottom, where a receiver would draw it, because the bottom of the
   picture is where every network keeps its score bug, and two rows of
   caption sat exactly over it: an operator watching the wall for the score
   could not read it while the game was captioned. Nothing of the card's own
   is drawn at the top of the picture (the bitrate chip row is at the
   bottom), so the words hide nothing there. After .vph in the markup, so it
   paints over "Preview: closed", which is when another sign that the game is
   captioned is most wanted. Clicks pass through to the video, which enlarges. */
.g-video .v-cc {
  position: absolute; left: 4%; right: 4%; top: 6px;
  font-size: clamp(11px, 2.2cqi, 22px);
  pointer-events: none;
}

/* The live leg's still, in the corner, while a slate is on air. Bottom
   right: the caption text has the top, the bitrate chip the bottom left,
   and on a slate there is no score bug to hide. A third of the tile, so
   that on a six-up wall it is a picture and not a swatch, and the slate
   it sits on is a card the operator chose and already knows. Clicks pass
   through to the video, which enlarges. The tag is the leg's name and its
   video tests; off is a still the panel cannot vouch for (not reporting,
   or not fetched), which dims the last picture rather than removing it. */
.g-video .v-pip {
  position: absolute; right: 6px; bottom: 6px;
  width: 34%; aspect-ratio: 16 / 9;
  background: #000; border: 1px solid rgba(255, 255, 255, 0.4);
  border-radius: 3px; overflow: hidden; pointer-events: none;
}
.v-pip[hidden] { display: none; }
.v-pip img { width: 100%; height: 100%; object-fit: contain; display: block; }
.v-pip.off img { opacity: 0.35; }
.v-pip-tag {
  position: absolute; left: 0; top: 0;
  font-size: 10px; font-weight: 600; line-height: 1.4;
  padding: 1px 5px; border-bottom-right-radius: 3px;
  background: rgba(0, 0, 0, 0.65); color: #eaeaea;
  white-space: nowrap;
}
.v-pip.err .v-pip-tag { background: rgba(220, 38, 38, 0.85); color: #fff; }
/* The tag and the fold button take clicks; the picture between them does
   not, so a click on it still enlarges the video underneath. */
.v-pip-tag, .v-pip-min { pointer-events: auto; cursor: pointer; }
.v-pip-min {
  position: absolute; right: 0; top: 0;
  width: 18px; height: 16px; padding: 0; border: 0;
  border-bottom-left-radius: 3px;
  background: rgba(0, 0, 0, 0.65); color: #eaeaea;
  font: 600 12px/16px inherit; font-family: inherit; text-align: center;
}
.v-pip-min:hover, .v-pip-tag:hover { background: rgba(0, 0, 0, 0.85); color: #fff; }
/* Folded: the tag and the button as one chip where the picture's corner
   was, no frame drawn (and none fetched, see card.js updatePip). The tag
   keeps the leg's name and its video tests, so folding loses the picture
   and nothing the card was saying about it. */
.v-pip.min {
  width: auto; height: auto; aspect-ratio: auto;
  background: none; border: 0; border-radius: 0;
  display: flex; align-items: stretch;
}
.v-pip.min img { display: none; }
.v-pip.min .v-pip-tag { position: static; border-radius: 3px 0 0 3px; }
.v-pip.min .v-pip-min { position: static; border-radius: 0 3px 3px 0; height: auto; }

/* source buttons */
.g-avrow {
  display: flex; flex-wrap: nowrap; align-items: center; gap: 6px 12px;
  padding: 5px 8px; border-bottom: 1px solid var(--border); min-width: 0;
}
/* nowrap, not wrap: flex-wrap picks lines using each item's unshrunk
   hypothetical size, before flex-shrink ever runs, so a wrapping row would
   still wrap on .g-avtabs's full width instead of letting it shrink first,
   right back to the same added-row height this change exists to avoid.
   Sources and meters are both flex-shrink: 0 (never compress); .g-avtabs
   is the only child that can, so nowrap plus that shrink budget is what
   keeps this row to one line without taking room from either. */
.g-sources {
  display: flex; gap: 6px; align-items: center; flex: 0 0 auto;
}
.g-sources .lbl { font-size: 11px; color: var(--muted); margin-right: 2px; }
/* Source buttons are the same backlit keycap as the lamps, on the same
   reasoning: they carry TSL tally, so an operator reads them at a glance and
   the states must stay instantly distinct. The optics are shared, a state
   sets only --lamp-c and --lamp-glow, and the tally palette still decides
   what a colour means. */
.src-btn {
  --lamp-c: var(--surface-2);
  --lamp-glow: transparent;
  font: inherit; font-size: 12.5px; font-weight: 600;
  border: 1px solid var(--border);
  background:
    linear-gradient(180deg,
      rgba(255, 255, 255, 0.28) 0%,
      rgba(255, 255, 255, 0.06) 42%,
      rgba(255, 255, 255, 0) 56%),
    radial-gradient(120% 90% at 50% 118%,
      rgba(255, 255, 255, 0.26) 0%,
      rgba(255, 255, 255, 0) 64%),
    var(--lamp-c);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.34),
    inset 0 -2px 3px rgba(0, 0, 0, 0.32),
    0 1px 2px rgba(0, 0, 0, 0.42),
    0 0 10px -1px var(--lamp-glow);
  color: var(--text); border-radius: 7px; padding: 5px 12px; cursor: pointer;
  position: relative; min-width: 52px;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.30);
}
.src-btn:hover:not(:disabled) { border-color: var(--accent); }
/* SRC legs carry the same TSL v5 tally states as the lamps and the wire:
   red on air, amber taking, green standby-and-safe-to-take, off unusable. */
.src-btn.t-red {
  --lamp-c: var(--lamp-active); --lamp-glow: var(--lamp-active);
  border-color: var(--lamp-active); color: #fff;
}
.src-btn.t-amber {
  --lamp-c: var(--lamp-latched); --lamp-glow: var(--lamp-latched);
  border-color: var(--lamp-latched);
  color: var(--lamp-latched-text);
  animation: srcpulse 1s ease-in-out infinite;
}
.src-btn.t-green {
  --lamp-c: var(--lamp-ok); --lamp-glow: var(--lamp-ok);
  border-color: var(--lamp-ok); color: var(--lamp-ok-text);
}
.src-btn.t-off {
  --lamp-c: var(--lamp-off); border-color: var(--lamp-off); color: var(--muted);
  text-shadow: none;
}
/* The fifth state, which the wire has no value for: a slate leg, a file
   playing from local disk with no ETR-290, no bitrate and no captions on it.
   Green is a promise that a standby leg has been checked and nothing checks
   this one; off says a standby leg is not usable, and the slate is takeable
   at any moment and is what an operator reaches for when everything else has
   failed. So it carries no tally colour at all: the plain button, full text
   colour, undimmed, which is how every other live control on this card reads.
   Stated in full rather than left to the absence of a class, because what it
   is saying is deliberate. */
.src-btn.t-unmeasured {
  --lamp-c: var(--surface-2); border-color: var(--border); color: var(--text);
  text-shadow: none;
}
/* fired but unconfirmed: still amber, edged to show the switch has not answered */
.src-btn.unconfirmed { border-style: dashed; border-color: var(--text); }
/* The switch has stopped reporting, so this button is lit for what it last
   said. The colour stays: taking it away would leave the operator with no
   picture of what is on air, which is worse than a marked one. Dotted rather
   than dashed, which is already taken by an unconfirmed take, and damped so
   it reads as a held reading rather than a live one. The tooltip says it in
   words, because a border is not a sentence.

   No edge strip: the whole border carries it, on the house rule at the top of
   this file. */
.src-btn.last-heard {
  border-style: dotted; border-width: 1px;
  filter: saturate(0.55) brightness(0.88);
}
/* escalated to a forced cut: stop pulsing, because the wait is no longer the
   expected one for an aligned IDR */
.src-btn.t-amber.forcing { animation: none; }
.src-btn:disabled { opacity: 0.6; cursor: not-allowed; }
@keyframes srcpulse { 50% { filter: brightness(0.66); } }
/* A leg whose input has no media arriving (app/tsl.py leg_down): a blinking
   yellow ring around the whole button, over whatever tally it has. On air it
   stays red and gains the ring; on standby it is off and gains the ring.
   Drawn on its own layer rather than as the button's border or animation, so
   it cannot cancel the amber pulse of a take in flight, and a hard blink
   rather than a pulse, so it does not read as that pulse either. Half a
   second on, half off, which is the Stream Deck key's own flash. */
.src-btn.down::after {
  content: ""; position: absolute; inset: -4px; pointer-events: none;
  border: 2px solid var(--down-ring); border-radius: 10px;
  animation: srcdown 1s steps(1, end) infinite;
}
@keyframes srcdown { 50% { border-color: transparent; } }
@media (prefers-reduced-motion: reduce) {
  /* The ring stays, steady: it is the only thing on the button saying the
     feed is dead, so it cannot go with the motion. */
  .src-btn.down::after { animation: none; }
}

/* Slate buttons in the slates tray. Same button as the SRC row on purpose -
   red on air, amber mid-take - because they do the same job and an operator
   should not have to learn a second vocabulary for it.
   
   One deliberate difference: an unpressed slate is neutral, not green. Green
   on this panel means a preview is running, and four green buttons on every
   card spent that meaning on the one state nobody needs pointed out.

   .armed says the first of two presses has landed and the next one puts
   something to air. Both rows carry it now that every take is armed first: it
   borrows srcpulse, which already means "this button is mid-something", rather
   than inventing a second flash that would have to be learned separately. */
.slate-btns { display: flex; flex-wrap: wrap; gap: 4px; }
.src-btn.armed {
  animation: srcpulse 0.6s ease-in-out infinite;
  font-weight: 700; letter-spacing: 0.04em;
}
@media (prefers-reduced-motion: reduce) {
  /* The flash is not the only carrier of the armed state - the label changes
     to TAKE as well - so removing it loses nothing an operator needs. */
  .src-btn.armed { animation: none; outline: 2px solid var(--lamp-latched); }
}

/* Tab switcher for the lamps/SCTE/break/files region below, folded into
   the SRC row instead of given a row of its own: a standalone strip cost
   video height (measured, see task-8-report.md), and this row already has
   spare width most of the time. Sources and meters carry the TSL tally
   colours and are the on-air controls, so they always keep full size;
   .g-avtabs is the only thing here allowed to shrink, and it truncates
   with an ellipsis rather than wrapping (wrapping would add a row, which
   is the exact regression this is avoiding). */
.g-avtabs {
  display: flex; gap: 3px; align-items: center;
  flex: 0 1 auto;
  /* No overflow:hidden here: that makes a flex item's automatic minimum
     size resolve to 0 (verified: it collapsed every tab out of existence
     under real pressure, not just its label). But leaving min-width at
     its default auto did not float this container down to its children's
     true minimum either, despite each .tab declaring its own min-width:0:
     measured, Chromium floors a nested flex container that is itself a
     flex item at close to its full unshrunk content width (168-200px
     here) when min-width is auto, regardless of what the children
     specify. flex-shrink:1 above was therefore inert. Fix: set the floor
     explicitly, sized to what the children actually need.

     That floor is now three tabs at the 19px each needs to keep its first
     letter (measured: 7px of capital at 10.5px semibold, plus 10px padding
     and 2px border) plus two gaps at 3px, 63px. It was 57px, arithmetic for
     FOUR tabs at a 12px padding-and-border floor, left behind when the empty
     SCTE tab went (635cfb3). That number was the only thing keeping a letter
     on screen at all: at the 42px the same arithmetic gives for three tabs,
     every label renders as nothing but a clipped ellipsis. Stating the floor
     as "what one letter needs" is what makes it survive the next tab being
     added or removed. */
  min-width: 63px;
}
.g-avtabs .tab {
  font: inherit; font-size: 10.5px; font-weight: 600;
  background: var(--surface-2); color: var(--muted);
  border: 1px solid var(--border); border-radius: 5px;
  padding: 3px 5px; cursor: pointer;
  flex: 1 1 auto; max-width: 60px;
  /* Its own floor as well as the strip's, because the strip shrinks its
     children in proportion to their unshrunk widths and "Lamps" is wider
     than "Files": at the strip's floor the proportional share of the
     narrowest tab is under a letter. 19px is the same measured number as
     above and it is per tab, so no tab can be squeezed below its key
     whatever the others are called. */
  min-width: 19px;
  overflow: hidden; white-space: nowrap;
  /* A flex container, so the label can be split into a part that never
     gives way and a part that does. At 1366x768 with six cards the strip has
     57px for three tabs, which is 7px of content each: one character, and
     no arrangement of this row can produce more without taking width from
     the SRC buttons, the audio meters or the video, which is where the row
     already overflows to. So the label degrades to its own initial rather
     than to a chopped word plus a clipped ellipsis, the full word is the
     button's title and aria-label, and where there is room (four cards or
     fewer at 1366, any count at 1920 and up) the whole word still shows.
     safe center: centred while it fits, which is every state today, and
     start-aligned rather than clipped from both edges if it ever does not.
     Browsers without `safe` drop the declaration and get flex-start, which
     is the same fallback. */
  display: flex; align-items: center; justify-content: safe center;
}
/* The initial never shrinks and the rest gives way first, with an ellipsis
   while there is room for one. Splitting the word is what makes the narrow
   case a deliberate abbreviation instead of a truncation artifact: "L" is the
   label's own first letter, and "L..." in a 17px box spent almost half of it
   on saying that something was missing, to an operator with no way to find
   out what. */
.g-avtabs .tab .tab-key { flex: 0 0 auto; }
.g-avtabs .tab .tab-rest {
  flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
}
/* A tab a game may not use is not drawn. Stated here rather than left to the
   user agent's own [hidden] rule: .g-tabbody > [hidden] below exists because a
   pane's own class beat that rule on source order, and a tab that stayed
   visible would be this feature failing in the one direction it must not. */
.g-avtabs .tab[hidden] { display: none; }
.g-avtabs .tab:hover { color: var(--text); border-color: var(--accent); }
.g-avtabs .tab.active {
  /* A filled, high-contrast active state rather than the border/background
     match a strip attached to its pane could use: these tabs now sit apart
     from the region they switch, so "which one is on" has to read on its
     own, not by adjacency. */
  background: var(--accent); color: #fff; border-color: var(--accent);
}

/* ETR-290 P1 lamps */
/* Height-bound to the body, so this pane never overflows its container.
   Left unbound it grew to its content (85px inside a 69px box), which put a
   scrollbar on .g-tabbody AND on .g-scte, two bars side by side at the same
   edge. Each column now scrolls in its own lane instead, so the lamps stay
   where the operator expects them while the ticker moves. */
.g-lampscte { display: flex; align-items: stretch; height: 100%; min-height: 0;
  border-bottom: 1px solid var(--border); }
.g-lamps { padding: 6px 10px 6px 8px; display: grid; gap: 4px; flex: 0 0 auto;
  /* Two legs already exceed the 69px body, so this scrolls in its own lane
     rather than being clipped by an ancestor. */
  overflow-y: auto; min-height: 0; border-right: 1px solid var(--border); }
.g-lamps::-webkit-scrollbar { width: 8px; }
.g-lamps::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.g-lamps::-webkit-scrollbar-track { background: transparent; }
/* In the tab order, because the output rows are only reachable by scrolling
   and a keyboard scrolls what has focus. Inset, because .g-tabbody clips
   anything drawn outside the pane. */
.g-lamps:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* A game with output probes gets two pages in this pane, its input rows and
   then the programme's own (see renderLamps). Each page is at least the
   pane's own height, so the default view is every input row and nothing
   else: an output row cannot peek in under a lone CBS input row, and the
   card is exactly as tall as it was. Rows inside a page stretch as they
   always did in the pane, which is what keeps a single row where it sat.

   The pane's 6px top and bottom padding moves inside each page, and each
   page clips what it draws. A lit lamp's glow reaches 8px past the cap and
   its count sits 5px above it, so with the pages merely butted together a
   red PID on PGM 1 put a red haze and half a "99+" along the bottom of the
   inputs. Clipped at its own edge, a page shows nothing of the other.

   A column of pages rather than the pane's grid, because min-height: 100% on
   a flex item is the pane's height and a page with more in it still grows. A
   grid track of minmax(100%, auto) held a taller input page to the pane's
   height, and the output page was drawn over its last row.

   Snapping to the pages means a wheel, a trackpad or an arrow key comes to
   rest on one page or the other, never between them with half a row
   showing. */
.g-lamps:has(> .lamp-page) {
  display: flex; flex-direction: column; gap: 0;
  padding-top: 0; padding-bottom: 0;
  scroll-snap-type: y mandatory;
  scroll-behavior: smooth;
}
.lamp-page {
  display: grid; gap: 4px; flex: 0 0 auto; min-height: 100%;
  padding: 6px 0; overflow: clip;
  scroll-snap-align: start;
}
@media (prefers-reduced-motion: reduce) {
  .g-lamps:has(> .lamp-page) { scroll-behavior: auto; }
}

/* The tab switcher itself now lives in .g-avrow, above (.g-avtabs); this
   is just the body it controls. Direct grid-row child (no wrapper div):
   the wrapper only existed to group the old strip with the body, and
   there's nothing left for it to group now that the strip moved out.

   Fixed height, not auto: this is an "auto" row in .game's grid, so
   whatever height this element renders at comes directly out of the
   video's flexible row. With auto sizing, switching to a pane taller than
   today's lamps pane (break/files in tasks 9/10, or a lamps pane with
   more queued SCTE lines than fit today) would grow this row and shrink
   the video to match: the exact regression this task exists to prevent,
   just moved one level down. Pinning the height gives every pane,
   present and future, the same budget the lamps pane already uses today;
   content that doesn't fit scrolls inside the box instead of growing it,
   the same trade-off .g-scte already makes for its own overflow.

   69px is .g-lampscte's own measured height at today's default content
   (2 lamp rows, empty SCTE ticker) at the wide viewports this app
   targets (2560x1440, 1920x1080). It is deliberately not the pane's
   worst case (measured up to 85px, from .g-scte's own pre-existing
   max-height: 84px cap, reached either by enough queued SCTE lines or by
   narrow-card text wrap): pinning to that worst case would cost real
   video at the common, wide-viewport, quiet-ticker state, which is the
   one thing this fix must never do. The narrower-viewport, busier-ticker
   case, which already costs video today via the auto-height row, scrolls
   under this fix instead and gives that video back: see
   task-8-report.md. */
.g-tabbody { background: var(--surface); height: 69px; overflow-y: auto; }
/* Same treatment as .g-scte's own scrollbar below: a default OS scrollbar on
   a box this short is easy to miss, and this row can now genuinely overflow
   (a wedged inserter's message stacked with an on-air row, or a lamps pane
   with three or more legs), so the affordance that it scrolls needs to be
   visible on its own, not just functional. */
.g-tabbody::-webkit-scrollbar { width: 8px; }
.g-tabbody::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.g-tabbody::-webkit-scrollbar-track { background: transparent; }
.g-tabbody > [hidden] {
  /* [hidden] alone ties .g-lampscte's specificity (one class equals one
     attribute selector) and loses on source order, so clicking another
     tab set hidden=true correctly but left the lamps pane rendered at
     display:flex anyway (verified: computed display stayed "flex" and
     height stayed unchanged after switching tabs). Scope a selector to
     this container's direct pane children so [hidden] outranks any
     single class a pane's own root element sets (.g-lampscte today,
     whatever break/files use tomorrow), instead of patching .g-lampscte
     alone. */
  display: none;
}

/* Break tray (data-pane="break"). Lives inside the same fixed 69px
   .g-tabbody as the lamps pane above, so it scrolls rather than growing the
   row when a state needs more room than that (the armed and on-air states,
   with their button rows, are the ones most likely to).

   The armed state's three rows (title, delta/caveat, GO+Disarm) measured at
   78.28px against this 69px box at the padding/gap/button-padding this block
   first shipped with: real overflow, not a rounding artifact, confirmed via
   scrollHeight vs clientHeight, and it clipped the bottom ~3px of the GO/
   Disarm row (hit-tests there landed on the health strip underneath instead
   of the button). GO is the one control in this whole tray that puts
   something to air, so a few clipped pixels on it is worse than on anything
   else here. A second, independent overflow turned up while re-checking the
   idle state's own fit once it could grow a third row (the capability
   reason below): the fill picker's global `select { padding: 9px 12px }`
   from tokens.css alone rendered at 36px, taller than this row's whole
   budget, present since this tray's very first version but never over 69px
   by itself until a third row joined it.

   Fixed both the same way: tightened padding, row gap, button/select
   padding and line-height (line-height specifically, not font-size, since
   font-size is what makes the text legible and line-height is just
   breathing room around it) until every state measured actually fit,
   re-checked after each change rather than computed by hand once and
   trusted (the first pass landed 1px over on the idle-with-reason states;
   caught by re-measuring, not by arithmetic). Confirmed zero overflow, by
   direct scrollHeight-vs-clientHeight measurement, for: armed (both the
   common case and a worst-case long fill name plus the RAP caveat, which
   still needed the white-space fix below to stop growing a second line);
   idle in all four generator/inserter combinations; on-air, clean and with
   a fill error; wedged alone and wedged stacked with an active plan. All
   at both 2560x1440 and 1366x768. Scrolling still exists as the fallback
   (unchanged from above) for whatever this didn't anticipate; it just
   should not be the normal case for any state actually reached today. */
.tray { padding: 4px 8px; display: flex; flex-direction: column; gap: 2px; }
/* white-space: nowrap caps every row at one line regardless of content
   length. An admin-entered fill name (up to 96 chars) or the server's own
   delta/RAP-caveat text, combined and at a narrow enough card, soft-wrapped
   the title or delta row onto a second line, growing .tray past 69px again
   even after the padding/gap trim above (measured: a long fill name plus the
   "no RAP cadence" caveat at 1366px wide pushed the armed state to 78px, the
   same failure this whole block exists to fix, just from content length
   instead of spacing). Chips and buttons are unaffected: flex items already
   never wrap to a second flex line by default (a separate mechanism from
   text white-space), so this only ever constrains free-flowing text. */
.tray-row {
  display: flex; gap: 6px; align-items: center; font-size: 11.5px; line-height: 1.25;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Pinned, not shrinkable. min-width is a floor for the short labels ("Break",
   "Fill", "Insert"), but it also replaces the flex default of min-width: auto,
   which is the only thing that stops a flex item shrinking below its own text.
   That made .k the one item in this row that could, so a row over its budget
   squeezed "Break closed, assumed" to 42px while its text carried on painting
   straight across the event id beside it: measured at 1366x768 with six cards
   once the play-head joined the row, 59.33px of box against 128px of text.
   The row's rule is that it clips from the right and the state word survives
   (see stateLabel in breaktray.js); overprinting inverted precisely that, and
   destroyed the word rather than the qualifier. Now what is over budget
   overflows to the right, where .tray-row's own overflow hides it. */
.tray-row .k { color: var(--muted); min-width: 42px; flex: 0 0 auto; }
/* Which part of an over-budget row gives way, and how it says so.
   .tray-row is a flex container, and text-overflow does not apply to the
   anonymous flex items a container's own text runs become: the ellipsis
   declared above has therefore never rendered on any of it. What an operator
   saw instead was the row stopping mid-phrase at whatever pixel the box ended
   on, "(configured) (no RAP", with nothing to say anything had been lost.
   Measured at 1366x768 with six cards: 205px cut, silently.
   .tx is the one item in a row allowed to shrink, so the cut happens in a
   stated place and carries a real ellipsis. .qual is a pinned qualifier: the
   panel's hedges about what it is not sure of, which are exactly the wrong
   thing to lose to a long sentence, since what is left then reads as a
   confident prediction where a hedged one was meant. */
.tray-row .tx { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tray-row .qual { flex: 0 0 auto; }
.tray-row.err { color: var(--danger); }
/* The row between plain and red. Plain is a state the panel has observed and
   has nothing to add to; red is one that is wrong. This is the middle: in
   flight, owed, or commanded and never corroborated. All three used to render
   as plain 11.5px text, so the only colour in this tray appeared once a break
   had already overrun and there was nothing left to catch.
   Colour only, no background and no edge strip: it marks a row that is
   already there rather than adding anything to the 69px the body has. */
.tray-row.warn { color: var(--warn-text); }
.tray .chip {
  font: inherit; font-size: 11px; background: var(--surface-2);
  border: 1px solid var(--border); border-radius: 5px;
  padding: 2px 8px; cursor: pointer; color: var(--text);
}
.tray .btn { padding: 2px 10px; }
/* Global `select { padding: 9px 12px }` from tokens.css (sized for a full
   admin form field) rendered the fill picker at 36px tall, alone taller than
   this row's whole budget: found while re-checking the idle state's own fit
   after adding the capability-reason row below, not something the review
   flagged directly, but the same 69px discipline applies here too. */
.tray select { padding: 2px 8px; width: auto; }
/* GO is deliberately the loudest control in the tray: it is the only button
   that puts anything to air, and it only ever exists once a plan is armed. */
.tray .btn-go {
  background: var(--lamp-active); border-color: var(--lamp-active);
  color: #fff; font-weight: 700;
}
.tray .btn-cue {
  background: var(--lamp-ok); border-color: var(--lamp-ok);
  color: var(--lamp-ok-text); font-weight: 700;
}
/* Disabled controls must not read the same as live ones: same treatment as
   .src-btn:disabled elsewhere in this file (opacity + not-allowed, no other
   change), reused rather than reinvented. Before this, an operator role that
   makes every control here inert (a viewer, or a game missing the generator
   or inserter a specific control needs) was computed-style identical to a
   fully live one: same opacity, same pointer cursor. */
.tray .btn:disabled, .tray .chip:disabled, .tray select:disabled,
.tray-msg .btn:disabled {
  opacity: 0.6; cursor: not-allowed;
}
/* .tray-msg is the wedge notice, which is a sibling of .tray rather than a
   row inside it, so the rule above did not reach the one button that lives
   there. Found by computed style: the restart button refused by the server
   while a fill is on air came back opacity 1 and cursor pointer, identical to
   a live one, which is the exact state that rule was written to stop. */
.tray-msg { padding: 8px; font-size: 11.5px; }
.tray-msg.err { color: var(--danger); }

/* Break play-head. A meter, drawn as content inside the on-air row: not a
   strip on the edge of the tray, the card or anything else. House rule at the
   top of this file, and a bar attached to a container edge would also be the
   one shape an operator could not read a position off.

   The track is the longer of the fill and the break (see playhead.js), the
   lane is the fill's own length inside it, and the bar is how much of that
   has played. Drawing the lane at full length from the first frame is what
   makes a fill that undershoots its break readable before it ends rather than
   only afterwards: the lane stops short of the tick and stays there.

   flex: 0 0 auto, so this never shrinks. .tray-row is nowrap and clips from
   the right, and the two muted notes after it are the rows that give way; a
   shrinking meter would distort nothing (every part of it is a percentage of
   the track) but it would become unreadable, which is worse than losing a
   diagnostic number that is also in the audit.

   .ph-meter and not .ph: .ph was already taken, by the empty-slot number in
   the admin view-grid editor (.vg-slot .ph below, admin.js), and a bare .ph
   rule here reached it. It changed nothing visible, because .vg-slot is a
   flex container that centres its one child either way, which is exactly what
   makes it the kind of collision that surfaces later as a mystery. */
.ph-meter { display: inline-flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.ph-track { position: relative; width: 84px; height: 6px; flex: 0 0 auto; }
.ph-lane {
  position: absolute; left: 0; top: 0; height: 100%;
  border-radius: 3px; background: var(--lamp-off);
}
/* Amber, the same colour the ticker's own BREAK chip uses, so the two things
   counting the same break out read as one colour language. */
.ph-bar {
  position: absolute; left: 0; top: 0; height: 100%; width: 0;
  border-radius: 3px; background: var(--warn);
}
/* A fill that has stopped inside its own break: the bar is frozen where the
   fill got to and is drawn hollow, because a solid amber bar is this panel's
   word for a fill that is playing and nothing is playing here. Same colour,
   so it still reads as the same thing it was, and the same width it had at
   the instant it stopped, so nothing jumps. The lane and the mark stay put:
   what is left on screen is the record of how far the fill got and the target
   the count is now counting to. */
.ph-stopped .ph-bar {
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--warn);
}
/* Full contrast and taller than the track it crosses, so the mark reads
   against both the lane and the bar as the bar passes over it. A positioned
   element rather than a border on the lane, so it can sit beyond the end of
   the lane, which is exactly where an undershooting fill puts it. */
.ph-tick {
  position: absolute; top: -2px; width: 2px; height: 10px;
  border-radius: 1px; background: var(--text);
}
.ph-clock {
  font-size: 11px; color: var(--text); font-variant-numeric: tabular-nums;
  min-width: 32px;
}

.lamp-row { display: flex; align-items: center; gap: 6px; }
.lamp-row .leg {
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em;
  color: var(--muted); width: 34px; flex: 0 0 auto; text-transform: uppercase;
}
.lamp-row .clr {
  margin-left: auto; font-size: 10px; color: var(--muted);
  border: 1px solid var(--border); background: var(--surface-2);
  border-radius: 5px; padding: 1px 6px; cursor: pointer;
}
.lamp-row .clr:hover { border-color: var(--accent); color: var(--accent); }
/* An output row's name never wraps: it is composed by the panel and short,
   and "PGM 2" measures 33.6px of this 34px column in Fira Sans, so any wider
   rendering would put it on two lines and make its page taller than the
   pane. */
.lamp-row[data-output] .leg { white-space: nowrap; }
.lamps { display: flex; gap: 4px; }

/* The CAP lamp is not a seventh ETR-290 test. The six beside it measure this
   leg's own transport; CAP is measured on the SRT output, downstream of the
   switch, so it describes the programme and appears on exactly one row
   however many legs are drawn. Flush against the six it read as one of them.
   The rule marks where this leg's own evidence stops, and the cap says whose
   the rest is.

   --border is #2a3a5f in dark and disappeared against the pane, so the rule
   is drawn in the muted text colour held back to a hairline's worth. */
.cc-rule {
  align-self: stretch; width: 1px; flex: 0 0 auto;
  background: var(--muted); opacity: 0.38; margin: 0 3px;
}
/* PGM sits on the cap's own top line. It buys the whole label for 0px, which
   is what .g-lamps can afford: see the note in lamps.js ccCell. */
.lamp.cc { flex-direction: column; gap: 0; line-height: 1; }
.lamp.cc .cc-scope {
  font-size: 7px; font-weight: 700; letter-spacing: 0.09em; opacity: 0.75;
}
/* Lamps are drawn as backlit translucent keycaps, the way a hardware MCR
   panel reads: the colour comes from inside the cap rather than being painted
   on it, a specular sweep catches the top, the cap sits down in its bezel, and
   a lit one blooms slightly into the surround.

   The optics live here once. A state only ever sets --lamp-c, its own colour,
   and --lamp-glow, how much it throws. That keeps the tally palette the single
   source of truth for what a colour MEANS, which matters because these are
   safety indicators before they are decoration: an operator reads state off
   them at a glance and the four states have to stay instantly distinct. The
   glass must not cost contrast, so the specular is kept off the label's own
   band and the bloom is tight rather than hazy. */
.lamp {
  --lamp-c: var(--lamp-off);
  --lamp-glow: transparent;
  position: relative;
  width: 40px; height: 26px; border-radius: 6px;
  background:
    /* specular sweep, top third only, so it never washes the glyph */
    linear-gradient(180deg,
      rgba(255, 255, 255, 0.30) 0%,
      rgba(255, 255, 255, 0.07) 40%,
      rgba(255, 255, 255, 0) 54%),
    /* the lamp burning from behind, brightest just under the cap */
    radial-gradient(118% 88% at 50% 116%,
      rgba(255, 255, 255, 0.30) 0%,
      rgba(255, 255, 255, 0) 62%),
    var(--lamp-c);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.38),
    inset 0 -2px 3px rgba(0, 0, 0, 0.34),
    inset 0 0 0 1px rgba(255, 255, 255, 0.07),
    0 1px 2px rgba(0, 0, 0, 0.45),
    0 0 9px -1px var(--lamp-glow);
  display: flex; align-items: center; justify-content: center;
  font-size: 9.5px; font-weight: 700; color: var(--muted);
  border: 1px solid transparent;
  cursor: pointer;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
  transition: filter .1s ease, box-shadow .1s ease;
}
/* Unlit: dark glass with no source behind it. Still catches the room, so the
   cap reads as present rather than as a hole in the row. */
.lamp:not(.ok):not(.active):not(.latched):not(.warn) {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.16),
    inset 0 -2px 3px rgba(0, 0, 0, 0.40),
    inset 0 0 0 1px rgba(255, 255, 255, 0.05),
    0 1px 2px rgba(0, 0, 0, 0.45);
  text-shadow: none;
}
.lamp:hover { filter: brightness(1.2); }
.lamp.ok { --lamp-c: var(--lamp-ok); --lamp-glow: var(--lamp-ok); color: var(--lamp-ok-text); }
.lamp.active { --lamp-c: var(--lamp-active); --lamp-glow: var(--lamp-active); color: #fff; }
.lamp.latched { --lamp-c: var(--lamp-latched); --lamp-glow: var(--lamp-latched); color: var(--lamp-latched-text); }
.lamp .cnt {
  position: absolute; top: -5px; right: -4px;
  background: var(--text); color: var(--bg);
  font-size: 8.5px; padding: 0 3px; border-radius: 6px; min-width: 12px; text-align: center;
}

/* Audio meters (PCMMonitor: per-channel true-peak bars + loudness) */
.g-meters {
  display: flex; flex-wrap: nowrap; align-items: center; gap: 6px;
  margin-left: auto; min-width: 0;
  /* Meters keep priority over .g-avtabs for room in a tight row, same as
     the SRC buttons: only the tabs shrink. */
  flex-shrink: 0;
}
.g-meters .muted { font-size: 11px; }
.ameter {
  display: flex; align-items: flex-end; gap: 5px;
  padding: 3px 6px; background: var(--bg);
  border: 1px solid var(--border); border-radius: 5px;
}
/* The meter already has a 1px border; colour it. An inset ring on top of it
   doubles into a two-tone band that reads as a glazed edge rather than an
   alarmed one, and on a chip this small the band is a noticeable fraction of
   the whole control. */
.ameter.clipped { border-color: var(--danger); }
/* A clip that is ageing out. The Darwin latches its alert and reports it
   forever, so a border driven straight off that flag says "clipped once" and
   an operator learns to ignore red. The panel times the alert instead: red
   while it is fresh, amber while it ages, gone when the panel clears the
   latch. Audio still clipping re-latches immediately and goes back to red,
   which is the distinction a latch cannot draw on its own. */
.ameter.clipped.ageing { border-color: var(--warn); }
/* Not reporting: the bars and the figure arrive already empty, so this only
   has to keep the empty box from reading as a measured silence. Dimmed, same
   border, same size, so the strip does not reflow. */
.ameter.stale { opacity: 0.5; }
.am-lbl {
  align-self: center; min-width: 20px;
  font-size: 9.5px; font-weight: 700; color: var(--muted); letter-spacing: 0.02em;
}
.am-bars { display: flex; align-items: flex-end; gap: 1.5px; height: 22px; }
.am-none { width: 12px; }
.am-ch {
  width: 4px; height: 100%; background: var(--border); border-radius: 1px;
  display: flex; align-items: flex-end; overflow: hidden;
}
.am-ch i { display: block; width: 100%; height: 0; background: var(--ok); transition: height 0.25s ease; }
.am-ch.low i  { background: #2f8f57; }
.am-ch.ok i   { background: var(--ok); }
.am-ch.hot i  { background: var(--warn); }
.am-ch.clip i { background: var(--danger); }
.am-lufs {
  align-self: center; font-size: 10px; font-weight: 700; color: var(--text);
  font-variant-numeric: tabular-nums;
}
.am-lufs i { font-size: 7.5px; font-weight: 600; font-style: normal; color: var(--muted); margin-left: 2px; }
.ameter.clipped .am-lufs { color: var(--danger); }

/* SCTE ticker */
.g-scte {
  padding: 6px 8px 6px 10px;
  /* Flex column, not grid: a fixed-height grid sizes its auto rows to fit the
     container, which crushed each event line to 2px instead of overflowing. */
  display: flex; flex-direction: column; gap: 3px; min-height: 0;
  flex: 1 1 auto; min-width: 0;
  /* Scroll the ticker rather than growing the row: the video above absorbs the
     slack, so an unbounded SCTE list would steal picture area. */
  max-height: 84px; overflow-y: auto; overscroll-behavior: contain;
}
.g-scte > * { flex: 0 0 auto; }
.g-scte::-webkit-scrollbar { width: 8px; }
.g-scte::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.g-scte::-webkit-scrollbar-track { background: transparent; }
/* The SCTE-35 header scrolls away with the events rather than sticking to the
   top of the ticker. It is a static label, not data: once an operator has
   scrolled into the list they already know what they are reading, and the
   pinned row was spending a line of a 84px-tall pane on saying so. */
.g-scte .scte-hd {
  display: flex; align-items: center; gap: 8px; padding-bottom: 2px;
}
.g-scte .scte-hd .t { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
/* The active break's countdown. It lives in the card's title row now, not in
   this ticker's header: measured at 1366x768 with six cards it was 0 x 0 in
   here on the Break tab, whose pane is display:none, and 49 x 32.78px sitting
   65.67px past the right edge of a .g-scte rendered 30px wide on the Lamps
   tab. The Break tab is the one holding CUE IN, so an operator watching a
   break was on the tab where the chip did not exist, and a marker-only break
   draws no play-head either, so that state had no countdown anywhere at all.
   No margin-left: auto any more, because the title row's own spacer does that
   job; flex: 0 0 auto and nowrap because at 49px in the ticker it wrapped onto
   two lines and stood 32.78px tall in a row built for one. */
.brk-countdown {
  font-size: 11px; font-weight: 700;
  color: #241a02; background: var(--warn); padding: 1px 8px; border-radius: 999px;
  font-variant-numeric: tabular-nums;
  flex: 0 0 auto; white-space: nowrap;
}
/* The spot on air inside the break, after the break's own figure in the same
   pill. A shade lighter, because the break's figure is the one the break is
   run to and the spot is the detail inside it. */
.brk-countdown .brk-spot { font-weight: 600; }
/* The chip's own Clear, inside the pill. Sized to the chip's text and drawn
   in the chip's own ink so it reads as part of the break rather than as a
   second control in the title row; the hover is what says it is pressable.
   Operator role only, so a viewer's chip is the pill and nothing more. */
.brk-countdown .brk-clear {
  font: inherit; line-height: 1; color: inherit; background: none; border: 0;
  margin: 0 -5px 0 3px; padding: 0 2px; border-radius: 999px;
  cursor: pointer; opacity: 0.7;
}
.brk-countdown .brk-clear:hover { opacity: 1; background: rgba(0, 0, 0, 0.15); }
/* A break this panel opened, never closed, and found written down when it came
   back. The same pill in the same slot as the countdown above, because it
   answers the same question and an operator's eye goes to one place for it;
   danger rather than warn because a break nothing is working on outranks one
   that is running to plan, and the two must never be mistaken for each other
   at a glance across six cards. Nothing here spends card height. */
.brk-unresolved {
  font-size: 11px; font-weight: 700;
  color: #fff; background: var(--danger); padding: 1px 8px; border-radius: 999px;
  font-variant-numeric: tabular-nums;
  flex: 0 0 auto; white-space: nowrap;
}
.scte-line {
  display: flex; gap: 8px; align-items: baseline;
  font-size: 11.5px; font-variant-numeric: tabular-nums;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.scte-line .ts { color: var(--muted); }
.scte-line .kind { font-weight: 700; width: 30px; }
.scte-line .kind.out { color: var(--warn); }
.scte-line .kind.in { color: var(--accent-2); }
.scte-line .kind.sig { color: var(--accent); }
.scte-line .seg { overflow: hidden; text-overflow: ellipsis; }
.scte-line .src { color: var(--muted); font-size: 10px; margin-left: auto; }
.g-scte .empty { color: var(--muted); font-size: 11px; font-style: italic; }

/* health strip */
/* One row, always. Wrapping looks harmless until an alarm chip appears: the
   card height is fixed by the wall grid, so a second row is taken straight out
   of the video, which resizes mid-break exactly when someone is watching it.
   Chips are ordered alarms-first, so what scrolls out of reach under pressure
   is an output bitrate rather than a fault. */
.g-health {
  padding: 6px 8px; display: flex; gap: 6px; flex-wrap: nowrap; align-items: center;
  background: var(--surface-2);
  overflow-x: auto; overflow-y: hidden;
  scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
.g-health::-webkit-scrollbar { height: 4px; }
.g-health::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.g-health::-webkit-scrollbar-track { background: transparent; }
.g-health > * { flex: 0 0 auto; }
.hchip {
  font-size: 10.5px; display: inline-flex; gap: 5px; align-items: center;
  border: 1px solid var(--border); border-radius: 5px; padding: 2px 7px;
  font-variant-numeric: tabular-nums;
}
.hchip .k { color: var(--muted); }
.hchip.alarm { border-color: var(--danger); color: var(--danger); }
.hchip.warn { border-color: var(--warn); color: var(--warn); }

/* The row's three runs: legs and their alarms, outputs, the caption chip.
   Each is laid out like the row itself, so the chips still read as one line,
   and an empty run takes no gap. */
.g-health > .hseg { display: inline-flex; gap: 6px; align-items: center; }
.g-health > .hseg:empty { display: none; }
/* A listener nobody has dialled. The surround goes red: an SRT output with no
   caller means nothing downstream is pulling the feed, which is the thing an
   operator needs to see from across the room. The panel still cannot tell a
   feed nobody has dialled yet from one that dropped, so the value stays the
   muted "no caller" and claims neither; the colour is what says it matters.
   Border only, deliberately: .hchip.alarm colours the text as well, and this
   is a missing consumer, not a fault in the stream. */
.hchip.idle { border-color: var(--danger); }
/* A live leg measured at 0 b/s: a dead input feed. Red surround, the value
   left alone, for the reason .hchip.idle gives. See renderLegHealth. */
.hchip.nosignal { border-color: var(--danger); }
.hchip.idle .v { color: var(--muted); }

/* An admin-only copy control: the chip is an <a>, so it has to be talked out
   of looking like a browser link. The affordance is the cursor and a dotted
   underline on the label, never a border colour, because border-color on this
   element already means alarm or warn and a copyable chip must not read as a
   fault. */
.hchip.copyable {
  color: inherit; text-decoration: none; cursor: pointer;
}
.hchip.copyable .k { text-decoration: underline dotted; text-underline-offset: 2px; }
.hchip.copyable:hover, .hchip.copyable:focus-visible { border-color: var(--muted); }
/* The copy affordance's hover must not wash out the no-caller alarm, which it
   would, being more specific than .hchip.idle. The dotted underline and the
   pointer already say the chip is clickable. */
.hchip.copyable.idle:hover, .hchip.copyable.idle:focus-visible { border-color: var(--danger); }
.hchip.copyable.opened .k { text-decoration: none; }
.hchip.copyable.opened .v { color: var(--ok, var(--muted)); }

/* system tile (6th cell) */
.system {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px;
  display: flex; flex-direction: column; gap: 10px; min-height: 0; overflow: auto;
}
.system h2 { font-size: 13px; font-weight: 650; margin: 0; }
.system .sys-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; }
.system .sys-sec { border-top: 1px solid var(--border); padding-top: 8px; }
.system .audit-line { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }

/* ===== enlarge modal ===== */
.modal-backdrop {
  position: fixed; inset: 0; background: rgba(0,0,0,0.8);
  display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px;
}
.modal-backdrop.open { display: flex; }
.modal {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  max-width: 90vw; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column;
}
.modal .m-head { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.modal .m-head .spacer { flex: 1; }
/* The pop-out's Captions selector. The global select padding from tokens.css
   is sized for a full-width form field; see .tray select. */
.modal .m-track { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.modal .m-track select { padding: 2px 8px; width: auto; }
.modal video { max-width: 88vw; max-height: 72vh; background: #000; }
/* The modal's video, wrapped so the caption text can sit on it. display:block
   keeps the gap under an inline video's line box out from under the picture. */
.modal .m-video { position: relative; }
.modal .m-video video { display: block; }
/* A layer exactly over the video, and the overlay's query container, so the
   text is sized from the picture as it is on a card. The wrapper cannot be the
   container itself: it takes its width from the video inside it, and an
   inline-size container may not take its width from its content. This layer
   takes its width from the wrapper instead, and lets every pointer through to
   the video's own controls. */
.modal .m-cc { position: absolute; inset: 0; container-type: inline-size; pointer-events: none; }
/* At the top, as on the card and for the same reason: the score bug is in
   the bottom of the picture. This also keeps the words clear of the native
   controls' bar without having to know how tall it is. */
.m-cc .v-cc {
  position: absolute; left: 4%; right: 4%; top: 12px;
  font-size: clamp(14px, 2.4cqi, 32px);
}
.modal .m-meters { display: flex; gap: 10px; padding: 8px 12px; flex-wrap: wrap; }

/* ===== login ===== */
.login-wrap {
  min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 24px;
}
.login-card {
  width: 340px; background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 24px; box-shadow: var(--shadow-lift);
}
.login-card h1 { font-size: 18px; margin: 0 0 4px; }
.login-card .sub { color: var(--muted); font-size: 13px; margin: 0 0 18px; }
.login-card label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 4px; }
.login-card input {
  width: 100%; padding: 9px 11px; border: 1px solid var(--border);
  border-radius: var(--radius-sm); background: var(--surface-2); color: var(--text); font: inherit;
}
.login-card input:focus { outline: none; border-color: var(--accent); box-shadow: var(--focus-ring); }
.login-card button { width: 100%; margin-top: 18px; }
.login-card .brand { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; font-weight: 700; }
.login-card .brand .mark { color: var(--accent); font-size: 20px; }

/* ===== admin ===== */
.admin-wrap { max-width: 1200px; margin: 0 auto; padding: 20px; }
/* The API reference wants the width: its own sidebar, text and examples. */
.admin-wrap:has(.apidoc-host) { max-width: none; }
.apidoc-intro { margin-bottom: 12px; }
.apidoc-host { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; min-height: 60vh; }
.admin-nav { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; }
.admin-nav a {
  padding: 6px 12px; border: 1px solid var(--border); border-radius: 8px;
  color: var(--text); text-decoration: none; font-size: 13px; background: var(--surface);
}
.admin-nav a:hover { border-color: var(--accent); color: var(--accent); }
.admin-nav a.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; margin-bottom: 16px; }
.panel h2 { font-size: 15px; margin: 0 0 12px; }
/* An admin panel stating a caveat rather than offering a control. Whole
   border and heading, never an edge strip. */
.panel.warn { border-color: var(--warn); }
.panel.warn h2 { color: var(--warn); }
table.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
table.tbl th, table.tbl td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
table.tbl th { color: var(--muted); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.03em; }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.form-grid label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.form-grid input, .form-grid select {
  width: 100%; padding: 8px 10px; border: 1px solid var(--border);
  border-radius: var(--radius-sm); background: var(--surface-2); color: var(--text); font: inherit;
}
.form-grid .full { grid-column: 1 / -1; }
/* A boolean rendered as a checkbox rather than a Yes/No select. Scoped the
   same way .pick-list is below, to outrank .form-grid's block label and
   full-width input rules, which would otherwise stretch the box across the
   column and stack its caption above it. */
.form-grid .check-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
.form-grid .check-row input[type="checkbox"] { width: auto; margin: 0; flex: 0 0 auto; }
.form-grid .check-row label {
  margin: 0; font-size: 12.5px; color: var(--text); cursor: pointer;
}
.row-actions { display: flex; gap: 6px; }
.form-grid .more-info {
  border: 1px solid var(--border); border-radius: 6px; padding: 0 12px;
  background: var(--surface-2);
}
.form-grid .more-info summary {
  cursor: pointer; list-style: none; padding: 8px 0; font-size: 12px;
  color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em;
  user-select: none;
}
.form-grid .more-info summary::-webkit-details-marker { display: none; }
.form-grid .more-info summary::before {
  content: "\25B8"; display: inline-block; width: 14px; transition: transform 0.15s;
}
.form-grid .more-info[open] summary::before { transform: rotate(90deg); }
.form-grid .more-info summary:hover { color: var(--accent); }
.form-grid .more-info[open] summary { border-bottom: 1px solid var(--border); }
.form-grid .more-info p { margin: 10px 0; }

/* Admin System page. The heavy card is told apart by its heading colour and
   a tinted ground, never an edge strip. */
.sys-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; margin-bottom: 16px; }
.sys-actions .panel { margin-bottom: 0; display: flex; flex-direction: column; gap: 10px; }
.sys-actions .panel p { flex: 1; margin: 0; }
.sys-actions .panel .btn { align-self: flex-start; }
.sys-card.sys-heavy { background: color-mix(in srgb, var(--danger) 8%, var(--surface)); }
.sys-card.sys-heavy h2 { color: var(--danger); }
.sys-confirm { width: min(520px, 92vw); }
.sys-confirm-body { padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
.sys-confirm-body p { margin: 0; }
.sys-confirm-body .row-actions { justify-content: flex-end; }
/* A label, so tokens.css makes it bold with a top margin, and a checkbox, so
   tokens.css makes it width: 100%, which put the box alone in half the row and
   the sentence in bold in the other half. Both undone here. */
.sys-force { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; cursor: pointer; font-weight: 400; margin: 0; }
.sys-force input { margin: 2px 0 0; width: auto; flex: 0 0 auto; }
ul.sys-breaks { margin: 6px 0 0; padding-left: 18px; }
ul.sys-breaks li { color: var(--warn-text, var(--warn)); }

/* System > Theme: one card per theme in app/themes.py, laid out to take as
   many as are added. The chosen one carries a full accent border and a tint,
   never an edge strip. The swatch is drawn from each theme's own colors,
   inline, so it shows the theme rather than the one this page is in. */
.theme-picker { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 12px; }
.theme-opt {
  display: grid; grid-template-columns: auto 1fr; gap: 6px 10px;
  align-items: center; align-content: start;
  border: 1px solid var(--border); border-radius: var(--radius); padding: 12px;
  background: var(--surface-2); cursor: pointer;
  font-weight: 400; margin: 0;   /* tokens.css styles every label as a form caption */
}
.theme-opt:hover { border-color: var(--accent); }
.theme-opt:has(input:checked) {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, var(--surface));
}
.theme-opt:has(input:focus-visible) { box-shadow: var(--focus-ring); }
.theme-opt:has(input:disabled) { cursor: progress; }
.theme-opt input { margin: 0; grid-row: 2; width: auto; }
.theme-opt .theme-swatch { grid-column: 1 / -1; }
.theme-opt .theme-name { font-weight: 650; grid-row: 2; }
.theme-opt .theme-blurb { grid-column: 1 / -1; font-size: 12.5px; }
.theme-swatch {
  display: flex; align-items: center; justify-content: center;
  height: 72px; border-radius: 6px; padding: 12px 16px;
  border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
}
.theme-swatch .ts-card {
  flex: 1; height: 100%; border-radius: 4px;
  display: flex; align-items: center; gap: 8px; padding: 0 10px;
}
.theme-swatch .ts-text { width: 34%; height: 5px; border-radius: 3px; opacity: 0.85; }
.theme-swatch .ts-accent { width: 22px; height: 10px; border-radius: 3px; }
.theme-swatch .ts-accent + .ts-accent { margin-left: -4px; }
.theme-swatch .ts-tally { display: flex; gap: 4px; margin-left: auto; }
.theme-swatch .ts-tally i { width: 14px; height: 10px; border-radius: 2px; display: block; }

.toast-host { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 200; }
.toast {
  background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
  padding: 10px 14px; font-size: 13px; box-shadow: var(--shadow-lift); max-width: 360px;
}
.toast.ok { border-color: var(--accent-2); }
.toast.err { border-color: var(--danger); }
.toast.warn { border-color: var(--warn); }

.muted { color: var(--muted); }
/* Not inside the API reference: Scalar's own markup says `hidden lg:flex` for
   its sidebar, and this rule's !important would keep it hidden at any width. */
.hidden:not(.scalar-app .hidden) { display: none !important; }

/* --- PID monitor (Services + Packets, colored moving bitrate graphs) --- */
.pids-btn { padding: 1px 8px; font-size: 11px; }
.modal.wide { width: min(1080px, 94vw); }
.pm-host { padding: 8px 10px 12px; max-height: 78vh; overflow: hidden; display: flex; }
.pidmon { display: flex; flex-direction: column; gap: 6px; width: 100%; min-height: 0; }

.pm-legs { display: flex; gap: 6px; align-items: center; }
.pm-leg {
  font: inherit; font-size: 12px; padding: 3px 12px; border-radius: 999px;
  background: var(--surface-2); color: var(--muted);
  border: 1px solid var(--border); cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
}
.pm-leg:hover { border-color: var(--accent); color: var(--text); }
.pm-leg.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.pm-live { width: 6px; height: 6px; border-radius: 50%; background: #fff;
  box-shadow: 0 0 0 2px rgba(255,255,255,.25); }
/* Between the legs and the programme's streams: what arrived, then what is
   sent. Drawn like the rule before a lamp row's CAP cell, and for the same
   reason: --border disappears against this surface in dark. */
.pm-rule { align-self: stretch; width: 1px; background: var(--muted); opacity: 0.38; margin: 2px 4px; }

.pm-scroll { overflow-y: auto; min-height: 0; padding-right: 4px; }
.pm-sec {
  position: sticky; top: 0; z-index: 2; background: var(--surface);
  font-weight: 650; font-size: 12px; letter-spacing: .02em; text-transform: uppercase;
  color: var(--muted); padding: 8px 2px 4px; border-bottom: 1px solid var(--border);
}
.pm-muxbr { text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--text); margin-left: 8px; }
.pm-svc { display: flex; gap: 8px; align-items: baseline; padding: 6px 4px 3px; font-size: 12.5px; }
.pm-svc .muted { font-size: 11.5px; }

.pm-cols, .pm-row {
  display: grid;
  grid-template-columns: 50px 18px minmax(120px, 1fr) 42px 96px 150px 52px;
  align-items: center; gap: 8px;
}
.pm-cols { padding: 2px 6px; font-size: 10.5px; text-transform: uppercase;
  letter-spacing: .03em; color: var(--muted); }
.pm-cols span:nth-child(4), .pm-cols span:nth-child(5), .pm-cols span:nth-child(7) { text-align: right; }

.pm-row { padding: 3px 6px; border-radius: 7px; font-size: 12.5px; }
.pm-row:hover { background: var(--surface-2); }
.pm-row.stale { opacity: .42; }
.pm-pid {
  font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px;
  text-align: center; color: var(--text); background: var(--surface-2);
  border: 1px solid var(--border); border-radius: 5px; padding: 1px 0;
}
.pm-dot { text-align: center; font-size: 12px; line-height: 1; }
.pm-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.pm-lock { font-size: 10px; }
.pm-cc { text-align: right; font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 11.5px; color: var(--muted); }
.pm-cc.bad { color: var(--warn); font-weight: 600; }
.pm-bps { text-align: right; font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 11.5px; color: var(--text); font-variant-numeric: tabular-nums; }
.pm-spark { height: 26px; }
.pm-spark canvas { width: 100%; height: 26px; display: block; }
.pm-pct { text-align: right; font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.pm-empty { padding: 24px 8px; text-align: center; }
/* The analysis module has stopped sending frames. The services and PIDs are
   the shape of the multiplex and stay; every rate on them is gone, so the
   table is a picture of what was there rather than a measurement of what is.
   .pm-row.stale above means a PID carrying nothing, which is a measurement,
   so this is a separate word on a separate element. */
.pm-stalemark { color: var(--poll); font-weight: 650; }
.pm-notlive .pm-row { opacity: 0.72; }

/* Admin views: game / view picker lists.
   Selectors are .pick-list-scoped to outrank the generic .form-grid label/input
   rules (which force block labels and full-width inputs). */
.pick-list { display: flex; flex-direction: column; gap: 6px; max-height: 300px; overflow-y: auto; padding: 4px 2px; }
.pick-list .pick { display: flex; align-items: center; gap: 8px; font-size: 13px; margin: 0; color: var(--text); }
.pick-list .pick > span { min-width: 170px; flex: 0 0 auto; }
.pick-list .pick input[type="checkbox"],
.pick-list .pick input[type="radio"] { width: auto; margin: 0; flex: 0 0 auto; }
.pick-list .pick .ord { width: 54px; flex: 0 0 auto; padding: 3px 6px; }
.pick-list .pick .def { color: var(--muted); font-size: 11px; display: inline-flex; align-items: center; gap: 3px; margin: 0 0 0 auto; }

/* Wall tile re-arranging: drag a card by its title bar onto another to swap */
.game .g-title { cursor: grab; }
.game .g-title:active { cursor: grabbing; }
.game.dragging { opacity: 0.55; }
.game.drop-target { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Admin view editor: draggable game tiles in two zones */
.vg-zone {
  display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
  min-height: 52px; padding: 10px;
  border: 1px dashed var(--border); border-radius: 8px;
}
.vg-zone.over { border-color: var(--accent); }

/* the panel grid mirroring the wall: three columns, as many rows as the view
   needs. admin.js sizes it (viewgrid.js), and the rows here are implicit. */
.vg-grid {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 8px;
}
.vg-slot {
  min-height: 64px; padding: 6px;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--border); border-radius: 8px;
  background: var(--surface-2);
}
.vg-slot.empty { border-style: dashed; background: transparent; }
.vg-slot .ph { font-size: 20px; font-weight: 700; color: var(--muted); opacity: 0.45; }
.vg-slot.over { border-color: var(--accent); border-style: solid; box-shadow: inset 0 0 0 1px var(--accent); }
.vg-slot .vg-tile {
  width: 100%; background: transparent; border: none; padding: 2px 4px;
  justify-content: flex-start;
}
.vg-slot .vg-tile .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vg-tile .net {
  font-style: normal; font-size: 9.5px; font-weight: 700; letter-spacing: 0.05em;
  color: var(--muted); border: 1px solid var(--border);
  padding: 0 5px; border-radius: 4px; text-transform: uppercase;
}
.vg-note { font-size: 11px; margin: 6px 0 0; }
.vg-empty { font-size: 12px; }
.vg-tile {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 6px 10px; font-size: 13px; user-select: none; cursor: grab;
  background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px;
}
.vg-tile:active { cursor: grabbing; }
.vg-tile.dragging { opacity: 0.5; }
.vg-tile .pos {
  font-style: normal; font-size: 10.5px; font-weight: 700;
  min-width: 18px; height: 18px; border-radius: 999px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--accent-2); color: #fff;
}
.vg-tile .x {
  border: none; background: none; color: var(--muted);
  font: inherit; font-size: 18px; line-height: 1; padding: 2px 6px; cursor: pointer;
}
.vg-tile .x:hover { color: var(--danger); }
.vg-tile.add { border-style: dashed; }
.vg-tile.add .plus { font-style: normal; font-weight: 700; color: var(--accent); }

/* Closed-caption cell in the lamp row (ccmon sidecar / Darwin extractor) */
.lamp.cc { letter-spacing: 0.04em; }
.lamp.cc.warn { --lamp-c: var(--warn); --lamp-glow: var(--warn); color: #241a02; }
/* The CAP lamp over the preview is also the game's caption-text switch, and
   so is the "cc out" chip for a post-switch tap. A pointer and a focus ring,
   and nothing else: the colour is the compliance answer and does not move
   with the switch. */
[data-cc-toggle] { cursor: pointer; }
[data-cc-toggle]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* --- /captions: one large lamp per monitored leg --------------------------- */
.hdr-link { color: var(--muted); text-decoration: none; margin-left: 10px; font-size: 13px; }
.hdr-link:hover { color: var(--accent); }
main.captions { padding: 18px; }
.captions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.cap-tile {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius, 10px);
  padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; align-items: center; text-align: center;
}
.cap-head { display: flex; gap: 8px; align-items: baseline; width: 100%; justify-content: center; }
.cap-game { font-weight: 600; font-size: 15px; }
.cap-net { color: var(--muted); font-size: 12px; }
.cap-leg { color: var(--muted); font-size: 12px; }
.cap-leg b { color: var(--text); margin-right: 4px; }
.lamp.cc.big { width: 150px; height: 84px; border-radius: 12px; font-size: 22px; cursor: default; margin: 6px 0 2px; }
.lamp.cc.big .cc-scope { font-size: 11px; opacity: .8; margin-bottom: 2px; }
.cap-state { font-size: 13px; font-weight: 600; }
.cap-detail { color: var(--muted); font-size: 12px; line-height: 1.5; }
/* A tile's caption rows: the wall's boxes, in the flow, all four rows. */
.cap-tile .v-cc { max-width: 100%; font-size: 13px; margin-top: 4px; }
/* One height for every tile, whatever it is showing (Bo, 2026-09-18). The
   grid row takes its tallest tile, so a tile that grew as captions arrived
   and shrank as they cleared moved every tile in its row. Each part that
   could change height has a fixed one instead:
     the caption rows  a slot of exactly MODAL_ROWS (4) rows, there even when
                       there are none: a clear still leaves no black box, but
                       the space stays. 4 x 1.4em a row (line-height 1.3 plus
                       0.1em padding) and 3 x 0.15em gaps, at the rows' 13px.
                       Rows never wrap: .v-cc-row is nowrap with an ellipsis.
     the detail lines  seven, the most tile() can emit, scrolling past that
                       rather than clipping, so no diagnostic is ever hidden.
     one-line texts    the game, leg and state cannot wrap to two lines in the
                       narrowest column; the game's full name is its title. */
.cap-tile .cap-head, .cap-tile .cap-leg, .cap-tile .cap-state {
  max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cap-tile .cap-game { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.cap-tile .cap-net { flex: none; }
.cap-tile .cap-detail { width: 100%; height: calc(7 * 1.5em); overflow-y: auto; }
.cap-tile .cap-text {
  width: 100%; font-size: 13px; margin-top: 4px; overflow: hidden;
  height: calc(4 * 1.4em + 3 * 0.15em + 1px);
  display: flex; justify-content: center; align-items: flex-start;
}
.cap-tile .cap-text .v-cc { margin-top: 0; }
.captions-empty { padding: 40px; text-align: center; }

/* All-to-slate: the per-game result column. Same vocabulary as an action
   button - confirmed is green, still-waiting is amber, refused is red, and a
   game the batch deliberately left alone is muted rather than alarming. */
.as-confirmed { color: var(--ok); font-weight: 600; }
.as-unconfirmed { color: var(--warn); font-weight: 600; }
.as-failed { color: var(--danger); font-weight: 600; }
.as-skipped, .as-already { color: var(--muted); font-weight: 600; }

/* The Break tray's webhook cues (breaktray.renderCueTray). Compact, since two
   pairs and their labels share one 11.5px row; armed reads TAKE in the slates'
   amber with the same pulse, so there is one armed look on the card. */
.tray-row .cue-btn { padding: 1px 8px; font-size: 11px; }
.tray-row .k + .cue-btn { margin-left: -2px; }
.tray-row .cue-btn + .k { margin-left: 10px; min-width: 0; }
.btn.cue-btn.cue-armed {
  background: var(--lamp-latched); border-color: var(--lamp-latched);
  color: var(--lamp-latched-text);
  animation: srcpulse 0.6s ease-in-out infinite;
  font-weight: 700; letter-spacing: 0.04em;
}
@media (prefers-reduced-motion: reduce) {
  .btn.cue-btn.cue-armed { animation: none; outline: 2px solid var(--lamp-latched); }
}

/* The armed half of the admin page's two-press take. Borrows the wall's
   srcpulse and its amber rather than inventing a second armed look: an admin
   who has armed a slate on a card already knows what this means. */
.btn.as-btn.as-armed {
  background: var(--lamp-latched); border-color: var(--lamp-latched);
  color: var(--lamp-latched-text);
  animation: srcpulse 0.6s ease-in-out infinite;
  font-weight: 700; letter-spacing: 0.04em;
}
@media (prefers-reduced-motion: reduce) {
  /* The label already reads TAKE, so losing the flash loses nothing. */
  .btn.as-btn.as-armed { animation: none; outline: 2px solid var(--lamp-latched); }
}

/* Device tokens on /account: a second card under the password one. */
.account-wrap:has(.token-card, .plugin-card) { flex-direction: column; align-items: center; gap: 16px; }
.account-wrap:has(.token-card, .plugin-card) .login-card { width: min(440px, 100%); }
.plugin-card .plugin-steps { margin: 14px 0 10px; padding-left: 20px; }
.plugin-card .plugin-steps li { margin: 6px 0; }
.token-card .token-list { margin: 4px 0 8px; }
.token-card .token-list td form { margin: 0; }
.token-card .token-list button { width: auto; margin: 0; }
.token-card .token-secret { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
/* The same one-time secret box on Admin > Users, outside a token-card. */
.panel .notice { margin-bottom: 12px; }
.panel .notice .token-secret { display: block; width: 100%; box-sizing: border-box; margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }

/* ===== State log (/log) ===== */
/* tokens.css makes every label a block with a 14px margin and every input
   full width with form padding, which is right for the admin forms and turned
   this row's checkboxes into boxes the size of buttons. Everything in the row
   is reset here first. */
.sl-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin: 14px 0 10px; font-size: 12.5px; color: var(--muted); }
.sl-filters label { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-weight: 400; white-space: nowrap; }
.sl-filters select, .sl-filters input[type="datetime-local"] {
  width: auto; padding: 5px 8px; border: 1px solid var(--border);
  border-radius: var(--radius-sm); background: var(--surface-2); color: var(--text); font: inherit;
}
.sl-filters #sl-custom { display: inline-flex; gap: 14px; }
.sl-filters #sl-custom[hidden] { display: none; }
.sl-kinds { display: flex; flex-wrap: wrap; gap: 6px; flex-basis: 100%; }
/* A type filter is a small toggle: the chip is the control and its fill is
   the state, so the checkbox itself is kept for the keyboard and a screen
   reader and not drawn. */
.sl-filters .sl-kind { gap: 0; padding: 3px 10px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 12px; line-height: 1.5; cursor: pointer; user-select: none; }
.sl-filters .sl-kind input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; border: 0; opacity: 0; pointer-events: none; }
.sl-filters .sl-kind:hover { border-color: var(--accent); color: var(--text); }
.sl-filters .sl-kind:has(input:checked) { border-color: var(--accent); color: var(--text); background: color-mix(in srgb, var(--accent) 16%, var(--surface)); }
.sl-filters .sl-kind:has(input:focus-visible) { box-shadow: var(--focus-ring); }
/* Shown only while something is filtered. A text button, so it does not read
   as one more filter chip. */
.sl-reset { margin-left: auto; padding: 3px 6px; border: 0; background: none; color: var(--accent); font: inherit; font-size: 12px; cursor: pointer; }
.sl-reset:hover { text-decoration: underline; }
.sl-reset[hidden] { display: none; }
.sl-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12.5px; }
.sl-table td { vertical-align: top; }
.sl-utc { display: block; font-variant-numeric: tabular-nums; white-space: nowrap; }
.sl-local { display: block; font-size: 11.5px; }
.sl-pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 600; white-space: nowrap;
  color: var(--pill, var(--muted)); background: color-mix(in srgb, var(--pill, var(--muted)) 14%, var(--surface)); }
.sl-etr290, .sl-ingress_los, .sl-egress_loss, .sl-audio_loss { --pill: var(--danger); }
.sl-captions_loss, .sl-telemetry_stale, .sl-auto_switch { --pill: var(--warn); }
.sl-link_down { --pill: var(--accent); }
tr.sl-open { background: color-mix(in srgb, var(--danger) 6%, var(--surface)); }
.sl-ongoing { color: var(--danger); font-weight: 600; }
#sl-more { margin-top: 12px; }
@media (max-width: 720px) {
  .sl-table th:nth-child(5), .sl-table td:nth-child(5) { display: none; }
}
