/* ============================================================================================
   SceneSat Cosmodrome — organizer workspace

   DESIGN RULES. Read these before adding anything; they are what keep the app from drifting
   back into generic dark-SaaS.

   1. ONE elevation. Surfaces are flat fills separated by a 1px hairline. No gradient fills,
      no drop shadows on content. Depth comes from value (--sunken < --ground < --surface <
      --raised), never from blur.
   2. Colour MEANS something. Violet is interaction (links, focus, the brand mark). The state
      colours are reserved for state. Nothing is tinted for decoration.
   3. Density over air. A list of three and a list of fifty get the same row grammar, so the
      page grows with the data instead of padding it out. Never set a min-height to make a
      short item look as substantial as a long one.
   4. Type is a scale, not a statement. The page title is 26px. Nothing on a workspace index
      needs to be 82px.
   5. The ground is flat and unlit. There is no backdrop artwork — a background image is
      coming, and it will sit behind opaque surfaces so nothing is ever read through it.
   6. Say it in sentence case. Uppercase letterspaced micro-labels are used for table column
      headers and data labels only — places where the label must not compete with the value.
   ============================================================================================ */

:root {
    /* Ground and surfaces — four steps, each a flat fill. */
    --ground:  #0b0b0f;
    --sunken:  #0e0e13;
    --surface: #131319;
    --raised:  #181820;

    /* Hairlines. --line separates components, --line-soft separates rows within one. */
    --line:      #23232d;
    --line-soft: #1b1b24;

    /* Ink — three weights. Anything quieter than --ink-3 is unreadable on --ground. */
    --ink:   #eceaf2;
    --ink-2: #9d9bab;
    --ink-3: #6d6b7a;

    /* Identity. Violet is Cosmodrome's; cyan is its second. */
    --violet: #9b7cff;
    --violet-dim: #7d63d8;
    --cyan:   #69dcf6;

    /* State. Only ever used to report state. */
    --success: #65e6a8;
    --orange:  #ff9d5c;
    --danger:  #ff747e;

    /* Kept so any rule or app still naming the old tokens resolves. */
    --space: var(--ground);
    --panel: var(--surface);
    --muted: var(--ink-2);

    font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
    color: var(--ink);
    background: var(--ground);
}

* { box-sizing: border-box }

body {
    margin: 0;
    min-height: 100vh;
    background: var(--ground);
    font-size: 14px;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
}

a { color: inherit }

/* --------------------------------------------------------------------------------------------
   Header — a rule across the top, not a floating pane of glass
   -------------------------------------------------------------------------------------------- */
.site-header {
    position: sticky;
    top: 0;
    z-index: 3;
    height: 56px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 20px;
    padding: 0 max(24px, calc((100vw - 1120px) / 2));
    border-bottom: 1px solid var(--line);
    background: #0b0b0fe6;
    backdrop-filter: blur(14px);
}
/* The brand lockup: SceneSat's Sputnik, then the Cosmodrome nameplate. Both are artwork, so the
   anchor carries no type of its own — its accessible name comes from aria-label. */
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none }
/* Sized by height so the two scale together; width follows from each viewBox. Both carry their
   own ink (#F2F6FB): they are referenced by <img>, so no rule in this file reaches inside them —
   a currentColor in either would resolve to black. */
.brand-sputnik { height: 20px; width: auto; flex: none }
.brand-mark    { height: 21px; width: auto; flex: none }
/* The nameplate's own baseline rule already reads as an underline; dim the whole lockup very
   slightly at rest so it does not out-shout the page title, and bring it up on hover. */
.brand { opacity: .92 }
.brand:hover { opacity: 1 }

nav { display: flex; align-items: center; gap: 3px; font-size: 13px; color: var(--ink-2) }
nav a { text-decoration: none; padding: 6px 10px; border-radius: 6px }
nav > a:hover { color: var(--ink); background: #ffffff0d }
.identity {
    color: var(--ink-3);
    padding-left: 14px;
    margin-left: 8px;
    border-left: 1px solid var(--line);
    white-space: nowrap;
}

main {
    position: relative;
    z-index: 1;
    max-width: 1120px;
    margin: 0 auto;
    padding: 34px 24px 90px;
}

/* --------------------------------------------------------------------------------------------
   Page head
   One line of title, one line of prose. `.hero` and `.hero.compact` are kept as the index
   heading; `.event-hero` is the detail-page variant that carries facts down the right side.
   -------------------------------------------------------------------------------------------- */
.hero { padding: 0 0 18px; border-bottom: 1px solid var(--line) }
.hero.compact { padding-top: 0 }
.hero h1 {
    font-size: 26px;
    font-weight: 600;
    line-height: 1.2;
    letter-spacing: -.021em;
    margin: 0;
    max-width: 40ch;
}
.hero p:not(.eyebrow) { font-size: 13.5px; color: var(--ink-2); max-width: 74ch; margin: 10px 0 0 }

/* A quiet line of context above a title — who you are here, or what this section is. Not a
   shouty cyan micro-caption; it sits under the title's weight, not over it. */
.eyebrow {
    font-size: 12.5px;
    font-weight: 400;
    color: var(--ink-3);
    letter-spacing: 0;
    text-transform: none;
    margin: 0 0 5px;
}
/* The Event page names its Series here and links to it, so the eyebrow can carry a link. */
.eyebrow a { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px }
.eyebrow a:hover { color: var(--ink) }

/* A sentence that reports a condition — a quota reached, a step not yet available. It is prose,
   not a badge: `.tag` was being used for these, which put pill-shaped paragraphs on the page. */
.notice { font-size: 13px; color: var(--ink-2); margin: 10px 0 0; max-width: 78ch }
.notice.warn { color: var(--orange) }

/* Explains the vocabulary of a page in a sentence. Used where two nouns are easily confused —
   the terms themselves lift out of the muted prose so the definition can be skimmed. */
.lede { margin: 14px 0 0; font-size: 13.5px; color: var(--ink-2); max-width: 78ch }
.lede strong, .hero p strong { color: var(--ink); font-weight: 600 }

/* A row of small facts under a page title — episode count, last sync, a link or two. */
.hero-meta {
    display: flex;
    flex-wrap: wrap;
    gap: 6px 22px;
    margin-top: 12px;
    font-size: 12.5px;
    color: var(--ink-3);
}
.hero-meta a { color: var(--violet); text-decoration: none }
.hero-meta a:hover { text-decoration: underline }

.back {
    display: inline-block;
    color: var(--ink-3);
    text-decoration: none;
    font-size: 13px;
    margin-bottom: 16px;
}
.back:hover { color: var(--ink) }

/* Detail-page hero: title on the left, the facts that identify this thing on the right. Both
   columns start at the top — the facts column carries the Event's links as well as its times,
   so it is routinely the taller of the two and bottom-aligning pushed the title down the page. */
.event-hero {
    display: flex;
    justify-content: space-between;
    gap: 40px;
    align-items: flex-start;
    border-bottom: 1px solid var(--line);
    padding: 0 0 20px;
}
.event-hero h1 { font-size: 30px; font-weight: 600; letter-spacing: -.022em; line-height: 1.15 }
.event-facts {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 5px;
    min-width: 210px;
    color: var(--ink-2);
    font-size: 13px;
    text-align: right;
}
.event-facts strong { color: var(--ink); font-weight: 600 }
.event-facts a { color: var(--violet); text-decoration: none }
.event-facts a:hover { text-decoration: underline }
/* The links below the times are actions, not facts — a rule separates the two so the column
   does not read as one undifferentiated list. */
.event-facts a:first-of-type { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line-soft) }

/* The cover thumbnail sits beside the title/description text, both inside the hero's left flex
   child — not a third sibling of .event-hero itself, which would break its two-column layout. */
.event-hero-main { display: flex; gap: 16px; align-items: flex-start; min-width: 0 }
.event-cover {
    width: 84px;
    height: 84px;
    border-radius: 8px;
    object-fit: cover;
    flex: 0 0 auto;
    border: 1px solid var(--line-soft);
}
/* A row of small facts under the description — website, location, party system. */
.event-meta-line { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; margin: 8px 0 0; font-size: 12.5px; color: var(--ink-2) }
.event-meta-line a { color: var(--violet); text-decoration: none }
.event-meta-line a:hover { text-decoration: underline }

/* --------------------------------------------------------------------------------------------
   Sections
   `.section-heading` does double duty in the views: a standalone section header (with an
   .eyebrow and an h2), and the h2 inside a panel. Both are styled here.
   -------------------------------------------------------------------------------------------- */
.section-heading {
    display: flex;
    justify-content: space-between;
    align-items: end;
    gap: 16px;
    margin: 38px 0 12px;
}
.section-heading h2 { font-size: 16px; font-weight: 600; letter-spacing: -.012em; margin: 0 }
/* As a plain h2 it is a heading rather than a flex row, so it must not inherit the section
   margins. It keeps the 16px section size — the Audience heading is a peer of "Streams & Now
   Playing", not a sub-heading of it — and only drops to 15px inside a panel. */
h2.section-heading { display: block; margin: 0 0 12px; font-size: 16px; font-weight: 600 }
.station-panel > h2.section-heading { font-size: 15px }

/* A count beside a section title, and the one-line gloss on the right of it. */
.sec-count { font-size: 12.5px; color: var(--ink-3); font-variant-numeric: tabular-nums }
.sec-note  { font-size: 12.5px; color: var(--ink-3); text-align: right }

.muted { color: var(--ink-2) }
p.muted { font-size: 13px; max-width: 78ch }

/* --------------------------------------------------------------------------------------------
   Surfaces
   `.card` is the one panel primitive: a flat fill and a hairline. It carries no padding, so a
   panel opts in with `.station-panel`.
   -------------------------------------------------------------------------------------------- */
.card {
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: 8px;
}
.station-panel { padding: 18px 20px; margin-top: 14px }
.station-panel > h2.section-heading { margin: 0 0 10px }

/* --------------------------------------------------------------------------------------------
   Series and Events
   THE distinction this app is built on, so the markup makes it structural rather than
   decorative: a Series is a container, and its editions are rows INSIDE it. An Event that
   belongs to no Series is a row in its own list under a heading that says so. Nothing about
   the two is interchangeable, which is what went wrong when both were the same card.
   -------------------------------------------------------------------------------------------- */
.series {
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: 8px;
    margin-top: 10px;
    overflow: hidden;
}
.series-head {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 16px;
    align-items: start;
    padding: 15px 18px;
    text-decoration: none;
}
.series-head:hover { background: var(--raised) }
.series-title { display: flex; align-items: baseline; gap: 4px; flex-wrap: wrap }
.series-title h2, .series-title h3 {
    font-size: 17px;
    font-weight: 600;
    letter-spacing: -.016em;
    margin: 0;
}
.series-desc { margin: 4px 0 0; font-size: 13px; color: var(--ink-2); max-width: 70ch }
.series-right { display: flex; align-items: center; gap: 8px; white-space: nowrap }

/* How often it runs, said as a word. It is a property of the Series, not a badge to be won. */
.cadence { font-size: 12.5px; color: var(--ink-3) }
.cadence:before {
    content: "";
    display: inline-block;
    width: 3px;
    height: 3px;
    border-radius: 50%;
    background: var(--ink-3);
    margin: 0 8px 3px 4px;
    vertical-align: middle;
}

/* The editions inside a Series, and — with `.rows` — any other list of the same shape. Sunken
   relative to the container so a Series reads as holding them. */
.editions { border-top: 1px solid var(--line-soft); background: var(--sunken) }
.rows {
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: 8px;
    margin-top: 10px;
    overflow: hidden;
}

/* The stations list adds a cover column ahead of the name; every other .rows list keeps the
   plain five-column shape below. The thumb opens the lightbox (see lightbox.js) — the row
   itself stays one big link. */
.rows.with-covers .ed { grid-template-columns: 104px 36px 1fr 96px 100px 140px }
.ed .cover, .ep-cover { width: 36px; height: 36px; object-fit: cover; border-radius: 6px; cursor: zoom-in; display: block }
.ed .cover-blank { width: 36px; height: 36px; border-radius: 6px; background: var(--raised); border: 1px dashed var(--line-soft) }
.ep-cover { width: 28px; height: 28px }
.episode-table .ep-cover-col { width: 36px; padding-right: 4px }

/* Opt-in image lightbox (data-lightbox-src). */
.cd-lightbox { position: fixed; inset: 0; z-index: 1000; display: none; align-items: center; justify-content: center; background: rgba(6, 10, 18, 0.88); padding: 32px }
.cd-lightbox.is-open { display: flex }
.cd-lightbox__image { max-width: 92vw; max-height: 92vh; border-radius: 10px; box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55) }
.cd-lightbox__close { position: absolute; top: 14px; right: 18px; border: 0; background: none; color: #fff; font-size: 34px; line-height: 1; cursor: pointer; opacity: 0.8 }
.cd-lightbox__close:hover { opacity: 1 }
body.cd-lightbox-open { overflow: hidden }

/* One Event. Five columns: state, what it is, when, what it is connected to, how many heard it.
   The figure column is right-aligned and tabular so a column of them can be compared. */
.ed {
    display: grid;
    grid-template-columns: 104px 1fr 96px 100px 140px;
    gap: 14px;
    align-items: center;
    padding: 11px 18px;
    font-size: 13px;
    text-decoration: none;
    border-top: 1px solid var(--line-soft);
}
.ed:first-child { border-top: 0 }
/* The Series page lists its own editions, and Korolev sends no binding counts or audience
   figures with those — so the row drops to the three columns it can actually fill rather than
   padding out two empty ones. */
.rows.compact .ed { grid-template-columns: 104px 1fr 96px }
.ed:hover { background: var(--raised) }
.ed .name { font-weight: 500; color: var(--ink) }
.ed .name .label { color: var(--ink-3); font-weight: 400 }
.ed .meta { color: var(--ink-3); font-size: 12.5px; font-variant-numeric: tabular-nums }
/* An Event with both a listening and a watching peak needs two lines; tighten the leading so
   that row does not tower over its neighbours. */
.ed .fig {
    color: var(--ink-2);
    font-size: 12.5px;
    line-height: 1.35;
    text-align: right;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}
.ed .fig strong { color: var(--ink); font-weight: 600 }
/* A Series with no editions yet says so, and says what to do about it. An empty container is
   a state worth naming — it is the difference between "nothing here" and "not set up". */
.ed-empty {
    padding: 12px 18px;
    font-size: 12.5px;
    color: var(--ink-3);
    border-top: 1px solid var(--line-soft);
}
.ed-empty a { color: var(--violet) }

/* --------------------------------------------------------------------------------------------
   State
   A dot and a word. The dot carries the colour so the label stays readable, and only `live`
   is allowed to draw attention to itself — it is the one state that is happening right now.
   -------------------------------------------------------------------------------------------- */
.st { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--ink-2) }
.st i { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); flex: none }
.st.live      { color: var(--success) }
.st.live i    { background: var(--success); box-shadow: 0 0 8px var(--success) }
.st.planned i { background: var(--cyan) }
.st.completed i { background: #4b4a58 }
.st.cancelled i { background: var(--danger) }
.st.archived i  { background: #3a3946 }
.st.draft i     { background: transparent; border: 1px solid var(--ink-3) }
/* Guest stations and their episodes speak a different vocabulary: a station is `active`, an
   episode is `draft` until it is `downloadable`. Any status not listed here keeps the neutral
   grey dot rather than being guessed at — the same default the old sheet gave everything but
   `live` and `planned`. */
.st.active i       { background: var(--success) }
.st.downloadable i { background: var(--success) }
.st.paused i, .st.inactive i { background: var(--orange) }

/* How you reach this thing — a membership role, or administrator reach. Said quietly: it is
   context, not status, and it is the same on every row. */
.who-tag {
    font-size: 11px;
    color: var(--ink-3);
    border: 1px solid var(--line);
    border-radius: 4px;
    padding: 2px 7px;
    white-space: nowrap;
}
.who-tag.admin { color: var(--violet); border-color: #9b7cff40 }

/* --------------------------------------------------------------------------------------------
   Badges
   `.status` reports a thing's own lifecycle state; `.tag` labels a kind or a property. Both
   are small, bordered and quiet — a page full of filled pills is a page where nothing stands
   out. `.kind` is kept as an alias of `.tag`.
   -------------------------------------------------------------------------------------------- */
.status, .kind, .tag {
    display: inline-flex;
    align-items: center;
    padding: 2px 8px;
    border: 1px solid var(--line);
    border-radius: 4px;
    background: transparent;
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0;
    color: var(--ink-2);
    white-space: nowrap;
}
/* A lifecycle state is always one lowercase word from Korolev ("live", "completed"), so it can
   be capitalised here. `.tag` cannot: it carries multi-word labels like "Video stream", which
   `capitalize` would turn into "Video Stream". Those are capitalised at the source instead. */
.status { text-transform: capitalize }
.status.live      { color: var(--success); border-color: #65e6a840; background: #65e6a812 }
.status.planned   { color: var(--cyan);    border-color: #69dcf640 }
.status.completed { color: var(--ink-3) }
.status.cancelled { color: var(--danger);  border-color: #ff747e40 }
.status.draft     { color: var(--ink-3);   border-style: dashed }
.status.downloadable { color: var(--success); border-color: #65e6a840 }
.status.active       { color: var(--success); border-color: #65e6a840 }
.tag.ok    { color: var(--success); border-color: #65e6a840 }
.tag.warn  { color: var(--orange);  border-color: #ff9d5c40 }
/* Reached as a SceneSat administrator rather than through an invitation. Violet, so it reads as
   "how you got here" next to the green/orange tags that report a thing's own state. */
.tag.admin { color: var(--violet);  border-color: #9b7cff40 }

/* --------------------------------------------------------------------------------------------
   Empty states
   An empty workspace is usually a permissions story, not a decoration opportunity, so it gets
   a sentence that explains what to do — and no 80px glowing ring.
   -------------------------------------------------------------------------------------------- */
.empty { text-align: left; padding: 30px 24px; max-width: 720px; margin-top: 14px }
.empty h2, .empty h3 { font-size: 17px; font-weight: 600; margin: 0 0 8px }
.empty p { color: var(--ink-2); font-size: 13px; margin: 0 }
.orbit-icon {
    display: block;
    width: 26px;
    height: 26px;
    border: 1px solid var(--violet-dim);
    border-radius: 50%;
    margin-bottom: 14px;
}

/* --------------------------------------------------------------------------------------------
   Bindings — the streams and Now Playing feeds an Event is connected to
   -------------------------------------------------------------------------------------------- */
.binding-list { display: grid; gap: 12px }
.binding { padding: 18px 20px }
.binding > header { display: flex; justify-content: space-between; align-items: center; gap: 20px }
/* The header's left half is a kind badge stacked over the binding's own name. */
.binding > header > div:first-child { display: grid; gap: 6px; justify-items: start }
.binding h3 { font-size: 16px; font-weight: 600; letter-spacing: -.012em; margin: 0 }
.binding-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end }

.now-preview {
    display: grid;
    grid-template-columns: auto 1fr auto;
    gap: 14px;
    align-items: center;
    background: var(--sunken);
    border: 1px solid var(--line-soft);
    border-radius: 6px;
    padding: 13px 15px;
    margin-top: 14px;
}
.now-preview div { display: flex; gap: 4px; flex-direction: column }
.now-preview small, .now-preview span { color: var(--ink-3); font-size: 12px }
.now-preview strong { font-size: 15px; font-weight: 600 }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: #3a3946 }
.live-dot.on { background: var(--danger); box-shadow: 0 0 10px var(--danger) }
.revision { font-family: ui-monospace, monospace; font-size: 12px; color: var(--ink-3) }

/* Resolved downstream identifiers. Monospace because these get copied into another system or
   quoted to staff — they are values, not prose. */
.binding-facts { display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 14px 0 0; padding: 0 }
.binding-facts > div { display: grid; gap: 3px }
.binding-facts dt {
    color: var(--ink-3);
    text-transform: uppercase;
    letter-spacing: .1em;
    font-size: 9.5px;
    font-weight: 700;
}
.binding-facts dd { margin: 0; font-family: ui-monospace, monospace; font-size: 12.5px }

/* The public Now Playing URL: long and unbreakable, so wrap it rather than let it widen the
   card on a phone. */
.feed-url { display: grid; gap: 4px; margin: 12px 0 0 }
.feed-url small {
    color: var(--ink-3);
    text-transform: uppercase;
    letter-spacing: .1em;
    font-size: 9.5px;
    font-weight: 700;
}
.feed-url a {
    font-family: ui-monospace, monospace;
    font-size: 12px;
    color: var(--cyan);
    word-break: break-all;
}

/* Zond's heartbeat, at the end of the "Chat recording" section heading. */
.zond-status { display: flex; align-items: center; gap: 10px; flex-wrap: wrap }
.zond-status small { color: var(--ink-3); font-size: 11.5px }

/* The recorded chat log's search box, in a `.cl-toolbar` row. The base `input` rule is
   width: 100%, which as a flex item takes the whole line and pushes Search and Clear onto one of
   their own; this makes it share the row and still grow into the space it has. */
.chat-search { width: auto; flex: 1 1 260px; max-width: 420px }

/* --------------------------------------------------------------------------------------------
   Live output status (Stage 2a — rendered on page load; Stage 2b patches the data-live-field
   nodes in place). realtime.js toggles the state class on these nodes by name, so every key in
   its LABELS map must have a rule here.

   Colour carries meaning, so it is used only where the state is actually known: green = on air,
   red = confirmed offline, orange = we could not ask. "Unavailable" must never look like
   "offline".
   -------------------------------------------------------------------------------------------- */
.live-state {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 9px 18px;
    margin-top: 14px;
    font-size: 12.5px;
    color: var(--ink-2);
}
.live-state strong { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 600 }
.live-state small { color: var(--ink-3) }
.live-state .state {
    display: inline-flex;
    padding: 2px 8px;
    border: 1px solid var(--line);
    border-radius: 4px;
    font-size: 11px;
    font-weight: 500;
    color: var(--ink-2);
}
.live-state .state.live    { color: var(--success); border-color: #65e6a840; background: #65e6a812 }
.live-state .state.offline { color: var(--danger);  border-color: #ff747e40 }
/* Not-found and not-yet-connected are configuration, not outage. */
.live-state .state.missing      { color: var(--orange); border-color: #ff9d5c40 }
.live-state .state.unconfigured { color: var(--ink-2) }
/* Explicitly muted, NOT red: we did not measure this, so do not imply we did. */
.live-state .state.unknown       { color: var(--ink-2) }
.live-state .state.not-monitored { color: var(--ink-3) }

/* --------------------------------------------------------------------------------------------
   Forms
   -------------------------------------------------------------------------------------------- */
.publisher { border-top: 1px solid var(--line-soft); margin-top: 16px; padding-top: 16px }
.publisher summary { cursor: pointer; font-weight: 600; color: var(--violet); font-size: 13px }

form { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 16px }
label { color: var(--ink-2); font-size: 12.5px; display: grid; gap: 6px }
.wide { grid-column: 1 / -1 }
/* Hint text under a field (what a value is, or where to find it). */
label small { color: var(--ink-3); font-size: 11px; line-height: 1.35 }
/* Beside a label's own text, not under it: "this value is the Series' default, not the Event's
   own" is about the FIELD's origin, which reads better next to the name than below the input. */
label .from-series {
    display: inline-block;
    margin-left: 6px;
    padding: 1px 6px;
    border-radius: 100px;
    background: var(--sunken);
    border: 1px solid var(--line-soft);
    font-size: 10.5px;
    text-transform: none;
}
/* The current cover, shown above its own upload widget on the Event/Series details forms. */
.event-cover-preview {
    display: block;
    max-width: 320px;
    max-height: 180px;
    width: auto;
    border-radius: 8px;
    object-fit: contain;
    border: 1px solid var(--line-soft);
    margin: 0 0 12px;
}

input, textarea, select {
    width: 100%;
    border: 1px solid #2c2b39;
    border-radius: 6px;
    background: var(--sunken);
    color: var(--ink);
    padding: 9px 11px;
    font: inherit;
    font-size: 13px;
}
/* The rule above is meant for fields. A submit/button input is not a field — the station editors
   use `<input type="submit" class="btn">`, and a full-width Save stretched across its toolbar. */
input[type="submit"], input[type="button"], input[type="reset"] { width: auto }
textarea { resize: vertical }
/* The base sheet styles `input`, so a `select` left alone falls back to the raw browser control
   and reads as foreign. These match the input rule exactly and add the chevron that
   `appearance: none` removes. */
select {
    appearance: none;
    padding-right: 34px;
    background-image: linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
                      linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
    background-position: calc(100% - 18px) calc(50% + 1px), calc(100% - 13px) calc(50% + 1px);
    background-size: 5px 5px, 5px 5px;
    background-repeat: no-repeat;
}
/* The dark theme does not reach the native option list on most platforms; set both so an
   opened menu is not a white flash. */
select option { background: #0e0e13; color: var(--ink) }
select:focus, input:focus, textarea:focus {
    outline: none;
    border-color: var(--violet);
    box-shadow: 0 0 0 3px #9b7cff26;
}
.check { display: flex; align-items: center; flex-direction: row; gap: 8px }
.check input { width: auto }
/* Several buttons on one line — lifecycle moves, editor toolbars. */
.button-row { display: flex; flex-wrap: wrap; gap: 8px }

/* A short list of resolved facts — a Series' inherited outputs, say. Not a bulleted list: these
   are labelled values, and the bullet adds nothing a monospace key does not already say. */
.fact-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 7px }
.fact-list li { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; font-size: 13px; color: var(--ink-2) }
.fact-list strong, .fact-list .target { font-family: ui-monospace, monospace; font-size: 12.5px }
.fact-list strong { color: var(--ink); font-weight: 600 }
.fact-list .arrow { color: var(--ink-3) }

/* One team member: who they are, what they may do, and the controls that change it. A member is
   its own <form>, so this overrides the two-column form grid rather than living inside it. */
.member-row {
    display: grid;
    grid-template-columns: 1fr 140px auto;
    gap: 10px;
    align-items: center;
    margin: 0;
    padding: 11px 0;
    border-top: 1px solid var(--line-soft);
}
.member-row .who { font-size: 13px; color: var(--ink) }
.member-row .acts { display: flex; gap: 8px }
@media (max-width: 720px) {
    .member-row { grid-template-columns: 1fr; gap: 8px }
}

/* Two fields read as a pair — "record from … until …". Collapses on narrow screens like the
   parent form grid does. */
.field-pair { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: 14px }

/* Platform-swapped recorder fields. `label { display: grid }` beats the [hidden] attribute's
   UA `display: none`, so hidden fields would stay visible without this — the whole point of
   the swap. */
[hidden] { display: none !important }

/* --------------------------------------------------------------------------------------------
   Buttons
   One filled button per context at most: the thing you came to do. Everything else is ghost.
   `.btn`/`.btn-primary` are the class names the station editors emit from JS; they map onto
   the same two shapes rather than falling back to the raw UA button.
   -------------------------------------------------------------------------------------------- */
/* Shape, shared by every button. Colour is decided by the two rules after it. */
.button, .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 7px;
    border: 1px solid transparent;
    border-radius: 6px;
    padding: 8px 14px;
    text-decoration: none;
    font: inherit;
    font-size: 13px;
    cursor: pointer;
}
/* Filled — the one thing you came to this panel to do. */
.button, .btn-primary {
    background: var(--violet);
    border-color: var(--violet);
    color: #14101f;
    font-weight: 600;
}
.button:hover, .btn-primary:hover { background: #a98dff; border-color: #a98dff }
/* Ghost — everything else. `.btn` is what the station editors emit from JS, and those toolbars
   are all secondary actions, so a bare `.btn` is a ghost. */
.button.ghost, .btn {
    background: var(--raised);
    border-color: var(--line);
    color: var(--ink);
    font-weight: 500;
}
.button.ghost:hover, .btn:hover { background: #1f1f29; border-color: #32313f; color: var(--ink) }
/* …but `.btn.btn-primary` is still filled: two classes beat the one above. */
.btn.btn-primary { background: var(--violet); border-color: var(--violet); color: #14101f }
.btn.btn-primary:hover { background: #a98dff; border-color: #a98dff }
.button:focus-visible, .btn:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px }

/* --------------------------------------------------------------------------------------------
   Flashes
   -------------------------------------------------------------------------------------------- */
.flash {
    padding: 11px 14px;
    border-radius: 6px;
    margin-bottom: 18px;
    font-size: 13px;
    background: var(--surface);
    border: 1px solid var(--line);
}
.flash.success { border-color: #65e6a855; color: var(--success) }
.flash.error   { border-color: #ff747e55; color: #ffabb0 }
.error-page .button-row { margin-top: 18px }

/* --------------------------------------------------------------------------------------------
   Tables — guest-station episodes, tracklists, transcripts, chat logs, daily audience
   -------------------------------------------------------------------------------------------- */
.episode-table { width: 100%; border-collapse: collapse; font-size: 13px }
.episode-table th, .episode-table td {
    padding: .5rem .7rem;
    text-align: left;
    border-bottom: 1px solid var(--line-soft);
}
.episode-table th {
    font-size: 9.5px;
    letter-spacing: .1em;
    text-transform: uppercase;
    font-weight: 700;
    color: var(--ink-3);
}
.episode-table th.num, .episode-table td.num { text-align: right; font-variant-numeric: tabular-nums }
.episode-table tbody tr:hover { background: var(--raised) }
.episode-table .ep-title { max-width: 28rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.episode-table a { text-decoration: none; color: var(--ink) }
.episode-table a:hover { text-decoration: underline }

/* Column widths that used to be inline `style` attributes on the <th>. Kept as classes so the
   table's proportions are a stylesheet decision, not seven copies of a magic number. */
.episode-table .col-role   { width: 12rem }
.episode-table .col-action { width: 7rem }
.episode-table .col-time   { width: 8rem }
.episode-table .col-artist { width: 30% }
.episode-table .col-year   { width: 70px }
/* The cuesheet's "Add track" sits in a <tfoot> cell spanning the table. */
.table-foot-action { text-align: center; padding-top: 1rem; border-bottom: 0 }

/* A form that lives inside a table cell: it must not inherit the page form's two-column grid. */
.inline-form { display: flex; gap: 6px; align-items: center; margin: 0 }
/* A line of guidance beside an editor toolbar. */
.hint { color: var(--ink-2); font-size: 12px }
/* A bare <audio> control needs a little room of its own above the editor it belongs to. */
.audio-row { margin: 8px 0 }
.audio-row audio { width: 100%; max-width: 34rem }

/* The episode table's search / status / sort bar. Its own flex layout — the global `form`
   rule is a two-column grid, which would stack these controls into a form-shaped block. */
.episode-filter { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 0 0 14px; padding: 0 }
.episode-filter input[type="search"] { flex: 1 1 220px; max-width: 340px; width: auto }
.episode-filter select { flex: 0 1 auto; width: auto }
.episode-filter .filter-result { font-size: 12.5px; color: var(--ink-2) }
.episode-filter .filter-result a { color: var(--violet); text-decoration: none }
.episode-filter .filter-result a:hover { text-decoration: underline }

/* Sortable column headers: the link inherits the header's smallcaps look, the active column
   carries a direction marker. */
.episode-table th .sort-link { color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 3px }
.episode-table th .sort-link:hover { color: var(--ink) }
.episode-table th[aria-sort] .sort-link { color: var(--ink) }
.episode-table th .sort-ind { font-size: 9px; color: var(--violet) }
.episode-table td.ep-length { font-variant-numeric: tabular-nums }

.pager { display: flex; gap: .4rem; margin-top: 1rem; flex-wrap: wrap; align-items: center; font-size: 12.5px }
/* The table's twin pager sits between the filter bar and the header row; its spacing belongs
   below it, not above. */
.pager.top { margin: 0 0 12px }
.pager a, .pager .current {
    padding: .2rem .6rem;
    border-radius: 5px;
    border: 1px solid var(--line);
    text-decoration: none;
}
.pager a:hover { background: var(--raised) }
.pager .current { background: var(--raised); color: var(--ink) }
/* The elision between pager windows — plain text, aligned with the links beside it. */
.pager .gap { padding: .2rem .2rem; color: var(--ink-3) }
/* Direct page entry beside the windowed links. Its own flex row (the global `form` rule is a
   grid), pushed to the pager's right edge. */
.pager .pager-jump { display: flex; gap: 6px; align-items: center; margin: 0 0 0 auto; padding: 0; color: var(--ink-3); font-size: 12.5px }
.pager .pager-jump label { display: flex; gap: 6px; align-items: center; font-size: inherit; color: inherit }
.pager .pager-jump input[type="number"] { width: 62px; padding: .2rem .4rem; font-size: 12.5px; text-align: center }
.pager .pager-jump .button { padding: .2rem .7rem; font-size: 12px }

/* --------------------------------------------------------------------------------------------
   Publisher keys
   The reveal is styled to be noticed: it is shown exactly once, and a person who scrolls past
   it has to revoke and reissue to get another.
   -------------------------------------------------------------------------------------------- */
.secret-reveal { border-color: var(--violet) }
.secret, .snippet {
    margin: 12px 0 0;
    padding: 12px 14px;
    background: var(--sunken);
    border: 1px solid var(--line-soft);
    border-radius: 6px;
    font-family: ui-monospace, monospace;
    font-size: 12.5px;
    line-height: 1.5;
    /* The key is one long unbroken token and the curl example has long lines: both must scroll
       inside the card rather than widen the page. */
    overflow-x: auto;
    white-space: pre;
}
.secret { color: var(--cyan); font-size: 14px; user-select: all }
.snippet { color: var(--ink-2) }

/* The embed-snippet card's option row: a checkbox and a select sharing one line, like the
   chat-log toolbar. The select only needs to stop stretching to the card's full width. */
.embed-options { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; margin-top: 10px }
.embed-options label { display: flex; align-items: center; gap: 8px; margin: 0 }
.embed-options select { width: auto }

/* --------------------------------------------------------------------------------------------
   AUDIENCE STATISTICS
   Cards carry bare totals; detail pages carry a tile per metric with a server-rendered inline
   SVG beside it. No chart library and no build step — the sparkline is markup, so it arrives
   with the numbers rather than after them, and there is nothing to load or fail.

   The em dash is load-bearing everywhere in here: it means "not measured", which is a different
   fact from zero. Nothing in this section may style the two alike.
   -------------------------------------------------------------------------------------------- */
.card-stats { display: flex; flex-wrap: wrap; gap: 5px 16px; margin-top: 8px; font-size: 12.5px; color: var(--ink-3) }
/* Tabular figures so totals line up down a list. */
.card-stats strong { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 600 }

.stats-section { margin-top: 28px }
.stats-note { margin: 9px 0 0; font-size: 12px; color: var(--ink-3); max-width: 62ch }

/* Tiles are capped rather than stretched to fill the row. A 30-point sparkline given the full
   page width flattens into a straight line — the shape is the whole point of drawing it. */
.stat-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(180px, 260px));
    justify-content: start;
    gap: 18px 26px;
    margin-top: 14px;
}
.stat-tile { display: grid; gap: 5px }
/* The micro-label treatment shared with .binding-facts dt: uppercase is allowed here because
   the label must not compete with the figure beside it. */
.stat-tile .label {
    text-transform: uppercase;
    letter-spacing: .1em;
    font-size: 9.5px;
    font-weight: 700;
    color: var(--ink-3);
}
.stat-tile .value {
    font-size: 27px;
    font-weight: 600;
    letter-spacing: -.025em;
    font-variant-numeric: tabular-nums;
    color: var(--ink);
    line-height: 1.1;
}
.stat-tile .sub { font-size: 11px; color: var(--ink-3) }

/* Fluid width, fixed height. preserveAspectRatio="none" stretches the geometry, so every
   stroked element carries vector-effect="non-scaling-stroke" to stay an even 2px — and
   overflow stays visible so the half-stroke at the extremes is not clipped. */
.sparkline { display: block; width: 100%; height: 44px; margin-top: 2px; overflow: visible }
.sparkline .line { fill: none; stroke: var(--violet); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round }
.sparkline .baseline { stroke: var(--line); stroke-width: 1 }
/* A measured day stranded between two gaps: a lone polyline point draws nothing. */
.sparkline .point { fill: var(--violet) }

.stats-daily { margin-top: 16px }
.stats-daily > summary { cursor: pointer; font-weight: 600; color: var(--violet); font-size: 13px }
.stats-daily table { margin-top: 10px }

/* Station header counters. */
.station-counters { display: flex; flex-wrap: wrap; gap: 10px 26px; margin-top: 14px; font-size: 12.5px; color: var(--ink-3) }
.station-counters strong { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 600 }

/* --------------------------------------------------------------------------------------------
   Station editors — cuesheet, chat log, transcript
   These build their own rows from JS, so the class names here are a contract with
   public/assets/js/*.js. Change a name in one place and the editor silently loses its styling.
   -------------------------------------------------------------------------------------------- */
.cs-toolbar, .cl-toolbar, .tr-toolbar, .cuesheet-nudges, .cs-group {
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
}
.cs-toolbar { margin: 14px 0 }
.cl-toolbar, .tr-toolbar { margin: 12px 0 4px }
/* Transport and zoom controls that belong together read as one control, not five. */
.cs-group { gap: 4px }
.cs-switch, .cs-import { display: inline-flex; align-items: center; gap: 8px }
.channel-picker, .track-picker { display: grid; gap: 6px; margin-bottom: 14px }
.command-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: end; margin-top: 10px }
.row-remove, .toggle-button { flex: none }
.waveform-wrapper {
    background: var(--sunken);
    border: 1px solid var(--line-soft);
    border-radius: 6px;
    padding: 10px;
    margin-bottom: 14px;
}
.chatlog-list, .transcript-list, .cuesheet-tracklist { width: 100% }
/* Which transcript track / chat channel is open. The picker is a row of ghost buttons, so the
   selected one is marked by a fill rather than by another colour. */
.track-picker .current, .channel-picker .current {
    background: #9b7cff26;
    border-color: #9b7cff59;
    color: var(--ink);
}
/* Cells the editors make editable in place. They must look editable without a box on every row,
   so the resting affordance is a dotted underline and the active one is a focus fill. */
[contenteditable] { display: inline-block; min-width: 2em; cursor: text; border-bottom: 1px dotted #4a4956 }
.textField, .timeField, .cl-timefield, .cl-author, .cl-message, .cl-type {
    outline: none;
    border-radius: 4px;
    padding: 2px 5px;
}
[contenteditable]:focus,
.textField:focus, .timeField:focus, .cl-timefield:focus,
.cl-author:focus, .cl-message:focus, .cl-type:focus {
    outline: none;
    background: var(--raised);
    box-shadow: 0 0 0 2px #9b7cff40;
    border-bottom-color: transparent;
}
.cl-time, .cl-seek, .timeField, .cl-timefield { font-variant-numeric: tabular-nums }
.cl-seek { color: var(--violet); cursor: pointer }
.text-center { text-align: center }
.time { font-variant-numeric: tabular-nums }

/* --------------------------------------------------------------------------------------------
   Landing page
   The only page here that speaks to someone without a session, so it is allowed a larger opening
   than a workspace index — but it stays inside the same grammar: flat fills, one hairline, and a
   type scale that stops well short of a splash screen (rule 4 caps the INDEX at 26px; this is the
   front door, not an index). No artwork, no gradient, nothing that has to be read through.
   -------------------------------------------------------------------------------------------- */
.landing-hero { padding: 6px 0 26px; border-bottom: 1px solid var(--line) }
.landing-hero h1 {
    font-size: 34px;
    font-weight: 600;
    line-height: 1.15;
    letter-spacing: -.024em;
    margin: 0;
    max-width: 22ch;
}
.landing-lead { font-size: 15px; line-height: 1.55; color: var(--ink-2); max-width: 62ch; margin: 14px 0 0 }
.landing-actions { margin-top: 22px }
/* The invitation caveat. It belongs under the button, not over it: someone who already has an
   invitation should not have to read a paragraph before finding the way in. */
.landing-admission { margin-top: 18px }

/* Sections below the hero. `h2.section-heading` deliberately carries no top margin — it is the
   in-panel heading elsewhere — so the spacing between landing sections is set here. Matched on
   the element too, or that rule's `margin: 0 0 12px` outranks this one and the sections collapse
   onto the block above them. */
h2.landing-heading { margin-top: 40px }

/* What the workspace does. Cells are the standard `.card`: same fill, same hairline, same radius
   as every panel in the app, so the landing page cannot drift into its own component set. */
.landing-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(272px, 1fr));
    gap: 12px;
    margin-top: 14px;
}
.landing-cell { padding: 17px 19px }
.landing-cell h3 { font-size: 15px; font-weight: 600; letter-spacing: -.012em; margin: 0 0 7px }
.landing-cell p  { font-size: 13px; color: var(--ink-2); margin: 0 }

.landing-panel { padding: 17px 19px; margin-top: 14px }
.landing-panel p { font-size: 13.5px; color: var(--ink-2); max-width: 78ch; margin: 0 }
.landing-panel p + p { margin-top: 10px }
/* --------------------------------------------------------------------------------------------
   Party slide directory
   The artwork IS the content here, which is the one place in this app where an image gets to be
   the largest thing on the page. Everything around it stays out of the way: the tile is a flat
   fill and a hairline like every other surface, and the caption is two short lines under the art
   rather than an overlay across it — a slide is someone's poster, and covering it to label it
   would be rude as well as unreadable.
   -------------------------------------------------------------------------------------------- */
.slide-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
    gap: 14px;
    margin-top: 14px;
}
.slide-tile { margin: 0; display: flex; flex-direction: column; gap: 8px }
/* 16:9, because that is what a stream overlay is. Letterboxing an odd submission inside the tile
   keeps the grid on its rows — object-fit: contain, never cover: cropping a poster can cut the
   party's name off it. */
.slide-art {
    display: flex;
    text-decoration: none;
    align-items: center;
    justify-content: center;
    aspect-ratio: 16 / 9;
    overflow: hidden;
    background: var(--sunken);
    border: 1px solid var(--line);
    border-radius: 8px;
}
.slide-art img { width: 100%; height: 100%; object-fit: contain; display: block }
.slide-art:hover { border-color: var(--violet-dim) }
.slide-art:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px }
/* A submitted video has no poster frame yet, so the tile says what it is instead of showing a
   broken image. */
.slide-video { font-size: 12px; color: var(--ink-3); letter-spacing: .08em; text-transform: uppercase }

.slide-tile figcaption { display: flex; flex-direction: column; gap: 2px; min-width: 0 }
.slide-title {
    font-size: 13px;
    font-weight: 500;
    color: var(--ink);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.slide-when { font-size: 12.5px; color: var(--ink-3); font-variant-numeric: tabular-nums }
/* Two lines at most in the grid — the tile is a poster with a label, not a listing entry. The
   full text is on the slide's own page when that exists; until then, clamped is honest. */
.slide-desc {
    margin: 4px 0 0;
    font-size: 12.5px;
    line-height: 1.45;
    color: var(--ink-2);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
/* Violet, because it is the one interactive thing in the caption (rule 2: violet is interaction).
   The scheme and any www. are stripped from the label — a party's domain is the recognisable part
   and the rest is noise at 12px. */
.slide-link {
    margin-top: 5px;
    font-size: 12.5px;
    color: var(--violet);
    text-decoration: none;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.slide-link:hover { text-decoration: underline }

/* History. Dimmed rather than hidden or greyed to a state colour: these are still real slides,
   they are simply not on air. Full opacity on hover, so browsing the archive is not a squint. */
.slide-grid.past .slide-tile { opacity: .62 }
.slide-grid.past .slide-tile:hover { opacity: 1 }

/* The archive shelf ships collapsed — it is history, and most visitors came for what is coming
   up. The summary reuses the section-heading grammar so a closed shelf reads as one more section,
   not as a widget; the default disclosure marker is kept as the affordance. */
.past-shelf { margin-top: 38px }
.past-shelf > .past-summary { cursor: pointer; margin: 0 0 12px }
.past-shelf > .past-summary::marker { color: var(--ink-3) }
.past-shelf > .past-summary h2 { display: inline }
.past-shelf > .past-summary .sec-count { margin-left: 8px }
.past-shelf > .past-summary:hover h2 { color: var(--violet) }
.past-shelf[open] > .past-summary .sec-note { visibility: hidden }

/* The byte-count beside the download label: a fact, quieter than the action it annotates. */
.button .dl-facts { font-weight: 400; color: var(--ink-3); font-size: 12px }

/* --------------------------------------------------------------------------------------------
   Slide curation
   A working queue, not a gallery: the thumbnail is there to recognise a slide by, and everything
   else on the row answers "does this still need me?". Same row grammar as `.ed`, so a directory
   of 205 reads like the Event lists rather than like a second design.
   -------------------------------------------------------------------------------------------- */
.slide-row {
    display: grid;
    grid-template-columns: 52px 1fr 96px 92px minmax(140px, auto);
    gap: 14px;
    align-items: center;
    padding: 9px 18px;
    font-size: 13px;
    text-decoration: none;
    border-top: 1px solid var(--line-soft);
}
.slide-row:first-child { border-top: 0 }
.slide-row:hover { background: var(--raised) }
.slide-row-art {
    display: block;
    aspect-ratio: 16 / 9;
    background: var(--sunken);
    border: 1px solid var(--line-soft);
    border-radius: 4px;
    overflow: hidden;
}
.slide-row-art img { width: 100%; height: 100%; object-fit: cover; display: block }
.slide-row-main { display: flex; flex-direction: column; gap: 1px; min-width: 0 }
.slide-row-main .name { font-weight: 500; color: var(--ink) }
/* The original filename. Monospace because it is an identifier being compared against a folder,
   not prose — and it is how a curator finds the same file on the old share. */
.slide-row-main .src {
    font-family: ui-monospace, monospace;
    font-size: 11.5px;
    color: var(--ink-3);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.slide-row-when { color: var(--ink-3); font-size: 12.5px; font-variant-numeric: tabular-nums }
.slide-row-gaps { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end }
/* What is missing. Deliberately NOT a state colour — an absent description is a task, not a
   fault, and painting 205 rows orange would say the library is broken when it is merely new. */
.gap {
    font-size: 11.5px;
    color: var(--ink-3);
    border: 1px solid var(--line);
    border-radius: 4px;
    padding: 1px 6px;
    white-space: nowrap;
}
/* Except this one: a note means the import could not read something and a person must decide. */
.gap.note { color: var(--orange); border-color: #ff9d5c40 }

.slide-note p { font-size: 13px; color: var(--ink-2); margin: 0; max-width: 78ch }

/* The edit page: artwork beside the form, so whoever writes the description can see the thing. */
.slide-edit { display: grid; grid-template-columns: 300px 1fr; gap: 22px; align-items: start; margin-top: 16px }
.slide-edit-art { margin: 0; position: sticky; top: 76px }
.slide-edit-art img {
    width: 100%;
    display: block;
    border: 1px solid var(--line);
    border-radius: 8px;
    background: var(--sunken);
}
.slide-edit-art figcaption { margin-top: 8px; font-size: 11.5px; color: var(--ink-3); line-height: 1.4 }
/* The form is already a two-column grid from the base sheet; it only needs its top margin
   removing, since the layout above supplies the gap. */
.slide-edit .slide-form { margin-top: 0 }

/* The queue's own action, under the hero prose rather than beside the title: adding a slide is a
   deliberate act, not the primary thing this page is for. */
.queue-actions { margin-top: 16px }
/* A one-button form sitting in a button row: the form must not bring the two-column form grid
   with it — it is a button wearing a CSRF token, nothing more. */
form.inline-action { display: inline; margin: 0 }

/* Upload progress. One bar, one line of text — a file either goes up or it does not, and a
   percentage plus a rate is the whole story. */
.slide-upload input[type="file"] { padding: 8px 10px }
.upload-progress {
    height: 6px;
    border-radius: 3px;
    background: var(--sunken);
    border: 1px solid var(--line-soft);
    overflow: hidden;
    margin-top: 14px;
}
.upload-bar { display: block; height: 100%; width: 0; background: var(--violet); transition: width .2s linear }
.upload-status { margin: 10px 0 0; font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums }
.upload-status.ok { color: var(--success) }
.upload-status.error { color: var(--danger) }

/* --------------------------------------------------------------------------------------------
   Footer
   -------------------------------------------------------------------------------------------- */
footer {
    position: relative;
    z-index: 1;
    border-top: 1px solid var(--line);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 18px;
    padding: 18px max(24px, calc((100vw - 1120px) / 2));
    font-size: 12px;
    color: var(--ink-3);
}
/* Footer timezone picker. Kept on one line so the zone name stays readable. */
.tz-form { display: inline-flex; gap: 8px; align-items: center; margin: 0; white-space: nowrap }
.tz-form label { white-space: nowrap; margin: 0; display: inline; color: inherit; font-size: 12px }
.tz-form select { width: auto; padding: 4px 26px 4px 8px; font-size: 12px; border-radius: 5px;
                  background-position: calc(100% - 14px) calc(50% + 1px), calc(100% - 9px) calc(50% + 1px) }
.tz-note { color: var(--ink-3); font-size: 12px }

/* Anchored/heading targets shouldn't land underneath the sticky header. */
:target { scroll-margin-top: 76px }

/* --------------------------------------------------------------------------------------------
   Narrow screens
   The five-column Event row is the thing that cannot survive a phone: it becomes two lines,
   state and name on the first, the facts beneath.
   -------------------------------------------------------------------------------------------- */
@media (max-width: 860px) {
    .ed { grid-template-columns: 100px 1fr; gap: 6px 12px }
    /* The cover column folds away on a phone with everything else; the name takes the room. */
    .rows.with-covers .ed { grid-template-columns: 100px 1fr }
    .rows.with-covers .ed .cover, .rows.with-covers .ed .cover-blank { display: none }
    .ed .meta, .ed .fig { grid-column: 2; text-align: left }
    .ed .fig { color: var(--ink-3) }
}
@media (max-width: 720px) {
    .site-header { height: auto; padding: 12px 18px; flex-wrap: wrap }
    /* The identity only; NOT nav's first link. That used to be "My Events", hidden because the
       brand mark led there too — since the landing page took "/", the brand no longer does, and
       hiding it would leave a phone header with no route into the workspace at all. */
    .identity { display: none }
    .slide-edit { grid-template-columns: 1fr }
    .slide-edit-art { position: static; max-width: 300px }
    .slide-row { grid-template-columns: 44px 1fr; gap: 6px 12px }
    .slide-row-when, .slide-row-status, .slide-row-gaps { grid-column: 2; justify-content: flex-start }
    .landing-hero h1 { font-size: 27px; max-width: none }
    .landing-lead { font-size: 14px }
    main { padding: 26px 18px 70px }
    .event-hero { display: block }
    .event-facts { align-items: flex-start; text-align: left; margin-top: 20px }
    .series-head { grid-template-columns: 1fr; gap: 10px }
    .binding > header { align-items: flex-start; flex-direction: column }
    .binding-actions { flex-direction: row; flex-wrap: wrap }
    form, .field-pair { grid-template-columns: 1fr }
    .wide { grid-column: auto }
    .command-row { grid-template-columns: 1fr }
    .section-heading { flex-wrap: wrap; gap: 10px }
    footer { flex-wrap: wrap; gap: 12px }
    :target { scroll-margin-top: 110px }
}

/* ==== New-version toast (assets/js/version.js) — keep this section LAST in this file ==========
   version.js compares <meta name="ss-version"> against the running server (the X-SceneSat-Version
   header on every response, plus a /api/version poll) and drops this in after a deploy. It never
   reloads on its own: an editor may be holding unsaved work, so the operator decides.
   The class names here are version.js's contract — see reloadButtonClass in layout.php.        */
.version-toast {
    position: fixed;
    right: 20px;
    bottom: 20px;
    z-index: 50; /* above the sticky header (3) */
    display: flex;
    align-items: center;
    gap: 12px;
    max-width: min(28rem, calc(100vw - 40px));
    padding: 11px 11px 11px 16px;
    color: var(--ink);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: 8px;
    /* The one shadow in the file: this floats over content by definition and has to read as
       detached from it. */
    box-shadow: 0 16px 48px #0009;
}
.version-toast__text { font-size: 12.5px; color: var(--ink-2) }
.version-toast .button { flex: none; padding: 7px 12px; font-size: 12.5px }
.version-toast__close {
    flex: none;
    background: none;
    border: 0;
    border-radius: 6px;
    color: var(--ink-3);
    font-size: 17px;
    line-height: 1;
    padding: 4px 7px;
    cursor: pointer;
}
.version-toast__close:hover { color: var(--ink); background: #ffffff12 }
@media (max-width: 720px) {
    .version-toast { right: 14px; left: 14px; bottom: 14px; max-width: none }
}

/* The shared <scenesat-app-menu> (starport's /client/app-menu.js). Its internals sit in a
   shadow root, so the only things that reach them are these custom properties and the parts
   the component exposes. Set nothing and it keeps its own defaults: a neutral grey panel and
   a stock blue accent — a blue that appears nowhere else in Cosmodrome, which is violet and
   cyan throughout. The trigger copies .button.ghost because it stands next to one in the nav. */
.app-switcher {
    --scenesat-menu-bg: var(--surface);
    --scenesat-menu-fg: var(--ink);
    --scenesat-menu-accent: var(--violet);
    --scenesat-menu-border: var(--line);
    --scenesat-menu-hover: #ffffff12;
}
/* Everything .button.ghost has EXCEPT its background. A ::part rule from out here outranks
   the component's own .trigger[aria-expanded="true"] rule, so setting the background would
   freeze the trigger at one shade and kill the open/hover affordance; --scenesat-menu-hover
   drives it instead. Resting is then transparent rather than ghost's fill — against this
   header, a difference nobody can see. */
.app-switcher::part(trigger) {
    border: 1px solid var(--line);
    border-radius: 6px;
    padding: 6px 12px;
    font-weight: 500;
}
.app-switcher::part(panel) { border-radius: 8px }
.app-switcher::part(item) { border-radius: 6px }
