/* SUB/WAVE call-in widget styles. Served by token_server as /style.css.

   THEMING CONTRACT (HOST-STYLE-GUIDE.md §2). Colour is named once, here, and
   nothing below may hardcode a value: if you need a tint, derive it with
   color-mix() from a token. A literal is a bug the moment a host repaints the
   widget with a palette of its own.

   Light and dark are deliberately NEUTRAL. 0.9.92 tuned them to one station's
   cream-and-pine page, and the result read as built-for-that-page everywhere
   else — which is the opposite of what a widget dropped onto pages nobody
   here has seen needs. Station colour is a THIRD theme layered on top: the
   host posts a token map, app.js sets these same custom properties on :root,
   and the neutral base is what shows through anywhere it doesn't.

   The token names stay the host's (--pine, --coral…) because that is the map
   a station sends. The older names (--bg, --panel, --line…) are kept as
   ALIASES: the settings panel is ~450 lines written against them and is
   full-page only, so it is not worth the churn of a rename.

   WHAT IS WHERE. Both pages load this file: only 193 of its lines are
   panel-only, and 308 are used by both, so splitting it would leave two files
   that still need each other. The sections below are the map instead.

     tokens and theme          the colour contract above, as custom properties
     base and typography       body, .micro
     the call card             identity, on-air state, the track
     buttons                   including every #callBtn state
     on-call chrome            the rig, pills, timer, agent-state chip
     level meters              the two bar meters
     the call row              volume, mute, hang up, status
     mic help and guest door   the two things that block a call before it starts
     the connection dot
     the ended bar             the collapsed transcript
     captions                  what was said, and what the DJ did
     corner controls           the three gear buttons and the ask popup
     settings                  the panel page (panel.html) only
*/
  /* --- tokens and theme -------------------------------------------------- */
  /* Named once. Nothing below may hardcode a colour — derive with color-mix(). */
  :root {
    color-scheme: light dark;
    /* Dark is the default look — it's a late-night radio booth. Light mode
       exists for embedding on pages that aren't. */
    --pine: #0b0b0c; --granite: #111113; --granite-hi: #1a1a1d;
    --alpenglow: #f2f0ec; --sage: #9a9aa2; --sage-dim: #6f6f78;
    --coral: #e0533d; --amber: #d8a13a;
    --hairline: #26262a; --edge: #2b2b31;
    --ok: #47b877; --cool: #4a8b8d;
    --shadow: none;

    /* System stacks, deliberately. The widget ships no font files and makes
       no third-party request: what carries the design is the mono uppercase
       micro-label against a sans body, and every platform has both. */
    --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas,
                 "Liberation Mono", monospace;
    --font-body: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
                 sans-serif;
    --control-h: 31px; --band-gap: 12px;
    /* The action row — Call, the state chip, the timer — is a status ribbon
       you read, not a row of things you press three of. At full control
       height it was the tallest band on a card whose actual subject is the
       conversation below it. */
    --action-h: 26px;
    /* FOUR lines of 12.5px/1.45 speech (18.125px each), plus the box's own
       9px padding top and bottom and its 1px border. Reserved from first
       paint and never changed — see .linebox. Was three; the operator asked
       for more of the conversation on screen (2026-08-09), and on the
       standalone page the card is the only thing there — the extra line is
       the cheapest reading surface available. The embed keeps two lines
       (--lines-h below in body.compact): that height is a promise to the
       host page's layout. */
    --lines-h: 93px;

    /* Legacy aliases — settings panel only. */
    --bg: var(--pine); --panel: var(--granite);
    --line: var(--hairline); --line2: var(--edge);
    --text: var(--alpenglow); --dim: var(--sage); --dimmer: var(--sage-dim);
    --accent: var(--coral); --warn: var(--amber);
  }
  /* Light is not an inversion: the accents DARKEN, because the dark-mode
     values wash out and fail contrast on light ground. */
  @media (prefers-color-scheme: light) {
    :root {
      --pine: #f6f5f3; --granite: #ffffff; --granite-hi: #f0eeea;
      --alpenglow: #1b1a18; --sage: #5f5c57; --sage-dim: #86827b;
      --coral: #c2402b; --amber: #9a6b16;
      --hairline: #e3e0db; --edge: #d5d1ca;
      --ok: #2f7d4f; --cool: #2f6f72;
      --shadow: 0 1px 2px rgba(0,0,0,.05);
    }
  }
  /* Explicit choice always wins over the OS. */
  :root[data-theme="dark"] {
    color-scheme: dark;
    --pine: #0b0b0c; --granite: #111113; --granite-hi: #1a1a1d;
    --alpenglow: #f2f0ec; --sage: #9a9aa2; --sage-dim: #6f6f78;
    --coral: #e0533d; --amber: #d8a13a;
    --hairline: #26262a; --edge: #2b2b31;
    --ok: #47b877; --cool: #4a8b8d;
    --shadow: none;
  }
  :root[data-theme="light"] {
    color-scheme: light;
    --pine: #f6f5f3; --granite: #ffffff; --granite-hi: #f0eeea;
    --alpenglow: #1b1a18; --sage: #5f5c57; --sage-dim: #86827b;
    --coral: #c2402b; --amber: #9a6b16;
    --hairline: #e3e0db; --edge: #d5d1ca;
    --ok: #2f7d4f; --cool: #2f6f72;
    --shadow: 0 1px 2px rgba(0,0,0,.05);
  }

  /* Micro-type: every label, button, chip and timer in the card. One spec,
     used by name, so they cannot drift apart again. */
  /* --- base and typography ----------------------------------------------- */
  .micro {
    font-family: var(--font-mono); font-size: 11px; font-weight: 700;
    letter-spacing: .16em; text-transform: uppercase;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0; min-height: 100vh; padding: 24px 16px;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
  }
  /* dvh, where the browser has it: in a standalone PWA 100vh is the LARGEST
     viewport — after a refresh the page lays out taller than what's visible
     and the operator scrolls to reach their own top and bottom (reported on
     the installed panel). dvh follows the visible viewport instead. */
  @supports (min-height: 100dvh) {
    body { min-height: 100dvh; }
  }
  body {
    font-family: var(--font-body);
    background: var(--pine); color: var(--alpenglow);
  }
  /* The card is a card again. §4.1 said drop it so the widget would dissolve
     into the host page; on every page that was not that host page it just
     left the widget with nothing to stand on and no edge to end at. It has a
     real surface, a radius, and a border a step stronger than a hairline —
     --edge, not --hairline, because "where does this thing end" was the
     operator's actual complaint. --granite over --pine means the surface is
     visible even where the border isn't.
     Width still matches the settings panel so the page reads as one column.
     The bottom inset is on the card: the bands inside it are separated by
     rules, and a rule sitting one pixel above a rounded corner reads as a
     rendering fault. */
  /* --- the call card: identity, and what is on air ----------------------- */
  .card {
    width: min(620px, 100%); padding: 0 16px 16px; position: relative;
    border: 1px solid var(--edge); border-radius: 16px;
    background: var(--granite); box-shadow: var(--shadow);
  }
  /* Flush mode, embeds only (call.js applyControls): the host page shows
     through where the card's own sheet was. The inner surfaces — transcript
     box, buttons, meters — keep their skins; it is the outer shell that
     disappears into the host's area. */
  .card.bare {
    border: 0; border-radius: 0; background: transparent; box-shadow: none;
  }
  /* Avatar beside the text rather than stacked above it — the stacked version
     wasted vertical space and pushed the captions off screen. */
  .who-row {
    display: flex; align-items: center; gap: var(--band-gap); text-align: left;
    padding: 11px 0; border-bottom: 1px solid var(--hairline);
  }
  .who-row .meta { min-width: 0; flex: 1; }

  /* --- status --- */
  /* The header band is 38px, which is also the height of the corner controls
     that sit in it. No rule under it — the who-row below already draws one,
     and two rules 38px apart made the name look like a table row. */
  .eyebrow {
    font-family: var(--font-mono); font-size: 11px; letter-spacing: .16em;
    text-transform: uppercase; font-weight: 700; color: var(--coral);
    height: 38px; margin: 0;
    display: flex; align-items: center; gap: 8px;
  }
  /* The corner controls are pinned right by a spacer that eats the free
     space, NOT by a margin on whatever happens to precede them. It was
     `margin-left:auto` on #status — which is hidden during a call — so the
     moment a call connected the three icons slid left and sat against
     ON AIR NOW. A spacer is in the row whatever else is. */
  .eyebrow .spacer { flex: 1 1 auto; }
  .corner { display: flex; align-items: center; margin-left: 10px; }
  .eyebrow .pip {
    width: 7px; height: 7px; border-radius: 0; background: var(--coral);
    box-shadow: 0 0 0 0 color-mix(in srgb, var(--coral) 60%, transparent);
    animation: live 2s infinite;
  }
  @keyframes live {
    70%  { box-shadow: 0 0 0 7px color-mix(in srgb, var(--coral) 0%, transparent); }
    100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--coral) 0%, transparent); }
  }
  .eyebrow.off { color: var(--sage-dim); }
  .eyebrow.off .pip { background: var(--sage-dim); animation: none; }

  /* 60px, and round unless the operator asked for square (`avatar_style`).
     §4.3 said square at 44px, on the reasoning that every other corner in
     the card is square. It was the wrong call for this one element: 44px is
     a favicon of a face, and the shape rule is about controls and chips —
     the one photograph on the card is a person, not a control. Shape is the
     operator's answer now because a host page whose own artwork has corners
     has a real case for square; the size is not, because too small was
     nobody's intention. */
  .avatar {
    width: 60px; height: 60px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
    border: 1px solid var(--hairline); background: var(--granite-hi); display: block;
    transition: border-color .25s ease, box-shadow .25s ease;
  }
  .card[data-avatar="square"] .avatar { border-radius: 0; }
  .avatar.hidden { display: none; }
  /* The initials shown when there is no usable image. Shares .avatar's size
     and shape (both classes on the element); only the fill differs. Single
     class on the display rule so .avatar.hidden still wins and hides it. */
  .avatar-mono {
    display: flex; align-items: center; justify-content: center;
    object-fit: initial; overflow: hidden; user-select: none;
    color: var(--coral); background: var(--granite-hi);
    font: 700 22px/1 var(--font-mono); letter-spacing: .04em; text-transform: uppercase;
  }
  /* The display:flex above would outrank the UA [hidden] rule, so re-hide it
     explicitly — the element carries aria-hidden and must stay hideable both
     ways (the class .avatar.hidden path and the attribute). */
  .avatar-mono[hidden] { display: none; }
  /* The one permitted glow: the DJ is speaking. Derived, not literal — the
     hardcoded rgba() here stayed the old coral through two palette changes. */
  .avatar.talking {
    border-color: var(--coral);
    box-shadow: 0 0 0 4px color-mix(in srgb, var(--coral) 16%, transparent);
  }
  /* SCOPED to the header. `.dj` is the DJ NAME here — but `.cap.dj` uses the
     same class for a DJ caption line, so a bare `.dj` rule (nowrap, and the
     line-clamp in the mobile block below) also clamped the DJ's chat/call
     captions to one line, and the caller couldn't read what the DJ said
     (operator-reported, mobile). The name still ellipsises; the caption wraps. */
  .who-row .dj {
    font-family: var(--font-body); font-size: 15px; font-weight: 600;
    color: var(--alpenglow); margin: 0 0 2px; letter-spacing: 0;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  /* Scoped to the who-row deliberately. "show" is also the ticker's
     visibility class, and the bare selector dressed every lit transcript
     line as a bold uppercase micro-label — seen on a real embed, reported as
     "the transcript font is horrible", and invisible in review because the
     two rules are 1,500 lines apart. */
  /* The show name and the tagline WRAP rather than truncate — a caller on an
     embed saw "DONOVAN'S PUB · IRISH F…" clipped with plenty of card beneath
     it. The header sits above a transcript that flexes, so a second line here
     costs the transcript a little room, not the card its shape. The DJ name
     itself still ellipsises (a name is one line). */
  .who-row .show {
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .16em;
    text-transform: uppercase; color: var(--sage-dim); font-weight: 700; margin: 0;
    white-space: normal; overflow-wrap: anywhere;
  }
  .tagline {
    font-size: 12.5px; line-height: 1.45; color: var(--sage); margin: 2px 0 0;
    white-space: normal; overflow-wrap: anywhere;
  }
  /* Now-playing sits in the spare space to the right of the DJ block. */
  .track {
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .16em;
    text-transform: uppercase; font-weight: 700;
    color: var(--sage-dim); margin: 0 0 0 auto;
    padding-left: 14px; max-width: 42%; text-align: right; align-self: center;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }

  /* The settings panel keeps the old button shape — it is full-page only and
     ~450 lines are written against it. Inside the card there are exactly TWO
     treatments (§4.7), and they differ only in fill. */
  /* --- buttons, and what the Call button looks like in each state -------- */
  button {
    font: inherit; font-weight: 600; font-size: 11.5px;
    padding: 6px 13px; border-radius: 8px; border: none; cursor: pointer;
    background: var(--line2); color: var(--text);
    transition: filter .15s ease, opacity .15s ease, background .15s ease;
  }
  button:hover:not(:disabled) { filter: brightness(1.15); }
  button:disabled { opacity: .5; cursor: default; }

  /* Secondary is the default in the card: same box, ghosted. min-width:0 and
     the clip are load-bearing — a flex button is floored at its min-content
     width, so "Reconnecting…" would otherwise push its whole row wider than
     "Hang up" did. */
  .card button {
    font: 700 11px/1 var(--font-mono);
    letter-spacing: .14em; text-transform: uppercase;
    height: var(--control-h); padding: 0 18px; min-width: 0;
    border-radius: 0; border: 1px solid var(--coral);
    background: transparent; color: var(--coral);
    overflow: hidden; white-space: nowrap;
  }
  .card button:hover:not(:disabled) {
    filter: none; background: color-mix(in srgb, var(--coral) 12%, transparent);
  }
  /* A new DJ or show brings a new palette — applyTokens() writes it onto
     :root and every var() resolves to the new colour in one frame, which
     snapped the whole card over abruptly (operator asked for a graceful
     phase). These transitions fade the SURFACES and TEXT instead. The meter
     bars (which animate many times a second) and the state chips (which must
     read instantly, listening vs speaking) are deliberately left off this so
     they keep their own timing. */
  .card,
  .card .who-row, .card .dj, .card .show, .card .tagline, .card .track,
  .card .eyebrow, .card .linebox, .card .avatar,
  .card .actionrow button, .card .talkrow button, .card .chatrow button {
    transition: background-color .6s ease, border-color .6s ease, color .6s ease;
  }
  /* Exactly one filled button in any state — so a disabled action gives up
     the fill rather than sitting there looking like the thing to press. */
  .card button:disabled {
    opacity: 1; cursor: default;
    background: transparent; color: var(--sage-dim); border-color: var(--hairline);
  }
  /* Primary: the one action in this state. Idle that is Call, in-call it is
     Hang up, behind the door it is the code button. */
  #callBtn:not(:disabled), #hangBtn, .guestgate:not([hidden]) #guestBtn {
    background: var(--coral); color: #fff; border-color: var(--coral);
  }
  #callBtn:not(:disabled):hover, #hangBtn:hover,
  .guestgate:not([hidden]) #guestBtn:hover {
    filter: brightness(1.08); background: var(--coral);
  }
  /* While the door code is up it owns the primary, so Call steps back even
     when it is technically enabled. */
  .card:has(.guestgate:not([hidden])) #callBtn:not(:disabled) {
    background: transparent; color: var(--coral); border-color: var(--coral);
  }
  /* Call progression on the button itself: Ringing (amber — nothing is live
     yet), then Answering and On the line in green, because the line IS live
     from pickup onward and green is the only colour here that says so without
     also being the colour of the button you press. Disabled during all three,
     but not ghosted — the button IS the status while a call is being placed,
     so it opts out of the disabled treatment above. */
  #callBtn.ringing, #callBtn.answering, #callBtn.live,
  #callBtn:disabled.ringing, #callBtn:disabled.answering, #callBtn:disabled.live {
    display: inline-flex; align-items: center; justify-content: center;
    height: var(--control-h); min-width: 0; padding: 0 13px;
    border-radius: 0; background: transparent; opacity: 1; cursor: default;
    font: 700 11px/1 var(--font-mono); letter-spacing: .16em;
    text-transform: uppercase;
  }
  #callBtn.ringing, #callBtn:disabled.ringing {
    color: var(--amber); border-color: var(--amber); animation: blink 1.2s infinite;
  }
  #callBtn.answering, #callBtn.live,
  #callBtn:disabled.answering, #callBtn:disabled.live {
    color: var(--ok); border-color: var(--ok);
  }

  /* --- in-call rig --- */
  /* --- on-call chrome: the rig, pills, the timer, the agent state chip --- */
  /* Reserved from first paint, not grown into. The card is ONE height, and
     pressing Call must not change it: this widget's main home is an embed in
     a station page's player column, and a card that grows there pushes the
     host's own layout around at the exact moment somebody is trying to
     listen. `visibility` keeps the box and takes it out of the accessibility
     tree.

     0.9.117 briefly traded this away — small idle, one growth at pickup —
     and it moved a real station page. The problem it was trying to solve was
     never the reservation, it was the SIZE of what was being reserved: the
     bands below have been cut to what they actually need instead. */
  /* A flex column so the transcript can flex to fill the working area and hold
     the card to one footprint across every mode — see the mode block above. */
  .rig { visibility: hidden; display: flex; flex-direction: column; }
  .rig.on { visibility: visible; }
  /* The rows that hold their place AND stay visible between calls: the line
     area (it carries the card's own messages), the meters band (idle the
     bars show the trough, which reads as a booth rather than as reserved
     emptiness — the volume in the middle of it is only meaningful on a call
     and stays rig-gated), and the action row (the Call button is the card's
     last row on every surface). The state and talk rows are reserved-but-
     hidden, because their controls are meaningless off a call.
     Visibility is inherited but a child may declare its own. */
  .rig > .linebox, .rig > .meters, .rig > .actionrow { visibility: visible; }
  /* The text line's input row lives in the rig too, and it is the ONE control
     that is meaningful off a call — so it declares its own visibility rather
     than inheriting the rig's reserved-hidden. Without this the chat input
     was present but invisible (visibility inherited from .rig), which is
     exactly the "I still can't see the text area" the operator reported. */
  .rig > .chatrow { visibility: visible; }
  .rig:not(.on) .meters .vol { visibility: hidden; }
  /* Hang up shares the action row but only exists mid-call; display, not
     visibility, because Call takes its place rather than leaving a gap. */
  .actionrow > [hidden] { display: none; }
  .actionrow #hangBtn { flex: 1 1 auto; }
  /* Structural, not scripted: while a call is up, Hang up is the row's ONLY
     button. The operator's live page caught the Call button ("On the line")
     sharing the row with Hang up mid-call — some path had raced the hidden
     attribute — and a class the connect/end pair owns cannot be raced by a
     poll repaint. */
  /* On a call or a voicemail, the action row is just Hang up, full width.
     Call and Leave-a-message have nothing to do mid-call; the text line does
     not either — you are already on the line — and leaving its button up put
     a second, differently-sized button on the row (operator-reported). The
     text-instead fallback still appears when a call FAILS, which is not a
     call in progress. */
  .card.oncall .actionrow #callBtn,
  .card.oncall .actionrow #chatBtn,
  .card.oncall .actionrow #vmBtn { display: none; }

  /* ---- Card modes -----------------------------------------------------
     The card is only ever in ONE mode (data-mode on .card), and shows only
     that mode's controls. Opening the text line used to leave the call's
     meters, its push-to-talk bar and the Call/Message buttons all on screen
     under the input row — the card grew to ~525px and the place to type was
     buried among controls for a call nobody was on (operator-reported). A
     band a mode does not own is hidden with DISPLAY, not merely reserved:
     the whole point is that the card shrinks to the one thing it is doing.
       idle       the doors (the action row); nothing live — unchanged, so
                  the embed's idle reservation still holds
       call       everything the phone needs — unchanged
       voicemail  no push-to-talk and no mute: the mic is simply open while
                  the machine records, and TAP TO TALK / MUTE over a
                  recording read as controls that did nothing
       chat       the transcript and the input only — no meters, no talk
                  bar, no state pills, and no Call/Message doors (Close
                  lives in the input row) */
  .card[data-mode="chat"] .staterow,
  .card[data-mode="chat"] .meters,
  .card[data-mode="chat"] .talkrow,
  .card[data-mode="chat"] .actionrow { display: none !important; }
  /* The post-call drawer ("Transcript · N lines") and the how-was-it strip
     belong to a FINISHED call, never a live chat — a caller reported seeing a
     previous call's transcript widget inside their chat. They live in .after;
     the text line hides the whole band. */
  .card[data-mode="chat"] .after { display: none !important; }
  /* Voicemail IS push-to-talk (operator's ask), so the talk bar stays — but
     Speaker and Mute do not belong on a recording: there is no DJ audio to
     route and the bar is already the mic control. The DJ meter and the volume
     are meaningless too (nothing is playing back), so the level band is just
     the caller's own YOU meter, which is the "is it registering?" feedback
     the operator wanted. */
  .card[data-mode="voicemail"] .talkrow #spkBtn,
  .card[data-mode="voicemail"] .talkrow #muteBtn,
  .card[data-mode="voicemail"] .meters .dj,
  .card[data-mode="voicemail"] .meters .vol { display: none !important; }
  /* ONE FOOTPRINT, whatever the entrance. A caller who calls, leaves a message
     or texts should meet the same object at the same size — an embed must not
     jar or resize when the mode changes (operator's design ask). The working
     area (.rig, everything below the identity header) holds a consistent
     height in every mode; the transcript flexes to fill whatever the mode's
     controls leave, so chat's fewer bands become MORE transcript rather than a
     shorter card, and it never grows or shrinks as messages arrive — it scrolls
     inside a fixed box. Standalone only; a compact embed keeps its own tighter
     budget. The header sits outside the rig, so this holds regardless of how
     long the station name or now-playing line is. */
  body:not(.compact) .rig { min-height: 272px; }
  /* Chat opens the transcript drawer (.open) the call reading-back uses, but
     here the flex fill owns the height — so drop .open's fixed 44vh back to the
     normal basis and let the rig's spare room size it, same as every mode. */
  .card[data-mode="chat"] .linebox { height: var(--lines-h); max-height: none; }

  /* Button, state pill and timer share one row — every status lives beside
     the action, never below it. No rule under it: whatever follows is either
     absent or a bordered block of its own, and a rule that sometimes ends the
     card and sometimes doesn't is worse than no rule. */
  .actionrow {
    display: flex; align-items: center; gap: 9px; flex-wrap: nowrap;
    padding: 9px 0 0;
  }
  /* Slimmer than every other control, on purpose — see --action-h. The id
     selectors are here to out-specify #callBtn's own state rules, which pin
     the control height while a call is being placed; a bare .actionrow
     button would lose to them and the row would change height at pickup. */
  /* All three idle doors share ONE height — Call was --action-h (slim) while
     Text and Message stayed the default 31px, so they sat at different
     heights on the same row (operator-reported). --action-h for all three. */
  .actionrow #callBtn, .actionrow #callBtn:disabled, .actionrow .pill,
  .actionrow #chatBtn, .actionrow #vmBtn {
    height: var(--action-h);
  }
  /* Centre the icon and the word as one unit, and never let a long word push
     the row wider than the card: the label ellipsises rather than wrapping to
     a second row or clipping the button beside it (operator-reported on a
     narrow embed). The icon has flex-shrink 0 so only the word gives. */
  /* All three worded doors have to FIT their own text on one shared row at the
     standalone width — "Leave a message" was clipping to "eave a messag"
     because the card's 18px button padding and .14em tracking pushed the
     longest label past its equal third (operator-reported). Tighter padding
     and tracking here, scoped to these three, so every default label fits
     without touching the card's look elsewhere. The ellipsis stays only as a
     last resort for pathologically long CUSTOM wording. */
  /* :not([hidden]) is load-bearing — an id selector out-specifies the plain
     [hidden] { display:none }, so WITHOUT it a hidden door still rendered at
     full width and stole the row from the visible ones, squeezing "Leave a
     message" until it clipped (the exact overflow the operator reported). */
  .actionrow #callBtn:not([hidden]),
  .actionrow #chatBtn:not([hidden]),
  .actionrow #vmBtn:not([hidden]) {
    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    padding: 0 8px; letter-spacing: .05em;
    overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  }
  .actionrow .btnemoji { flex: 0 0 auto; }
  /* Idle, the doors sit on ONE row and size to their CONTENT. A WORDED door
     takes the slack so its label fits ("Call Danny Boy" gets the room it
     needs); an ICON-ONLY door hugs its glyph and gives that room up, rather
     than every door sitting at an equal third and clipping the worded one
     (operator-reported from an embed). The [hidden]-aware guard is still what
     stops a hidden door from stealing the row. They never wrap to a second
     row. */
  .actionrow #callBtn:not(.ringing):not(.answering):not(.live),
  .actionrow #chatBtn:not([hidden]),
  .actionrow #vmBtn:not([hidden]) {
    flex: 1 1 auto; min-width: 0;
  }
  /* Icon-only: natural width, no growing — two bare icons must not eat the
     space a worded door needs. */
  .actionrow #callBtn.icononly,
  .actionrow #chatBtn.icononly,
  .actionrow #vmBtn.icononly { flex: 0 1 auto; }
  /* The icon on a button: a line drawing in the button's own ink (it inherits
     currentColor), so it is neutral by construction — no filter can make an
     emoji glyph behave the way this does across OSes. Sized a touch larger
     than the cap height of the word beside it and centred on the row. */
  .actionrow .btnemoji, .chatrow .btnemoji {
    display: inline-flex; align-items: center; line-height: 0;
    opacity: .85;
  }
  .actionrow .btnemoji svg, .chatrow .btnemoji svg { width: 18px; height: 18px; }
  /* Square, transparent, hairline-bordered, control height. Colour carries
     state; fill never does (§4.4) — the only filled thing in any state is the
     one primary button. */
  .pill {
    display: inline-flex; align-items: center; gap: 7px; height: var(--control-h);
    padding: 0 12px; border-radius: 0;
    background: transparent; border: 1px solid var(--hairline); color: var(--sage-dim);
    font: 700 11px/1 var(--font-mono); letter-spacing: .16em; text-transform: uppercase;
    transition: color .2s, border-color .2s;
    /* A pill's text never wraps inside it. "Reaching the booth" used to break
       onto a second line and stand the chip up two rows tall, which read as the
       timer beside it jumping to its own line (operator-reported). */
    white-space: nowrap;
  }
  /* Same reservation as the rig, for the same reason: these wrap onto a
     second row in a narrow embed, so appearing at pickup would move
     everything below them by a whole control height. */
  .pill[hidden] { visibility: hidden; }
  /* The elapsed timer is data, not a warning — it only takes a colour once
     the call is genuinely near its limit. Amber from 0:00 taught you to
     ignore amber by the time it meant something. */
  .timechip { letter-spacing: .1em; font-variant-numeric: tabular-nums; }
  .timechip .tmax { color: var(--sage-dim); font-weight: 400; margin-left: 4px; }
  .timechip.warn { color: var(--amber); border-color: var(--amber); }
  .timechip.urgent { color: var(--coral); border-color: var(--coral); }
  /* Square pip, like every other dot in the widget. */
  .statechip .sdot { width: 7px; height: 7px; border-radius: 0; background: currentColor; }
  /* Three states, three colours, and they have to be told apart at a glance
     while you are talking: the line is open (green), the DJ is thinking
     (amber), the DJ is talking (coral). 0.9.92 collapsed open and talking
     into the same coral to stay inside one station's palette, which made the
     chip stop reporting the one transition a caller actually waits on. */
  .statechip[data-state="listening"] { color: var(--ok); border-color: var(--ok); }
  .statechip[data-state="thinking"]  { color: var(--amber); border-color: var(--amber); }
  .statechip[data-state="thinking"] .sdot { animation: blink .7s infinite; }
  .statechip[data-state="speaking"]  { color: var(--coral); border-color: var(--coral); }
  .statechip[data-state="speaking"] .sdot { animation: blink 1.1s infinite; }
  .statechip[data-state="reconnecting"] { color: var(--amber); border-color: var(--amber); }
  .statechip[data-state="reconnecting"] .sdot { animation: blink .7s infinite; }
  /* The station has the microphone — the one state where the DJ going quiet
     is correct rather than broken. It used to be the widget's only filled
     chip; it now carries in amber with a blinking pip and the bloom, because
     a second filled element competes with the primary button and fill is not
     allowed to mean state. */
  .statechip[data-state="onair"] {
    color: var(--amber); border-color: var(--amber);
    box-shadow: 0 0 18px color-mix(in srgb, var(--amber) 30%, transparent);
  }
  .statechip[data-state="onair"] .sdot { animation: blink 1.1s infinite; }
  .card.onair .avatar { border-color: var(--amber); }
  @keyframes blink { 50% { opacity: .25; } }


  /* A spectrum, not a progress bar. §4.6 collapsed this to a 3px trough and
     one fill — cheaper to paint and honest about level, but a horizontal bar
     that grows is the shape of a download, and what it has to say is "there
     is a voice here". The segmented meter says that at a glance and reads as
     a booth. The cost is real (SEGMENTS style writes per meter per frame,
     against one) so paintMeter writes a segment only when its height
     actually changed.
     Idle is a row of floor-height segments rather than an empty box: the
     meter's own shape has to be visible before anyone speaks, or the row
     looks broken until it isn't. */
  /* --- level meters ------------------------------------------------------ */
  .meters { display: flex; gap: var(--band-gap); padding: 9px 0 0; flex-wrap: nowrap; }
  /* Volume sits to the LEFT, so the two WAVEFORMS — YOU and DJ — sit next to
     each other where they can be read as a pair, rather than split by the
     volume rail between them (operator's ask). Order only; the DOM keeps the
     you/vol/dj sequence the reservation logic relies on. */
  .meters .vol { order: -1; }
  .meters .meter.you { order: 0; }
  .meters .meter.dj { order: 1; }
  /* Floors, not just flex: squeezed by the volume rail, the You meter once
     shrank until "MIC OFF" wrapped into a two-line tower and the DJ meter
     left the row entirely. From the operator's live call. */
  .meter { flex: 1 1 90px; text-align: left; min-width: 72px; }
  .meter .mlabel { white-space: nowrap; }
  .meter .mlabel {
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .16em;
    text-transform: uppercase; font-weight: 700; color: var(--sage-dim);
    margin-bottom: 4px;
  }
  /* Label and mic-state chip on one line above the bars. The chip carries
     "MIC OFF" / "MUTED" so the label never has to grow to hold it. */
  .meter.you .mtop {
    display: flex; align-items: center; gap: 6px; min-width: 0; margin-bottom: 4px;
  }
  .meter.you .mtop .mlabel { margin-bottom: 0; }
  .micchip {
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .12em;
    text-transform: uppercase; font-weight: 700; line-height: 1;
    padding: 2px 5px; border: 1px solid currentColor; border-radius: 999px;
    color: var(--coral); white-space: nowrap; flex: 0 0 auto;
  }
  .micchip[hidden] { display: none; }
  /* Mic off on a push-to-talk card is the resting state, not an alarm — dim
     it. Mute is a deliberate act, so that one stays coral and loud. */
  .micchip.off { color: var(--sage-dim); }
  .micchip.muted { color: var(--coral); }
  .bars {
    display: flex; align-items: flex-end; gap: 2px;
    height: 18px; border-radius: 0;
  }
  /* A 22% floor, not 12: at 16px the idle segments were 1.9px of a
     near-transparent hairline, and the operator reported the DJ meter as
     MISSING. A meter's shape has to be visible before anyone speaks. */
  .bars i {
    flex: 1 1 0; min-width: 0; height: 22%;
    background: currentColor; border-radius: 0;
    transition: height .08s linear;
  }
  /* You and the DJ never share a colour — the two meters sit side by side and
     the whole point is telling at a glance which one is moving. */
  .meter.you { color: var(--cool); }
  .meter.dj { color: var(--coral); }
  /* Off the call the segments are the trough colour, not a dimmed voice: a
     dead meter must not look like a quiet one. Keyed on the rig rather than
     a class of their own, so there is nothing for JS to forget to clear. */
  .meter .bars i { background: var(--hairline); }
  .rig.on .meter .bars i { background: currentColor; }
  .meter.muted .mlabel { color: var(--coral); }
  .rig.on .meter.muted .bars i { background: var(--hairline); }

  /* --- volume ------------------------------------------------------------
     Lives INSIDE the meters band since 0.9.134 — you | vol | DJ. It had a
     row of its own, and on an embed that row was ~41px of mostly air, which
     was part of what stretched a real station page's player column. Bottom-
     aligned so the rail sits level with the meter bars beside it. */
  .meters .vol { align-self: flex-end; padding-bottom: 3px; }
  .vol { display: flex; align-items: center; gap: 10px; flex: 1 1 130px;
         min-width: 110px; max-width: 260px; }
  .vol label {
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .16em;
    text-transform: uppercase; color: var(--sage-dim); font-weight: 700;
  }
  /* A 3px trough with a fill in the accent — the same shape as every other
     level in this card, and the same shape §4.6 specifies. It was the
     browser's own slider tinted coral with `accent-color`, which on every
     platform is a fat rounded bar with a big round knob: at full volume that
     is a bright solid stripe across the widest row of the card, and it
     out-shouted the Call button. It is a setting nobody changes, sitting
     next to the two controls that matter.
     The element keeps a taller box than the trough so there is still
     something a finger can hit. */
  .vol input[type=range] {
    flex: 1; min-width: 0; height: 18px;
    -webkit-appearance: none; appearance: none;
    background: transparent; cursor: pointer;
  }
  /* --vol is written by applyVolume(); webkit has no ::-moz-range-progress
     equivalent, so the fill is a hard-stop gradient on the track itself. */
  .vol input[type=range]::-webkit-slider-runnable-track {
    height: 3px; border-radius: 0;
    background: linear-gradient(to right,
      var(--coral) var(--vol, 100%), var(--hairline) var(--vol, 100%));
  }
  .vol input[type=range]::-webkit-slider-thumb {
    -webkit-appearance: none; appearance: none;
    width: 9px; height: 9px; border-radius: 0; border: 0;
    background: var(--coral); margin-top: -3px;
  }
  .vol input[type=range]::-moz-range-track { height: 3px; background: var(--hairline); }
  .vol input[type=range]::-moz-range-progress { height: 3px; background: var(--coral); }
  .vol input[type=range]::-moz-range-thumb {
    width: 9px; height: 9px; border-radius: 0; border: 0; background: var(--coral);
  }
  .vol input[type=range]:focus { outline: none; }
  .vol input[type=range]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 2px var(--amber); }
  .vol .pct {
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .16em;
    font-weight: 700; color: var(--sage-dim); width: 38px; text-align: right;
    font-variant-numeric: tabular-nums;
  }

  /* --- the talk row ------------------------------------------------------
     The controls that shape being HEARD — the push-to-talk bar (only on
     cards configured for it), Mute, and the speaker switch — one row above
     the one that ends the call. The bar IS the microphone: lit means the DJ
     can hear you; its title carries what the removed hint line used to say. */
  .talkrow {
    display: flex; align-items: center; gap: 9px; padding: 9px 0 0;
  }
  .talkrow .pttbar { display: none; }
  .card.ptt .talkrow .pttbar { display: flex; }
  /* Without the bar, the two buttons keep to the right, over the hang-up
     row's own right edge. */
  .card:not(.ptt) .talkrow #spkBtn { margin-left: auto; }
  /* Open mic is a state like muted, not a second primary — the fill only
     arrives while the bar is genuinely live. */
  .card .pttbar {
    /* 36px here, 60px on phones (media block below). The "hard to press"
       report that grew this bar was about THUMBS — the operator later
       clarified desktop was never the problem — so the tall target is a
       phone-only treatment and regular screens keep the ordinary height. */
    flex: 1 1 auto; min-width: 0; height: 36px; padding: 0;
    display: flex; flex-direction: column; align-items: center;
    justify-content: center; gap: 3px;
    border: 1px solid var(--amber); background: transparent; color: var(--amber);
    /* touch-action: NONE, not manipulation. `manipulation` still lets the
       browser claim a held touch for scrolling/panning, which fires
       pointercancel a second or so in — and the release handler then shut the
       mic mid-hold (operator: "push to talk only stays engaged ~1s on mobile").
       `none` gives the element the whole gesture so a hold stays a hold. The
       callout/select disables stop iOS's long-press menu, which cancels the
       pointer the same way. */
    touch-action: none; -webkit-user-select: none; user-select: none;
    -webkit-touch-callout: none;
  }
  .card .pttbar:hover:not(:disabled) {
    background: color-mix(in srgb, var(--amber) 10%, transparent); filter: none;
  }
  .card .pttbar.on {
    background: var(--amber); color: var(--pine);
    box-shadow: 0 0 18px color-mix(in srgb, var(--amber) 35%, transparent);
  }
  .pttbar .pttmain {
    font: 700 11.5px/1 var(--font-mono); letter-spacing: .14em;
    text-transform: uppercase;
  }
  .pttbar .pttsub {
    font: 700 8px/1 var(--font-mono); letter-spacing: .1em;
    text-transform: uppercase; opacity: .7;
  }
  /* The bar owns the wire while it is up; Mute would be a second switch on
     the same circuit. */
  .card.ptt .talkrow #muteBtn { display: none; }

  /* The state row sits above the words it describes. One line, always: the
     state chip gives up room first (its text ellipsises) so the elapsed timer
     beside it keeps its own place instead of being shoved off the row. */
  .staterow { display: flex; align-items: center; gap: 9px; padding: 9px 0 0; flex-wrap: nowrap; }
  .staterow .statechip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
  .staterow .statechip #stateText {
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
  }
  .staterow .timechip { flex: 0 0 auto; }
  /* On the STANDALONE page — never an embed — an empty state row is just
     blank space above the words (operator-reported, the gap over "Reaching
     the booth"). The reserved-height trick that keeps it exists to stop an
     EMBED jumping inside a host layout; the full page has no host to protect,
     so collapse the row when idle. The small shift when a call starts is
     invisible on a page that is only this card. Embeds keep the reservation. */
  body:not(.compact) .staterow .pill[hidden] { display: none; }
  body:not(.compact) .staterow:not(:has(.pill:not([hidden]))) { padding: 0; }

  /* Same reasoning as the volume slider above: these rows' heights are
     paid for on every idle embed, whether or not anybody is on a call. */
  .card .talkrow #spkBtn, .card .talkrow #muteBtn { height: var(--action-h); flex: 0 0 auto; }
  /* Which way out the DJ's voice goes. Hidden outright on a browser with no
     routing API at all — see canRouteAudio(). */
  #spkBtn[hidden] { display: none; }
  /* Muted is a state, so it colours the label — it does not fill the button,
     which would put two filled controls in the row. */
  .controls button.on { color: var(--amber); border-color: var(--amber); }

  /* #status is styled with the line area it now lives in, further down —
     it stopped being a fragment of the header band in 0.9.117. */
  /* No #logsResult height here. It used to be pinned to 320px from this
     block — in the middle of the call card's rules, three hundred lines from
     the log viewer it governs — and being an id it silently outranked every
     later attempt to change it. The height lives with the viewer now. */

  /* Version footer in the settings panel — which build am I looking at,
     for tracking changes and troubleshooting over time. */
  .versionline { margin-top: 16px; font-size: 10.5px; color: var(--dimmer);
                 text-align: right; }

  /* In-place guidance when the page can't capture the mic (plain http on a
     LAN address). The whole point is a clickable way out, so it is a bordered
     block rather than a band — it has to read as an interruption. */
  /* --- microphone help, and the guest code door -------------------------- */
  .michelp { margin: var(--band-gap) 0 0; font-size: 12.5px; line-height: 1.45;
             color: var(--amber); border: 1px solid var(--amber);
             border-radius: 0; padding: 10px 12px; }
  .michelp a { color: inherit; font-weight: 600; }
  .michelp .alt { display: block; margin-top: 4px; font-size: 11px;
                  color: var(--sage-dim); }

  /* The caller's door code, when the operator has set one. It lives INSIDE
     the line area — the card's one message surface — because "this line is
     private" is the thing the card has to say right now, and a band of its
     own was one more thing that changed the card's height between
     deployments. Absolute over the box, like #status: either the door is
     being asked about or the call is being spoken, never both. */
  .guestgate {
    position: absolute; inset: 9px 12px; text-align: left;
    font-size: 12.5px; line-height: 1.45;
    display: flex; flex-direction: column; justify-content: center; gap: 7px;
  }
  .guestgate[hidden] { display: none; }
  .guestgate .glabel { display: block; color: var(--sage); font-size: 11.5px; }
  .guestgate .grow { display: flex; gap: 9px; }
  .linebox:has(.guestgate:not([hidden])) #status { display: none; }
  /* §4.8. The placeholder is deliberately smaller than the typed text so a
     long prompt fits without shrinking what the caller actually types. */
  .guestgate input {
    flex: 1 1 140px; min-width: 0;
    height: var(--control-h); padding: 0 10px; border-radius: 0;
    font: 12.5px var(--font-body);
    border: 1px solid var(--hairline); background: var(--granite-hi);
    color: var(--alpenglow);
  }
  .guestgate input::placeholder { color: var(--sage-dim); font-size: 11px; }
  .guestgate input:focus { outline: none; border-color: var(--amber); }
  .guestgate button { flex: 0 0 auto; }
  #guestMsg { display: block; margin-top: 7px; color: var(--amber); font-size: 11px; }
  #guestMsg:empty { display: none; }

  /* The floating unsaved-changes verdict. Fixed so it is on screen from
     any scroll depth, and it does not leave until answered. */
  .saveoverlay {
    position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
    z-index: 2147483000;
    display: flex; align-items: center; gap: 12px;
    padding: 10px 16px; border-radius: 12px;
    background: var(--panel); border: 1px solid var(--accent);
    box-shadow: 0 8px 30px rgba(0,0,0,.35);
  }
  .saveoverlay[hidden] { display: none; }
  .saveoverlay span { font-size: 12.5px; color: var(--text); font-weight: 600; }
  .saveoverlay #saveOverlaySave { background: var(--accent); color: #fff; }

  /* One staged greeting per row: who and which voice, the words the clip
     speaks (editable), and the three things you can do about it. */
  /* One row per DJ — name, state, the editable line and the three buttons
     across, wrapping only when the width genuinely runs out. Two bands per
     persona doubled the list's height for nothing; operator-reported. */
  .soundlist li.vmrow { flex-wrap: wrap; }
  .vmrow .sname { flex: 0 1 auto; white-space: nowrap; overflow: hidden;
                  text-overflow: ellipsis; max-width: 220px; }
  .vmrow .vmstate {
    font-family: var(--font-mono); font-size: 9px; letter-spacing: .1em;
    text-transform: uppercase; font-weight: 700; color: var(--warn);
  }
  .vmrow .vmstate.ok { color: var(--ok); }
  .vmrow .vmline {
    flex: 1 1 160px; order: 0; min-width: 0;
    font: 12px var(--font-body); padding: 5px 8px; border-radius: 6px;
    border: 1px solid var(--line2); background: var(--panel); color: var(--text);
  }

  /* Locked panel: the login gate is the ONLY thing shown (plus the bar that
     gets you back to the phone). The old rule hid the settings sections but
     left the DASHBOARD standing — so anyone who could reach /settings read the
     station's live state (The Line on/off, the call / voicemail / text
     switches, what's on air) before logging in. This hides every direct child
     of the panel except the way-back bar and the gate, so nothing at all leaks
     until the password is right. Robust to new sections: they are hidden by
     default rather than needing to be added to a list. */
  #panel.locked > *:not(.panelbar):not(#loginGate) { display: none !important; }
  #loginGate .loginrow { display: inline-flex; gap: 8px; margin: 0 8px; }
  #loginGate input { width: 180px; }
  #loginMsg { font-size: 11.5px; color: var(--accent); }

  /* --- the connection dot ------------------------------------------------ */
  .dot { display: inline-block; width: 7px; height: 7px; border-radius: 0; background: var(--sage-dim); vertical-align: middle; }
  .dot.connecting { background: var(--amber); animation: pulse 1s infinite; }
  .dot.connected { background: var(--ok); }
  .dot.error { background: var(--coral); }
  @keyframes pulse { 50% { opacity: .3; } }

  /* --- the post-call strip ----------------------------------------------
     One row overlaid on the line area's bottom edge: the transcript drawer
     toggle, and (if the operator asked) the two how-was-it buttons. These
     used to be two BANDS below the box, which meant every call's end grew
     the card and moved the host page — the exact thing the reserved line
     area exists to prevent. Inside the box, appearing costs nothing.
     display:flex on the container only when something in it is visible;
     otherwise it must not eat pointer events over the box. */
  .after { position: absolute; left: 9px; right: 9px; bottom: 7px;
           display: flex; align-items: center; gap: 8px; pointer-events: none; }
  .after > * { pointer-events: auto; }
  /* The drawer toggle is a label on a closed drawer, not an action — it takes
     the 9.5px micro-label the transcript's own speaker labels use. `.card
     button` (0,1,1) outranks a bare class, hence the .card prefix. */
  .card .callended {
    height: 24px; padding: 0 9px; text-align: left;
    display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
    background: var(--granite); border: 1px solid var(--hairline);
    color: var(--sage); font: 700 9.5px/1 var(--font-mono);
    letter-spacing: .14em; text-transform: uppercase;
  }
  .card .callended:hover { background: var(--granite); color: var(--alpenglow);
                           border-color: var(--edge); }
  .callended[hidden] { display: none; }
  .callended .chev { font-size: 9px; transition: transform .18s ease; }
  .callended.open .chev { transform: rotate(90deg); }
  .callended .when { display: none; }

  /* Was that any good? Two small buttons on the strip's right, findable but
     ignorable — a caller who has just been hung up on by a robot should not
     also be blocked by a modal about it. */
  .rate { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
  .rate[hidden] { display: none; }
  .rate .rlabel {
    font: 700 9.5px/1 var(--font-mono); letter-spacing: .14em;
    text-transform: uppercase; color: var(--sage-dim);
  }
  .rate .rbtns { display: inline-flex; gap: 6px; }
  .rate .rbtns[hidden] { display: none; }
  .card .rate button {
    height: 24px; width: auto; padding: 0 8px; line-height: 1; font-size: 12px;
    border: 1px solid var(--edge); background: var(--granite); color: var(--sage);
  }
  .card .rate button:hover:not(:disabled) {
    border-color: var(--coral); color: var(--coral);
    background: var(--granite); filter: none;
  }
  /* While the strip is up, the last transcript line and the box's own message
     must not run underneath it. */
  .linebox:has(.callended:not([hidden])) .captions,
  .linebox:has(.rate:not([hidden])) .captions { padding-bottom: 30px; }
  .linebox:has(.callended:not([hidden])) #status,
  .linebox:has(.rate:not([hidden])) #status { bottom: 39px; }

  /* --- captions --- */
  /* Captions: speaker in a narrow left column so the transcript reads as a
     script. Previously the label sat on its own line above each turn, which
     at 13px in a 430px card meant a few words per line and constant scrolling. */
  /* §5 — THE PART THAT ACTUALLY MATTERS.
     The height is FIXED, not capped. Every new line used to grow this block,
     which grew the card, which made the widget report a taller height, which
     made embed.js resize the frame — so the host page moved while somebody
     was reading it. A fixed height means the reported height stops changing
     during a call. States that genuinely need more room (the door code, a mic
     warning) still grow the frame; speech no longer does.

     Three lines, not fourteen. It was 210px — a transcript window on a card
     whose only job during a call is the last thing that was said, and the
     rest of it scrolled past unread anyway. The whole back-and-forth is
     still there and still scrolls; the drawer below opens it to full height
     when someone actually wants to read back. */
  /* --- the line area: what the card has to say right now -----------------
     TWO LINES, reserved from first paint and never changing. That is the
     whole specification. Speech goes here while a call runs; between calls
     the card's own messages do — "Not connected", "No answer", "Call ended"
     — so there is one place to look rather than two.

     Two, not three, and certainly not the fourteen it was: this sits inside
     a station page's player column, and every line reserved here is a line
     of somebody else's layout spent on a widget that is usually idle. Two is
     what a spoken turn actually needs to be readable while it is being said;
     the rest of the conversation is a click away on the drawer. */
  .linebox {
    position: relative; margin: 10px 0 0; padding: 9px 12px; text-align: left;
    background: var(--pine); border: 1px solid var(--hairline); border-radius: 0;
    /* height is the FLEX-BASIS/floor; flex-grow lets the box absorb the working
       area's spare room so the card stays one size across modes. Because the
       rig height is fixed, the box does not resize mid-call — the invariant the
       reserved height always protected, now kept by a stable parent instead. */
    height: var(--lines-h); flex: 1 1 auto;
  }
  /* Reading back a finished call is a deliberate click by somebody who wants
     the room, and by then there is no call for the resize to interrupt. This
     is the ONLY thing allowed to change this height. */
  .linebox.open { height: 200px; }
  .captions {
    display: none; height: 100%; overflow-y: auto; overflow-x: hidden;
    font-family: var(--font-body); font-size: 12.5px; line-height: 1.45;
    scrollbar-width: thin; scrollbar-color: var(--edge) transparent;
  }
  .captions.on { display: block; }
  /* The one message surface. "No answer — the booth didn't pick up" and
     "Call ended" land here, where the caller is already looking, instead of
     in the header band next to ON AIR NOW. Absolutely placed so it SHARES
     the box with the transcript rather than stacking above it — either the
     card is saying something or the DJ is, never both. */
  #status {
    position: absolute; inset: 9px 12px;
    display: flex; align-items: center; gap: 8px;
    font-family: var(--font-body); font-size: 12.5px; font-weight: 400;
    text-transform: none; letter-spacing: 0; color: var(--sage);
    margin: 0;
  }
  /* Nothing to say means nothing shown. "Not connected" used to sit here on
     every idle card — a permanent grey sentence restating what the eyebrow
     and the Call button already say between them, and on a host page it read
     as something being wrong. The element stays (transient messages need it);
     the idle filler is gone, so an empty box is simply quiet. */
  #status:has(#statusText:empty) { display: none; }
  /* Speech wins the box the moment there is any. `.on` is in the test on
     purpose: after a call the lines are still in the DOM, just collapsed
     behind the drawer, and that is exactly when "Call ended" has to show. */
  .linebox:has(.captions.on .cap) #status,
  .linebox:has(.ticker.show) #status { display: none; }
  .captions::-webkit-scrollbar { width: 8px; }
  .captions::-webkit-scrollbar-thumb { background: var(--edge); border-radius: 0; }
  /* The label runs INLINE with the speech. It was a right-aligned gutter
     column — 86px, then 62, then 44 — and at every width the operator read
     the same thing: dead space to the left of the words, and continuation
     lines indented past it. Inline, the label costs its own width once and
     every wrapped line gets the full box. The tint still says who is
     speaking; the script-column look was the thing being paid for, and
     nobody was buying it (operator, 2026-08-09). */
  .cap {
    display: block; margin: 0 0 6px; max-width: 100%;
    /* What stops a long unbroken turn pushing wider than the container. */
    overflow-wrap: break-word; word-break: break-word;
  }
  .cap:last-child { margin-bottom: 0; }
  .cap .who {
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .16em;
    text-transform: uppercase; font-weight: 700;
    white-space: nowrap; margin-right: 8px;
  }
  /* Identity is carried by the label and REINFORCED by a text tint, never
     replaced by it — tinted body text at 13.5px has to stay readable. */
  .cap.dj .who { color: var(--coral); }
  .cap.you .who { color: var(--cool); }
  .cap .said {
    color: var(--alpenglow);
    white-space: normal; overflow-wrap: break-word; word-break: break-word;
    font-size: 12.5px; font-weight: 400; line-height: 1.45;
  }
  .cap.dj .said { color: var(--alpenglow); }
  .cap.you .said { color: var(--sage); }
  /* Interim speech is dimmer, and that is ALL it is. It was italic too, and
     italics re-measure every word each time the recogniser revises the line
     — a few times a second — which read as the text twitching while you
     talked. The finalisation flip is now a colour change only, which the eye
     reads as settling rather than movement. */
  .cap.interim .said { color: var(--sage-dim); }

  /* A change should register as movement, not as text that was simply
     different when you looked back. Same rise-and-fade the host uses for
     track changes. */
  @keyframes roll-in {
    from { opacity: 0; transform: translateY(0.5em); }
    to   { opacity: 1; transform: translateY(0); }
  }
  .roll { animation: roll-in .42s cubic-bezier(.22,.7,.3,1) both; }

  /* A system action — a song reaching the queue, a message reaching the air.
     Tinted and inset so it reads as something that HAPPENED, clearly not
     another thing the DJ said. */
  .cap.sys {
    background: color-mix(in srgb, var(--coral) 9%, transparent);
    border-left: 2px solid var(--coral);
    border-radius: 0;
    padding: 7px 9px; margin: 9px 0;
    /* display here, not inherited: .cap stopped being a grid when the
       speech labels went inline, and this card's icon column still is one. */
    display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 9px;
    align-items: start;
  }
  .cap.sys .ico { font-size: 12.5px; line-height: 1.2; text-align: center; }
  .cap.sys .what {
    display: block; color: var(--coral); font-weight: 700;
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .16em;
    text-transform: uppercase;
  }
  .cap.sys .detail {
    display: block; color: var(--sage); font-size: 12.5px; margin-top: 3px;
  }
  /* A schedule the DJ laid out as a Markdown table on the text line, rendered
     as a real table inside a card — the same receipt look as a tool result, so
     a roster reads at a glance instead of as a run-on sentence. It scrolls
     sideways rather than widening the card (a schedule has more columns than a
     narrow embed has room for). The DJ still talks around it in prose. */
  /* The speech labels made .said inline; a schedule table needs it to flow as
     a block so the table gets the line's width and can scroll instead of
     collapsing. JS flags the line rather than CSS :has(), for older engines. */
  .cap .said.has-table { display: block; }
  .tablewrap {
    margin: 9px 0; overflow-x: auto; max-width: 100%;
    border: 1px solid color-mix(in srgb, var(--coral) 30%, var(--hairline));
    border-radius: 8px; background: color-mix(in srgb, var(--coral) 7%, transparent);
  }
  .chattable { border-collapse: collapse; width: 100%; font-size: 12px; }
  .chattable th, .chattable td {
    text-align: left; padding: 6px 10px; white-space: nowrap;
    border-bottom: 1px solid color-mix(in srgb, var(--coral) 14%, transparent);
  }
  .chattable thead th {
    color: var(--coral); font-family: var(--font-mono); font-size: 9px;
    letter-spacing: .12em; text-transform: uppercase; font-weight: 700;
  }
  .chattable tbody td { color: var(--alpenglow); }
  .chattable tbody tr:last-child td { border-bottom: 0; }
  /* The first column of a schedule is the time/slot — number-aligned and quiet,
     the way the meters' labels are, so the show names carry the weight. */
  .chattable tbody td:first-child { color: var(--sage); font-variant-numeric: tabular-nums; }
  .cap.sys .detail:empty { display: none; }
  .ticker.sys .who { color: var(--amber); }

  /* The typing cue: three dots pulsing in sequence while the booth composes a
     reply, in the DJ caption's own ink. Removed the instant real words arrive,
     so it never overlaps the text it was standing in for. */
  .cap.typing .typedots { display: inline-flex; gap: 4px; align-items: center; height: 1.45em; }
  .cap.typing .typedots i {
    width: 5px; height: 5px; border-radius: 50%;
    background: currentColor; opacity: .3; animation: typedot 1.1s infinite;
  }
  .cap.typing .typedots i:nth-child(2) { animation-delay: .18s; }
  .cap.typing .typedots i:nth-child(3) { animation-delay: .36s; }
  @keyframes typedot {
    0%, 60%, 100% { opacity: .3; transform: translateY(0); }
    30%           { opacity: 1; transform: translateY(-2px); }
  }

  /* The corner controls sit in the 38px header band, level with its text.
     Chrome, not actions — never competing with the primary button. Which of
     them exist at all is the backend's call; see applyControls() in call.js. */
  /* The text line's input area, in TWO rows: the box gets a full row to
     itself, and Close / Send sit on a row beneath it. It used to be one row
     — [Close] [input] [Send] — which squeezed the box into the middle third
     and made the two buttons (44px, big padding) the loudest thing on the
     card (operator-reported: "the input box should be a full row, those
     buttons are too large"). flex-wrap + order gives the box the whole width
     and drops the buttons below without touching the markup. Hidden until a
     chat opens. */
  .chatrow { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
  .chatrow[hidden] { display: none; }
  /* A real, unmistakable text box — the earlier version used undefined
     tokens (--field/--ink/--soft-border) and rendered transparent on a
     transparent card, so the operator quite reasonably could not see it.
     These are the same tokens the door-code input uses. Full width, its own
     row (order:1, 100% basis), a comfortable typing height. */
  .chatrow input {
    order: 1; flex: 1 1 100%; min-width: 0; height: 40px;
    background: var(--granite-hi); color: var(--alpenglow);
    border: 1px solid var(--hairline); border-radius: 0;
    padding: 0 12px; font-family: var(--font-body); font-size: 14px;
  }
  .chatrow input::placeholder { color: var(--sage-dim); }
  .chatrow input:focus { outline: none; border-color: var(--coral); }
  /* The button row beneath the box: Close on the left, Send filling the rest,
     both at the slim action height so they match the card's other doors
     rather than towering over the message. */
  .chatrow #chatEndBtn { order: 2; flex: 0 0 auto; }
  .chatrow #chatSendBtn { order: 3; flex: 1 1 auto; }
  .chatrow button { height: var(--action-h); padding: 0 14px; }
  /* Close is a quiet exit, never competing with Send: no fill, muted ink.
     Send stays the one lit action on the row. */
  .chatrow .chatend {
    background: transparent; color: var(--sage);
    border: 1px solid var(--hairline);
  }
  .chatrow .chatend:hover { color: var(--alpenglow); border-color: var(--sage-dim); }

  /* --- the corner controls and the ask popup ----------------------------- */
  /* Big enough to see and to hit. These were 26px of --sage-dim at 14px
     type — chrome so quiet that the theme toggle was reported missing while
     it was on screen. Still chrome (no fill, no border at rest), but sized
     like a control and a step brighter. */
  .card .gear {
    width: 34px; height: 38px; padding: 0; border-radius: 0;
    border: 1px solid transparent;
    background: transparent; color: var(--sage);
    font-family: var(--font-mono); font-size: 16px; line-height: 1;
    letter-spacing: 0;
  }
  .card .gear:hover:not(:disabled) { border-color: var(--hairline); }
  /* :not(:disabled) only to out-specify `.card button:hover:not(:disabled)`,
     which would otherwise wash these in the primary button's coral tint. */
  .card .gear:hover:not(:disabled) { background: transparent; color: var(--alpenglow); }
  .card #helpBtn { font-weight: 700; }

  /* "What can I ask?" — a fixed overlay rather than a box inside the card.
     Absolutely positioning it inside the widget meant it was squeezed into
     whatever room the card had, which on an embed is almost none. Fixed
     positioning lets it size to its content and sit over the page, and the
     open handler flips it above the button when there is more room up there.
     Downwards by default. */
  /* This floats over the host page, so it is the one surface that keeps a
     shadow — and it takes the card's radius, because it is a card that
     happens to be above the page rather than in it. */
  .askpop {
    position: fixed; z-index: 2147483000;
    width: min(380px, calc(100vw - 24px));
    border: 1px solid var(--edge); border-radius: 12px; background: var(--granite);
    box-shadow: 0 16px 44px rgba(0,0,0,.35); padding: 13px 14px;
    overflow: auto; text-align: left;
  }
  .askpop-head {
    display: flex; align-items: center; justify-content: space-between;
    gap: 8px; margin-bottom: 6px; font-size: 12.5px; color: var(--text);
  }
  /* Whose menu this is — a micro-chip, not a heading: the answer matters
     once, quietly, and the list itself is the point. */
  .askrole {
    margin-left: auto; white-space: nowrap;
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .1em;
    text-transform: uppercase; font-weight: 700; color: var(--sage-dim);
    border: 1px solid var(--hairline); border-radius: 999px; padding: 2px 7px;
  }
  .askpop-close {
    background: transparent; border: 0; color: var(--dimmer);
    font-size: 20px; line-height: 1; padding: 0 4px; cursor: pointer;
  }
  .askpop-close:hover { color: var(--text); }
  .askpop-list { list-style: none; margin: 0; padding: 0; }
  .askpop-list li {
    padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 12.5px;
  }
  .askpop-list li:last-child { border-bottom: none; }
  /* Group heading: the label that turns the flat menu into "talk / request /
     on air / message". Mono and spaced like the card's other section labels,
     with room above it so the groups read apart. */
  .askpop-list li.askhead {
    color: var(--accent); font: 700 10px/1.3 var(--font-mono);
    letter-spacing: .16em; text-transform: uppercase;
    padding: 12px 0 5px; border-bottom: 1px solid var(--line);
  }
  .askpop-list li.askhead:first-child { padding-top: 2px; }
  .askpop-list .say { display: block; color: var(--text); line-height: 1.4; }
  .askpop-list .why {
    display: block; color: var(--dimmer); font-size: 11px; margin-top: 2px;
    line-height: 1.45;
  }
  /* The settings gear on the full page only — the panel is never embedded. */
  .gear:hover { background: var(--line); color: var(--text); }
  /* Embeds keep the theme toggle (it hides itself when the host page forces
     a theme via data-theme); positioning lives with the other card chrome. */

  /* --- settings --- */
  /* The panel's own header row. It has one since 0.9.105, when the panel
     stopped being a section of the call page and lost access to that page's
     corner controls — without this there is no way back to the phone and no
     theme toggle, on the surface an operator sits in front of longest. */
  .panelbar {
    display: flex; justify-content: flex-end; gap: 4px; margin-bottom: 2px;
  }
  .panelbar .gear { text-decoration: none; }
  /* The back arrow is an <a>, and anchors never inherit the button skin the
     other two controls get from the generic button rule — it floated bare
     beside two boxed buttons and read as a stray glyph. Same chrome, by hand. */
  .panelbar a.gear {
    display: inline-flex; align-items: center; justify-content: center;
    padding: 6px 13px; border-radius: 8px;
    background: var(--line2); color: var(--text);
    font-weight: 600; font-size: 11.5px; line-height: 1.2;
  }
  /* Sign out is a word, not a glyph — same quiet chrome, its own width. */
  .panelbar .signout {
    width: auto; padding: 0 9px;
    font: 700 9.5px/1 var(--font-mono); letter-spacing: .12em;
    text-transform: uppercase;
  }

  /* Wider than the call card on purpose, and the one place the two surfaces
     are allowed to disagree about width. The card is a phone and wants to be
     phone-shaped; the panel is a form full of sentences, and at 620px almost
     every hint wrapped to three lines — which is what made a page of ordinary
     help text read as a wall.
     1120 rather than 860 because each row now carries its own help in a third
     column (see .row). At 860 that column got about half the width the help
     used to have on its own line, so it wrapped to twice as many lines and
     the page came out TALLER — measured, not guessed. The width is what makes
     three columns pay. Still collapses to 100% under 1200px. */
  .panel {
    width: min(1120px, 100%); margin-top: 14px; display: none;
    border: 1px solid var(--line); border-radius: 16px; background: var(--panel);
    padding: 20px 22px; text-align: left;
  }
  .panel.open { display: block; }
  .panel h2 { margin: 0 0 2px; font-size: 15px; font-weight: 700; }
  .panel .sub { margin: 0 0 16px; font-size: 12px; color: var(--dimmer); line-height: 1.5; }

  /* --- getting around: the jump bar and the line's own state ------------- */
  /* Six super-groups and roughly forty disclosures below them. Without this
     the only way to the bottom is scrolling past every section in between,
     which is also how a switch gets flipped on the way past. */
  /* The sections' menu, not a stray row of chips: full width, every entry an
     equal share of the row, hairlines above and below like a nav strip, and
     a micro-label saying what it is. Chips (999px pills, content-sized) read
     as filters; a jump-to reads as a menu. Operator-requested. */
  .panelnav {
    display: flex; flex-wrap: wrap; gap: 4px; align-items: stretch;
    width: 100%; margin: 0 0 14px; padding: 7px 0;
    border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  }
  .panelnav::before {
    content: "Jump to"; align-self: center; white-space: nowrap;
    font-family: var(--font-mono); font-size: 9px; letter-spacing: .14em;
    text-transform: uppercase; font-weight: 700; color: var(--sage-dim);
    padding: 0 10px 0 2px;
  }
  /* Each entry wears the quiet button skin at rest — bare text read as a
     header row, not as eight places to go. flex-grow snaps every row of
     them to the full width. Operator-requested, twice. */
  .panelnav a {
    flex: 1 1 auto; text-align: center;
    text-decoration: none; color: var(--dim);
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .12em;
    text-transform: uppercase; font-weight: 700;
    padding: 7px 9px; border-radius: 8px;
    border: 1px solid var(--line2);
    background: color-mix(in srgb, var(--text) 3%, transparent);
  }
  .panelnav a:hover {
    color: var(--text); background: var(--line2); border-color: var(--line);
  }
  .panelnav:empty { display: none; }

  /* The settings half's own header row: heading and promise left, the
     finder right, folding to two lines on a narrow panel. */
  .settingshead {
    display: flex; align-items: center; justify-content: space-between;
    gap: 14px; flex-wrap: wrap; margin: 18px 0 10px;
  }
  .settingshead h2 { margin: 0; }
  .settingshead .sub { margin: 3px 0 0; }
  .settingshead .settingsearch {
    margin: 0; flex: 0 1 300px; min-width: 220px;
  }

  /* The dashboard's own band — the same voice as a super-group heading, but
     its own class: applyVisibility() hides a .supergroup with no visible
     sections under it, and this one is followed by cards, not sections. */
  .dashband {
    margin: 4px 0 7px; font-size: 11px; letter-spacing: .14em;
    text-transform: uppercase; font-weight: 700; color: var(--text);
  }

  /* --- the sound board: six moments as cards over one shelf -------------- */
  /* Same grammar as the dashboard's control cards — micro label, value,
     note, 10px surface — because they are the same kind of thing: press to
     act, coloured by state. The ▶ is a SIBLING (buttons cannot nest). */
  .slotarea { position: relative; }
  /* The .row skin sets display, and an author display beats the UA's
     [hidden] rule — so the six URL rows sat fully visible under the cards,
     which read (correctly) as the whole section duplicated. */
  .row.sloturl[hidden] { display: none; }
  .slotgrid {
    display: grid; gap: 8px; grid-template-columns: repeat(3, 1fr);
    grid-auto-rows: 1fr; margin: 12px 0 4px;
  }
  @media (max-width: 760px) { .slotgrid { grid-template-columns: repeat(2, 1fr); } }
  .slotwrap { position: relative; display: flex; }
  .slotcard {
    flex: 1; display: flex; flex-direction: column; align-items: flex-start;
    gap: 2px; padding: 9px 36px 9px 11px; min-height: 64px;
    border-radius: 10px; border: 1px solid var(--line2);
    background: var(--panel); color: var(--text); text-align: left;
    cursor: pointer; transition: border-color .15s, transform .15s;
  }
  .slotcard:hover { transform: translateY(-1px); border-color: var(--line); }
  .slotcard .ck {
    font-family: var(--font-mono); font-size: 9px; letter-spacing: .14em;
    text-transform: uppercase; font-weight: 700; color: var(--sage-dim);
  }
  .slotcard .cv { font-size: 12.5px; font-weight: 700; overflow-wrap: anywhere; }
  .slotcard .cn { font-size: 10.5px; color: var(--dim); }
  /* A missing file or an unplayable beep is a fault ON THE CARD — the thing
     that is broken wears the colour, not a paragraph somewhere below. */
  .slotcard.missing, .slotcard.bad { border-color: var(--accent); }
  .slotcard.missing .cv, .slotcard.bad .cv { color: var(--accent); }
  .slotplay {
    position: absolute; top: 8px; right: 8px; width: 24px; height: 24px;
    padding: 0; border-radius: 8px; font-size: 10px; line-height: 1;
  }
  /* Same trap as .guestgate: an author display beats [hidden], and the
     empty picker sat as a ghost box floating over the cards. Spot rule,
     the house pattern. */
  .slotmenu[hidden] { display: none; }
  .slotmenu {
    position: absolute; z-index: 30; min-width: 240px; max-width: 340px;
    display: flex; flex-direction: column; gap: 2px; padding: 6px;
    border: 1px solid var(--line2); border-radius: 10px;
    background: var(--panel);
    box-shadow: 0 8px 22px color-mix(in srgb, var(--bg) 55%, transparent);
  }
  .slotmenu button {
    text-align: left; background: transparent; border-radius: 8px;
    padding: 6px 9px; font-size: 11.5px; font-weight: 500;
  }
  .slotmenu button:hover { background: var(--line2); }
  .slotmenu button.current { color: var(--ok); font-weight: 700; }
  /* The shelf: sortable headers, a kind chip on every name, and used-for
     chips reading the same fields the cards read. */
  .soundboard th.sortable { cursor: pointer; user-select: none; }
  .soundboard th.sortable.asc::after { content: " ▲"; color: var(--dim); }
  .soundboard th.sortable.desc::after { content: " ▼"; color: var(--dim); }
  .kindchip {
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .08em;
    text-transform: uppercase; color: var(--dim);
    border: 1px solid var(--line2); border-radius: 4px;
    padding: 1px 5px; margin-left: 7px; white-space: nowrap;
  }
  .usedchip {
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .08em;
    text-transform: uppercase; color: var(--ok);
    border: 1px solid color-mix(in srgb, var(--ok) 45%, transparent);
    border-radius: 4px; padding: 1px 5px; margin-right: 3px; white-space: nowrap;
  }
  .soundboard td.unused { color: var(--dimmer); }
  /* MADE for a slot but not on duty — dim and dashed so a suggestion can
     never be misread as an assignment. */
  .suggestchip {
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .08em;
    text-transform: uppercase; color: var(--dimmer);
    border: 1px dashed var(--line2); border-radius: 4px;
    padding: 1px 5px; white-space: nowrap;
  }
  .shelfpager {
    display: flex; align-items: center; justify-content: flex-end;
    gap: 10px; margin-top: 8px;
  }
  .shelfpager[hidden] { display: none; }
  .shelfacts { white-space: nowrap; }
  /* The two shelf selects wear the field skin every other control wears —
     bare UA selects in the table read as foreign. */
  .shelfacts .usefor, .shelfcat {
    font: inherit; font-size: 10.5px; padding: 4px 7px; width: auto;
    margin-left: 3px; border-radius: 8px; border: 1px solid var(--line2);
    background: var(--bg); color: var(--text);
  }
  .shelfacts a.btnquiet { text-decoration: none; display: inline-block; }
  /* The drop target announces itself only while a file is over it. */
  details.advanced.dropping { outline: 2px dashed var(--accent); outline-offset: 4px; }
  .shelffind {
    font: inherit; font-size: 11.5px; padding: 5px 9px; width: 170px;
    border-radius: 8px; border: 1px solid var(--line2);
    background: var(--bg); color: var(--text);
  }
  .shelffind:focus { outline: none; border-color: var(--accent); }

  /* The dashboard: the kill switch, and the four answers worth having before
     you change anything below. Bordered and above the fold because none of it
     is one of the settings — the button acts immediately, and the tiles are
     read-back rather than input. */
  /* The newspaper order (spec §2b): the read-only station strip leads —
     four columns directly under the masthead — then TRANSMISSION with its
     LINES indented beneath THE LINE, each door beside the traffic it
     produces. Stacks to one column when narrow. */
  .dash {
    margin: 0 0 16px; padding: 12px 13px;
    border: 1px solid var(--line2); border-radius: 12px; background: var(--bg);
    display: flex; flex-direction: column; gap: 10px;
  }
  .stationstrip {
    display: grid; gap: 10px; grid-template-columns: repeat(4, minmax(0, 1fr));
  }
  .dashgroup {
    border: 1px solid var(--line2); border-radius: 10px;
    padding: 9px 10px 8px; display: flex; flex-direction: column;
  }
  /* Caption row: the group's micro-label left, the what-it-amounts-to
     sentence right, sharing one rule. */
  .dashcaption {
    display: flex; align-items: baseline; justify-content: space-between;
    gap: 12px; flex-wrap: wrap;
  }
  .dashcaption .dashmodesay { margin: 0; text-align: right; }
  .lines { margin-top: 8px; }
  .lineslabel {
    margin: 0 0 2px; font-family: var(--font-mono); font-size: 9px;
    letter-spacing: .14em; text-transform: uppercase; font-weight: 700;
    color: var(--sage-dim);
  }
  .lineslabel em {
    font-style: normal; font-weight: 400; letter-spacing: .04em;
    color: var(--sage-dim); margin-left: 8px; text-transform: none;
  }
  /* One row per line: the switch card takes the width, the stat pair keeps
     a fixed right column so numbers line up down the block. */
  .linerow {
    display: grid; grid-template-columns: minmax(0, 1fr) minmax(96px, auto);
    gap: 8px; align-items: stretch;
  }
  @media (max-width: 940px) {
    .stationstrip { grid-template-columns: repeat(2, 1fr); }
  }
  @media (max-width: 700px) {
    .stationstrip { grid-template-columns: 1fr; }
    .linerow { grid-template-columns: 1fr; }
  }
  /* The real field behind the button. Not `hidden` and not display:none —
     both remove it from the form in ways that have surprised people before —
     but it must not be clickable, focusable or visible, because clicking it
     would change the value without posting it. */
  /* Moot rows: readable, visibly out of play, and not editable — a
     voicemail-only line has no live Call button for these to configure.
     Dimmed, not hidden: hiding them would read as the feature being gone
     rather than waiting. No line-through — striking out a dropdown's text
     read as a rendering fault, not a state. Operator-reported. */
  .row.moot, .check.moot, .prow.moot { opacity: .4; }
  .row.moot select, .row.moot input, .check.moot input, .prow.moot input {
    pointer-events: none;
  }

  /* Matrix help, inside the label cell: same muted voice as every hint,
     flowing after the label on its own line's terms. */
  .plabel .hint.inlabel,
  label.check .hint.inlabel {
    display: inline; margin: 0 0 0 8px; font-weight: 400;
  }
  .runbtn.on { border-color: var(--accent); color: var(--accent); }
  /* set / not set beside a credential heading */
  .settingsearch {
    display: block; width: 100%; max-width: 340px; margin: 0 0 10px;
    font: inherit; font-size: 12.5px; padding: 7px 10px;
    border-radius: 8px; border: 1px solid var(--line2);
    background: var(--bg); color: var(--text);
  }
  .settingsearch:focus { outline: none; border-color: var(--accent); }
  /* A run of buttons after content is the section's FOOTER, and says so
     with a hairline — buttons drifting after whatever came before read
     as appended. First-thing button rows (right under a heading) keep
     their place without the line. */
  .body .row + .testrow, .body .result + .testrow, .body table + .testrow,
  .body .keyblock + .testrow, .body ul + .testrow, .body .hint + .testrow {
    border-top: 1px solid var(--line); padding-top: 12px; margin-top: 14px;
  }
  /* the on-air face, riding the tile's right edge */
  .tile { position: relative; transition: border-color .15s, transform .15s; }
  .tile:hover { transform: translateY(-1px); border-color: var(--dimmer, var(--sage-dim)); }
  details.sec > summary { transition: background .15s; border-radius: 8px; }
  details.sec > summary:hover {
    background: color-mix(in srgb, var(--text) 4%, transparent);
  }
  .tile .tavatar {
    position: absolute; top: 10px; right: 10px;
    width: 36px; height: 36px; border-radius: 50%; object-fit: cover;
  }

  /* A table is a surface like any other here: contained, header band,
     rows that answer hover — bare rows floating in the section read as
     unfinished. Operator-reported. */
  .soundboard {
    width: 100%; border-collapse: separate; border-spacing: 0;
    margin-top: 10px; font-size: 12px;
    border: 1px solid var(--line2); border-radius: 10px; overflow: hidden;
    background: var(--bg);
  }
  .soundboard th {
    font-family: var(--font-mono); font-size: 9px; letter-spacing: .14em;
    text-transform: uppercase; font-weight: 700; color: var(--sage-dim);
    text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line2);
    background: var(--panel);
  }
  .soundboard td { padding: 6px 10px; border-bottom: 1px solid var(--line2); }
  .soundboard tbody tr:last-child td { border-bottom: 0; }
  .soundboard tbody tr:hover td {
    background: color-mix(in srgb, var(--text) 4%, transparent);
  }
  .soundboard td:last-child { text-align: right; white-space: nowrap; }
  .soundboard .catbox {
    width: 110px; font: inherit; font-size: 11.5px; padding: 3px 7px;
    border-radius: 8px; border: 1px solid var(--line2);
    background: var(--bg); color: var(--text);
  }
  .soundboard button { margin-left: 6px; }

  .setchip {
    font-family: var(--font-mono); font-size: 9px; letter-spacing: .12em;
    text-transform: uppercase; font-weight: 700; margin-left: 8px;
  }
  .setchip[data-state="on"] { color: var(--sage); }
  .setchip[data-state="off"] { color: var(--coral); }

  /* The section tag carries its state in colour — see setTag(). */
  .tag[data-state="on"] { color: var(--sage); }
  .tag[data-state="off"] { color: var(--sage-dim); opacity: .65; }

  .fieldonly {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
  }
  /* Every dashboard group carries the same micro-label voice. The box
     itself is .dashgroup, defined with the dash grid above. */
  .dashgroup .tlabel {
    margin: 0 0 7px; font-family: var(--font-mono); font-size: 9px;
    letter-spacing: .14em; text-transform: uppercase; font-weight: 700;
    color: var(--sage-dim);
  }
  .ctrlcard.kill { position: relative; padding-right: 66px; }
  .ctrlcard { position: relative; }
  /* Drawn switches, so every card READS as the toggle it is — the whole
     card is still the button; the switch is its state, not a second
     control. Off: knob left, quiet. On: knob right, line-green. The kill
     switch alone goes coral when thrown, because a paused line is the one
     off-state that is a deliberate act against the callers of right now. */
  .ctrlcard .switch {
    position: absolute; right: 13px; top: 50%; transform: translateY(-50%);
    width: 40px; height: 22px; border-radius: 999px;
    background: var(--line2); transition: background .15s;
  }
  .ctrlcard .switch::after {
    content: ""; position: absolute; top: 2px; left: 2px;
    width: 18px; height: 18px; border-radius: 50%;
    background: var(--bg); transition: left .15s;
  }
  .ctrlcard.on .switch,
  .ctrlcard.kill:not(.paused) .switch { background: var(--ok); }
  .ctrlcard.on .switch::after,
  .ctrlcard.kill:not(.paused) .switch::after { left: 20px; }
  .ctrlcard.kill.paused .switch { background: var(--accent); }
  /* The three lines wear smaller switches than the master — same vocabulary,
     lower rank. No padding-right: since 0.10.54 the switch sits in the grid's
     own right column, not absolutely over reserved padding. */
  .ctrlcard .switch.small { width: 30px; height: 17px; right: 11px; }
  .ctrlcard .switch.small::after { width: 13px; height: 13px; }
  .ctrlcard.on .switch.small::after { left: 15px; }
  /* Held, not broken: while the line is paused the three lines keep saying
     which way they point, dimmed and warmed amber — waiting, in the state
     vocabulary, rather than off or at fault. */
  .linerow .ctrlcard:disabled {
    border-color: color-mix(in srgb, var(--warn) 55%, var(--line2));
  }
  .linerow .ctrlcard:disabled .cv { color: var(--warn); }
  .ctrlcard {
    display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
    padding: 9px 11px; min-height: 64px; border-radius: 10px;
    border: 1px solid var(--line2); background: var(--panel);
    color: var(--text); text-align: left; cursor: pointer;
    transition: border-color .15s, background .15s, transform .15s;
  }
  .ctrlcard:hover:not(:disabled) { transform: translateY(-1px); }
  /* The two doors hang off the line itself: paused, they grey out and stop
     taking presses (opacity only, per the moot-row rule — no strikethrough). */
  .ctrlcard:disabled { opacity: .45; cursor: default; }
  .ctrlcard .ck {
    font-family: var(--font-mono); font-size: 9px; letter-spacing: .14em;
    text-transform: uppercase; font-weight: 700; color: var(--sage-dim);
  }
  .ctrlcard .cv { font-size: 15px; font-weight: 700; }
  .ctrlcard .cn { font-size: 10.5px; color: var(--dim); }
  /* State on the card: an open door is green-edged, a shut one neutral,
     the paused line coral — colour carries state, fill stays quiet. */
  .ctrlcard.on { border-color: var(--ok); }
  .ctrlcard.on .cv { color: var(--ok); }
  .ctrlcard.kill .cv { color: var(--ok); }
  .ctrlcard.kill.paused { border-color: var(--accent); }
  .ctrlcard.kill.paused .cv { color: var(--accent); }
  .dashmodesay { font-size: 11.5px; color: var(--dim); margin: 8px 0 0; }
  /* No own top margin since the dash grid gaps for it. */

  /* Read-backs, not settings. Buttons because each one jumps to the
     section that owns its answer — the whole reason to look at a dashboard
     is to find out where to go next. The tiles live inside the four
     labelled groups now; .grouprow above is their grid. */
  .tile {
    display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
    padding: 9px 11px; text-align: left; min-width: 0; min-height: 64px;
    background: var(--panel); color: var(--text);
    border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line);
  }
  .tile:hover { box-shadow: inset 0 0 0 1px var(--line2); }
  .tile .tk {
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .13em;
    text-transform: uppercase; font-weight: 700; color: var(--dimmer);
  }
  .tile .tv {
    font-size: 13px; font-weight: 600; line-height: 1.3;
    max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .tile .tn {
    font-size: 11px; color: var(--dimmer); line-height: 1.35;
    max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .tile .tn:empty { display: none; }
  .tile.ok .tv { color: var(--text); }
  .tile.warn .tv { color: var(--warn); }
  .tile.bad .tv { color: var(--accent); }
  .tile.bad { box-shadow: inset 0 0 0 1px
              color-mix(in srgb, var(--accent) 45%, transparent); }

  /* An "Advanced" fold inside a section, for a field that is derived, or one
     that only a non-standard setup needs. It is a real setting either way —
     hiding it outright is how a station that publishes MCP somewhere unusual
     ends up with no way to say so. */
  details.advanced { margin: 14px 0 10px; }
  /* A visible door, not a whisper: bare micro-caps read as a stray label,
     and the operator missed that the shelf was openable at all. Same quiet
     chip chrome the jump menu wears — recognisably pressable, still
     subordinate to real section summaries. */
  details.advanced > summary {
    list-style: none; cursor: pointer; user-select: none;
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .14em;
    text-transform: uppercase; font-weight: 700; color: var(--dim);
    padding: 7px 10px; border: 1px solid var(--line2); border-radius: 8px;
    background: color-mix(in srgb, var(--text) 3%, transparent);
  }
  details.advanced[open] > summary { margin-bottom: 8px; }
  details.advanced > summary::-webkit-details-marker { display: none; }
  details.advanced > summary::before { content: "▸ "; }
  details.advanced[open] > summary::before { content: "▾ "; }
  details.advanced > summary:hover {
    color: var(--text); border-color: var(--line); background: var(--line2);
  }
  /* Prose directly under a table sat on its bottom border — reported from
     the shelf's sources line, and visible under every table on the page. */
  .body table + .hint, .body .soundlist + .hint, .body .result + .hint {
    margin-top: 12px;
  }

  /* A band, not just a rule: the category row sits a step DARKER than the
     section rows under it, so the hierarchy reads at a glance instead of
     five same-weight lines in a column. Mixed toward black so it lands
     darker in both themes — mixing toward --text would brighten dark mode. */
  .supergroup {
    display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
    margin: 15px 0 1px; padding: 9px 10px 7px;
    border-top: 2px solid var(--line2); border-radius: 0 0 8px 8px;
    background: color-mix(in srgb, #000 14%, transparent);
    font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
    font-weight: 700; color: var(--text);
  }
  .supergroup:first-of-type { margin-top: 6px; border-top: none; }
  .supergroup em {
    font-style: normal; text-transform: none; letter-spacing: 0;
    font-size: 11.5px; font-weight: 400; color: var(--dimmer);
  }
  details.sec { border-top: 1px solid var(--line); }
  details.sec > summary {
    list-style: none; cursor: pointer; padding: 9px 2px;
    font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
    color: var(--dimmer); font-weight: 700;
    display: flex; align-items: center; gap: 9px; user-select: none;
  }
  details.sec > summary::-webkit-details-marker { display: none; }
  details.sec > summary:hover { color: var(--text); }
  details.sec > summary .chev { transition: transform .18s ease; font-size: 9px; }
  details.sec[open] > summary .chev { transform: rotate(90deg); }
  details.sec > summary .tag {
    margin-left: auto; font-size: 9.5px; letter-spacing: .02em; text-transform: none;
    color: var(--dimmer); font-weight: 500; opacity: .85;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 45%;
  }
  /* The section's one-line subtitle, set from the schema by layoutPanel().
     Undoes the summary's uppercase mono treatment — it is a sentence, not a
     label — and gives way first when the row runs out of room. */
  details.sec > summary .secblurb {
    font-style: normal; text-transform: none; letter-spacing: 0;
    font-weight: 400; font-size: 11px; color: var(--dimmer);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    flex: 0 1 auto; min-width: 0;
  }
  .secname { flex: 0 0 auto; }
  details.sec > summary .secblurb:empty { display: none; }
  @media (max-width: 620px) { details.sec > summary .secblurb { display: none; } }
  /* The body of a section is recessed against its header, so it is obvious
     where one section ends and the next begins when several are open. */
  details.sec > .body {
    padding: 14px 14px 12px; margin: 0 0 12px;
    background: var(--bg); border: 1px solid var(--line); border-radius: 12px;
  }
  /* …which means anything that WAS recessed has to lift, or it disappears
     into the body it sits on. */
  details.sec > .body .result,
  details.sec > .body .banner,
  details.sec > .body .snippet,
  details.sec > .body .soundlist li,
  details.sec > .body input,
  details.sec > .body select,
  details.sec > .body textarea { background: var(--panel); }

  /* Diagnostics rows: same folded look as a settings section, but the action
     lives in the header so a check can be run without opening anything. The
     tag gives up its auto-margin to the button. */
  details.diag > summary .tag { margin-left: auto; max-width: 38%; }
  .runbtn {
    flex: 0 0 auto; margin-left: 12px;
    background: transparent; color: var(--dim);
    box-shadow: inset 0 0 0 1px var(--line2);
  }
  .runbtn:hover:not(:disabled) { background: var(--accent); color: #fff;
                                 box-shadow: none; }
  /* The problems filter wears its mark in the fault colour INSIDE the chip —
     the same red "!" the failed rows carry — instead of a plain glyph that
     read as punctuation. Inherits back on hover, when the chip itself fills. */
  .runbtn .bang { color: var(--accent); font-weight: 700; }
  .runbtn:hover:not(:disabled) .bang { color: inherit; }
  .runbtn.running { opacity: .55; }
  /* Results in a diagnostics row scroll rather than growing the page. */
  .result.scrolly { max-height: 460px; overflow: auto; }
  details.diag .result { font-size: 11px; line-height: 1.5; }
  details.diag .stages { font-size: 11.5px; }

  /* Label, field, and what the field is for — one line, left to right.
     The help used to sit on a line of its own under each row, which cost a
     line per setting down a panel with ~50 of them, while the right two
     thirds of the row it described sat empty. Wraps at narrow widths, where
     three columns genuinely do not fit. */
  .row {
    display: flex; gap: 10px; align-items: center; margin-bottom: 8px;
    flex-wrap: wrap;
  }
  .row label { flex: 0 0 168px; font-size: 12.5px; color: var(--dim); }
  /* Bounded rather than greedy. `flex: 1` let a text field take the whole
     row, which is what left the help nowhere to go but underneath — and the
     help is the thing that needs the width, because it is a sentence. A
     dropdown is as wide as its longest option and a URL box does not become
     more readable at 600px than at 260. */
  /* .testrow carries the same field skin: the Access credentials moved out
     of .rows so each could share a line with its buttons, and the inputs
     fell back to the browser's own chrome — which read as a different,
     broken control two inches from a styled one. Operator-reported. */
  .row select, .row input[type=text], .row input[type=number], .row input[type=password],
  .testrow input[type=text], .testrow input[type=password] {
    flex: 0 1 260px; max-width: 260px;
    min-width: 0; font: inherit; font-size: 12.5px;
    padding: 6px 9px; border-radius: 8px;
    border: 1px solid var(--line2); background: var(--bg); color: var(--text);
  }
  /* The only greedy thing in the row. It gets whatever the label and the
     field did not take, which at the panel's width is more than the help had
     when it sat on a line of its own. */
  .row > .hint.inrow {
    flex: 1 1 300px; min-width: 0; margin: 0; text-align: left;
  }
  .row select:focus, .row input:focus,
  .testrow input:focus { outline: none; border-color: var(--accent); }
  /* A field that holds "2" does not need 600px to hold it. Every number in
     the panel was stretched the full width of the row, which read as five
     enormous empty boxes and made the numbers themselves hard to compare —
     the thing you are actually doing in Usage controls. */
  .row.narrow input[type=number] { flex: 0 0 96px; }
  /* One column, and the ROW is what has columns now.
     This was two columns of short number rows — but the schema help was
     injected as each row's next sibling and had to span both cells to stay
     under the field it described, which pushed the following row back to
     column one. So every row landed in column one, every hint took a full
     line, and column two was empty down the whole section. The three-column
     row above spends that same width on something readable instead. */
  .grid2 { display: grid; grid-template-columns: minmax(0, 1fr); }
  .grid2 > .row { margin-bottom: 8px; }
  /* Non-schema hints written into the markup still sit under their row. */
  .grid2 > .hint { margin: -2px 0 8px; }

  .check { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; font-size: 12.5px; color: var(--text); }
  .check input { width: 15px; height: 15px; accent-color: var(--accent); }
  .hint { font-size: 11px; color: var(--dimmer); margin: -3px 0 9px 178px; line-height: 1.4; }
  .hint.wide { margin-left: 0; }

  /* "Needs the station's own admin credentials." Coral while they are
     missing, grey once they are stored — a switch that quietly never happens
     is the failure this exists to name, and the colour is the difference
     between the two states. Deliberately a small outlined tag rather than a
     red banner: it is a prerequisite, not a warning. */
  .needsadmin {
    margin-left: auto; flex: 0 0 auto; cursor: help;
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .12em;
    text-transform: uppercase; font-weight: 700;
    padding: 2px 6px; border-radius: 999px;
    color: var(--dimmer); border: 1px solid var(--line);
  }
  .needsadmin.missing {
    color: color-mix(in srgb, var(--accent) 78%, var(--dim));
    border-color: color-mix(in srgb, var(--accent) 35%, transparent);
    background: color-mix(in srgb, var(--accent) 7%, transparent);
  }

  /* --- the permission matrix: one row, three tiers ----------------------
     ONE grid for the whole block, with each row set to display:contents, so
     every row shares the same columns. Per-row grids are what made the four
     other lists in this panel unreadable — see the log viewer — and a matrix
     whose columns do not line up is not a matrix. It also lets a modifier
     (the plain checkboxes between the tiered rows) span the full width
     without leaving a hole where its cells would have been. */
  .permgrid {
    display: grid; grid-template-columns: minmax(0, 1fr) 66px 66px 66px;
    align-items: center; margin: 0 0 6px;
  }
  .permrow { display: contents; }
  /* The select IS the field — it is what Save diffs and what the server
     stores — but nobody looks at it. Hidden rather than removed so the whole
     of the rest of panel.js keeps working on it unchanged. */
  .permrow > select { display: none; }
  .permgrid > .phead {
    display: contents;
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .14em;
    text-transform: uppercase; color: var(--dimmer);
  }
  .permgrid > .phead > span {
    padding: 9px 0 6px; border-bottom: 1px solid var(--line);
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .14em;
    text-transform: uppercase; color: var(--dimmer);
  }
  .permgrid > .phead > span:not(:first-child) { text-align: center; }
  .permrow > .plabel {
    display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
    padding: 8px 10px 8px 0; font-size: 12.5px; color: var(--text);
  }
  .permrow.off > .plabel { color: var(--dim); }
  .permrow > .tcell { display: flex; justify-content: center; padding: 8px 0; }
  .permrow > .tcell input { width: 15px; height: 15px; accent-color: var(--accent); }
  /* A tier nobody can be. Disabled rather than hidden: the level exists, and
     an operator needs to see that it is shut and why (the title says which
     password is missing) rather than wondering where the column went. */
  .permrow > .tcell.unreachable { opacity: .3; cursor: help; }
  /* Modifiers and injected help span the whole width — they are not answers
     to the column headings. */
  .permgrid > .check, .permgrid > .hint {
    grid-column: 1 / -1; margin: 0 0 0 0; padding: 4px 0;
  }
  .permgrid > .hint { padding: 0 0 8px; margin: -2px 0 0; }
  .permgrid > .check { padding: 6px 0 6px 18px; }

  /* Which callers reach an entry in the ask list. */
  .whotier {
    display: inline-block; margin-left: 7px; padding: 1px 6px; border-radius: 999px;
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .1em;
    text-transform: uppercase; font-weight: 700;
    border: 1px solid var(--line2); color: var(--dimmer);
  }
  .whotier.t-open {
    color: color-mix(in srgb, var(--warn) 80%, var(--text));
    border-color: color-mix(in srgb, var(--warn) 40%, transparent);
  }
  /* No switch behind it — quieter than a granted tier, but present: an
     unlabelled row in a column of who-gets-this answers read as a gap. */
  .whotier.t-always { color: var(--dimmer); border-style: dashed; }
  .askbar { margin-bottom: 10px; }

  @media (max-width: 620px) {
    .permgrid { grid-template-columns: minmax(0, 1fr) 46px 46px 46px; }
    .permrow > .plabel { font-size: 12px; }
  }

  /* --- the Player settings matrix: one row, two surfaces ---------------- */
  /* A grid rather than two stacked columns of checkboxes, because the whole
     point of the section is comparing the two answers for one control. The
     column headings carry the surface, so the labels don't have to repeat it
     eight times. */
  .pmatrix { margin: 0 0 4px; }
  .phead, .prow {
    display: grid; grid-template-columns: minmax(0, 1fr) 54px 54px;
    align-items: center; gap: 8px;
  }
  .phead {
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .14em;
    text-transform: uppercase; color: var(--dimmer);
    padding: 10px 0 5px; border-bottom: 1px solid var(--line);
  }
  .phead span:not(:first-child), .prow input, .prow .pnone { justify-self: center; }
  .phead:first-child { padding-top: 0; }
  .prow { padding: 7px 0; font-size: 12.5px; color: var(--text); }
  .prow input { width: 15px; height: 15px; accent-color: var(--accent); }
  /* Not a disabled checkbox: a disabled box still reads as a setting that
     could be enabled, and this one can't be. The dash says "no such thing
     here", and the title says why. */
  .pnone { color: var(--dimmer); cursor: help; }
  /* The schema's help sits under its row, indented to the label column so a
     wall of eight rows still scans as rows. */
  .prow + .hint { margin: -2px 0 4px; }
  /* Louder than .hint, quieter than .banner. For the switches that change the
     station rather than the call: in grey body text they read as three more
     checkboxes, which is how one gets ticked on the way past. */
  .alert {
    font-size: 11.5px; line-height: 1.5; color: var(--dim);
    margin: 0 0 10px; padding: 8px 11px; border-radius: 8px;
    border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent);
    background: color-mix(in srgb, var(--warn) 8%, transparent);
  }
  .alert strong { color: var(--warn); font-weight: 700; }
  .alert em { color: var(--text); font-style: normal; font-weight: 600; }
  /* Banners share the michelp look: theme-var colors so light mode holds. */
  .banner {
    font-size: 12px; padding: 10px 12px; border-radius: 10px; line-height: 1.5;
    background: var(--bg); border: 1px solid var(--warn); color: var(--warn);
    margin-bottom: 14px;
  }
  .banner.ok { border-color: var(--ok); color: var(--ok); }
  /* First run: the one banner that is a form rather than a sentence. It is
     asking for something, so it gets room to ask. */
  .banner.setup { color: var(--text); border-color: var(--accent); }
  .banner.setup strong { color: var(--accent); display: block; margin-bottom: 5px; }
  .banner.setup p { margin: 0 0 10px; color: var(--dim); font-size: 11.5px; }
  .banner.setup .loginrow { display: inline-flex; gap: 8px; margin: 0; }
  .banner.setup input { width: 220px; }
  .banner.setup #firstPwMsg { display: block; margin-top: 7px; font-size: 11.5px;
                              color: var(--accent); }
  .banner.setup #firstPwMsg:empty { display: none; }
  /* Buttons created from JS use these instead of inline colors. */
  .btnaccent { background: var(--accent); color: #fff; }
  .btnquiet { flex: 0 0 auto; background: transparent; color: var(--dim);
              box-shadow: inset 0 0 0 1px var(--line2); }
  .btnquiet:hover:not(:disabled) { color: var(--text); background: var(--line2); }
  .testrow { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; align-items: center; }

  /* A section with two jobs in it (set the admin password / set the guest
     code) needs a divider inside the section, or the two runs of fields read
     as one long form. */
  .subhead {
    font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
    color: var(--dimmer); font-weight: 700;
    margin: 18px 0 9px; padding-top: 13px; border-top: 1px solid var(--line);
  }
  details.sec > .body > .subhead:first-child { margin-top: 4px; padding-top: 0; border-top: none; }

  /* The boundary of what a caller can ever do, shown with the things they
     can — otherwise every blocked action looks like one toggle away. */
  .asklist li.nevergroup { margin-top: 12px; padding-top: 11px;
                           border-top: 1px solid var(--line); opacity: 1; }
  .asklist li.nevergroup .mark { color: var(--dimmer); }
  .asklist li.nevergroup .say { color: var(--dim); font-weight: 600; }

  /* The station's tool surface. A fixed status column on the left so the
     reachable / off / never split can be read down the page at a glance. */
  .toollist { list-style: none; margin: 4px 0 0; padding: 0; }
  .toollist li {
    display: grid; grid-template-columns: 44px 152px minmax(0, 1fr); gap: 12px;
    padding: 7px 0; border-top: 1px solid var(--line); align-items: baseline;
  }
  .toollist li:first-child { border-top: none; }
  .toollist .tstate {
    font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase;
    font-weight: 700; text-align: right; color: var(--ok);
  }
  .toollist li.off .tstate { color: var(--dimmer); }
  .toollist li.blocked .tstate { color: var(--warn); }
  .toollist .tbody { min-width: 0; }
  /* The subwave_ prefix is on nineteen of nineteen names and earns none of
     the 70px it was taking out of the description column. Full name in the
     title, where anyone who needs to type it can still get at it. */
  .toollist code {
    display: block; font-size: 11.5px; color: var(--text);
    font-family: var(--font-mono); overflow-wrap: break-word;
  }
  .toollist li.off code, .toollist li.blocked code { color: var(--dim); }
  .toollist .twhat { display: block; font-size: 11.5px; color: var(--dim);
                     line-height: 1.45; }
  .toollist .tnote { display: block; font-size: 11px; color: var(--dimmer);
                     margin-top: 3px; line-height: 1.45; }
  @media (max-width: 700px) {
    .toollist li { grid-template-columns: 44px minmax(0, 1fr); }
    .toollist code { grid-column: 2; margin-bottom: 2px; }
    .toollist .tbody { grid-column: 2; }
  }

  .soundlist { list-style: none; margin: 4px 0 10px; padding: 0; }
  .soundlist li {
    display: flex; gap: 8px; align-items: center; padding: 6px 9px;
    margin-bottom: 5px; border: 1px solid var(--line); border-radius: 8px;
    background: var(--bg); font-size: 12px;
  }
  .soundlist .sname {
    flex: 1; min-width: 0; color: var(--text);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    font-family: ui-monospace, SFMono-Regular, monospace;
  }
  .soundlist select {
    font: inherit; font-size: 11.5px; padding: 4px 6px; border-radius: 6px;
    border: 1px solid var(--line2); background: var(--panel); color: var(--dim);
  }
  /* The Download control is an <a> so the browser's own save-as behaviour
     does the work, but it sits in a row of buttons and has to dress like
     one — an anchor gets none of the button element's base styling. */
  .soundlist a.sdl {
    display: inline-flex; align-items: center; text-decoration: none;
    font: 600 11.5px var(--font-body); padding: 4px 10px; border-radius: 10px;
    cursor: pointer;
  }
  .result {
    font-size: 12px; line-height: 1.55; padding: 10px 12px; border-radius: 10px;
    background: var(--bg); border: 1px solid var(--line); color: var(--dim);
    white-space: pre-wrap; display: none; font-family: ui-monospace, SFMono-Regular, monospace;
  }
  .result.on { display: block; }
  /* Derived from --ok / --accent rather than pinned to two dark-mode hexes.
     The literals held their dark-mode value through two palette changes and
     read as mud on the light theme, which is the failure this file's header
     warns about. */
  .result.good {
    border-color: color-mix(in srgb, var(--ok) 45%, transparent);
    color: color-mix(in srgb, var(--ok) 88%, var(--text));
  }
  .result.bad {
    border-color: color-mix(in srgb, var(--accent) 45%, transparent);
    color: color-mix(in srgb, var(--accent) 82%, var(--text));
  }

  /* The log viewer. Rows rather than one pre-wrapped block, so a level can be
     coloured and a line can be hidden — neither of which a single string
     allows. Colours come from the existing theme tokens rather than new ones,
     so this reads correctly in light mode without a second set of values. */
  /* The toolbar above a diagnostic's results. ONE class for both Recent calls
     and Server logs — they used to be `.logfilters` and `.callfilter`, two
     unrelated bars that disagreed about gap, margin and type size, and the
     calls one was built in JavaScript after the fetch while the logs one sat
     in the markup. Reading the two sections back to back was the tell: the
     pipeline and speed sections above them share a shape, and these did not. */
  .diagbar {
    display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
    margin: 0 0 8px; font-size: 11.5px; color: var(--dimmer);
  }
  /* display:flex beats [hidden] — the calls toolbar ships hidden until its
     records load, and without this it sat empty on the page. */
  .diagbar[hidden] { display: none; }
  .diagbar label { font-size: 11.5px; color: var(--dimmer); }
  .diagbar .grow { flex: 1 1 auto; }
  /* All of a diagbar's selects, not just multi-selects — the calls bar's
     three dropdowns and the logs' level floor wear one skin. */
  .diagbar select {
    font-size: 11.5px; padding: 3px 6px; border-radius: 7px;
    background: var(--bg); color: var(--text); border: 1px solid var(--line2);
    min-width: 104px;
  }
  .diagbar input[type="search"] {
    flex: 1 1 140px; min-width: 120px; font-size: 12px; padding: 5px 8px;
    border-radius: 7px; background: var(--bg); color: var(--text);
    border: 1px solid var(--line2);
  }
  /* The checkbox filter reads as one control, so the whole thing is the label. */
  .diagbar .pick {
    display: flex; align-items: center; gap: 7px; cursor: pointer;
  }
  .diagbar .pick input { margin: 0; }
  .diagbar .pick input:disabled + span { opacity: .5; cursor: default; }
  /* Destructive, so it is stated rather than styled loud: the same button as
     every other one until you are on it. */
  .runbtn.danger:hover:not(:disabled) {
    background: color-mix(in srgb, var(--accent) 22%, var(--bg));
    border-color: var(--accent);
    color: color-mix(in srgb, var(--accent) 60%, var(--text));
  }
  .result.logs { white-space: normal; padding: 5px 4px; max-height: 430px; }
  /* FIXED columns, not `auto`. Every row is its own grid, so `auto` sized each
     one to its own content and nothing lined up with the row above it — the
     timestamp, the level letter and the logger all stepped in and out down the
     page, and the message, which is the part you are reading for, started at a
     different x on every line. It read as noise because it was. Fixed widths
     are what make forty rows a column of four columns. */
  .logline {
    display: grid; grid-template-columns: 62px 14px 104px minmax(0, 1fr);
    gap: 10px; align-items: baseline; padding: 3px 7px; border-radius: 5px;
    font-family: var(--font-mono); font-size: 11px; line-height: 1.55;
  }
  /* Banding rather than a rule per row: at eleven pixels a hairline between
     every line is heavier than the text it separates. */
  .logline:nth-child(even) { background: color-mix(in srgb, var(--text) 3%, transparent); }
  .logline:hover { background: var(--line); }
  .logline .lt { color: var(--dimmer); font-variant-numeric: tabular-nums; }
  /* One letter, not the whole word: the level is a glance, and eight columns
     of "WARNING" pushes the message — the part you are reading for — off the
     right of a narrow panel. The full word is the title attribute. */
  .logline .ll { text-align: center; font-weight: 700; color: var(--dimmer); }
  /* Truncated rather than wrapped. A logger name is how you tell a station
     read from a token mint at a glance; it is never the thing you read, so it
     may not be allowed to set the message column's left edge. */
  .logline .lg {
    color: var(--cool); white-space: nowrap; overflow: hidden;
    text-overflow: ellipsis;
  }
  .logline .lm { color: var(--dim); overflow-wrap: anywhere; }
  /* Pages back through older lines — the tail (newest) shows first. Sits at the
     top of the buffer, above the lines it reveals. */
  .logmore {
    display: block; width: 100%; margin: 0 0 4px; padding: 5px 8px;
    background: transparent; border: 1px dashed var(--line2); border-radius: 6px;
    color: var(--dim); font-size: 11px; cursor: pointer;
  }
  .logmore:hover { color: var(--text); border-color: var(--edge); }
  @media (max-width: 620px) {
    /* Three fixed columns plus a message do not fit; the logger is the one
       that can go, and it stays in the row's title. */
    .logline { grid-template-columns: 62px 14px minmax(0, 1fr); }
    .logline .lg { display: none; }
  }
  .logline.lvl-warning .ll, .logline.lvl-warning .lm { color: var(--warn); }
  .logline.lvl-error .ll, .logline.lvl-error .lm,
  .logline.lvl-critical .ll, .logline.lvl-critical .lm { color: var(--accent); }
  .logline.lvl-critical { background: color-mix(in srgb, var(--accent) 12%, transparent); }
  .logline.lvl-debug .lt, .logline.lvl-debug .lg, .logline.lvl-debug .lm {
    color: var(--dimmer);
  }
  .actions {
    display: flex; gap: 10px; align-items: center; margin-top: 16px;
    padding-top: 16px; border-top: 1px solid var(--line);
  }
  .actions button { flex: 0 0 auto; }
  #saveBtn { background: var(--accent); color: #fff; }
  #saveBtn { background: var(--accent); color: #fff; }
  #saveBtn.clean { background: var(--line2); color: var(--dim); }
  #resetBtn { background: transparent; color: var(--dim);
              box-shadow: inset 0 0 0 1px var(--line2); }
  #resetBtn:hover:not(:disabled) { color: var(--text); background: var(--line2); }
  #saveMsg { font-size: 12px; color: var(--dimmer); }
  /* "What callers can ask" reference. Two readable columns — what a caller
     says, and what it actually does — because those are two different
     questions and stacking them made nineteen rows into thirty-eight lines. */
  .asklist { list-style: none; margin: 0; padding: 0; }
  /* fit-content, not 1fr: with two fr tracks the boundary sat at mid-page
     regardless of the text, so a short quote left a gulf before its
     explanation. Sized to content the quote column is exactly as wide as the
     longest quote (up to 46%), and the explanations start together just after
     it. */
  .asklist li {
    padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 12.5px;
    display: grid; grid-template-columns: 16px fit-content(46%) minmax(0, 1fr);
    gap: 10px; align-items: baseline;
  }
  .asklist li:last-child { border-bottom: none; }
  /* Group heading in the panel: a full-width band (it opts out of the three
     grid columns) with the label and a one-line note on what the group is. */
  .asklist li.askhead {
    display: block; padding: 14px 0 5px; border-bottom: 1px solid var(--line);
  }
  .asklist li.askhead:first-child { padding-top: 2px; }
  .asklist li.askhead .askheadname {
    color: var(--accent); font: 700 10px/1.3 var(--font-mono);
    letter-spacing: .16em; text-transform: uppercase;
  }
  .asklist li.askhead .askheadwhy {
    display: block; color: var(--dimmer); font-size: 11px; margin-top: 3px;
    line-height: 1.4;
  }
  .asklist .mark { color: var(--ok); font-size: 11px; }
  .asklist li.off { opacity: .45; }
  .asklist li.off .mark { color: var(--dimmer); }
  .asklist .say { color: var(--text); font-style: italic; line-height: 1.45; }
  .asklist .why { color: var(--dimmer); font-size: 11.5px; line-height: 1.45; }
  @media (max-width: 700px) {
    .asklist li { grid-template-columns: 16px minmax(0, 1fr); }
    .asklist .why { grid-column: 2; margin-top: 2px; }
  }

  /* Run-all pipeline check */
  #runAllBtn.running { opacity: .6; }
  .stages { list-style: none; margin: 13px 0 0; padding: 0; display: none; }
  .stages.on { display: block; }
  .stages li {
    display: grid; grid-template-columns: 18px 148px minmax(0, 1fr); gap: 12px;
    align-items: baseline; padding: 6px 0; font-size: 12px;
    border-bottom: 1px solid var(--line);
  }
  .stages li:last-child { border-bottom: none; }
  .stages .icon { text-align: center; font-size: 12px; }
  .stages .nm { color: var(--dim); font-weight: 600; }
  /* Not monospace. These cells are sentences — "the station only offered
     private addresses", a voice-coverage warning three lines long — and a
     proportional face fits noticeably more of one on a line. The numbers that
     DO want a column of their own get it in .timings below. */
  .stages .dt {
    color: var(--dimmer); overflow-wrap: anywhere;
    font-family: var(--font-body); font-size: 11.5px; line-height: 1.5;
  }
  .stages li.pending .icon { color: var(--dimmer); }
  .stages li.running .icon { color: var(--warn); }
  .stages li.pass .icon { color: var(--ok); }
  .stages li.warn .icon { color: var(--warn); }
  .stages li.fail .icon { color: var(--accent); }
  .stages li.fail .dt { color: color-mix(in srgb, var(--accent) 78%, var(--text)); }
  .stages li.warn .dt { color: color-mix(in srgb, var(--warn) 78%, var(--text)); }
  .stages li.skip { opacity: .45; }

  /* Timings reuse the stage rows, with the elapsed time where the status
     icon sits. Right-aligned and tabular so the numbers line up as a column
     without the padded-monospace trick that broke on long notes. */
  /* Lives inside .result, which is monospace and pre-wrap — neither of which
     suits a table of rows, so both are reset here. */
  .result .stages.timings { white-space: normal; }
  /* Identical grid to the pipeline check above it, so the two lists read as
     one design: status, name, detail. The elapsed time leads the detail cell
     at a fixed width, which keeps the numbers in a column without shifting
     the names out of line with the list above. */
  .stages.timings .nm {
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  }
  .stages.timings .dt { display: flex; gap: 10px; align-items: baseline; }
  .stages.timings .ms {
    flex: 0 0 58px; text-align: right; font-variant-numeric: tabular-nums;
    font-family: var(--font-mono); color: var(--dim);
  }
  .stages.timings .note { flex: 1; min-width: 0; overflow-wrap: anywhere; }
  .stages.timings li .icon { color: var(--ok); }

  /* One-off stages are dimmed rather than coloured: real work, but not work
     the caller waits through on every turn. */
  .stages.timings li.oneoff { opacity: .55; }
  .stages.timings li.oneoff .icon { color: var(--dimmer); }

  /* The chokepoint: the largest stage, marked only when the turn is actually
     slow. On a fast call the biggest stage is just the biggest stage, and
     colouring it anyway would teach you to ignore the colour. */
  .stages.timings li.choke .icon,
  .stages.timings li.choke .ms { color: var(--warn); }
  .stages.timings li.choke .nm { color: var(--text); }

  .stages.timings li.total {
    border-bottom: none; border-top: 1px solid var(--line2);
    margin-top: 3px; padding-top: 9px;
  }
  .stages.timings li.total .ms,
  .stages.timings li.total .nm { color: var(--text); font-weight: 700; }
  .stages.timings li.total.good .icon, .stages.timings li.total.good .ms { color: var(--ok); }
  .stages.timings li.total.warn .icon, .stages.timings li.total.warn .ms { color: var(--warn); }
  .stages.timings li.total.bad .icon,  .stages.timings li.total.bad .ms  { color: var(--accent); }

  /* Recent calls: one collapsed row per call, in the same three columns as
     the pipeline check and the speed test. Forty records rendered as forty
     walls of transcript could not be skimmed at all. */
  .result .calllist { white-space: normal; }
  .callrow { border-bottom: 1px solid var(--line); }
  .callrow:last-child { border-bottom: none; }
  /* Six columns: verdict, when, who, how long, what happened, what went wrong.
     They were one 132px date and one catch-all cell holding a joined sentence,
     so nothing below the date lined up and the only way to find the calls that
     went badly was to read every row. */
  /* width:100% is load-bearing, not tidiness. A grid <summary> is
     shrink-to-fit, so each row sized its flexible tracks to ITS OWN content —
     the DJ column came out 39px on one row and 124px on the next, and the
     columns this list exists to provide did not line up at all. Rows 1 and 2
     differed by three pixels, which reads as a rendering fault rather than as
     a layout to fix. */
  .callrow > summary {
    display: grid; width: 100%;
    /* The tools column is flexible, not 116px: with the tool chips beside the
       turn count, a fixed track cut chips off mid-word ("search…" and a bare
       clipped border, operator screenshot) while the note column beside it
       sat empty on every passing call. */
    grid-template-columns: 16px 144px minmax(0, 1fr) 46px minmax(0, 1.4fr) minmax(0, 1fr);
    gap: 10px; align-items: baseline; padding: 6px 2px; font-size: 12px;
    cursor: pointer; list-style: none;
  }
  .callrow > summary::-webkit-details-marker { display: none; }
  .callrow > summary:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
  .callrow > summary:hover .when { color: var(--text); }
  .callrow > summary .icon { text-align: center; }
  /* The list reads in the diagnostics' own voice — the row type ran two
     sizes and the DJ name a weight louder than the pipeline check beside
     it, and the seam showed. Operator-reported. */
  .callrow > summary { font-size: 11.5px; }
  .callrow > summary .dj { font-size: 12px; font-weight: 600; }
  .ctier {
    font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .12em;
    text-transform: uppercase; font-weight: 700; color: var(--sage-dim);
    border: 1px solid var(--line2); border-radius: 4px; padding: 1px 5px;
    margin-left: 7px;
  }
  .ctools { margin-left: 7px; }
  .ctools .ctool {
    font-family: var(--font-mono); font-size: 8.5px; color: var(--dim);
    border: 1px solid var(--line2); border-radius: 4px; padding: 1px 5px;
    margin-right: 3px;
  }
  .callrow > summary .when, .callrow > summary .dj {
    color: var(--dim); font-weight: 600;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .callrow > summary .len {
    color: var(--dimmer); text-align: right;
    font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  }
  .callrow > summary .did, .callrow > summary .dt {
    color: var(--dimmer); font-family: var(--font-mono); font-size: 11px;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .callrow.pass > summary .icon { color: var(--ok); }
  .callrow.warn > summary .icon { color: var(--warn); }
  .callrow.warn > summary .dt { color: color-mix(in srgb, var(--warn) 78%, var(--text)); }
  /* A call nobody could be heard on is not a warning, it is a failed call. */
  .callrow.fail > summary .icon { color: var(--accent); }
  .callrow.fail > summary .dt { color: color-mix(in srgb, var(--accent) 78%, var(--text)); }
  @media (max-width: 720px) {
    /* Drop to the three that answer "which call was that, and did it work". */
    .callrow > summary {
      grid-template-columns: 16px minmax(0, 1fr) minmax(0, 1fr);
    }
    .callrow > summary .dj, .callrow > summary .len,
    .callrow > summary .did { display: none; }
  }
  /* An opened call, read top to bottom: what it was, what it ran on, who
     called, what went wrong, then the conversation. */
  /* Derived, not literal. This was rgba(255,255,255,.03) — white on white in
     light mode, so an opened call had no surface under it at all and the
     transcript sat loose on the page. */
  .callbody {
    margin: 2px 0 12px; padding: 10px 12px; border-radius: 8px;
    background: color-mix(in srgb, var(--text) 4%, transparent);
    border: 1px solid var(--line); font-size: 11.5px;
    max-height: 420px; overflow: auto;
  }
  .cbhead {
    font-size: 10px; letter-spacing: .09em; text-transform: uppercase;
    color: var(--dimmer); font-weight: 700; margin: 12px 0 5px;
  }
  .cbhead:first-child { margin-top: 0; }
  .cbfacts {
    display: grid; grid-template-columns: 116px 1fr; gap: 3px 10px; margin: 0;
  }
  .cbfacts dt { color: var(--dimmer); }
  .cbfacts dd {
    margin: 0; color: var(--dim); overflow-wrap: anywhere;
    font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px;
  }
  .cbproblems { margin: 0; padding-left: 16px; color: color-mix(in srgb, var(--accent) 78%, var(--text)); }
  .cbproblems li { margin: 3px 0; overflow-wrap: anywhere; }
  .cbempty { margin: 0; color: var(--dimmer); font-style: italic; }

  .cbtalk { display: grid; gap: 2px; }
  .cbline {
    display: grid; grid-template-columns: 62px 52px 1fr; gap: 9px;
    align-items: baseline; padding: 2px 0;
    font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px;
    line-height: 1.5;
  }
  .cbline .t { color: var(--dimmer); font-variant-numeric: tabular-nums; }
  .cbline .w { color: var(--dimmer); font-weight: 700; }
  .cbline .x { color: var(--dim); overflow-wrap: anywhere; white-space: pre-wrap; }
  /* The same voice colours a caller sees during the call — the DJ's meter
     and captions are coral, the caller's are cool blue — so the log reads
     in the vocabulary the card already taught. Green here read as "ok",
     which is a verdict, not a speaker. */
  .cbline.dj .w { color: var(--coral); }
  .cbline.caller .w { color: var(--cool); }
  .cbline.caller .x { color: var(--text); }
  .cbline.tool .w, .cbline.tool .x { color: var(--dimmer); }
  /* A tool that came back with a refusal is the line you are looking for. */
  .cbline.tool.bad .w, .cbline.tool.bad .x { color: color-mix(in srgb, var(--accent) 78%, var(--text)); }

  /* Every toolbar filter hides rows through the one JS pass in
     panel-viewers.js — the one-class-per-filter CSS this replaced could not
     combine a dropdown's value with the thumbs and problems toggles. */
  .callrow[hidden] { display: none; }

  .snippet {
    width: 100%; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11.5px;
    padding: 11px; border-radius: 10px; border: 1px solid var(--line2);
    background: var(--bg); color: var(--text); resize: vertical; min-height: 70px; line-height: 1.5;
  }

  /* --- responsive: phones and tablets -----------------------------------
     The desktop layout floats a fixed-width card in the middle of the
     viewport; on a touch screen that reads as a small box adrift in black.
     Anchor to the top, take the width, and size type and touch targets
     for fingers. Embeds get this too — they are the same widget, and a narrow
     host column has the same problem a phone does. */
  @media (max-width: 900px) {
    body { justify-content: flex-start; padding: 14px 12px 24px; }
    .card { width: 100%; }
  }
  /* The panel gives up its fixed width earlier than the card does — it is
     1120 now, and a 1000px window would otherwise leave it hanging off the
     side rather than simply being narrower. */
  @media (max-width: 1200px) { .panel { width: 100%; } }
  @media (max-width: 600px) {
    .avatar { width: 52px; height: 52px; }
    /* Was 19px, which is two steps off the scale and the largest thing in the
       widget by a distance. The scale is 9.5 / 11 / 12.5 / 15 and the name is
       already the top of it — a narrow screen is a reason to spend the width
       differently, not to introduce a fifth size. */
    .dj { font-size: 15px; }
    /* Idle, the call button IS the page's action — give it the full row.
       Height stays --control-h: the spec has no exceptions to that, and a
       taller-on-mobile button was one of the four treatments this replaced.
       The in-call chip states keep their own width. */
    #callBtn:not(.ringing):not(.answering):not(.live) { width: 100%; }
    /* Three lines on a phone, and only on a phone. `:not(.compact)` is
       load-bearing: this query fires on the FRAME's width, so a 360px embed
       column on a desktop matches it too — and there the box is borrowing
       room from a station page's own layout, where two lines is right. On
       the standalone page the card is the only thing on screen, and the
       reading surface is what the extra 18px is worth spending on. */
    .linebox.open { height: 44vh; }
    /* .cap is a block with inline labels now; only the ticker keeps a grid,
       and its column is already as narrow as the labels allow. */
    .ticker { grid-template-columns: 26px minmax(0, 1fr); }
    /* Nothing gets ellipsized while most of the screen sits empty: text
       wraps onto new lines, and the now-playing track takes a full row of
       its own instead of a squeezed corner.

       But wrapping freely is the OTHER thing that resizes an embed, and this
       one fires on its own: the track changes every three or four minutes,
       and "Sweetest Pie (David Guetta Dance Remix Extended) — Megan Thee
       Stallion" is three lines where the track before it was one. The host
       page moved with it, with nobody touching anything. So each line still
       wraps, and each has a fixed number of lines to wrap into. */
    .who-row { flex-wrap: wrap; }
    /* .who-row .dj, NOT bare .dj — a bare .dj also matches .cap.dj and clamped
       the DJ's captions to one line (the caller couldn't read the reply). The
       header name/show/tagline still hold to two lines so a track change never
       resizes an embed; the captions are free to wrap. */
    .who-row .dj, .who-row .show, .tagline {
      white-space: normal; overflow: hidden; text-overflow: clip;
      display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
    }
    .track {
      flex: 1 0 100%; max-width: 100%; text-align: left; padding-left: 0;
      margin: 7px 0 0; white-space: normal; overflow: hidden;
      text-overflow: clip; font-size: 10px; letter-spacing: .12em;
      /* ONE line, always. It was two, on the reasoning that a remix credit
         needs the room — but two lines is 18px of empty row under every
         ordinary track title, on the surface with the least room to spare,
         and the truncated half of "Sweetest Pie (David Guetta Dance Remix
         Extended)" is not what anyone is reading the card for. Still fixed
         rather than free, which is the part that matters: a track change
         must not resize the host page. */
      display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1;
      min-height: 1.45em; line-height: 1.45;
    }
  }

  /* --- the embed ---------------------------------------------------------
     There is almost nothing here, on purpose. The embedded widget and the
     standalone call page are THE SAME WIDGET: same card, same border, same
     palette, same avatar, same tagline and now-playing line, same controls.
     What used to live here was a second, smaller design — a 38px avatar, a
     15px name, a hidden tagline and track, a card stretched to the frame with
     the Call button pinned to its bottom — which meant every change to the
     call page had to be made twice and only ever looked right once.

     Two differences remain and both are differences of FACT, not of style: an
     embed has no settings panel, so it has no gear; and an embed defaults to
     the one-line ticker rather than the full transcript, so a call doesn't
     grow the host's frame to ~500px (?captions=full overrides that).

     The body goes transparent so the host page shows through the card's
     rounded corners, and the 10px inset is what keeps the card's own border
     off the edge of the frame. */
  body.compact {
    /* Edge to edge. The 10px inset existed to keep the card's border off
       the frame edge — but on any host whose color-scheme the browser
       thinks disagrees with ours, the frame's backdrop paints opaque and
       the inset shows as a white ring around the card. The operator's
       page wore exactly that ring. The card IS the frame now. */
    padding: 0; background: transparent;
    min-height: 100vh; display: flex; flex-direction: column;
    /* Two lines in an embed — the box borrows room from the host page. */
    --lines-h: 57px;
    /* Never a scrollbar inside the widget: the frame follows the reported
       height, and a widget that scrolls itself inside somebody's page reads
       as broken. If a host pins the frame too short, the bottom clips — the
       honest trade, because data-height is the host saying it knows best. */
    overflow: hidden;
  }
  body.compact .card {
    width: 100%; flex: 1 0 auto; display: flex; flex-direction: column;
    /* Square in a frame: the rounded corners exposed the frame backdrop
       at all four corners, and the host page's own controls are square. */
    border-radius: 0;
  }
  /* Idle, the Call button sinks to the BOTTOM of whatever room the host gave
     the frame, so it lines up with the host's own row of buttons instead of
     floating halfway up a tall column with dead space under it. Keyed on the
     RIG being up, not on .oncall: the rig appears the moment the call starts
     ringing and .oncall only once it connects, so keyed on the latter every
     call spent its ringing seconds with the button pinned to the bottom and
     the meters rendering underneath it. */
  /* The whole working half of the card sinks to the bottom of whatever
     room the host gave the frame — the words, the meters and the buttons
     line up with the host page's own bottom rows, and the spare room sits
     between identity and rig where empty space reads as intentional.
     Unconditional now: every band that used to render outside the rig lives
     inside it, so there is nothing to jump when a call starts. */
  body.compact .card .rig { margin-top: auto; }
  /* ...EXCEPT the text line, which should FILL the frame rather than sink to
     the bottom with a tall empty gap above the greeting (operator-reported
     from an embed: "blank space above the text box"). The transcript flexes,
     so filling here puts the conversation up top and the input at the bottom
     with no dead band between the header and the chat. */
  body.compact .card[data-mode="chat"] .rig { margin-top: 0; flex: 1 1 auto; }
  /* Idle in an embed the Call button is the frame's one action, sized like
     the host page's own bottom-row buttons rather than a chip beside them —
     including in HEIGHT: the station page's Send and Listen Live are ~44px,
     and a 31px chip beside them read as belonging to a different page. With
     the machine's second door up, the two share the row instead. */
  /* Idle in an embed, the doors are ~44px to match the host page's own
     bottom-row buttons (Send, Listen Live), and they stay on ONE row,
     content-sized — an emoji door is narrow, a worded one wider — shrinking
     to share the width rather than wrapping to a second row (operator's ask:
     these three actions on one line). */
  body.compact .actionrow { flex-wrap: nowrap; gap: 8px; }
  body.compact .actionrow #callBtn:not(.ringing):not(.answering):not(.live),
  body.compact .actionrow #chatBtn:not([hidden]),
  body.compact .actionrow #vmBtn:not([hidden]) {
    flex: 1 1 auto; width: auto; min-width: 0; height: 44px;
    font-size: 11px; padding: 0 10px; letter-spacing: .05em;
  }
  body.compact .actionrow #callBtn.icononly,
  body.compact .actionrow #chatBtn.icononly,
  body.compact .actionrow #vmBtn.icononly { flex: 0 1 auto; }
  body.compact #hangBtn { height: 44px; flex: 1 1 auto; }
  body.compact .ticker .line { -webkit-line-clamp: 2; }
  /* THE HEIGHT BUDGET. A real station page gives its player column 400px and
     stretches its own marquee to match whatever this frame reports — every
     pixel here reappeared on that page as blank space between the sleeve and
     "Up next". The compact card owes the host a total (card + the host's own
     caption row) under that 400, so every band gets a pinned height, cut to
     what it shows. Pinned also means a call cannot change any of them: the
     in-call controls appear INSIDE these fixed rows, never as new ones. */
  body.compact .card { padding-bottom: 10px; }
  body.compact .eyebrow { height: 30px; font-size: 10px; }
  /* Two lines, at EVERY width: name + show inline on the first, the track
     clamped to one on the second. The row's height must never depend on how
     wide the host made the frame — at 300px the track used to wrap under
     the meta block and hand the card 22px it then owed the host page. */
  body.compact .who-row { padding: 6px 0; row-gap: 3px; }
  body.compact .who-row .avatar { width: 30px; height: 30px; }
  body.compact .who-row .avatar-mono { font-size: 12px; }
  /* The tagline is the host page's own line on most embeds — the station
     page already introduces the show in its own type one column over. */
  body.compact .tagline { display: none; }
  body.compact .dj { font-size: 14px; display: inline; margin: 0; }
  body.compact .who-row .show { display: inline; margin-left: 7px; }
  /* One line however long the show's name runs — an inline pair that wraps
     hands the row back its width-dependence through the side door. On the
     children too: the phone media block sets them back to `normal`, and an
     inherited nowrap loses to that. */
  body.compact .who-row .meta {
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  body.compact .dj, body.compact .who-row .show { white-space: nowrap; }
  body.compact .track {
    flex: 1 1 100%; margin-top: 0; font-size: 10px;
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1;
    overflow: hidden;
  }
  body.compact .staterow { padding: 4px 0 0; min-height: 24px; }
  body.compact .staterow .pill { height: 20px; padding: 0 8px; font-size: 9px; }
  body.compact .meters { padding: 5px 0 0; }
  body.compact .bars { height: 14px; }
  body.compact .talkrow { padding: 5px 0 0; }
  body.compact .card .pttbar { height: 31px; }
  body.compact .pttbar .pttsub { display: none; }
  body.compact .card .talkrow #spkBtn,
  body.compact .card .talkrow #muteBtn { height: 31px; }
  /* Measuring mode. The stretch above would otherwise report the frame's own
     height straight back to the frame, and a frame sized from that can only
     ever grow — it would never shrink again after a call ended.
     notifyHeight() flips this on for a single synchronous read. */
  body.measuring { min-height: 0; }
  body.measuring .card { flex: 0 0 auto; }
  body.measuring .card .rig { margin-top: 0; }

  /* Overlay mode: the ask list is taller than the frame, so embed.js has made
     the frame an overlay that extends UPWARD past the host's slot. The frame
     grew at the top, so the card has to move down by the same amount or it
     would appear to jump. Opening downward needs nothing — the card is
     already at the frame's top. */
  body.overlay-up { padding-top: var(--overlay-px, 0px); }

  /* Ticker: the latest spoken line only, fading out after a few seconds —
     keeps embeds short wherever they're dropped. It is also the caption
     surface an embed actually uses, so §5's reservation matters MORE here
     than in the full transcript: this is the thing that was resizing the
     station page mid-call. The row is always two lines tall whether it holds
     nothing, one line or a paragraph, so the frame stops moving. */
  .ticker {
    /* 26px fits "YOU", the widest label, with nothing spare — this row keeps
       its grid (unlike .cap) because it reserves exact height for the host
       page, but the 44px script gutter went with the transcript's. */
    display: grid; grid-template-columns: 26px minmax(0, 1fr); gap: 7px;
    align-items: baseline; padding: 0;
    font-family: var(--font-body); font-size: 12.5px; line-height: 1.45;
    color: var(--sage); opacity: 0; transition: opacity .5s ease;
  }
  /* Reserved, not removed — it holds its lines from the moment the page
     loads, empty or not, because this is the caption surface an EMBED uses
     and it is the one that was making the host page jump. `visibility`
     rather than `display` so a screen reader still skips it while empty. */
  .ticker[hidden] { display: grid; visibility: hidden; }
  .ticker.show { opacity: 1; }
  .ticker .who {
    font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
    letter-spacing: .16em; text-transform: uppercase; text-align: right;
    color: var(--coral); white-space: nowrap;
  }
  /* Reserved on the element that HOLDS the text, not an ancestor — reserving
     on the row lets a clamped one-line message collapse the block anyway. */
  .ticker .line {
    min-height: calc(1.45em * 2);
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3;
    white-space: normal; overflow: hidden;
  }

  /* --- narrow screens -----------------------------------------------------
     A folded phone is ~320px, and a host column can be narrow on a desktop
     too, so this is keyed on width rather than on the embed. Everything here
     was already "fine" in the sense that it wrapped and nothing overflowed —
     but wrapping is what made it read as scattered: a row would break into
     fragments of different widths sitting at different left edges. Below 420
     the rows stack deliberately, and anything sharing a row splits it evenly
     so there is one left edge and one right edge down the whole widget. */
  @media (max-width: 420px) {
    /* The door code. At 320px the field you type into came out NARROWER than
       the button next to it — 121px against 159px. Each takes the full
       width instead. */
    .guestgate input, .guestgate button { flex: 1 1 100%; }
    /* Volume and the call controls each take a row, and Mute / Hang up split
       it evenly rather than huddling against the left edge. */
    .vol, .controls { flex: 1 1 100%; }
    .controls button { flex: 1 1 0; padding: 0 10px; }
    /* Status chips likewise: they share one row edge to edge instead of
       breaking into a ragged second line. */
    .actionrow .pill { flex: 1 1 auto; justify-content: center; }
  }

  /* --- mobile: a whole-phone experience ----------------------------------
     On a phone the standalone player becomes the real thing — a call, a text
     line, or a voicemail, not a card floating on a page. The DJ's face LEADS
     (the top quarter), the words and state sit in the middle, and the actions
     pin to the bottom where a thumb expects them. Standalone only: an embed
     lives inside a host page and keeps its compact shape. */
  @media (max-width: 500px) {
    body:not(.panelpage):not(.compact) {
      padding: 0; min-height: 100dvh;
      align-items: stretch; justify-content: flex-start;
    }
    body:not(.panelpage):not(.compact) .card {
      width: 100%; min-height: 100dvh; border: 0; border-radius: 0;
      display: flex; flex-direction: column; padding: 0 20px 20px;
    }
    /* Identity as a centred PORTRAIT — the call-screen look. The text line
       keeps a tight left-aligned header so the conversation gets the room. */
    body:not(.compact) .card:not([data-mode="chat"]) .who-row {
      flex-direction: column; align-items: center; text-align: center;
      gap: 12px; padding: 7vh 0 18px;
    }
    body:not(.compact) .card:not([data-mode="chat"]) .who-row .avatar {
      width: 124px; height: 124px;
    }
    body:not(.compact) .card:not([data-mode="chat"]) .who-row .avatar-mono { font-size: 46px; }
    body:not(.compact) .card:not([data-mode="chat"]) .who-row .meta { flex: 0 0 auto; }
    body:not(.compact) .card:not([data-mode="chat"]) .who-row .dj { font-size: 22px; }
    body:not(.compact) .card:not([data-mode="chat"]) .who-row .show { text-align: center; }
    /* Now-playing drops below the name, centred, instead of clinging to a
       right edge that no longer exists in a column. */
    body:not(.compact) .card:not([data-mode="chat"]) .who-row .track {
      margin: 2px 0 0; max-width: 100%; padding-left: 0; text-align: center;
      align-self: center;
    }
    /* The working half fills the middle; the action row is the bottom of the
       screen. A generous tap target for the one big action. */
    body:not(.compact) .card .rig { flex: 1 1 auto; margin-top: 0; }
    body:not(.compact) .actionrow #callBtn,
    body:not(.compact) .actionrow #hangBtn,
    body:not(.compact) .actionrow #vmBtn,
    body:not(.compact) .actionrow #chatBtn { height: 52px; font-size: 12px; }
    /* Push-to-talk is the control a thumb rests on for a whole sentence, so on
       a phone it is the tallest thing on the card — bigger than the 52px doors.
       The operator called the old bar hard to press. */
    body:not(.compact) .card .pttbar { height: 60px; gap: 4px; }
    body:not(.compact) .pttbar .pttmain { font-size: 13px; }
    body:not(.compact) .pttbar .pttsub { font-size: 8.5px; }
    /* The meters WRAP here: volume takes its own row, and the two waveforms
       (You / DJ) sit side by side on the next — otherwise, squeezed onto one
       narrow row with volume, "YOU — MIC OFF" ran straight over the DJ label
       (operator-reported). The `max-width: none` is load-bearing: the desktop
       rail is capped at 260px, and that cap alone kept volume from ever taking
       a full row — so it sat on the same line as the meters, collapsing You and
       DJ to ~10px each and putting the mic chip straight over the DJ label at
       Fold-width (measured 2026-08-10). Clearing the cap lets the row break. */
    body:not(.compact) .meters { flex-wrap: wrap; }
    body:not(.compact) .meters .vol { flex: 1 1 100%; order: 2; max-width: none; }
    body:not(.compact) .meters .meter { flex: 1 1 0; min-width: 0; }
    body:not(.compact) .meters .meter.you { order: 0; }
    body:not(.compact) .meters .meter.dj { order: 1; }
  }

  /* --- the panel's live preview ------------------------------------------
     Panel-only. The frame holds the real call page, so everything inside it
     is styled by the rules above rather than by anything here — which is the
     point: there is no second set of preview styles to drift. */
  .preview {
    margin: 12px 0 4px; border: 1px solid var(--line2); border-radius: 10px;
    background: var(--bg); overflow: hidden;
  }
  .previewbar {
    display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
    padding: 7px 10px; border-bottom: 1px solid var(--line);
  }
  .previewbar .plabel {
    font-size: 11px; font-weight: 700; letter-spacing: .1em;
    text-transform: uppercase; color: var(--dimmer);
  }
  .previewbar .pnote { font-size: 11px; color: var(--dimmer); margin-left: auto; }
  .preview .segs { display: inline-flex; }
  .preview .seg {
    font-size: 11px; padding: 3px 10px; border-radius: 0;
    border: 1px solid var(--line2); background: transparent; color: var(--dim);
  }
  .preview .seg:first-child { border-radius: 7px 0 0 7px; }
  .preview .seg:last-child { border-radius: 0 7px 7px 0; border-left: 0; }
  .preview .seg.on { color: var(--accent); border-color: var(--accent); }
  .preview .seg.on + .seg { border-left-color: var(--accent); }
  /* Sized for the card at rest plus the two things that legitimately grow it
     — the door code and the mic warning — so switching a toggle never
     scrolls the frame. It was 470px, from before the idle card stopped
     reserving room for a call it is not on; that left a third of the preview
     as empty frame under the widget, which is the exact complaint the idle
     card itself was fixed for. */
  /* Tall enough to read, short enough that the SECTION never grows a
     scrollbar of its own — the operator reported exactly that. */
  #previewFrame { display: block; width: 100%; height: 300px; border: 0; }

  /* --- the preview stage: the frame dressed as the chosen embed shape ------
     Inline is the bare frame (no stage chrome). The other three turn the stage
     into a little mock "host page": a trigger the operator can press, and the
     real frame appearing where that shape would put it. A representation, not
     a running embed — enough to SEE the shape before copying its snippet. */
  .previewstage[data-shape="inline"] { display: block; }
  .previewstage.shaped {
    position: relative; height: 340px; overflow: hidden;
    background:
      repeating-linear-gradient(0deg, transparent 0 22px,
        color-mix(in srgb, var(--line) 55%, transparent) 22px 23px),
      var(--bg2, var(--bg));
    border-radius: 0 0 10px 10px;
  }
  /* The frame is hidden at rest (the shape is "closed"); pressing the trigger
     reveals it as that shape's pop-up. */
  .previewstage.shaped #previewFrame {
    position: absolute; display: none; height: 260px; width: min(300px, 86%);
    border-radius: 14px; box-shadow: 0 16px 40px rgba(0,0,0,.4); background: var(--bg);
  }
  .previewstage.shaped.open #previewFrame { display: block; }
  /* The mock trigger, styled like embed.js's dark pill/bar/button. */
  .previewtrigger {
    position: absolute; cursor: pointer; z-index: 2;
    background: #191b1f; color: #f2efe9; border: 1px solid rgba(255,255,255,.18);
    font: 600 12.5px/1.2 system-ui, sans-serif;
  }
  .previewtrigger[hidden] { display: none; }
  /* launcher — corner pill, frame opens above it */
  .previewstage[data-shape="launcher"] .previewtrigger {
    right: 14px; bottom: 14px; padding: 9px 15px; border-radius: 999px;
    box-shadow: 0 8px 22px rgba(0,0,0,.35);
  }
  .previewstage[data-shape="launcher"].open #previewFrame { right: 12px; bottom: 58px; }
  /* dock — bar across the bottom, frame opens centred above it */
  .previewstage[data-shape="dock"] .previewtrigger {
    left: 0; right: 0; bottom: 0; width: 100%; padding: 11px 15px;
    border-width: 1px 0 0 0; text-align: center;
  }
  .previewstage[data-shape="dock"].open #previewFrame {
    left: 50%; transform: translateX(-50%); bottom: 50px;
  }
  /* button — inline button near the top, frame opens as a centred modal */
  .previewstage[data-shape="button"] .previewtrigger {
    top: 16px; left: 16px; padding: 9px 15px; border-radius: 12px;
    box-shadow: 0 2px 10px rgba(0,0,0,.25);
  }
  .previewstage[data-shape="button"].open::after {
    content: ""; position: absolute; inset: 0; z-index: 1; background: rgba(0,0,0,.45);
  }
  .previewstage[data-shape="button"].open #previewFrame {
    z-index: 2; top: 50%; left: 50%; transform: translate(-50%, -50%);
  }

  /* --- installed as an app -----------------------------------------------
     No browser chrome, so the widget is the whole window and owns the edges
     the browser used to keep clear. The viewport is `viewport-fit=cover` so
     the background reaches under a notch; without these insets the Hang up
     row sits under an iPhone's home indicator and the header under the
     status bar. Vertically centred is also wrong here — a full-screen app
     that floats its one card in the middle of a phone reads as a web page
     that failed to load. */
  @media (display-mode: standalone), (display-mode: minimal-ui) {
    body {
      justify-content: flex-start;
      padding: calc(14px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right))
               calc(24px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));
    }
  }

  /* §3 — nothing moves for anyone who asked for that. */
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after { animation: none !important; transition: none !important; }
  }

  /* ============================================================================
     PANEL NEWSPAPER REDESIGN (3a). Scoped to body.panelpage so the neutral,
     embeddable CALL CARD is untouched (HOST-STYLE-GUIDE section 2). It rides the
     same token cascade — system default, data-theme override, and the station
     third theme all keep working: station mode sets inline tokens on :root and
     adds .theme-station to the body, which drops the newspaper palette so those
     inline tokens inherit straight through. Everything is behind the scope
     class, so a grep for body.panelpage is the leak check.
     ============================================================================ */

  /* --- tokens: light = cream newspaper sheet, dark = near-black ------------- */
  body.panelpage:not(.theme-station) {
    --pine: #100e0c; --granite: #100e0c; --granite-hi: #181513;
    --alpenglow: #f3efe6; --sage: #969187; --sage-dim: #5c574f;
    --coral: #c5302a; --amber: #d8a13a;
    --hairline: rgba(243,239,230,.12); --edge: rgba(243,239,230,.30);
    --shadow: none;
    --bg: var(--pine); --panel: var(--granite); --line: var(--hairline);
    --line2: var(--edge); --text: var(--alpenglow); --dim: var(--sage);
    --dimmer: var(--sage-dim); --accent: var(--coral); --warn: var(--amber);
  }
  @media (prefers-color-scheme: light) {
    body.panelpage:not(.theme-station) {
      --pine: #f3efe6; --granite: #f3efe6; --granite-hi: #eae5d9;
      --alpenglow: #1a1613; --sage: #7a7268; --sage-dim: #a49b8e;
      --coral: #c5302a; --amber: #9a6b16;
      --hairline: rgba(26,22,19,.12); --edge: rgba(26,22,19,.30);
    }
  }
  :root[data-theme="dark"] body.panelpage:not(.theme-station) {
    --pine: #100e0c; --granite: #100e0c; --granite-hi: #181513;
    --alpenglow: #f3efe6; --sage: #969187; --sage-dim: #5c574f;
    --coral: #c5302a; --amber: #d8a13a;
    --hairline: rgba(243,239,230,.12); --edge: rgba(243,239,230,.30);
  }
  :root[data-theme="light"] body.panelpage:not(.theme-station) {
    --pine: #f3efe6; --granite: #f3efe6; --granite-hi: #eae5d9;
    --alpenglow: #1a1613; --sage: #7a7268; --sage-dim: #a49b8e;
    --coral: #c5302a; --amber: #9a6b16;
    --hairline: rgba(26,22,19,.12); --edge: rgba(26,22,19,.30);
  }

  /* --- global chrome: flat page, square corners, ruled not nested ----------- */
  body.panelpage { background: var(--pine); }
  body.panelpage .panel {
    background: transparent; border: 0; border-radius: 0;
    box-shadow: none; padding: 26px 30px 40px; max-width: 1180px;
  }
  body.panelpage :is(button, input, select, textarea, .tile, .ctrlcard,
    .supergroup, .panelnav a, .runbtn, .tag, .gear, .banner, .permgrid,
    details.sec > summary, .snippet, .result, .preview, .previewstage,
    .switch, .chip, .pill) {
    border-radius: 0 !important;
  }

  /* --- masthead ------------------------------------------------------------ */
  body.panelpage .masthead {
    display: flex; align-items: flex-start; justify-content: space-between;
    gap: 16px; padding: 0 0 10px; margin: 0 0 26px;
    border-bottom: 1px solid var(--edge); position: relative;
  }
  body.panelpage .masthead::after {
    content: ""; position: absolute; left: 0; right: 0; bottom: -1px;
    border-top: 3px solid var(--coral);
  }
  body.panelpage .masttitle {
    font: 700 30px/1 var(--font-mono); letter-spacing: .06em; color: var(--text);
  }
  body.panelpage .mastsub {
    margin-top: 6px; font: 700 11px/1 var(--font-mono); letter-spacing: .24em;
    text-transform: uppercase; color: var(--sage);
  }
  body.panelpage .masthead .panelbar {
    display: flex; gap: 8px; align-items: center; margin: 0;
  }
  /* :not([hidden]) so the sign-out button (hidden until logged in) and the
     call page's corner gears stay hideable — a bare .gear display rule beats
     the UA [hidden] and un-hides them (TestHiddenActuallyHides). */
  body.panelpage .masthead .gear:not([hidden]) {
    border: 1px solid var(--edge); background: transparent; color: var(--text);
    font: 700 11px/1 var(--font-mono); letter-spacing: .1em; text-transform: uppercase;
    min-width: 40px; height: 34px; padding: 0 12px; cursor: pointer;
    display: inline-flex; align-items: center; justify-content: center;
  }
  body.panelpage .masthead .gear:hover { background: var(--granite-hi); }
  body.panelpage .dashband { display: none; }

  /* --- super-group section slabs: heading + a coral rule filling the line --- */
  body.panelpage .supergroup {
    background: transparent !important; border: 0 !important; padding: 0 !important;
    margin: 30px 0 10px; display: flex; align-items: center; gap: 14px;
    font: 700 15px/1.1 var(--font-mono); letter-spacing: .08em;
    text-transform: uppercase; color: var(--text);
  }
  body.panelpage .supergroup > * { flex: 0 0 auto; }
  body.panelpage .supergroup::after {
    content: ""; flex: 1 1 auto; height: 2px; background: var(--coral); min-width: 30px;
  }

  /* --- section rows (details.sec) ------------------------------------------ */
  body.panelpage details.sec { border: 0; background: transparent; }
  body.panelpage details.sec > summary {
    background: transparent; border: 0; border-bottom: 1px solid var(--hairline);
    padding: 11px 2px;
  }
  body.panelpage details.sec > summary:hover {
    background: color-mix(in srgb, var(--alpenglow) 4%, transparent);
  }
  body.panelpage .chev { color: var(--coral); }
  body.panelpage .secname {
    font-family: var(--font-mono); letter-spacing: .06em; text-transform: uppercase;
  }

  /* --- jump bar, buttons, inputs: square, ruled, transparent --------------- */
  body.panelpage .panelnav a {
    border: 1px solid var(--edge); background: transparent; color: var(--dim);
  }
  body.panelpage .panelnav a:hover { background: var(--granite-hi); color: var(--text); }
  body.panelpage :is(.runbtn, .snippet) {
    border: 1px solid var(--edge); background: transparent; color: var(--text);
    font-family: var(--font-mono); letter-spacing: .04em;
  }
  body.panelpage .runbtn:hover { background: var(--granite-hi); }
  body.panelpage input, body.panelpage select, body.panelpage textarea {
    border: 1px solid var(--edge); background: transparent;
  }

  /* --- dashboard: de-nest the groups, rule the cards, square red toggles ---- */
  body.panelpage .dashgroup {
    background: transparent; border: 0; padding: 0;
  }
  body.panelpage .dashgroup .tlabel {
    font: 700 11px/1 var(--font-mono); letter-spacing: .28em;
    text-transform: uppercase; color: var(--sage);
    border-bottom: 1px solid var(--hairline); padding-bottom: 6px; margin-bottom: 6px;
  }
  body.panelpage .tile, body.panelpage .ctrlcard {
    border: 0; border-bottom: 1px solid var(--hairline);
    background: transparent; box-shadow: none;
  }
  body.panelpage .tile:hover, body.panelpage .ctrlcard:hover {
    transform: none; background: color-mix(in srgb, var(--alpenglow) 4%, transparent);
  }
  body.panelpage .ctrlcard.kill:not(.paused) {
    border-left: 4px solid var(--coral); padding-left: 11px;
    background: color-mix(in srgb, var(--coral) 8%, transparent);
  }
  body.panelpage .ctrlcard .switch {
    border: 1px solid var(--edge); background: transparent;
  }
  body.panelpage .ctrlcard .switch::after {
    width: 16px; height: 16px; top: 3px; left: 3px; background: var(--sage-dim);
  }
  body.panelpage .ctrlcard.on .switch,
  body.panelpage .ctrlcard.kill:not(.paused) .switch,
  body.panelpage .ctrlcard.kill.paused .switch {
    border-color: var(--coral);
    background: color-mix(in srgb, var(--coral) 16%, transparent);
  }
  body.panelpage .ctrlcard.on .switch::after,
  body.panelpage .ctrlcard.kill:not(.paused) .switch::after,
  body.panelpage .ctrlcard.kill.paused .switch::after {
    background: var(--coral); left: 21px;
  }
  body.panelpage .ctrlcard .switch.small { width: 30px; height: 17px; }
  body.panelpage .ctrlcard .switch.small::after { width: 13px; height: 13px; top: 2px; left: 2px; }
  body.panelpage .ctrlcard.on .switch.small::after { left: 15px; }

  /* --- spec §2b/§6: the station strip and the LINES hierarchy -------------- */
  /* The four read-backs as one ruled strip under the masthead: columns, not
     cards — each tile keeps only its own ink and the strip carries the rule. */
  body.panelpage .stationstrip {
    border-bottom: 1px solid var(--hairline); padding-bottom: 6px;
  }
  body.panelpage .stationstrip .tile { border-bottom: 0; }
  /* Square avatar, per the spec's data-avatar note — circles are the one
     rounded shape left on a page that zeroed every other radius. */
  body.panelpage .stationstrip .tavatar { border-radius: 0; }
  body.panelpage .dashcaption {
    border-bottom: 1px solid var(--hairline); padding-bottom: 6px;
    margin-bottom: 8px;
  }
  body.panelpage .dashcaption .tlabel {
    border-bottom: 0; padding-bottom: 0; margin-bottom: 0;
  }
  body.panelpage .dashcaption .dashmodesay {
    font-style: italic; color: var(--sage);
  }
  /* THE LINE speaks a size up — it is the master, and "Open" in 19px coral
     is the one glance the whole band exists for. */
  body.panelpage .ctrlcard.kill .cv { font-size: 19px; }
  /* LINES indent under the door they hang off: a 1px --edge left rule, the
     sub-caption, and one hairline-ruled row per line. */
  body.panelpage .lines {
    margin: 10px 0 0 10px; padding-left: 14px;
    border-left: 1px solid var(--edge);
  }
  body.panelpage .linerow { border-bottom: 1px solid var(--hairline); }
  body.panelpage .linerow:last-child { border-bottom: 0; }
  body.panelpage .linerow .ctrlcard,
  body.panelpage .linerow .tile { border-bottom: 0; }
  /* A line row reads left to right: "Live calls · On" with the chips and
     detail underneath, and the switch on the RIGHT edge spanning both text
     rows — mirroring THE LINE's switch above it (operator's ask, 0.10.54;
     it originally led the row on the left). */
  body.panelpage .linerow .ctrlcard {
    display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: "name state sw" "note note sw";
    column-gap: 10px; row-gap: 4px; align-items: center;
    padding: 9px 2px; min-height: 0;
  }
  /* relative, not static: the knob is an absolute ::after and must keep
     anchoring to its own track — static re-anchored it to the card and the
     knob sat loose above the outline. */
  body.panelpage .linerow .ctrlcard .switch {
    position: relative; right: auto; top: auto; transform: none;
    grid-area: sw; justify-self: end;
  }
  body.panelpage .linerow .ctrlcard .ck {
    grid-area: name; font-size: 13px; letter-spacing: .02em;
    color: var(--alpenglow); text-transform: none;
  }
  body.panelpage .linerow .ctrlcard .cv {
    grid-area: state; font-size: 13px; justify-self: start;
  }
  body.panelpage .linerow .ctrlcard .cv::before { content: "· "; }
  body.panelpage .linerow .ctrlcard .cn {
    grid-area: note; display: flex; flex-wrap: wrap; align-items: center;
    gap: 4px 6px; font-size: 11px; color: var(--sage);
  }
  /* Permission chips (spec §6): what each tier gets, worn on the row rather
     than buried in a muted sentence. paintDash builds them. */
  body.panelpage .permchip {
    font: 700 9.5px/1 var(--font-mono); letter-spacing: .12em;
    text-transform: uppercase; color: var(--sage);
    border: 1px solid var(--edge); padding: 3px 6px; white-space: nowrap;
  }
  /* The stat pair rides the row's right edge: value over note, failures in
     coral — the spec is explicit that a failure count is never amber. */
  body.panelpage .linerow .tile {
    display: flex; flex-direction: column; align-items: flex-end;
    justify-content: center; gap: 2px; text-align: right;
    padding: 6px 2px; min-height: 0;
  }
  body.panelpage .linerow .tile .tk { display: none; }
  body.panelpage .linerow .tile .tv {
    font-size: 15px; font-weight: 700; color: var(--alpenglow);
  }
  body.panelpage .linerow .tile .tn { font-size: 11px; color: var(--sage); }
  body.panelpage .linerow .tile.warn .tn { color: var(--coral); }
  @media (max-width: 700px) {
    body.panelpage .linerow .tile {
      align-items: flex-start; text-align: left; padding-top: 0;
    }
  }

  /* --- spec §7: the ACTIVITY strip ------------------------------------------ */
  body.panelpage .activity { margin: 0 0 20px; }
  body.panelpage .actcaption {
    display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
    padding-bottom: 7px; margin-bottom: 12px;
    border-bottom: 1px solid var(--hairline);
  }
  body.panelpage .actcaption .tlabel {
    margin: 0; font: 700 11px/1 var(--font-mono); letter-spacing: .28em;
    text-transform: uppercase; color: var(--sage);
  }
  /* The hairline that fills the caption's middle. */
  body.panelpage .actspacer { flex: 1 1 30px; height: 1px; background: var(--hairline); }
  body.panelpage .actshow {
    font: 700 9.5px/1 var(--font-mono); letter-spacing: .12em;
    text-transform: uppercase; color: var(--sage);
    display: inline-flex; align-items: center; gap: 6px;
  }
  body.panelpage .actshow input {
    width: 40px; text-align: center; padding: 5px 0;
    font: 700 10px var(--font-mono);
  }
  /* Square segments, -1px overlap so the shared borders read as one rule;
     the active segment is the coral one. */
  body.panelpage .actcaption .segs { display: inline-flex; }
  body.panelpage .actcaption .seg {
    margin-left: -1px; border: 1px solid var(--edge); border-radius: 0;
    background: transparent; color: var(--dim); cursor: pointer;
    font: 700 9.5px/1 var(--font-mono); letter-spacing: .12em;
    text-transform: uppercase; padding: 6px 11px;
  }
  body.panelpage .actcaption .seg:first-child { margin-left: 0; }
  body.panelpage .actcaption .seg.on {
    position: relative; z-index: 1; border-color: var(--coral);
    color: var(--coral);
    background: color-mix(in srgb, var(--coral) 10%, transparent);
  }
  /* The two multi-select pickers: a square chip that folds open into a
     checkbox list. Same voice as the segments beside them; the ▾ marks the
     chip as openable and the summary always names what is ticked. */
  body.panelpage .actpick { position: relative; }
  body.panelpage .actpick > summary {
    list-style: none; cursor: pointer; user-select: none;
    border: 1px solid var(--edge); padding: 6px 9px; color: var(--dim);
    font: 700 9.5px/1 var(--font-mono); letter-spacing: .1em;
    text-transform: uppercase; white-space: nowrap;
  }
  body.panelpage .actpick > summary::-webkit-details-marker { display: none; }
  body.panelpage .actpick > summary::after { content: " ▾"; color: var(--sage-dim); }
  body.panelpage .actpick[open] > summary,
  body.panelpage .actpick > summary:hover { background: var(--granite-hi); }
  body.panelpage .actmenu {
    position: absolute; right: 0; top: calc(100% + 4px); z-index: 45;
    display: flex; flex-direction: column; gap: 2px; min-width: 150px;
    background: var(--pine); border: 1px solid var(--edge); padding: 8px 10px;
  }
  body.panelpage .actmenu label {
    display: flex; align-items: center; gap: 8px; cursor: pointer;
    font: 700 9.5px/1.6 var(--font-mono); letter-spacing: .1em;
    text-transform: uppercase; color: var(--dim); white-space: nowrap;
  }
  body.panelpage .actmenu label:hover { color: var(--alpenglow); }
  /* Native checkboxes, like every other checkbox on the panel — and no
     accent-color: TestTheVolumeSliderIsNotTheBrowsersOwn bans that property
     from this file so the call card's drawn slider can't regress to it. */
  body.panelpage .actmenu input { margin: 0; }
  /* 2×2, generous column gap — four across was too cramped (spec's words). */
  body.panelpage .actgrid {
    display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 20px 44px;
  }
  @media (max-width: 760px) {
    body.panelpage .actgrid { grid-template-columns: 1fr; }
  }
  body.panelpage .actchart { margin: 0; }
  body.panelpage .actchart figcaption {
    display: flex; align-items: baseline; justify-content: space-between;
    gap: 10px; margin-bottom: 7px;
  }
  body.panelpage .actchart figcaption span {
    font: 700 9.5px/1 var(--font-mono); letter-spacing: .18em;
    text-transform: uppercase; color: var(--sage);
  }
  body.panelpage .actchart figcaption em {
    font-style: normal; font-size: 11px; color: var(--sage);
    text-align: right;
  }
  body.panelpage .actbody { min-height: 64px; }
  body.panelpage .actempty { margin: 0; color: var(--sage-dim); }
  /* Bars: flat rects over a 1px baseline; clean buckets neutral ink,
     buckets holding a failure coral. */
  body.panelpage .actbars {
    display: flex; align-items: flex-end; gap: 2px; height: 74px;
    border-bottom: 1px solid var(--edge);
  }
  body.panelpage .actbar {
    flex: 1 1 0; min-width: 2px;
    background: color-mix(in srgb, var(--alpenglow) 18%, transparent);
  }
  body.panelpage .actbar.bad { background: var(--coral); }
  body.panelpage .actaxis { display: flex; margin-top: 4px; }
  body.panelpage .actaxis span {
    flex: 1 1 0; min-width: 0; overflow: hidden; text-align: center;
    font: 700 8px/1.2 var(--font-mono); letter-spacing: .06em;
    color: var(--sage-dim); white-space: nowrap;
  }
  body.panelpage .actaxis span:first-child { text-align: left; }
  body.panelpage .actaxis span:last-child { text-align: right; }
  /* The mix: a flat 100% band — the ruled-newspaper answer to a donut. */
  body.panelpage .mixband {
    display: flex; height: 18px; border: 1px solid var(--edge);
  }
  body.panelpage .mixseg.calls, body.panelpage .mixkey .sw.calls { background: var(--coral); }
  body.panelpage .mixseg.texts, body.panelpage .mixkey .sw.texts { background: var(--alpenglow); }
  body.panelpage .mixseg.vm, body.panelpage .mixkey .sw.vm {
    background: color-mix(in srgb, var(--alpenglow) 18%, transparent);
  }
  body.panelpage .mixlegend {
    display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px;
  }
  body.panelpage .mixkey {
    display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
    border: 0; background: transparent; padding: 0;
    font: 700 9.5px/1 var(--font-mono); letter-spacing: .1em;
    text-transform: uppercase; color: var(--sage);
  }
  body.panelpage .mixkey:hover { color: var(--alpenglow); }
  body.panelpage .mixkey .sw {
    width: 10px; height: 10px; border: 1px solid var(--edge); display: inline-block;
  }
  body.panelpage .mixfoot {
    margin: 7px 0 0; font: 700 8.5px/1 var(--font-mono);
    letter-spacing: .1em; text-transform: uppercase; color: var(--sage-dim);
  }
  /* The listener curve: one polyline, area at 8% — gaps in the sampled
     series break the line rather than drawing zeros. */
  body.panelpage .lissvg {
    width: 100%; height: 74px; display: block;
    border-bottom: 1px solid var(--edge);
  }
  body.panelpage .lissvg polyline {
    fill: none; stroke: var(--coral); stroke-width: 2;
    vector-effect: non-scaling-stroke;
  }
  body.panelpage .lissvg polygon {
    fill: color-mix(in srgb, var(--coral) 8%, transparent); stroke: none;
  }

  /* --- spec §8: the settings header sticks, jump bar and all ---------------- */
  /* The whole band — heading, promise, finder, jump chips — travels with the
     reader (operator's ask, extending the spec's sticky bar): search and
     navigation are never a scroll away on a six-screen page. */
  body.panelpage .settingsbar {
    position: sticky; top: 0; z-index: 40; background: var(--pine);
    border-bottom: 1px solid var(--edge);
    margin: 0 -2px; padding: 4px 2px 0;
  }
  body.panelpage .settingsbar .settingshead { margin: 6px 0 8px; }
  /* One row always: chips slide horizontally on a narrow panel instead of
     wrapping into a block that eats the viewport the band now rides in. */
  body.panelpage .panelnav {
    border-top: 1px solid var(--hairline); border-bottom: 0; margin-bottom: 0;
    flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
  }
  body.panelpage .panelnav::-webkit-scrollbar { display: none; }
  body.panelpage .panelnav a { flex: 1 0 auto; white-space: nowrap; }
  body.panelpage .panelnav a.navtop {
    color: var(--coral); border-color: var(--coral);
    background: color-mix(in srgb, var(--coral) 10%, transparent);
  }
  /* The you-are-here chip: coral ink, no fill — the filled tint stays the
     dashboard chip's, so the two marks read as different claims. */
  body.panelpage .panelnav a.here {
    color: var(--coral); border-color: var(--coral);
  }
  /* Jump targets clear the sticky band instead of vanishing under it — the
     band measures itself into --stickybar (see watchNav in panel.js). */
  body.panelpage .supergroup,
  body.panelpage details.sec,
  body.panelpage details.diag {
    scroll-margin-top: calc(var(--stickybar, 96px) + 10px);
  }

  /* Call-in access as a cascade row: three ticks in the permission matrix's
     own voice, Admin locked on. The hidden select behind them is the stored
     field (the kill-switch pattern). */
  body.panelpage .accesscells {
    display: inline-flex; gap: 18px; align-items: center; flex-wrap: wrap;
  }
  body.panelpage .acell {
    display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
    font: 700 10px/1 var(--font-mono); letter-spacing: .1em;
    text-transform: uppercase; color: var(--dim);
  }
  body.panelpage .acell input { margin: 0; }
  body.panelpage .acell input:disabled + span { color: var(--sage-dim); }

  /* Legibility (operator: the section rows ran too small to read down the
     page): the name a step up, the sentence at body size in body ink. The
     quiet right-hand value keeps its whisper but gains a point too. */
  body.panelpage details.sec > summary { font-size: 11.5px; }
  body.panelpage details.sec > summary .secblurb {
    font-size: 12.5px; color: var(--dim);
  }
  body.panelpage details.sec > summary .tag { font-size: 11px; }

  /* --- a phone-width panel loses the ceremony ------------------------------- */
  /* Body + sheet padding cost 46px per side on a ~390px screen — the matrix
     labels wrapped into twelve-character ribbons (operator screenshot, on the
     installed PWA). Near edge-to-edge below 640px; the type carries the
     hierarchy without the margins. */
  @media (max-width: 640px) {
    body.panelpage { padding: 10px 6px 20px; }
    body.panelpage .panel { padding: 12px 10px 28px; }
    body.panelpage details.sec > .body { padding: 10px 8px; }
    body.panelpage .dashgroup { padding: 8px 8px 6px; }
    body.panelpage .lines { margin-left: 4px; padding-left: 8px; }
    body.panelpage .actgrid { gap: 16px; }
  }

  /* --- footer -------------------------------------------------------------- */
  body.panelpage .panelfoot {
    display: flex; justify-content: space-between; align-items: center;
    margin-top: 26px; padding-top: 12px; border-top: 1px solid var(--edge);
    font: 700 11px/1 var(--font-mono); letter-spacing: .08em; color: var(--sage);
    text-transform: uppercase;
  }

  /* --- redesign polish pass ------------------------------------------------ */
  /* the whole dashboard was one bordered card; flatten it like the groups */
  body.panelpage .dash {
    border: 0; background: transparent; padding: 0; margin: 0 0 6px;
  }
  /* square toggle knob (the base ::after is a circle) */
  body.panelpage .ctrlcard .switch::after { border-radius: 0; }
  /* ON states in chrome read CORAL, not the semantic pass/fail green */
  body.panelpage .ctrlcard.on .cv,
  body.panelpage .ctrlcard.kill .cv { color: var(--coral); }
  body.panelpage .ctrlcard.kill.paused .cv { color: var(--coral); }
  /* section action buttons: square, transparent, ruled — not grey fills */
  body.panelpage .body button:not(.tile):not(.ctrlcard):not(.chev),
  body.panelpage .testrow button {
    border: 1px solid var(--edge); background: transparent; color: var(--text);
    font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
    text-transform: uppercase; padding: 7px 12px;
  }
  body.panelpage .body button:not(.tile):not(.ctrlcard):hover:not(:disabled),
  body.panelpage .testrow button:hover:not(:disabled) {
    background: var(--granite-hi); filter: none;
  }
