/* Terminal-flavoured, but deliberately restrained: the glow lives on the
   chrome and the big readouts, never on text you have to read or type into. */

:root {
  --bg: hsl(220 25% 7%);
  --panel: hsl(220 22% 10%);
  --line: hsl(220 18% 20%);
  --fg: hsl(210 20% 86%);
  --dim: hsl(215 12% 52%);
  --green: hsl(140 65% 58%);
  --amber: hsl(38 92% 62%);
  --cyan: hsl(190 80% 62%);
  --red: hsl(0 72% 62%);
  /* Neutral surfaces: the inside of a control, and what it does on hover.
     Named rather than written inline so a palette only has to set these two
     and every input, button and dot follows. */
  --well: hsl(220 20% 14%);
  --hover: hsl(220 20% 18%);
  --backdrop: hsl(220 30% 4% / 0.7);
  --radius: 4px;
  --gap: 0.75rem;
  /* The sticky toolbar's height. Overwritten with the measured value on load
     and whenever it changes — see `watchBarHeight` in ui/app.js. This is the
     first-frame fallback, and it is what the set list's height is subtracted
     from. */
  --bar-h: 3.3rem;
  /* The footer, measured in app.js. Zero when it is turned off. */
  --foot-h: 1.9rem;
  /* Set from the text-size slider. Everything else is rem or em, so this one
     number scales the whole app for a stand three meters from the stage. */
  --font-scale: 1;
  font-size: calc(16px * var(--font-scale));
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--fg);
  font-family: 'Fira Code', 'SF Mono', ui-monospace, monospace;
  font-size: calc(14px * var(--font-scale));
  line-height: 1.5;
  /* Checkboxes and sliders are drawn by the browser, so without this they stay
     the platform blue no matter which palette is on — which looked like a bug
     in the settings dialog. Themes redefine --green, so this follows them. */
  accent-color: var(--green);
}

.dim { color: var(--dim); }

/* --- bars --- */
.bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap);
  padding: 0.5rem 1rem;
  border-bottom: 1px solid var(--line);
  background: var(--panel);
}
/* The toolbar holds still. Not decoration: the set list below it is pinned to
   the viewport (see #panel-set), and a bar that scrolled away would leave a
   bar-height strip of nothing above it for the rest of the page. Under the
   dropped context menu and every dialog, which are 60 and up. */
header.bar { position: sticky; top: 0; z-index: 20; }
/* Stuck to the bottom of the window, because in flow it never reached the
   screen: the set-list panel beside it is a full viewport tall, so the footer
   began exactly one screen down and Wes reported he had never seen it.

   `sticky` rather than `fixed`: fixed would take it out of flow and let the
   page scroll underneath it, hiding the last row of anything. Sticky keeps its
   box in the layout, so `--foot-h` (measured in app.js) genuinely reserves the
   space, and `bottom: 0` holds it against the edge. It has to be the LAST
   thing in the document for that to work — a sticky element un-sticks once its
   own place in the flow scrolls into view. */
footer.bar {
  border-bottom: 0;
  border-top: 1px solid var(--line);
  font-size: 12px;
  position: sticky;
  bottom: 0;
  z-index: 30;
  background: var(--panel);
}
footer.bar[hidden] { display: none; }

/* On a phone the key hints wrap to three lines and cost 95px of an 844px
   screen — measured, not guessed — to name shortcuts for a keyboard that is
   not there. The status line beside them is worth keeping (it is where an
   import says how many songs it added), so only the hints go. */
@media (max-width: 640px) {
  #footer-keys { display: none; }
  /* The stand's strip goes with it, for the same reason and at the same width.
     A phone at a music stand has no keyboard to press any of these on, and the
     rail is the narrowest column in the app — three lines naming keys that are
     not there is the most expensive space in the whole layout. */
  .rail-keys { display: none; }
}
.bar-right { display: flex; align-items: center; gap: 0.5rem; }

h1 {
  margin: 0;
  font-size: 15px;
  letter-spacing: 0.22em;
  color: var(--green);
  text-shadow: 0 0 12px color-mix(in srgb, var(--green) 40%, transparent);
}

h2 {
  margin: 0 0 0.5rem;
  font-size: 11px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--dim);
}

/* A heading with a right-justified counterpart on the same line — the set
   list heading carries the service name there in live mode. */
h2.split {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.75rem;
}

/* Inherits the heading's size, tracking and case, so the name reads as part
   of the heading line, not as a control. Only exists in live mode. */
#service-name-live {
  display: none;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-align: right;
  color: var(--fg);
}
body.live #service-name-live { display: block; }

/* --- layout --- */
main {
  flex: 1;
  display: grid;
  grid-template-columns: minmax(190px, 1fr) minmax(340px, 2fr) minmax(220px, 1.2fr);
  /* One row, filling whatever height `flex: 1` handed us — `1fr` is
     `minmax(auto, 1fr)`, so it grows to the window and still refuses to squash
     a panel below its own content. */
  grid-template-rows: 1fr;
  gap: var(--gap);
  padding: var(--gap);
  /* Every panel sits at its natural height. The set list is the one that also
     sticks to the viewport — see #panel-set below. */
  align-items: start;
}

/* THE SET LIST FILLS THE SCREEN AND SCROLLS INSIDE ITSELF.

   Wes, twice. First: "allow the set to span the full vertical height of the
   window it is in rather than only being a certain length and needing to
   scroll within that." Then: "there should still be a scrollable window for
   the set-list. The scrollable section should just take up the whole height of
   the screen where before it did not take up the full height."

   Stretching the panel to the grid row satisfied the first and broke the
   second, because the row is as tall as the SONG EDITOR — a good bit taller
   than a laptop screen — so the bottom of a long set list was off the bottom
   of the page and the way to it was scrolling the whole app. `sticky` is what
   makes "the height of the screen" mean the screen: the panel is bounded by
   the viewport, holds still while the middle column scrolls past it, and the
   list inside it takes every pixel the buttons, the total and the two note
   boxes do not — and scrolls when the songs outrun it.

   `align-self: start` rather than `stretch`, because a grid item stretched to
   the full row height has nothing left to stick to. */
/* THE LIBRARY FILLS THE SCREEN TOO.

   Wes, 2026-08-11: "the library window on the left leaves blank space beneath
   it. Let that expand to fill the window since nothing else is under it."
   Nothing is: it is the only panel in that column, and with `align-items:
   start` above it drew at the height of however many songs there were and left
   the rest of the column empty next to a set list running to the bottom.

   Exactly the treatment `#panel-set` gets below, for exactly the reasons the
   long comment there gives — the two columns are now the same shape, both
   pinned to the viewport with the song editor scrolling between them. */
#panel-library {
  align-self: start;
  position: sticky;
  top: calc(var(--bar-h) + var(--gap));
  height: calc(100vh - var(--bar-h) - var(--foot-h) - var(--gap) * 2);
  height: calc(100dvh - var(--bar-h) - var(--foot-h) - var(--gap) * 2);
}
#song-list {
  /* Basis 0 for the same reason `#entry-list` has it: take what the search bar
     and the "+ new song" button leave, rather than negotiating down from the
     height of every song in the library. */
  flex: 1 1 0;
  /* Beats `.list`'s 42vh on specificity — an id against a class. This list is
     measured against its panel now, not against a fraction of the window. */
  max-height: none;
  min-height: 6rem;
}

#panel-set {
  align-self: start;
  position: sticky;
  top: calc(var(--bar-h) + var(--gap));
  /* `height`, not `max-height`: "the scrollable section should just take up
     the whole height of the screen." A max only caps a panel that is already
     content-sized, so a six-song set would sit in a short box again with the
     screen empty under it — which is the half of this he asked for first.

     `--bar-h` is the toolbar, measured in app.js rather than guessed: it holds
     five controls that scale with the text-size slider and wrap onto a second
     line under 480px, so its height is three different numbers on three of
     Wes's devices. The fallback is only ever used for the frame before the
     first measurement, and being a little short for one frame is invisible
     where being a little tall is a note box cut off by the bottom of the
     screen.

     dvh, so the iPhone's collapsing address bar does not leave the bottom of
     the set list under it. vh first for anything that has never heard of it. */
  height: calc(100vh - var(--bar-h) - var(--foot-h) - var(--gap) * 2);
  height: calc(100dvh - var(--bar-h) - var(--foot-h) - var(--gap) * 2);
}
#entry-list {
  /* Basis 0, not auto. With `auto` the list negotiates down from the height of
     all its songs, and the shrink it needs gets shared with the note boxes —
     which cannot shrink, because a textarea's automatic minimum size is its
     own content. The leftover lands outside the panel and the bottom note box
     is cut off by the edge of the screen. From 0 the list simply takes what is
     left after everything else has had its say, which is the whole idea. */
  flex: 1 1 0;
  /* Beats `.list`'s 42vh and live mode's 60vh on specificity — an id against a
     class — which is the whole point: this list is measured against its panel
     now, not against a fraction of the window. */
  max-height: none;
  /* A floor, so an empty set list is still an obvious drop target rather than
     a hairline between the heading and the buttons. */
  min-height: 6rem;
}

.panel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--gap);
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-width: 0;
}

/* --- controls: one instrument panel, so everything is the same size --- */
button, input, select, textarea {
  font: inherit;
  color: var(--fg);
  background: var(--well);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.35rem 0.55rem;
  min-height: 2.15rem;
}
/* ...except a checkbox, which is not an instrument-panel control and must not
   be sized like one.

   Wes sent a screenshot of "tempo counts the dotted beat" with the tick sitting
   a line above its own words. The cause is the rule directly above: `min-height:
   2.15rem` applies to `input[type=checkbox]` as much as to a text field, so the
   box was 13px of tick inside 34px of layout. A browser draws that tick at the
   TOP of the box it was given, while `align-items: center` centers the words on
   the whole 34px — so the two are nine pixels apart, in every inline checkbox in
   the app, and no amount of aligning the label fixes it. Measured in chromium
   before and after. */
input[type='checkbox'], input[type='radio'] {
  min-height: 0;
  padding: 0;
  margin: 0;
  flex: none;
}
/* A native select sizes its own box from the font and ignores most of the
   padding, so it came out visibly shorter than the inputs beside it. Dropping
   the native appearance and drawing the arrow here makes a select exactly the
   same box as an input — the "ramp out" row lines up, and so does every other
   dropdown in the app. */
select {
  appearance: none;
  -webkit-appearance: none;
  height: 2.15rem;
  padding-right: 1.6rem;
  background-image: linear-gradient(45deg, transparent 50%, var(--dim) 50%),
                    linear-gradient(135deg, var(--dim) 50%, transparent 50%);
  background-position: right 0.75rem center, right 0.5rem center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}
select option { background: var(--panel); color: var(--fg); }
input:not([type='range']):not([type='checkbox']) { height: 2.15rem; }
textarea { resize: vertical; line-height: 1.45; height: auto; }
button {
  cursor: pointer;
  /* Without this, dragging a few pixels across a row of buttons selects their
     labels as text and swallows the click that should have followed. */
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
}
button:hover:not(:disabled) { border-color: var(--dim); background: var(--hover); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
:focus-visible { outline: 2px solid var(--cyan); outline-offset: 1px; }

button.ghost { background: none; padding: 0.25rem 0.5rem; font-size: 12px; }
/* A link that reads as a ghost button — sign-in is a navigation, not an action.
   A <label> wrapping a hidden file input wears the same clothes: it is not an
   action either, it is the gesture that opens the operating system's picker. */
a.button-link, label.button-link {
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.25rem 0.5rem;
  min-height: 2.15rem;
  box-sizing: border-box;
  font-size: 12px;
  color: var(--fg);
  text-decoration: none;
  user-select: none;
  -webkit-user-select: none;
}
a.button-link:hover, label.button-link:hover { border-color: var(--dim); background: var(--hover); }
label.button-link { cursor: pointer; }
/* display:inline-flex beats the [hidden] attribute's UA display:none, so the
   nav sign-in link would show while signed in without this — the same trap the
   .field[hidden] rule exists for. */
a.button-link[hidden] { display: none; }
label.button-link[hidden] { display: none; }
label.file { display: inline-flex; align-items: center; cursor: pointer; }
button.wide { width: 100%; }

/* --- library / set list --- */
.list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 42vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.list.ordered { counter-reset: item; }

.list li {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.3rem 0.45rem;
  border: 1px solid transparent;
  border-radius: var(--radius);
  cursor: pointer;
  /* Rows are dragged and shift-clicked; a drag that also sweeps a blue text
     selection across the list reads as a mistake. Wes asked for this. */
  user-select: none;
  -webkit-user-select: none;
}
.list li:hover { background: var(--well); }
.list li.active { border-color: var(--green); background: color-mix(in srgb, var(--green) 18%, var(--panel)); }
.list li .row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.list li .row-top { display: flex; align-items: baseline; gap: 0.4rem; }
.list li .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list li .tempo { color: var(--dim); font-size: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; }
/* The time signature on a library row, beside the tempo. Same weight as the
   tempo because they are one reading in two halves, and tabular so a column
   of 4/4 and 12/8 does not jitter. Optional — see LIBRARY_FIELDS. */
.list li .meter {
  color: var(--dim);
  font-size: 12px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  opacity: 0.8;
}
/* The song's key on a set-list row. In the accent color rather than dim,
   because on a stage it is the one thing on that row you look for and the
   tempo beside it is already reference material. Toggled in settings. */
.list li .song-key {
  color: var(--cyan);
  font-size: 12px;
  white-space: nowrap;
  letter-spacing: 0.04em;
}
/* "No click on this one today" — a per-set mute, not a song setting. Boxed
   rather than colored text like the key beside it, because it contradicts the
   tempo printed next to it and a badge reads as a stamp on the row where plain
   colored text reads as one more piece of the song's data. Red, dimmed: it is
   a deliberate choice somebody made, not a fault. */
.no-click-chip,
.rail-list li .no-click-chip {
  color: var(--red);
  font-size: 11px;
  white-space: nowrap;
  letter-spacing: 0.02em;
  border: 1px solid color-mix(in srgb, var(--red) 45%, transparent);
  border-radius: var(--radius);
  padding: 0 0.3rem;
  background: color-mix(in srgb, var(--red) 12%, transparent);
}

/* An imported song nobody has confirmed yet. The numbers themselves go amber
   rather than a badge appearing beside them, because it is precisely those
   numbers that are a guess: an import always produces a tempo and a meter
   whether or not the plan carried either, and 120 · 4/4 looks identical
   whether somebody chose it or nobody did. Turned off in
   settings → display. See `song.confirmed`. */
.list li .tempo.unconfirmed,
.rail-list li .tempo.unconfirmed {
  color: var(--amber);
  border-bottom: 1px dotted color-mix(in srgb, var(--amber) 60%, transparent);
}

/* How long the song runs, when the row is set to show it. */
.list li .row-len { font-size: 11px; white-space: nowrap; font-variant-numeric: tabular-nums; }

/* Whether the click cuts off at the end of the song. Both states draw: from
   behind a kit "it will keep going" is as much a thing to know before the last
   bar as "it will stop", and a badge that only sometimes appears makes its
   absence ambiguous with the setting being off. Dim for the ordinary case,
   amber for the one that outlives the song. */
.stop-chip {
  font-size: 11px;
  white-space: nowrap;
  color: var(--dim);
  letter-spacing: 0.02em;
}
.stop-chip.runs-on { color: var(--amber); }

/* Notes on the row: two lines at most, so one wordy song can't push the rest
   of the set off the screen. */
.list li .sub {
  font-size: 11px;
  line-height: 1.35;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.list li .note { color: var(--amber); opacity: 0.8; }

/* --- drag to reorder --- */
.list li[draggable='true'] { cursor: grab; }
.list li .grip { cursor: grab; font-size: 11px; letter-spacing: -1px; user-select: none; }
.list li.dragging { opacity: 0.4; cursor: grabbing; }
.list li.drop-before { box-shadow: inset 0 2px 0 var(--cyan); }
.list li.drop-after { box-shadow: inset 0 -2px 0 var(--cyan); }
.list li .row-btn {
  min-height: auto;
  padding: 0 0.3rem;
  background: none;
  border: none;
  color: var(--dim);
  font-size: 12px;
}
.list li .row-btn:hover { color: var(--red); background: none; }

/* Version groups: one row per title, opening into its arrangements. The
   indent and the rule down the left are what stop the open versions reading as
   three more unrelated songs in the list. */
/* Sized and boxed rather than left to the glyph: at 11px the triangle rendered
   small enough to read as a stray comma beside the title. */
.list li .twisty {
  width: 1em;
  flex: none;
  text-align: center;
  font-size: 13px;
  line-height: 1;
  user-select: none;
}
.list li.group-head .name { color: var(--fg); }
.list li.version-row {
  margin-left: 1.1rem;
  border-left: 2px solid color-mix(in srgb, var(--green) 40%, transparent);
}
.list li.version-row .name { color: var(--dim); font-size: 13px; }
.list.ordered li::before {
  counter-increment: item;
  content: counter(item);
  color: var(--dim);
  font-size: 11px;
  min-width: 1.2em;
}
.empty { padding: 0.5rem; color: var(--dim); font-size: 12px; }

/* --- transport --- */
.now-playing { display: flex; flex-direction: column; }
#now-name { font-size: 15px; color: var(--cyan); }
/* A song that has never been named, and so is not in the library. Dim, so the
   words "unnamed song" cannot be mistaken for a song called that. */
#now-name.unnamed { color: var(--dim); }
#now-meta, .now-line { font-size: 12px; }
/* The two double-click targets in the now-playing block. Dotted underline on
   hover rather than a button: it is text that happens to be editable, and it
   is read far more often than it is edited. */
#now-name, #now-artist { cursor: text; }
#now-name:hover, #now-artist:hover { text-decoration: underline dotted; }

/* An input standing in for the text it is over. It inherits the size and the
   color of what it replaced, so the number does not jump when you start
   typing on it. */
.inline-edit {
  font: inherit;
  color: var(--fg);
  background: var(--well);
  border: 1px solid var(--cyan);
  border-radius: var(--radius);
  padding: 0 0.2em;
  width: 100%;
  max-width: 100%;
  text-align: inherit;
}
/* Editing the tempo happens *in* the big number, not in a small box beside
   where it was — same size, same amber, same tabular digits, with only a
   baseline underline to say it is now a field. Wes: "it should edit with the
   same theme it is in rather than reverting to a tiny text box". */
#bpm + .inline-edit {
  width: 4ch;
  text-align: center;
  font-size: clamp(2.6rem, 9vw, 4.2rem);
  line-height: 1.05;
  /* The shared control rules force every input to a 2.15rem box; without
     clearing both the big amber digits are cropped to a letterbox slot (Wes:
     "a tiny text window much shorter than the height of the text"). */
  height: auto;
  min-height: 0;
  color: var(--amber);
  font-variant-numeric: tabular-nums;
  background: transparent;
  border: none;
  border-bottom: 2px solid var(--cyan);
  border-radius: 0;
  padding: 0;
  outline: none;
  -moz-appearance: textfield;
  appearance: textfield;
}
#bpm + .inline-edit::-webkit-outer-spin-button,
#bpm + .inline-edit::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
/* The name and artist editors inherit the size and color of the line they
   replace, so typing over them doesn't jump the layout. */
#now-name + .inline-edit { font-size: 15px; color: var(--cyan); }

/* A row picked out for a bulk delete, and only ever drawn when two or more
   rows are — see `isSelected`. Distinct from .active, which means "this is the
   song that is loaded"; on a stage those two must never be confused, so active
   keeps the green outline and this is an outline in the accent color rather
   than the fill it used to be. The fill read as a second "you are here". */
.list li.selected {
  background: color-mix(in srgb, var(--cyan) 10%, var(--panel));
  border-color: color-mix(in srgb, var(--cyan) 55%, transparent);
}
.list li.selected.active { border-color: var(--green); }

/* The artist under a library title. Clipped rather than wrapped: a second
   line per row halves how much of the library fits on a phone. */
.list .artist {
  display: block;
  max-width: 14ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The whole tempo block, with nothing left under it. The bottom padding is
   gone: the time signature no longer hangs below the word "bpm" adding its own
   line to the bottom of this row, so the beat dots and the transport come up
   to meet the number — the second half of what Wes asked for. */
.tempo-display {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.35rem 0 0;
}
/* `stretch`, so the unit column is as tall as the digits and can push its two
   lines to opposite ends of them. */
.bpm { display: flex; align-items: stretch; gap: 0.35rem; min-width: 6.5ch; justify-content: center; }
#bpm {
  font-size: clamp(2.6rem, 9vw, 4.2rem);
  line-height: 1;
  color: var(--amber);
  /* Toned down from 22px/35%: at 4.2rem the wider halo bled into the nudge
     buttons either side and softened the digits themselves. Wes asked for less. */
  text-shadow: 0 0 10px color-mix(in srgb, var(--amber) 20%, transparent);
  font-variant-numeric: tabular-nums;
}
/* Wes drew this: "4/4" level with the TOP of the number and "bpm" level with
   its baseline, the two of them at opposite ends of the digits rather than
   sitting together under them. `space-between` over the full height of the
   column is what spreads them.

   The two nudges are not symmetric on purpose. The signature is pushed down a
   hair because a 12px cap-height starts below the top of its own line box,
   while the digits at `line-height: 1` start at the very top of theirs; the
   word is pulled up because it would otherwise sit on the bottom of its
   descender box rather than on the number's baseline. */
.unit {
  color: var(--dim);
  font-size: 12px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: space-between;
  line-height: 1.2;
}
.unit .sig { margin-top: 0.12em; }
.unit .unit-word { margin-bottom: 0.2rem; }
.nudge { min-width: 2rem; color: var(--dim); }

.beats {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  justify-content: center;
  min-height: 1.5rem;
}
.beat-dot {
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  border: 1px solid var(--line);
  background: var(--well);
  transition: background 90ms linear, box-shadow 90ms linear;
}
.beat-dot.accent { border-color: var(--amber); }
.beat-dot.on { background: var(--green); box-shadow: 0 0 14px var(--green); }
.beat-dot.accent.on { background: var(--amber); box-shadow: 0 0 16px var(--amber); }

.transport { display: flex; gap: 0.5rem; justify-content: center; }
.ctl { min-width: 4rem; }
.ctl.play { min-width: 7rem; color: var(--green); border-color: color-mix(in srgb, var(--green) 55%, var(--line)); }
.ctl.play.playing { color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, var(--line)); }
.position { text-align: center; font-size: 12px; font-variant-numeric: tabular-nums; }

/* The click is running and nothing is coming out. Red rather than amber: this
   is not information, it is the service currently failing. No `display` rule,
   deliberately, so the `hidden` attribute is enough to put it away — see the
   contract test that sweeps for exactly that mistake. */
.audio-stall {
  margin: 0.4rem 0 0;
  text-align: center;
  font-size: 12px;
  line-height: 1.4;
  cursor: pointer;
  color: var(--red);
  border: 1px solid color-mix(in srgb, var(--red) 55%, var(--line));
  padding: 0.35rem 0.5rem;
  background: color-mix(in srgb, var(--red) 12%, transparent);
}

/* The diagnostics readout. Monospace and pre-wrapped because it is meant to be
   read as a column of numbers and pasted into a message. */
.audio-health {
  margin: 0.4rem 0 0;
  font-size: 11px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-word;
  font-variant-numeric: tabular-nums;
}

/* --- settings form --- */
.settings {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 0.5rem;
  border-top: 1px solid var(--line);
  padding-top: var(--gap);
}
/* display:grid beats the hidden attribute. Both the "your version" and team
   blocks are .settings toggled with `hidden`, so without this they drew as
   empty bordered boxes on a deployment that doesn't bill — which is Wes's
   own tailnet copy, the one that must never mention money. */
.settings[hidden] { display: none; }
.settings label {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
  min-width: 0;
}
/* `inline` means "checkbox and its words on one line", and it has to mean that
   everywhere. It used to be defined only under `.settings` and `.mix-head`, so
   the same class on the Planning Center row silently did nothing: the label
   stayed `display: inline`, the checkbox baseline-aligned, and the tick sat a
   line above its own text. Defined here it applies wherever it is written. */
label.inline { display: flex; flex-direction: row; align-items: center; gap: 0.4rem; text-transform: none; }
.settings label.inline { flex-direction: row; align-items: center; gap: 0.4rem; text-transform: none; }
.settings label input:not([type='checkbox']),
.settings label select,
.settings label textarea { color: var(--fg); text-transform: none; letter-spacing: normal; }
.full { grid-column: 1 / -1; }
/* Every control in a settings row lines up along the bottom, whatever its
   label does. Wes sent a screenshot of "voice pitch (hz)" wrapping to two
   lines and dragging its own box a line lower than the two dropdowns beside
   it — a grid cell stretches to the tallest row, so the fix is to push the
   control to the foot of the cell rather than to shorten the words.

   Not on an inline label, where the "control" is a checkbox sitting beside its
   own words rather than under them: an auto margin beats `align-items`, so the
   box was dropped to the bottom of the row and the words it belongs to were
   left floating a line above it. */
.settings label:not(.inline) > :last-child { margin-top: auto; }
/* A line of explanation between a caption and its control. Prose, so it opts
   out of the label's uppercasing — and `display: block` on it would beat the
   hidden attribute, hence the guard, the same trap .field and a.button-link
   were both bitten by. */
.sub-hint {
  font-size: 10px;
  line-height: 1.3;
  color: var(--amber);
  text-transform: none;
  letter-spacing: normal;
}
.sub-hint[hidden] { display: none; }

/* A named block inside a fold, for the parts Wes asked to be folded together —
   recording the count under "sound & spoken count", recording the phrases
   under "spoken cues in this song". A rule above it does the work a separate
   <summary> used to. */
.fold-head {
  margin: 0.9rem 0 0;
  padding-top: 0.6rem;
  border-top: 1px solid var(--line);
  font-size: 11px;
  font-weight: normal;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--dim);
}

/* --- sound & spoken count --- */
/* Collapsed by default: it is set-up-once territory, not something you touch
   mid-service, so it should not push the song settings down the page. */
.sound {
  margin-top: var(--gap);
  border-top: 1px solid var(--line);
  padding-top: var(--gap);
}
.sound > summary {
  cursor: pointer;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
  list-style: none;
}
.sound > summary::-webkit-details-marker { display: none; }
.sound > summary::before { content: '▸ '; color: var(--cyan); }
.sound[open] > summary::before { content: '▾ '; }
.sound > summary:hover { color: var(--fg); }
.sound .settings { border-top: 0; }
.sound input[type='range'] { accent-color: var(--cyan); padding: 0; }
.sound label:has(input:disabled) { opacity: 0.45; }

/* --- recording your own count --- */
.hint { font-size: 11px; line-height: 1.5; margin: 0.5rem 0; }
.pack-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.pack-head input { flex: 1; min-width: 0; }
.pack-head span { font-size: 11px; white-space: nowrap; }

/* Two columns of six, so all twelve numbers are visible without scrolling. */
.pack-grid {
  display: grid;
  /* Wide enough that the nudge and level sliders have usable travel once each
     carries a label; still two columns on a laptop, one on a phone. */
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 0.25rem;
}
.pack-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.15rem;
  padding: 0.15rem 0.35rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
/* The nudge and the level each take a whole line of the row, so the sliders
   get the width. Two stacked sliders are only telling apart by their labels
   and readouts, which is why both carry one. */
.pack-nudge {
  flex: 1 0 100%;
  display: flex;
  align-items: center;
  gap: 0.35rem;
  min-width: 0;
}
.pack-slider-label {
  flex: 0 0 auto;
  font-size: 10px;
  letter-spacing: 0.02em;
}
/* No center tick: a range input's background is covered by the native track,
   and at zero the thumb sits on dead center anyway — which is the "on the
   beat" cue, and the readout says it in words besides. */
.pack-nudge .nudge, .pack-nudge .trim { flex: 1; min-width: 0; height: 1.1rem; }
.pack-nudge .nudge-val, .pack-nudge .trim-val {
  min-height: auto;
  padding: 0 0.2rem;
  min-width: 5.2rem;
  background: none;
  border: none;
  color: var(--amber);
  font-size: 11px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.pack-nudge .nudge-val:hover:not(:disabled), .pack-nudge .trim-val:hover:not(:disabled) {
  color: var(--fg);
  background: none;
}
.pack-nudge .nudge-val:disabled, .pack-nudge .trim-val:disabled {
  opacity: 0.45;
  color: var(--dim);
  cursor: default;
}
/* The level reads in cyan against the nudge's amber. Two stacked sliders in
   one color are one control drawn twice; the readouts are the fastest thing
   on the row to tell them apart by. */
.pack-nudge .trim-val { color: var(--cyan); }
/* The row's second slider is the level, and the trim class rides on top of
   .pack-nudge only so the layout is shared — nothing else distinguishes it. */
.pack-trim { margin-top: -0.1rem; }
.pack-row.has-clip { border-color: var(--green); }
.pack-row .num { min-width: 1.4em; font-variant-numeric: tabular-nums; }
.pack-row .len { flex: 1; font-size: 11px; text-align: right; padding-right: 0.2rem; }
.pack-row .row-btn {
  min-height: auto;
  padding: 0 0.25rem;
  background: none;
  border: none;
  color: var(--dim);
  font-size: 12px;
}
.pack-row .row-btn:hover:not(:disabled) { color: var(--fg); background: none; }
.pack-row .row-btn:disabled { opacity: 0.3; cursor: default; }
/* Recording in progress: the one control that must be unmistakable. */
.pack-row .rec.on { color: var(--red); animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0.25; } }
.pack-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; flex-wrap: wrap; }
/* Cue rows carry a phrase rather than a single digit, so they want more room
   than a count-in row before they wrap to one column. */
.cue-pack-grid { grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); }
.pack-row .cue-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pack-row .cue-name + .len { flex: 0 0 auto; }
/* A button that reads as a link — the way out of a warning line, inline in it. */
.linkish {
  min-height: auto;
  padding: 0;
  background: none;
  border: none;
  color: inherit;
  font-size: inherit;
  text-decoration: underline;
}
.linkish:hover { background: none; color: var(--fg); }

.ts { display: flex; align-items: center; gap: 0.25rem; }
.ts input { width: 3.5rem; }
.slash { color: var(--dim); }

.accents { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.accents button {
  min-width: 2.2rem;
  min-height: 2rem;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}
.accents button.on { color: var(--amber); border-color: var(--amber); background: color-mix(in srgb, var(--amber) 20%, var(--panel)); }

fieldset.ramp { border: 1px solid var(--line); border-radius: var(--radius); padding: 0.5rem; margin: 0; }
fieldset.ramp legend { padding: 0 0.3rem; font-size: 11px; }
.ramp-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 0.5rem; }
.ramp-fields[hidden] { display: none; }
/* With the ramp off there is nothing under the legend, and the fieldset's own
   padding left an empty box sitting there looking broken. */
fieldset.ramp:has(.ramp-fields[hidden]) { padding-bottom: 0; border-color: transparent; }

.errors { margin: 0; color: var(--red); font-size: 12px; min-height: 1em; }
.errors:empty { display: none; }

.stack { display: flex; flex-direction: column; gap: 0.2rem; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim); }
.stack textarea { text-transform: none; letter-spacing: normal; color: var(--fg); }
/* A box that is one line high until there is something in it worth more —
   `autogrow()` in app.js sets the height from the content's own scrollHeight.
   `resize: none` because a hand-dragged height would be overwritten by the
   next keystroke, and `overflow-y: hidden` so the measurement never has to
   account for a scrollbar that only exists for an instant. */
textarea.autogrow { resize: none; overflow-y: hidden; }
.total { text-align: right; font-size: 12px; }

/* --- help --- */
dialog {
  background: var(--panel);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  max-width: 30rem;
}
dialog::backdrop { background: var(--backdrop); }
.shortcuts { list-style: none; margin: 0 0 1rem; padding: 0; }
.shortcuts li { display: flex; justify-content: space-between; gap: 1rem; padding: 0.15rem 0; }
.shortcuts kbd {
  background: var(--hover);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0 0.35rem;
  color: var(--cyan);
  font-size: 12px;
}

/* --- field groups that are not <label>s ---
   A <label> forwards its click to the first labelable thing inside it, which
   makes it wrong for any group containing buttons. These carry the same look
   without that behavior. */
.field { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
/* `display: flex` beats the hidden attribute, so a .field the app hides —
   the account panel swaps whole blocks — would stay visible without this. */
.field[hidden] { display: none; }
.field .cap {
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
}

/* --- lists of people: the admin roster, and the team's members and invites.
   One rule set for both, because they are the same object drawn twice; the
   class is global rather than scoped to the admin panel so the team lists
   don't fall back to an unstyled inline list. --- */
.admin-roster,
.roster {
  list-style: none;
  margin: 0.4rem 0 0;
  padding: 0;
  max-height: 14rem;
  overflow-y: auto;
}
.admin-roster li,
.roster li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 0.35rem 0;
  border-bottom: 1px solid var(--line);
}
.admin-roster li.empty,
.roster li.empty { border-bottom: 0; color: var(--dim); }
/* `flex: 1` rather than leaning on the row's space-between: a row with TWO
   buttons on it (hand over, remove) would otherwise have the first one land
   wherever the address beside it happened to end, so the same button sits at
   a different place on every row. Taking the slack here pushes both against
   the right edge, where they line up with each other and with the single
   control on the rows that have one. */
.admin-roster .who,
.roster .who { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; flex: 1; }
.admin-roster .who .hint,
.roster .who .hint { margin: 0; }
.admin-roster .email,
.roster .email { overflow-wrap: anywhere; }
.admin-roster .plan-admin,
.roster .plan-admin {
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cyan);
}
/* The grant box: the email input takes the room the select and button leave. */
#admin-email, #admin-team-email { flex: 1; min-width: 10rem; }

/* --- the admin management panel (settings → admin) --- */
/* A price is a short number and must not draw as a full-width text field
   beside a one-character "$". The row is the pair, not the box. */
.price-box { width: 5rem; flex: none; }
/* The family headings ("one-off licenses", "yearly subscriptions") separate
   two groups of otherwise identical rows, so they carry the rule rather than
   a margin — a gap alone reads as accidental spacing. */
.price-family {
  border-top: 1px solid var(--line);
  padding-top: 0.5rem;
  margin-top: 0.2rem;
}
.price-family:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
/* Accounts and licenses carry more per row than the plain roster does, so
   they get the height rather than scrolling four rows at a time. */
#admin-accounts, #admin-licenses { max-height: 20rem; }
/* The license rows take real money back, so the two destructive actions —
   refund, end now — wear the red the rest of the app reserves for them, and an
   action that no longer applies is grayed out in place rather than dropped.
   Kept in place rather than sunk to the bottom because this list is a receipt
   book: it reads newest-first by date, and re-sorting it by what can still be
   done to a row would put a refund somewhere nobody would look for it. */
/* A floor, not a fixed width: enough for "stop renewing" and "end now" side by
   side, so the badge to their left lines up down the column whatever a row's
   own controls are. */
#admin-licenses .nw { white-space: nowrap; }
#admin-licenses .acts {
  display: flex;
  gap: 0.4rem;
  justify-content: flex-end;
  flex: 0 0 auto;
  min-width: 11.5rem;
}
#admin-licenses .ghost.danger { color: var(--red); }
#admin-licenses .ghost.danger:hover:not(:disabled) { border-color: var(--red); }
#admin-licenses .ghost:disabled {
  color: var(--dim);
  border-color: var(--line);
  cursor: not-allowed;
}
/* A refunded row reads as spent — the detail line already says so in words,
   and dimming it lets a live sale stand out beside it at a glance. A class set
   by the renderer rather than `:has()`, so this does not depend on a selector
   that is younger than some of the browsers Wes points at his own pages. */
#admin-licenses li.refunded .who { opacity: 0.55; }
/* On a phone the badge column is not worth starving the address for: the
   11.5rem floor leaves `.who` so narrow that an email breaks mid-word and the
   two-button rows are pushed off the edge. Below 620px the row stacks and the
   actions take a line of their own, right-aligned under the address. */
@media (max-width: 620px) {
  #admin-licenses li { flex-wrap: wrap; }
  #admin-licenses .acts { min-width: 0; width: 100%; }
}
/* An address is one long unbreakable-looking token, and the default break is
   mid-word. Breaking at the @ or a dot reads as an address still. */
#admin-licenses .email { overflow-wrap: anywhere; word-break: break-word; }
/* Two selects on a row need to stay legible without pushing the email off
   the screen on a phone. */
#admin-accounts select { font-size: 11px; }

/* --- earlier versions of the library (settings → general → account) --- */
/* Two lines a row plus a button, and the thinning schedule can leave ~80 of
   them, so the plain 14rem roster box cut the fifth row in half — which reads
   as a rendering fault rather than as "there is more, scroll". */
#history-list { max-height: 20rem; }
/* The one sentence that has to stay: the button says "restore", and what
   restoring does to the songs added since is not guessable from the word. */
#history-details > .hint { margin: 0.4rem 0 0; }
#history-details .rev-when { display: flex; align-items: baseline; gap: 0.4rem; }
/* On a phone the summary line wraps and squeezed "restore +21 songs" onto two
   lines. The button is the fixed thing on the row; the words beside it are
   what gives way. */
#history-list button { white-space: nowrap; flex: none; }
/* Two buttons on a row now — download and restore. They wrap onto a second
   line rather than squeezing the date and song count out of the row, and stay
   right-justified when they do, so the column of restores still reads as a
   column on a 390px phone. */
#history-list .rev-acts {
  display: flex;
  flex: none;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: center;
  gap: 0.3rem;
}
/* The live row's "no restore here" dash now sits beside a button rather than
   alone in the column, and butted up against one it reads as punctuation on
   the label instead of as the empty slot it is. */
#history-list .rev-acts > .dim { padding: 0 0.4rem; }

/* --- browsing a nightly capture (settings → admin → backups) --- */
/* The same list furniture as the revision history, and for the same reason:
   it is the same object — a copy of the library, with a date on it and one
   button. Only the parts that differ are stated here. */
#captures-list, #capture-contents { max-height: 18rem; }
#captures-list button, #capture-contents button { white-space: nowrap; flex: none; }
/* An account's row carries a line per collection under the address, so the
   row is a column rather than the roster's single flex line. */
#capture-contents li { flex-direction: column; align-items: stretch; gap: 0.25rem; }
/* The line naming the open capture, so the rows under it are not an unlabeled
   list of song counts once the capped list above has scrolled out of view. */
#capture-contents .capture-head .rev-when { color: var(--cyan); }
/* Each collection is its own line: what it held on the left, its button hard
   right, indented so the lines read as belonging to the address above them. */
#capture-contents .capture-kind {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding-left: 0.8rem;
}
#captures-list .rev-acts, #capture-contents .rev-acts {
  display: flex;
  flex: none;
  justify-content: flex-end;
  align-items: center;
  gap: 0.3rem;
}
#captures-list .rev-when { display: flex; align-items: baseline; gap: 0.4rem; }
/* Nothing to browse until it is asked for: the list is empty markup at boot
   and must not leave a gap under the button. */
#captures-list:empty, #capture-contents:empty { margin: 0; }

/* --- teams (settings → general) --- */
/* The add-by-email box and the minted invite link both take the room the
   buttons beside them leave, the same way the grant box does. */
#team-add-email, #team-rename { flex: 1; min-width: 10rem; }
#team-invite-link { flex: 1; min-width: 12rem; }
/* An invite link is long and must not push the row wider than the dialog. */
#team-invite-row { flex-wrap: wrap; min-width: 0; }

/* --- links --- */
.links { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.35rem; }
/* Stops "+ add link" stretching the full panel width and reading as the
   primary action of the whole page. */
#btn-add-link { align-self: flex-start; }
.link-row { display: flex; gap: 0.25rem; align-items: center; }
.link-row input:first-child { width: 7rem; flex: none; }
.link-row input:nth-child(2) { flex: 1; min-width: 0; }
.link-row .row-btn {
  min-height: auto;
  padding: 0 0.3rem;
  background: none;
  border: none;
  color: var(--dim);
}
.link-row .row-btn:hover:not(:disabled) { color: var(--fg); background: none; }

/* --- click mixer --- */
.mix-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin: 0.5rem 0; }
.mix-head label.inline {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
}
/* A click voice, drawn as a channel strip on the same console as everything
   else — see .mixer-strip below, which this shares wholesale. Wes: "combine the
   click mixer with the general mixer... They should have the fader sort of
   graphic and also implement the volume level bar that is in the mixer."

   A little wider than a channel because a voice carries the name of the sound
   it plays, and "Pro Tools" clipped to "Pro To" is not a name. */
.click-voice { width: 4.6rem; }
/* A song that has its own sound gets a mark, so "why is this one different"
   has an answer you can see. */
.click-voice.overridden { border-color: var(--amber); }

/* The level fader, drawn rather than native: a vertical slot with rule marks
   and a console-style cap with a line across the grip. Every fader on the
   console wears it — the channels, master and the click voices — because two
   fader graphics in one panel is exactly the split this work removed. */
.mixer-fader input[type="range"] {
  appearance: none;
  -webkit-appearance: none;
  /* `writing-mode: vertical-lr` with `direction: rtl` is the standard way to
     get a vertical range with its minimum at the bottom; the old
     `-webkit-appearance: slider-vertical` is gone from current browsers.
     Falling back to a horizontal slider in anything that does not support it
     is a degradation, not a break — it still sets the level. */
  writing-mode: vertical-lr;
  direction: rtl;
  width: 2.2rem;
  /* The column decides the height, so one number moves every fader on the
     console and the meter beside it stays exactly as tall. */
  height: 100%;
  min-height: 0;
  touch-action: none;
  padding: 0;
  margin: 0;
  border: 0;
  background:
    /* the slot */
    linear-gradient(to right,
      transparent calc(50% - 2px),
      hsl(0 0% 0% / 0.55) calc(50% - 2px) calc(50% + 2px),
      transparent calc(50% + 2px)),
    /* rule marks either side of it, every 10% of travel */
    repeating-linear-gradient(to bottom,
      color-mix(in srgb, var(--dim) 55%, transparent) 0 1px,
      transparent 1px calc(10% - 0px));
  background-size: 100% 100%, 60% calc(100% - 0.9rem);
  background-position: center, center;
  background-repeat: no-repeat;
}
.mixer-fader input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 2rem;
  height: 0.9rem;
  border: 1px solid hsl(0 0% 0% / 0.7);
  border-radius: 2px;
  cursor: ns-resize;
  /* A ridged cap with the position line across the middle of the grip. */
  background: linear-gradient(to bottom,
    hsl(0 0% 34%), hsl(0 0% 16%) 42%,
    var(--fg) 47% 53%,
    hsl(0 0% 16%) 58%, hsl(0 0% 8%));
  box-shadow: 0 1px 3px hsl(0 0% 0% / 0.6);
}
.mixer-fader input[type="range"]::-moz-range-thumb {
  width: 2rem;
  height: 0.9rem;
  border: 1px solid hsl(0 0% 0% / 0.7);
  border-radius: 2px;
  cursor: ns-resize;
  background: linear-gradient(to bottom,
    hsl(0 0% 34%), hsl(0 0% 16%) 42%,
    var(--fg) 47% 53%,
    hsl(0 0% 16%) 58%, hsl(0 0% 8%));
  box-shadow: 0 1px 3px hsl(0 0% 0% / 0.6);
}
.mixer-fader input[type="range"]::-moz-range-track { background: none; }

/* The small block under the fader, holding the sound picker. */
.click-voice .mix-mini {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.1rem;
  width: 100%;
}

/* A whole click sound under one name. The picker takes the room it needs —
   "Pro Tools marimba" clipped to "Pro Tools m" is two presets that read the
   same. */
.preset-row {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0.5rem 0;
  flex-wrap: wrap;
}
.preset-row select { flex: 1 1 10rem; min-width: 0; }
.click-voice .row-btn { min-height: auto; padding: 0 0.3rem; background: none; border: none; color: var(--dim); }
.click-voice .row-btn:hover { color: var(--fg); background: none; }
/* The tone picker. It is a button carrying the current
   sound's name and opening the browser, not a dropdown: with eighteen sample
   packs behind it, a list of names you cannot hear is not a way to choose. */
.click-voice .tone-row { display: flex; width: 100%; gap: 0.1rem; align-items: center; }
.click-voice .mix-tone {
  flex: 1;
  min-width: 0;
  min-height: 0;
  padding: 0.15rem 0.1rem;
  font-size: 10px;
  /* One line: a two-word pack name must not make one strip taller than the
     rest and break the row of faders lining up. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* --- the click sound browser --- */
.sound-browser {
  width: min(46rem, 94vw);
  max-width: none;
  height: min(38rem, 88vh);
}
.sound-browser[open] { display: flex; flex-direction: column; gap: 0.5rem; }
.sb-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.sb-head h2 { margin: 0; flex: 0 0 auto; }
.sb-head #sb-search { flex: 1; min-width: 8rem; }
.sb-toolbar { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.sb-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  border: 1px solid var(--line);
  padding: 0.3rem;
}
.sb-group {
  margin: 0.5rem 0 0.2rem;
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--dim);
}
.sb-group:first-child { margin-top: 0.1rem; }
.sb-item { display: flex; align-items: stretch; gap: 0.2rem; }
.sb-row {
  flex: 1;
  min-width: 0;
  display: grid;
  grid-template-columns: minmax(6rem, 10rem) 1fr auto;
  gap: 0.6rem;
  align-items: baseline;
  text-align: left;
  background: none;
  border: 1px solid transparent;
  padding: 0.3rem 0.4rem;
  min-height: 0;
}
.sb-row:hover { border-color: var(--line); background: var(--panel); }
.sb-row.current { border-color: var(--accent); color: var(--accent); }
.sb-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-detail { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-row.substituted .sb-detail { color: var(--amber); }
.sb-mark { font-size: 10px; letter-spacing: 0.1em; color: var(--accent); }
.sb-item .sb-hear {
  flex: 0 0 auto;
  min-height: 0;
  padding: 0 0.5rem;
  background: none;
  border: 1px solid transparent;
  color: var(--dim);
}
.sb-item .sb-hear:hover { color: var(--fg); border-color: var(--line); background: none; }
.sb-list .empty { color: var(--dim); padding: 0.6rem 0.4rem; }

/* At phone width the detail column has nowhere to go, so the row stacks and
   the name keeps its own line rather than being squeezed to two characters. */
@media (max-width: 560px) {
  .sb-row { grid-template-columns: 1fr auto; gap: 0.1rem 0.4rem; }
  /* Placed explicitly, not left to auto-flow: the detail spans both columns,
     which starts a new row, and "in use" would then land on a *third* line
     below its own name instead of beside it. */
  .sb-name { grid-area: 1 / 1; }
  .sb-mark { grid-area: 1 / 2; }
  .sb-detail { grid-area: 2 / 1 / 3 / -1; white-space: normal; }
}

/* --- the saved-sets browser and the song stats --- */

.service-browser {
  width: min(52rem, 96vw);
  max-width: none;
  height: min(38rem, 88vh);
}
/* Flex only while open, or it beats the UA's `dialog:not([open])` rule and the
   browser sits permanently on screen — the same trap `#help[open]` guards. */
.service-browser[open] { display: flex; flex-direction: column; gap: 0.5rem; }
.service-browser .sb-head #svcb-search,
.service-browser .sb-head #stats-search { flex: 1; min-width: 8rem; }
.service-browser .sb-head #svcb-sort { flex: 0 0 auto; max-width: 10rem; color: var(--dim); }

/* The list and the mini viewer side by side: pick on the left, read on the
   right. Wes asked for the viewer so a set can be looked over BEFORE it is
   loaded, which is the whole reason this is not still a dropdown. */
.svcb-body {
  flex: 1;
  min-height: 0;
  display: grid;
  grid-template-columns: minmax(12rem, 1fr) minmax(0, 1.4fr);
  gap: 0.6rem;
}
.svcb-list {
  min-height: 0;
  overflow-y: auto;
  border: 1px solid var(--line);
  padding: 0.3rem;
}
.svcb-list.wide { flex: 1; }
.svcb-view { display: flex; flex-direction: column; gap: 0.5rem; min-height: 0; }
.svcb-preview {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  border: 1px solid var(--line);
  padding: 0.5rem;
}
.svcb-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.svcb-actions > button { flex: 1 1 auto; }
.svcb-actions .danger { color: var(--red); }
.svcb-actions .danger:hover { border-color: var(--red); }

.svcb-row {
  width: 100%;
  display: block;
  text-align: left;
  background: none;
  border: 1px solid transparent;
  padding: 0.35rem 0.4rem;
  min-height: 0;
}
.svcb-row:hover { border-color: var(--line); background: var(--panel); }
.svcb-row.current { border-color: var(--accent); color: var(--accent); }
/* The service that is loaded right now. Marked rather than hidden from the
   list: a browser that silently omits where you already are makes you count
   the rows to work out whether it is missing or you are on it. */
.svcb-row.loaded .svcb-name::after {
  content: ' · open';
  color: var(--green);
  font-size: 10px;
  letter-spacing: 0.1em;
}
.svcb-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.svcb-meta { display: block; font-size: 11px; color: var(--dim); }
.svcb-missing { color: var(--amber); }

.svcb-preview h3 { margin: 0 0 0.2rem; font-size: 13px; }
.svcb-preview .svcb-sub { color: var(--dim); font-size: 11px; margin: 0 0 0.5rem; }
/* The songs are numbered and the markers are not — and the numbering has to
   SKIP the markers, or a five-song set reads 3, 4, 5, 6, 9 and disagrees with
   the set list it is a preview of. A browser's own <ol> counter cannot skip a
   row, so the number is a CSS counter this list drives itself. */
.svcb-preview ol { margin: 0; padding-left: 1.6rem; list-style: none; counter-reset: svcb-song; }
.svcb-preview li { margin-bottom: 0.15rem; }
.svcb-preview li:not(.marker) { counter-increment: svcb-song; }
.svcb-preview li:not(.marker)::before {
  content: counter(svcb-song) ". ";
  display: inline-block;
  width: 1.6rem;
  margin-left: -1.6rem;
  color: var(--dim);
}
.svcb-preview li.marker { margin-left: -1.6rem; color: var(--dim); }
.svcb-preview .svcb-tempo { color: var(--dim); font-size: 11px; margin-left: 0.4rem; }
.svcb-preview .svcb-notes {
  margin-top: 0.6rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--line);
  color: var(--amber);
  font-size: 12px;
  white-space: pre-wrap;
}

/* One song's line in the stats list: the count is the thing being compared, so
   it is a fixed column on the left rather than trailing after names of every
   different length. */
.stat-row {
  display: grid;
  grid-template-columns: 3.5rem 1fr auto;
  gap: 0.5rem;
  align-items: baseline;
  padding: 0.25rem 0.4rem;
  border-bottom: 1px solid color-mix(in srgb, var(--line) 50%, transparent);
}
.stat-row:last-child { border-bottom: 0; }
.stat-count { color: var(--accent); text-align: right; font-variant-numeric: tabular-nums; }
.stat-count.never { color: var(--dim); }
.stat-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat-when { color: var(--dim); font-size: 11px; white-space: nowrap; }

/* At phone width there is no room for two panes, so the viewer goes under the
   list rather than beside it and each takes half the height. */
@media (max-width: 620px) {
  .svcb-body { grid-template-columns: 1fr; grid-template-rows: 1fr 1.2fr; }
  .stat-row { grid-template-columns: 3rem 1fr; }
  .stat-when { grid-area: 2 / 2; }
}

/* --- context menu --- */
.ctx-menu {
  position: fixed;
  z-index: 50;
  min-width: 11rem;
  display: flex;
  flex-direction: column;
  padding: 0.2rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: 0 8px 24px var(--backdrop);
}
.ctx-menu[hidden] { display: none; }
.ctx-item {
  background: none;
  border: none;
  border-radius: 3px;
  text-align: left;
  min-height: 1.9rem;
  padding: 0.25rem 0.5rem;
  font-size: 12px;
}
.ctx-item:hover:not(:disabled) { background: var(--hover); }
.ctx-item.danger { color: var(--red); }
.ctx-item:disabled { opacity: 0.35; }

.hint-inline { font-size: 10px; letter-spacing: 0.06em; text-transform: none; }
/* Above "bpm" now, not under it — see the `.unit` column and index.html. */
.sig { display: block; font-variant-numeric: tabular-nums; }
.wide-ctl { flex: 1; max-width: 17rem; font-size: 12px; color: var(--dim); }
#btn-live.on { color: var(--amber); border-color: var(--amber); }

/* --- live mode ---
   Sunday morning: the set list and the transport, nothing that edits a song.
   Hidden rather than shrunk, so a stray tap can't retune the next song. */
body.live #panel-library,
body.live #song-settings,
body.live #details-song,
body.live .sound,
body.live #btn-export,
body.live #wrap-service-notes,
body.live .service-bar,
body.live #set-actions,
body.live label.file { display: none; }
/* ...except the mixer and talkback, the two `.sound` blocks that are
   *performance* controls rather than editing ones. "The click is too loud in
   my ears" and "say something to the band" are Sunday-morning problems, and
   talkback that is only reachable by leaving live mode is talkback nobody will
   reach for mid-service. An id beats the `.sound` class rule above on
   specificity.

   Talkback was covered by that class rule until Wes said so: "add talkback
   controls to the live mode rather than the output picker with the mixer
   dropdown." Both halves of that sentence are here — talkback comes in, and
   the output picker below goes out. */
body.live #mixer-box,
body.live #talkback-box { display: block; }
/* Out of live mode: which sound card to use is set up before a service, and
   how many bars a herd runs for is a rehearsal decision. The herd *button*
   stays — it lives on the transport, not in here. */
body.live #output-settings,
body.live #output-hint,
body.live #herd-settings,
body.live #herd-hint { display: none; }
body.live main { grid-template-columns: minmax(340px, 1fr) minmax(280px, 1fr); }
body.live .list { max-height: 60vh; }
body.live .list li { padding: 0.5rem 0.55rem; }
body.live #bpm { font-size: clamp(3.4rem, 12vw, 5.5rem); }

@media (max-width: 900px) {
  body.live main { grid-template-columns: 1fr; }
  /* Stacked, the panels are three rows rather than three columns. A set list
     pinned to the viewport there would be a panel that never scrolls off while
     the two above and below it do, which on a phone reads as a stuck page — so
     it goes back to a measured height and the page scrolls, which is how a
     phone reads a long list anyway. */
  main { grid-template-columns: 1fr; grid-template-rows: auto; }
  #panel-set,
  #panel-library { position: static; height: auto; }
  /* And the toolbar comes unstuck with it. It only sticks to hold a place for
     the pinned set list, and at this width it wraps to two lines — 86px of a
     phone screen permanently spent on five buttons, for nothing.
     `relative`, not `static`: the scan-line overlay is an `inset: 0`
     pseudo-element and needs a positioned parent, or it escapes to the
     viewport and paints lines over the whole app. */
  header.bar { position: relative; }
  .list { max-height: 30vh; }
  /*
   * `flex: none` here is load-bearing, and leaving it out rendered the set list
   * as *nothing at all* on a phone — the panel drew its heading, its buttons and
   * "5 songs", with no songs between them.
   *
   * The base rule gives this `flex: 1 1 0`, which is right upstairs: there
   * `#panel-set` is a pinned column with a definite height, and a zero basis is
   * how the list takes whatever the note boxes leave. Here the rule two lines
   * above unpins the panel to `height: auto` — so there is no definite height to
   * take a share OF, and a `1 1 0` item in an auto-height column resolves to
   * zero. The `min-height: 6rem` floor that would have hidden it is overridden
   * on this very line.
   *
   * So on a phone the list is sized by its content, capped at half the screen
   * so a long service scrolls inside itself instead of pushing the note boxes
   * off the bottom.
   */
  #entry-list { flex: none; max-height: 50vh; min-height: 0; }
  /* Same unpinning for the library, but back to the 30vh `.list` cap it had
     before it was stretched — the blank space Wes reported is a desktop
     column's, and on a phone the panels are stacked rows where a taller
     library only pushes the song editor further down the page. Spelled out
     rather than left to `.list`, because the base rule above sets
     `max-height: none` on this id and an id beats a class. */
  #song-list { flex: none; max-height: 30vh; min-height: 0; }
}

/*
 * On a phone the title plus the four header buttons need about 433px, and a
 * layout wider than the viewport makes the browser shrink-to-fit: the whole app
 * renders zoomed out and scrolls sideways. Letting the bar wrap keeps the page
 * exactly as wide as the screen.
 */
@media (max-width: 480px) {
  .bar { flex-wrap: wrap; row-gap: 0.4rem; padding: 0.5rem 0.6rem; }
  .bar-right { flex: 1 1 100%; justify-content: flex-end; }
}

@media (prefers-reduced-motion: reduce) {
  .beat-dot { transition: none; }
}

/* --- themes ---------------------------------------------------------------
   Each palette sets the same handful of custom properties and nothing else.
   --green is the primary accent (playing, active, the logo) and --amber the
   secondary (tempo, accents, notes); they keep those names because that is
   what they are in the default terminal palette, and renaming them across the
   file would have made this a much riskier change than it needs to be. */

/* The default palette also gets an explicit class rather than relying on the
   :root values alone, so every theme in the picker is one rule you can find. */
body.theme-terminal {
  --bg: hsl(220 25% 7%);
  --panel: hsl(220 22% 10%);
  --line: hsl(220 18% 20%);
  --fg: hsl(210 20% 86%);
  --dim: hsl(215 12% 52%);
  --green: hsl(140 65% 58%);
  --amber: hsl(38 92% 62%);
  --cyan: hsl(190 80% 62%);
  --well: hsl(220 20% 14%);
  --hover: hsl(220 20% 18%);
}

body.theme-amber {
  --bg: hsl(28 30% 6%);
  --panel: hsl(28 24% 9%);
  --line: hsl(30 20% 22%);
  --fg: hsl(38 40% 86%);
  --dim: hsl(32 16% 55%);
  --green: hsl(38 92% 62%);
  --amber: hsl(48 96% 70%);
  --cyan: hsl(22 80% 64%);
  --well: hsl(28 22% 13%);
  --hover: hsl(28 22% 18%);
}

body.theme-ice {
  --bg: hsl(215 40% 7%);
  --panel: hsl(214 34% 11%);
  --line: hsl(210 26% 24%);
  --fg: hsl(200 30% 90%);
  --dim: hsl(205 18% 58%);
  --green: hsl(190 85% 62%);
  --amber: hsl(215 90% 72%);
  --cyan: hsl(165 70% 60%);
  --well: hsl(214 30% 15%);
  --hover: hsl(214 30% 20%);
}

/* Flat, maximum contrast, no color that depends on ambient light. For a dark
   stage and a screen someone is reading at a distance and at an angle. */
body.theme-stage {
  --bg: hsl(0 0% 0%);
  --panel: hsl(0 0% 4%);
  --line: hsl(0 0% 38%);
  --fg: hsl(0 0% 100%);
  --dim: hsl(0 0% 72%);
  --green: hsl(120 100% 68%);
  --amber: hsl(50 100% 66%);
  --cyan: hsl(190 100% 74%);
  --red: hsl(0 100% 68%);
  --well: hsl(0 0% 9%);
  --hover: hsl(0 0% 16%);
  --backdrop: hsl(0 0% 0% / 0.85);
}

/* Daylight, a laptop by a window, and printing a set list to look at. */
body.theme-paper {
  --bg: hsl(45 25% 94%);
  --panel: hsl(45 30% 98%);
  --line: hsl(40 12% 76%);
  --fg: hsl(30 15% 16%);
  --dim: hsl(35 10% 42%);
  --green: hsl(150 60% 30%);
  --amber: hsl(28 85% 38%);
  --cyan: hsl(205 70% 38%);
  --red: hsl(0 65% 44%);
  --well: hsl(45 25% 92%);
  --hover: hsl(45 22% 87%);
  --backdrop: hsl(40 20% 40% / 0.5);
}
/* The one place a light palette needs more than colors: glow on a light
   background reads as a smudge. */
body.theme-paper h1,
body.theme-paper #bpm,
body.theme-paper .big-count { text-shadow: none; }

/* --- screen effects -------------------------------------------------------
   The two effects are independent and additive, which is what Wes asked for:
   scan lines without the glow, glow without the lines, or both at once. They
   are drawn on ::before/::after of <body> so nothing in the layout moves when
   one is switched on. */

:root {
  --scanlines: repeating-linear-gradient(
    to bottom,
    hsl(0 0% 0% / 0.22) 0 1px,
    transparent 1px 3px
  );
}

body.fx-scanlines:not(.fx-scan-under)::before {
  content: '';
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 50;
  background: var(--scanlines);
}

/* "Under the text": the same pick as the portal site, per Wes. The lines live
   on the surfaces — the page background and the panels — and everything inside
   a panel is lifted one layer above them, so words, lists and form fields are
   always painted on top of the texture rather than behind it. */
body.fx-scan-under { background-image: var(--scanlines); }
/* `relative` only to give the ::before below something to be `inset: 0` in.
   NEITHER BAR is in this list, and both are out for the same reason: each is
   already `sticky`, which is a positioned box and does the job on its own —
   and this rule outranks it, so naming one here silently unsticks it.

   It cost that twice. Listing the header unstuck the toolbar and took the
   pinned set list under it with it. The footer was then made sticky (2026-08-02,
   because Wes had never once seen it) while still named here, and `under` is
   the DEFAULT scan placement — so it went straight back to sitting a screen
   below the window for every single user, with the space for it correctly
   reserved and empty. Only a measured `getBoundingClientRect` found it; the
   screenshot just looked like a page with nothing at the bottom.

   Anything given `position: sticky` elsewhere in this file must be kept out of
   this selector. */
body.fx-scan-under .panel { position: relative; }
body.fx-scan-under .panel::before,
body.fx-scan-under .bar::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--scanlines);
  pointer-events: none;
  z-index: 0;
}
body.fx-scan-under .panel > *,
body.fx-scan-under .bar > * { position: relative; z-index: 1; }

/* The one exception: the wordmark keeps its lines in this mode too. It is a
   picture of a CRT, not something being read, and stripping the texture from
   it just makes it look flat — same call the portal makes for its banner. */
body.fx-scan-under h1::after {
  content: '';
  position: absolute;
  inset: -2px -4px;
  background: var(--scanlines);
  pointer-events: none;
}

body.fx-glow h1,
body.fx-glow #bpm,
body.fx-glow #now-name,
body.fx-glow .big-count { text-shadow: 0 0 0.4em currentColor; }
/* The tempo number is by far the largest thing this rule catches — 0.4em of a
   4.2rem glyph at full opacity is a ~27px halo, which is what Wes was looking
   at when he asked for less bloom. Everything else the rule covers is small
   enough that 0.4em reads as a glow rather than a smear, so only #bpm is
   pulled back. Must come after the shared rule to win. */
body.fx-glow #bpm { text-shadow: 0 0 0.16em color-mix(in srgb, currentColor 45%, transparent); }
body.fx-glow .panel { box-shadow: inset 0 0 3rem hsl(0 0% 0% / 0.35); }

/* --- beat flash ---
   The whole transport panel lifts on the beat. Deliberately a background
   change rather than a border or a shadow: at 4 meters you see the block of
   color and nothing else. */
body.flash .panel { background: color-mix(in srgb, var(--green) 12%, var(--panel)); }

/* --- big beat number --- */
.big-count { display: none; }
body.bigcount .big-count {
  display: block;
  text-align: center;
  font-size: clamp(4rem, 22vw, 11rem);
  line-height: 0.95;
  color: var(--green);
  font-variant-numeric: tabular-nums;
}
/* Nothing is running, so there is no beat to show. Lit in the theme color at
   this size it reads as a glowing bar rather than as a placeholder, so the
   waiting state is dim and flat — `body` prefix to outrank the fx-glow rule. */
body .big-count.waiting { color: var(--dim); text-shadow: none; opacity: 0.5; }

/* With the number this big the dots are redundant, and both together take up
   the whole panel on a phone. */
body.bigcount .beats { display: none; }

/* Off-pulse beats under compound grouping: same row, visibly lighter, so a
   6/8 bar reads as two groups of three rather than six of anything. */
.beat-dot.offbeat { width: 0.7rem; height: 0.7rem; align-self: center; opacity: 0.7; }

/* --- settings dialog --- */
dialog h3 {
  margin: 1rem 0 0.4rem;
  font-size: 11px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--dim);
}
dialog h3:first-of-type { margin-top: 0.25rem; }

/* Tabbed settings: general / appearance / controls. The head keeps the title
   and the tab strip on one line; each panel is a plain block toggled by the
   [hidden] attribute from the tab wiring. */
.settings-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.5rem 1rem;
  border-bottom: 1px solid var(--line);
  margin-bottom: 0.75rem;
}
.settings-head h2 { margin: 0; }
.settings-tabs { display: flex; gap: 0.2rem; }
.settings-tabs .tab {
  padding: 0.35rem 0.7rem;
  background: none;
  border: 1px solid transparent;
  border-bottom: none;
  border-radius: 4px 4px 0 0;
  color: var(--dim);
  font: inherit;
  font-size: 12px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  cursor: pointer;
}
.settings-tabs .tab:hover { color: var(--fg); background: var(--hover); }
.settings-tabs .tab.active {
  color: var(--green);
  border-color: var(--line);
  background: var(--panel);
  margin-bottom: -1px;
}
.tabpanel[hidden] { display: none; }
.tabpanel h3:first-of-type { margin-top: 0.25rem; }

/* Wes: "the width and vertical position change significantly when changing
   tabs. It is jarring." They did, because a <dialog> sizes to its content and
   the browser then re-centers it — so a short tab drew a narrow box high up
   and a long one a wide box filling the screen.
   The fix is to stop content being a layout input at all: the settings dialog
   carries its own fixed box, and the panels scroll inside it. The head and the
   close row are pinned; only the middle moves.

   A closed <dialog> is display:none from the UA stylesheet, so the flex box is
   put on [open] only — an unconditional `display: flex` here would beat that
   rule and leave the settings permanently on screen, the same trap that
   `.field[hidden]` and `a.button-link[hidden]` already guard against. */
#help {
  box-sizing: border-box;
  width: min(34rem, calc(100vw - 2rem));
  height: min(42rem, calc(100vh - 4rem));
  max-width: none;
}
#help[open] { display: flex; flex-direction: column; }
.tabpanels { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* The scrollbar would otherwise sit on top of the right-hand controls. */
.tabpanels { padding-right: 0.35rem; }

/* --- lite / full ---
   The badge reads as a status, not a button: bracketed like every other badge
   in the theme, amber for lite (something to act on) and green for full. */
.tier-badge {
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--amber);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0.1rem 0.4rem;
}
.tier-badge.full { color: var(--green); }
.price { color: var(--amber); font-variant-numeric: tabular-nums; }

/* Until the terms link there was no plain <a> anywhere in the app — only
   .button-link — so an ordinary link rendered in the browser's default blue,
   which on this palette is close to unreadable. One rule, globally, or the
   next link added somewhere else has the same problem. */
a { color: var(--cyan); }
a:hover { color: var(--fg); }
a.hint { color: var(--dim); text-decoration-color: var(--line); }
a.hint:hover { color: var(--fg); }

/* The welcome explainer. Sized like the settings dialog so a first-time
   visitor doesn't get a box that jumps as the two plan cards wrap. */
#welcome {
  box-sizing: border-box;
  width: min(38rem, calc(100vw - 2rem));
  max-width: none;
  max-height: calc(100vh - 3rem);
  overflow-y: auto;
}
#welcome h2 { margin-top: 0; }
.welcome-lede { margin: 0 0 1rem; font-size: 13px; color: var(--fg); }
.welcome-try { font-size: 13px; color: var(--fg); }
.plans { display: flex; flex-wrap: wrap; gap: 0.75rem; }
/* min-width:0 on the card AND flex-basis, or a long <li> sets a min-content
   floor wider than a phone and the whole dialog shrink-to-fits. */
.plan {
  flex: 1 1 14rem;
  min-width: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.6rem 0.8rem;
}
.plan-full { border-color: var(--green); }
.plan h3 {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.5rem;
  margin: 0 0 0.5rem;
}
.plan-price { color: var(--amber); letter-spacing: normal; text-transform: none; }
.plan ul { margin: 0; padding-left: 1.1rem; font-size: 12px; }
.plan li { margin-bottom: 0.25rem; }

/* The gear button in the header: line the SVG up with the ghost-button text. */
.gear { display: inline-flex; align-items: center; padding: 0.2rem 0.5rem; }
.gear svg { display: block; }

/* The Planning Center key twisty. openSettings() re-shuts it every open, so the
   id and secret are never on screen unless someone clicks to reveal them. */
.pco-keys > summary {
  cursor: pointer;
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--dim);
}
.pco-keys[open] > summary { margin-bottom: 0.5rem; color: var(--fg); }
.effects { display: flex; flex-wrap: wrap; gap: 0.35rem 0.9rem; }
.effects label { display: flex; align-items: center; gap: 0.35rem; font-size: 12px; }
.dialog-actions { display: flex; justify-content: space-between; gap: 0.5rem; margin-top: 1rem; }
/* Two ways to accept a dialog, kept together at the right-hand end so `cancel`
   is still the one thing on its own. `space-between` on three loose buttons
   would strand the middle one halfway across the row. */
.action-pair { display: flex; gap: 0.5rem; align-items: stretch; }

/* A shortcut row is now label / key / unbind, and the key is a button you
   press to change it rather than a static <kbd>. */
.shortcuts li { align-items: center; }
.shortcuts li > span:first-child { flex: 1; }
.keycap {
  background: var(--hover);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0.1rem 0.45rem;
  min-height: auto;
  color: var(--cyan);
  font-size: 12px;
  min-width: 7rem;
}
.keycap.capturing { color: var(--amber); border-color: var(--amber); }
.keycap.clash { color: var(--red); border-color: var(--red); }
.shortcuts .row-btn {
  min-height: auto;
  padding: 0 0.3rem;
  background: none;
  border: none;
  color: var(--dim);
  font-size: 12px;
}

/* --- pasted set list import --- */
#import { max-width: 38rem; width: min(38rem, 92vw); }
#import textarea {
  width: 100%;
  font-family: inherit;
  font-size: 13px;
  resize: vertical;
}
#import .hint code {
  background: var(--hover);
  border-radius: 3px;
  padding: 0 0.25rem;
  color: var(--cyan);
}
.import-preview {
  max-height: 40vh;
  overflow-y: auto;
  margin: 0.4rem 0 0;
}
/* The whole row is the hit target, so on a phone a tick is a thumb rather
   than a fingernail on a 13px checkbox. */
.import-preview li { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
/* Struck through on .name only. Putting line-through on the row wrapper drew
   it across the "not a song" badge too, and a descendant cannot undo an
   ancestor's text-decoration. */
.import-preview li.skipped .row-main { opacity: 0.5; }
.import-preview li.skipped .name { text-decoration: line-through; }
.badge {
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cyan);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0 0.3rem;
  white-space: nowrap;
}
.badge.new { color: var(--amber); }
/* A manual match is colored differently from an automatic one: "I chose this"
   and "the matcher chose this" carry very different amounts of trust. */
.badge.manual { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, transparent); }

/* The song / not-a-song toggle. A button rather than a label, because it is
   the one control on the row that changes what gets written. */
button.kind-toggle {
  background: none;
  min-height: 0;
  line-height: 1.6;
  cursor: pointer;
  font-family: inherit;
}
button.kind-toggle:hover { border-color: var(--cyan); color: var(--fg); background: none; }

/* "match to…" — full width under the row, because a library song's name is
   long and truncating it defeats the point of choosing one by name. */
.match-picker {
  margin-top: 0.25rem;
  width: 100%;
  max-width: 100%;
  font-size: 11px;
  height: 1.8rem;
  color: var(--dim);
}
.match-picker:focus { color: var(--fg); }

/* --- markers: the non-song landmarks in a set list --- */
.list li.marker-row {
  margin-left: 1.4rem;
  border-style: dashed;
  border-color: color-mix(in srgb, var(--line) 70%, transparent);
  background: color-mix(in srgb, var(--bg) 55%, var(--panel));
}
.list li.marker-row .name {
  color: var(--dim);
  font-size: 12px;
  font-style: italic;
  letter-spacing: 0.02em;
}
.list li.marker-row:hover { background: color-mix(in srgb, var(--bg) 35%, var(--panel)); }
/* Markers are outside the numbering. The numbers are how you find "song 3" on
   a stage, and counting the offering as one of them makes every number after
   it disagree with the plan everyone else is reading. */
/* The counter is incremented by the ::before, not by the li, so cancelling it
   on the li does nothing at all — which is how the first render came back
   numbered 3, 4, 5, 6, 9. */
.list.ordered li.marker-row::before { counter-increment: none; content: '·'; }

/* Two buttons side by side under a list, each taking half the width. */
/* Four buttons under the set list now, so they wrap rather than shrinking to
   a row of unreadable stubs on a phone. 8ch is about "new service" wrapped. */
.row-buttons { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.row-buttons > button { flex: 1 1 8ch; min-width: 0; }

/* The icon version of that row (the set list's five actions). `flex: 0 0 auto`
   undoes the stretch above — the whole point is that these do NOT take the
   width they can get. Qualified on .icons so the text rows elsewhere keep it. */
.row-buttons.icons { flex-wrap: nowrap; gap: 0.25rem; }
.row-buttons.icons > button { flex: 0 0 auto; }
.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* 32px square: the smallest thing worth aiming a finger at, and what the
     row-height budget was cut to. */
  width: 2rem;
  height: 2rem;
  padding: 0;
  color: var(--dim);
}
.icon-btn:hover, .icon-btn:focus-visible { color: var(--fg); border-color: var(--accent); }
.icon-btn.danger-icon:hover, .icon-btn.danger-icon:focus-visible { color: var(--red); border-color: var(--red); }

/* --- tooltips ---------------------------------------------------------- *
 *
 * Wes, 2026-08-09: "hovering over the buttons at the bottom of the setlist for
 * importing and whatnot should sooner pop up the name for the button and it
 * should be in theme."
 *
 * Both halves are the browser's native `title`, which nothing on a page can
 * style and which waits about a second before it says anything — long enough
 * that on a row of five unlabeled icons you give up and press one to find out.
 * So `data-tip` draws its own, in the terminal palette, after 120ms.
 *
 * `title` is deliberately NOT kept alongside on the same element: two tooltips
 * for one button is the native one appearing a second later underneath this
 * one. The name still reaches a screen reader through `aria-label`, which is
 * what was carrying it anyway.
 *
 * Wes, 2026-09-19: "Pop-ups that show up when you hover over the buttons at
 * the bottom of the set list builder go behind other elements/text fields and
 * they should not." They did, and no z-index on the bubble could have fixed
 * it — see the long note at the top of src/core/tooltip.js. There is now ONE
 * bubble for the whole app, appended to <body> by src/ui/tooltip.js and
 * positioned from script, so it is a sibling of the panels rather than
 * something trapped inside one.
 *
 * Which is also why there is no `:hover` here and no `@media (hover: none)`
 * escape hatch: the module decides from the real `pointerType`, so a phone
 * gets nothing on a tap and a screenshot run can raise one by dispatching an
 * event, without a `.tip-shown` class the stylesheet has to know about.
 */
.tip-layer {
  position: fixed;
  /* Above the sticky panels, the toolbar (20) and the set-list scan layer;
     below the settings sheet (90/91), which covers the page anyway. */
  z-index: 80;
  padding: 0.25rem 0.45rem;
  max-width: 15rem;
  width: max-content;
  border: 1px solid var(--green);
  border-radius: var(--radius);
  background: var(--bg);
  color: var(--fg);
  font-family: inherit;
  font-size: 11px;
  line-height: 1.3;
  text-align: center;
  white-space: normal;
  opacity: 0;
  transition: opacity 90ms linear;
}

.tip-layer.on { opacity: 1; }

/* The set-list total and those five icons on one line — the total is the thing
   being read and it keeps the left, the actions sit out of the way on the
   right. In live mode the icons are hidden and the total simply has the row. */
.set-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  flex-wrap: wrap;
}
.set-foot .total { text-align: left; }

/* A control that lives on a panel's heading line: the set list's "saved sets"
   and the library's "stats". Small and dim so the heading still reads as a
   heading, but a real bordered button rather than a link, because it does
   something. */
.heading-btn {
  flex: 0 0 auto;
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: none;
  color: var(--dim);
}
.heading-btn:hover, .heading-btn:focus-visible { color: var(--accent); border-color: var(--accent); }
/* Live mode: the heading line is where the service NAME goes, and browsing
   for a different service is not something to do mid-song by accident. */
body.live .heading-btn { display: none; }

/* The service name and the switcher, stacked. Side by side they fought over a
   narrow panel and the dropdown showed "Wednesday" for "Wednesday youth" —
   which is exactly the information a switcher exists to give you. Full width
   each, and the dropdown is only in the tree at all once there are two. */
.service-bar { display: flex; flex-direction: column; gap: 0.3rem; }
.service-bar > * { width: 100%; min-width: 0; }

/* Search wide, sort narrow — the search is the thing used every Sunday. */
.library-bar { display: flex; gap: 0.3rem; align-items: stretch; }
.library-bar #search { flex: 1; min-width: 0; }
.library-bar #library-sort { flex: 0 0 auto; max-width: 7.5rem; color: var(--dim); }

/* ------------------------------------------------------- Planning Center */
/* The two ways into the import dialog. They are the same kind of thing — a
   source you can fold away — so they get the same box; the paste one used to
   be an <h3> and reading as a different species of control was confusing when
   they sat one above the other. */
#pco-block,
#paste-block {
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0.4rem 0.6rem;
  margin-bottom: 0.8rem;
}
#pco-block > summary,
#paste-block > summary { cursor: pointer; color: var(--cyan); }
#pco-block[hidden],
#paste-block[hidden] { display: none; }
.pco-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-top: 0.5rem;
}
/* display:flex beats the hidden attribute, so every .pco-row that is toggled
   with `hidden` stayed on screen — this showed the owner a "leave this team"
   button they can never use, an empty invite-link box, and (already, before
   teams existed) the upgrade button to accounts that had already paid. The
   fourth time this trap has been sprung here, after .field[hidden],
   a.button-link[hidden] and #help[open]. */
.pco-row[hidden] { display: none; }
/* The status line takes the leftover width and wraps rather than pushing the
   dialog wider — a 401 explanation is three lines long on purpose. */
#pco-state { flex: 1 1 12rem; min-width: 0; }

/* ---------------------------------------------------------------- offline */

.offline-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.offline-row button[hidden] { display: none; }

/* A network drop mid-service is worth one calm line in the footer, colored
   so it reads as information rather than as an error — the app carries on. */
.net-badge {
  color: var(--amber);
  border: 1px solid var(--amber);
  border-radius: 3px;
  padding: 0 0.3rem;
  font-size: 11px;
  letter-spacing: 0.06em;
}
.net-badge[hidden] { display: none; }

/* ------------------------------------------------------------------ mixer */

/* A console, not a settings screen. Wes: "make the audio mixer actually look
   like a mixer with audio faders."

   So: one vertical strip per source, faders side by side with their travel
   lined up, a meter beside each fader, and master on the right where a desk
   puts it. The strips scroll horizontally rather than wrapping — a wrapped
   channel strip stops reading as a console at exactly the moment (a phone)
   where it most needs to. */
.mixer {
  display: flex;
  align-items: stretch;
  gap: 0.3rem;
  padding: 0.4rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--fg) 3%, transparent);
  overflow-x: auto;
}
.mixer-strips { display: flex; gap: 0.3rem; }
.mixer-strip {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  flex: 0 0 auto;
  width: 3.7rem;
  padding: 0.3rem 0.2rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--bg);
}
/* Master is the one strip that is not a source, so it is set apart — to the
   right of the channels, as on a desk, with a divider rather than a stretch of
   empty panel. It was pushed to the far edge first, and on a wide screen that
   left a hole in the middle of the console that read as a rendering fault. */
.mixer-strip.master {
  border-color: var(--green);
  margin-left: 0.5rem;
  border-left-width: 2px;
  background: color-mix(in srgb, var(--green) 7%, var(--bg));
}
/* Nothing beyond master, so the console does not stretch to fill a wide
   column — a channel strip is a fixed-width object. */
.mixer::after { content: ""; flex: 1 1 auto; }

/* THE CLICK BUS, OPENED UP. Wes asked for the click channel to expand into the
   voices it sums rather than for those to live in a second panel with a second
   set of faders. A panel under the console rather than six more strips inline:
   the console already scrolls sideways on a phone at six strips, and twelve
   would put master a long way off the edge. */
.click-bus {
  display: block;
  margin-top: 0.4rem;
  padding: 0.4rem 0.5rem 0.2rem;
  border: 1px solid var(--line);
  border-left: 2px solid var(--green);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--green) 4%, transparent);
}
/* .click-bus is display:block, which beats the hidden attribute — the same
   trap .field, a.button-link, .pco-row and #help[open] were all bitten by. */
.click-bus[hidden] { display: none; }
/* The twisty, and the name beside it, on the click strip's top line. */
.mixer-head { display: flex; align-items: center; gap: 0.1rem; width: 100%; min-width: 0; }
.mixer-expand {
  min-height: auto;
  padding: 0 0.1rem;
  /* Bigger than the strip's own 10px lettering on purpose: it is the one
     control on the console that is not a fader, and a twisty nobody finds is a
     feature nobody finds. */
  font-size: 13px;
  line-height: 1;
  background: none;
  border: none;
  color: var(--green);
}
.mixer-expand:hover { color: var(--fg); background: none; }
/* The strip the open panel belongs to, marked so the two read as one thing. */
.mixer-strip.expanded { border-color: var(--green); }
.mixer-strip.expanded .mixer-expand { color: var(--green); }
.mixer-name {
  font-size: 10px;
  line-height: 1.2;
  text-align: center;
  width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The fader column: meter on the left, fader on the right, both running the
   same height so a level and its travel can be read against each other. */
.mixer-fader {
  position: relative;
  display: flex;
  align-items: stretch;
  justify-content: center;
  gap: 0.25rem;
  height: 8rem;
}
/* The fader itself is drawn once, up beside the click voices — one graphic for
   every fader on this console, which is the point of the whole exercise. */
.mixer-strip.master .mixer-fader input[type="range"]::-webkit-slider-thumb {
  border-color: color-mix(in srgb, var(--amber) 60%, hsl(0 0% 0%));
}
.mixer-strip.master .mixer-fader input[type="range"]::-moz-range-thumb {
  border-color: color-mix(in srgb, var(--amber) 60%, hsl(0 0% 0%));
}

/* The meter. Post-fader, so it shows what actually left the channel — see
   audio/mixer.js for why this is a diagnostic and not decoration. */
.mixer-meter {
  position: relative;
  display: block;
  width: 0.45rem;
  border: 1px solid var(--line);
  background: color-mix(in srgb, var(--fg) 6%, transparent);
  overflow: hidden;
}
.mixer-meter i {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 0;
  display: block;
  background: linear-gradient(to top,
    var(--green) 0%,
    var(--green) 60%,
    var(--amber) 82%,
    var(--red) 100%);
}
/* The peak-hold line, drawn off a custom property the paint loop writes, so a
   held peak costs no extra element per strip. */
.mixer-meter i::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: var(--hold, 0%);
  height: 1px;
  background: var(--fg);
  opacity: 0.7;
}
.mixer-read {
  font-size: 10px;
  text-align: center;
  color: var(--amber);
  font-variant-numeric: tabular-nums;
}
/* Silent for a reason that is not this channel's own mute — see
   silenceReason() in core/mixer.js. The whole strip dims so "why can't I hear
   the click" is answerable at a glance instead of during a service. */
.mixer-strip.silenced { opacity: 0.55; }
.mixer-strip.silenced .mixer-name,
.mixer-strip.silenced .mixer-read { opacity: 0.6; }

.mixer-btns { display: flex; gap: 0.15rem; }
.mixer-btn {
  min-height: auto;
  padding: 0 0.3rem;
  font-size: 11px;
  line-height: 1.5;
  background: none;
  border: 1px solid var(--line);
  color: var(--dim);
}
.mixer-btn:hover:not(:disabled) { color: var(--fg); background: none; }
.mixer-btn.on { color: var(--bg); background: var(--red); border-color: var(--red); }
.mixer-btn.solo.on { background: var(--amber); border-color: var(--amber); }
/* The master strip has no mute or solo — that is what the fader is for — but
   the empty box has to hold the column so the strips stay the same height. */
.mixer-strip.master .mixer-btns { visibility: hidden; }
#mixer-hint { cursor: pointer; color: var(--amber); }
#output-hint { margin-top: 0.2rem; }

/* A hint that is a warning: the browser opened a microphone other than the one
   that was chosen. Amber rather than red — nothing is broken, but it is not
   doing what was asked, and that must not read as ordinary help text. */
.hint.warn { color: var(--amber); }

/* The mixer's own silence notice, beside the stall one on the transport. Amber,
   not red: this is a fader someone moved, not the browser taking the audio
   away, and the two want telling apart at a glance. */
.audio-stall.quiet {
  color: var(--amber);
  border-color: color-mix(in srgb, var(--amber) 55%, var(--line));
  background: color-mix(in srgb, var(--amber) 12%, transparent);
}

/* ------------------------------------------------------------------- cues */

.cues { margin-top: 0.6rem; border-top: 1px solid var(--line); padding-top: 0.5rem; }
.cue-buttons { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.cue-wrap { display: inline-flex; }
.cue-btn {
  min-height: auto;
  padding: 0.2rem 0.45rem;
  font-size: 12px;
  background: none;
  border: 1px solid var(--line);
  color: var(--fg);
}
.cue-btn:hover:not(:disabled) { border-color: var(--green); color: var(--green); background: none; }
/* A cue in your own voice, which is the one that goes through the fader, the
   meter and the chosen output. Marked because otherwise you find out which
   voice a button speaks in by pressing it in front of the band. */
.cue-btn.recorded { border-color: var(--green); }
.cue-btn.recorded::before { content: '●'; color: var(--green); font-size: 8px; vertical-align: 2px; margin-right: 0.25rem; }
.cue-del {
  min-height: auto;
  padding: 0.2rem 0.3rem;
  font-size: 11px;
  background: none;
  border: 1px solid var(--line);
  border-left: none;
  color: var(--dim);
}
.cue-del:hover { color: var(--red); background: none; }
/* Nothing gets deleted by a stray tap during a service. */
body.live .cue-del { display: none; }
.cue-add { display: flex; gap: 0.3rem; margin-top: 0.4rem; }
.cue-add input { flex: 1 1 auto; min-width: 0; }

/* ------------------------------------------- cues pinned to a bar of a song */

.cuepoints { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 0.4rem; }
.cuepoint-row {
  display: grid;
  grid-template-columns: auto 1fr auto auto;
  gap: 0.3rem;
  align-items: center;
}
/* The description sits under its own row rather than beside it: it is a
   sentence ("bar 17, spoken 4 beats early"), and a sentence squeezed into a
   column is a sentence nobody reads. */
.cuepoint-note { grid-column: 1 / -1; font-size: 11px; line-height: 1.3; }
.cuepoint-note.warn { color: var(--amber); }
.cuepoint-at { display: inline-flex; align-items: center; gap: 0.25rem; }
.cuepoint-at .cap { font-size: 10px; }
.cuepoint-at input { width: 3.4rem; padding: 0.2rem 0.3rem; }
/* Wider than its neighbours because its placeholder is a word, not a
   number: "a bar" clipped to "a b" says nothing about the default. */
.cuepoint-at input[data-field="lead"] { width: 5.4rem; }
/* The count-in tick sits with the position controls because that is what it
   is: it decides which bar gets counted, not what gets said. `width: auto`
   because the rule above sizes every input in this row to 3.4rem, and a
   checkbox stretched to that is a rectangle nobody reads as a checkbox. */
.cuepoint-countin { display: inline-flex; align-items: center; gap: 0.2rem; cursor: pointer; }
.cuepoint-countin .cap { white-space: nowrap; }
.cuepoint-at input[type="checkbox"] { width: auto; margin: 0; }
.cuepoint-row input[data-field="text"] { min-width: 0; }
.cuepoint-row .row-btn {
  min-height: auto;
  padding: 0 0.35rem;
  background: none;
  border: none;
  color: var(--dim);
}
.cuepoint-row .row-btn:hover:not(:disabled) { color: var(--fg); background: none; }
/* Same mark, same meaning as on a cue button: this one is in your own voice,
   so it goes through the cue fader and the chosen output rather than the
   browser's. */
.cuepoint-say.recorded { color: var(--green); }

@media (max-width: 620px) {
  /* The position controls take a line of their own rather than shrinking the
     text field to nothing — the words are the part you have to be able to
     read back. */
  .cuepoint-row { grid-template-columns: 1fr auto auto; }
  .cuepoint-at { grid-column: 1 / -1; }
}

/* What is about to be said. On the transport, so live mode keeps it. No
   `display` rule, deliberately, so the `hidden` attribute is enough — the
   contract test sweeps for exactly that mistake. */
.cue-next {
  margin-top: 0.25rem;
  text-align: center;
  font-size: 12px;
  color: var(--dim);
}
/* Inside a bar of it. The one moment the line is worth looking at. */
.cue-next.soon { color: var(--amber); }

/* ---------------------------------------------------- song-length recording

   What "end here" just measured and wrote to the song. Green, because unlike
   the two notices above it this is something that worked — but it is still a
   change made by one tap to a stored setting, so the way back is in the box
   rather than somewhere else on the screen. `[hidden]` guard because the box
   is a flex column; see the contract test that sweeps for exactly that. */
.length-note {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  margin: 0.5rem 0 0;
  padding: 0.4rem 0.5rem;
  font-size: 12px;
  line-height: 1.4;
  text-align: center;
  color: var(--green);
  border: 1px solid color-mix(in srgb, var(--green) 55%, var(--line));
  background: color-mix(in srgb, var(--green) 10%, transparent);
}
.length-note[hidden] { display: none; }
/* How far off the bar line the tap landed. Dim: it qualifies the number above
   it rather than competing with it. */
.length-note .off { color: var(--dim); font-size: 11px; }
.length-note .row { display: flex; gap: 0.35rem; flex-wrap: wrap; justify-content: center; }
.length-note button {
  min-height: auto;
  padding: 0.15rem 0.45rem;
  font-size: 11px;
  background: none;
  border: 1px solid var(--line);
  color: var(--fg);
}
.length-note button:hover:not(:disabled) { border-color: var(--green); color: var(--green); }

/* ------------------------------------------------------------ herding cats

   The click is out front right now, and here is how much of it is left. Amber
   rather than green: this is not a thing that succeeded and went away, it is a
   temporary state of the click that someone needs to be able to see is on.
   `[hidden]` guard because the box is a flex column; see the contract test. */
.herd-note {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  margin: 0.5rem 0 0;
  padding: 0.35rem 0.5rem;
  font-size: 12px;
  line-height: 1.4;
  text-align: center;
  color: var(--amber);
  border: 1px solid color-mix(in srgb, var(--amber) 55%, var(--line));
  background: color-mix(in srgb, var(--amber) 10%, transparent);
}
.herd-note[hidden] { display: none; }
/* What it actually lifted. Dim: it qualifies the countdown above it. */
.herd-note .lift { color: var(--dim); font-size: 11px; }
/* The button while a herd is running, so the state is readable from the
   control as well as from the notice. */
#btn-herd.on {
  border-color: var(--amber);
  color: var(--amber);
}

/* --------------------------------------------------------- the track player

   The reference recording, played from one of the song's links. Neutral rather
   than colored: unlike the three notices above it this is not a state anyone
   needs warning about, it is a second transport that happens to be on screen.
   `[hidden]` guard because the box is a flex column — see the contract test
   that sweeps for exactly that, and the note in learnings about an author
   `display` rule beating the HTML `hidden` attribute outright. */
.track-player {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin: 0.5rem 0 0;
  padding: 0.4rem 0.5rem;
  border: 1px solid var(--line);
  background: color-mix(in srgb, var(--fg) 4%, transparent);
}
.track-player[hidden] { display: none; }
.track-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  /* Without this the row's own min-content floor is set by the children's
     specified widths and the whole block overflows at phone width — the nested
     flex trap already noted for the button rows. */
  min-width: 0;
}
/* The name takes whatever is left and gives it up first: a long file name must
   not push the transport button or the clock off the row. */
.track-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 12px;
}
.track-time {
  font-size: 11px;
  /* So the digits don't shuffle sideways once a second while it plays. */
  font-variant-numeric: tabular-nums;
  flex: none;
}
#btn-track-play {
  min-height: auto;
  padding: 0.2rem 0.6rem;
  font-size: 12px;
  flex: none;
}
/* The framed video shares the player's chrome, so the two reference-track
   panels read as one feature rather than two. */
.track-embed {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin: 0.5rem 0 0;
  padding: 0.4rem 0.5rem;
  border: 1px solid var(--line);
  background: color-mix(in srgb, var(--fg) 4%, transparent);
}
.track-embed[hidden] { display: none; }
/* 16:9 of whatever width the column has. `aspect-ratio` rather than the old
   padding-box trick because every browser this app supports has it, and a
   fixed height would letterbox on a phone and crop on a desktop. */
.track-frame {
  width: 100%;
  aspect-ratio: 16 / 9;
  border: 0;
  display: block;
  /* A video is the brightest thing on a dark page. Nothing else in the app has
     a radius, so this is the smallest one that stops it looking pasted on. */
  background: #000;
}
.track-player .row-btn,
.track-embed .row-btn {
  min-height: auto;
  padding: 0 0.3rem;
  background: none;
  border: none;
  color: var(--dim);
  flex: none;
}
.track-player .row-btn:hover:not(:disabled),
.track-embed .row-btn:hover:not(:disabled) { color: var(--fg); background: none; }
.track-seek { width: 100%; margin: 0; }
/* What the player could not deliver: a file whose server won't share it with
   the mixer, or a link that isn't audio at all. Amber, because it is a working
   thing that is working differently — the same weight as the mixer's "playing,
   but silent" line rather than the red of an audio stall. */
.track-note {
  margin: 0;
  font-size: 11px;
  line-height: 1.4;
  color: var(--amber);
}
.track-note[hidden] { display: none; }

/* A disabled ▶ on a link that cannot be played has to *look* unavailable, or
   it reads as a button that does nothing when pressed. */
.link-row .row-btn:disabled { opacity: 0.35; cursor: not-allowed; }

/* --------------------------------------------------------------- talkback */

.talkback { margin-top: 0.6rem; border-top: 1px solid var(--line); padding-top: 0.5rem; }
/* Wes: "have 'arm the mic' and the hold to talk button in line with the input
   and trigger drop-downs/buttons if possible." All four on one row.

   Flex rather than the `.settings` grid it inherits from, and the "if
   possible" is exactly why: an auto-fit grid picks a column count from one
   minimum width, so four cells either all fit or the fourth drops to a row of
   its own with three-quarters of the width empty beside it. Flex keeps them on
   one line while there is room and wraps only what does not fit — which is
   what happens in this panel at half of Wes's laptop and on his phone.

   `align-items: end` is what actually puts the buttons *in line*: a settings
   label is a cap stacked over its control, so a button aligned to the top of
   that row would float a whole cap-height above the selects beside it. */
.talkback-head {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 0.5rem;
  margin-bottom: 0.4rem;
}
/* The selects share the slack; the buttons stay the width of their own words,
   so "hold to talk" never stretches into a target the size of a select.

   A 7rem base rather than the 9rem the two selects would like: wrapping is
   decided on the base widths before any shrinking happens, so a base that only
   just fits puts "hold to talk" on its own line the first time a font metric
   moves. The selects grow into whatever is left over anyway. */
/* `>`, and it is load-bearing: the autoarm checkbox is a label nested inside
   the .field on the second line, and .field is a COLUMN flex container — where
   `flex-basis: 7rem` is a HEIGHT, not a width. So the one-line checkbox row was
   112px tall, which is most of the empty space Wes photographed. */
.talkback-head > label { flex: 1 1 7rem; min-width: 0; }
.talkback-head .ghost { flex: 0 1 auto; }
.talkback-head .field { flex: 1 1 100%; }
.talkback-head .ghost.on { color: var(--bg); background: var(--green); border-color: var(--green); }
#btn-talkback-talk.on { background: var(--red); border-color: var(--red); }
/* Its own line under the row, and never taller than one: the device name it
   grows must not push the buttons about. */
#talkback-warn, #talkback-hint { font-size: 11px; margin: 0.4rem 0 0; }
/* The key that triggers talkback, in the "trigger" header beside the word.
   Set off by a dot rather than run on, so it reads as a key and not as the
   rest of the label. */
/* A settings label is a COLUMN flex container, so a bare span beside the
   caption text becomes its own line and drags the whole control down. The
   caption and its key are one row of their own. */
.cap-key { display: flex; align-items: baseline; gap: 0.3rem; }
.keyhint { color: var(--cyan); text-transform: none; letter-spacing: normal; }
.keyhint:not(:empty)::before { content: '· '; color: var(--dim); }
/* The set-list row field ticks. Two columns where there is room, one on a
   phone, rather than seven checkboxes down a settings page nobody scrolls. */
.tickgrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 0.15rem 0.75rem;
}
.auto-eighths { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem; }
.auto-eighths input.narrow { width: 4.5rem; }
/* Holding a key to talk must not also select the button's label. */
#btn-talkback-talk { user-select: none; touch-action: none; }

@media (max-width: 480px) {
  /* A fourth tab (history) pushed the strip past the dialog at phone width and
     the last one was simply cut off the right edge — a tab nobody can reach.
     Tighter chrome makes four fit; the wrap is the belt to that brace, so a
     fifth one later drops to a second line rather than disappearing. */
  .settings-tabs { flex-wrap: wrap; }
  .settings-tabs .tab { padding: 0.35rem 0.35rem; letter-spacing: 0.04em; }
}

@media (max-width: 480px) {
  /* Narrower strips, and the row scrolls sideways rather than wrapping. Master
     keeps its place at the right of the scroll, not of the screen — being able
     to swipe to it beats it sitting on top of a channel. */
  .mixer-strip { width: 3.2rem; }
  .mixer-strip.master { margin-left: 0.3rem; }
  /* A voice strip stays wider than a channel: it carries the name of the sound
     it plays, and a clipped pack name is not a name. */
  .click-voice { width: 4.2rem; }
  .mixer-fader { height: 6.5rem; }
  .mixer-fader input[type="range"] { width: 1.9rem; }
  .mixer-fader input[type="range"]::-webkit-slider-thumb { width: 1.7rem; }
  .mixer-fader input[type="range"]::-moz-range-thumb { width: 1.7rem; }
  .click-bus { padding: 0.35rem 0.3rem 0.2rem; }
}

/* --------------------------------------------------------- edit history

   What changed, when and by whom. A dense two-line row rather than a table:
   the log is read by scanning down the left edge for a time, and a table would
   make the change column — the part with the actual words in it — the narrow
   one on a phone. */

.history-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin-bottom: 0.6rem;
}
.history-head select { max-width: 14rem; }
.history {
  list-style: none;
  margin: 0 0 0.6rem;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}
.hrow {
  border-left: 2px solid var(--line);
  padding: 0.25rem 0 0.25rem 0.5rem;
}
.hrow:hover { background: var(--hover); }
/* The three things that happened to a song read differently at a glance —
   which is the whole reason to scan this list at all. */
.hrow.added { border-left-color: var(--green); }
.hrow.deleted { border-left-color: var(--red); }
.hrow.imported { border-left-color: var(--amber); }
.hwhen { display: flex; gap: 0.5rem; font-size: 11px; color: var(--dim); }
.hwho { color: var(--cyan); }
.hwhat { display: flex; flex-wrap: wrap; gap: 0.4rem; font-size: 13px; }
.htitle { color: var(--fg); }
.hchange { color: var(--dim); }
/* The "last edited …" line under the song form. A button, because it opens the
   log — but it must not look like one, or it competes with the controls it
   sits under for a glance it does not deserve. */
.song-history {
  align-self: start;
  margin: 0.1rem 0 0;
  padding: 0;
  min-height: auto;
  background: none;
  border: none;
  color: var(--dim);
  font: inherit;
  font-size: 11px;
  text-align: left;
  text-decoration: underline dotted;
  cursor: pointer;
}
.song-history:hover:not(:disabled) { color: var(--fg); background: none; border: none; }
/* An author rule setting `display` beats the `hidden` attribute, which is only
   a user-agent style — without this the empty button stays on screen. */
.song-history[hidden] { display: none; }

/* --- music stand ------------------------------------------------------- */
/* The floating mini player that used to live here is gone — Wes: "I want this
   floating click player to be gone - I have no use for it now." Music stand
   mode is the reader plus its rail, and nothing floats over the music any more.
   Its styles went with it; only the amber flag line survives, because the rail
   paints it through the same shared painter. See src/ui/musicstand.js. */
.stand-flags { font-size: 11px; color: var(--amber); }
/* An author rule setting `display` beats the `hidden` attribute, which is only
   a user-agent style. */
.stand-flags[hidden] { display: none; }

/* --- the sheet music reader -------------------------------------------- */
/* Wes asked for the PDF drawn here rather than downloaded or handed to a
   pop-up window, so this covers the app while it is open. */
.reader {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  flex-direction: column;
  background: var(--bg);
}
.reader[hidden] { display: none; }
.reader-bar {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
  padding: 0.4rem 0.6rem;
  border-bottom: 1px solid var(--line);
  background: var(--panel);
}
.reader-title {
  flex: 1;
  min-width: 6rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.reader-nav, .reader-tools { display: flex; align-items: center; gap: 0.3rem; }
.reader-tools { flex-wrap: wrap; }
.reader-pos { font-size: 12px; min-width: 8.5rem; text-align: center; }
.reader-zoom { font-size: 12px; min-width: 3rem; text-align: center; }
.reader-page-btn { min-width: 2.2rem; font-size: 1.1rem; line-height: 1; }
/* A toggle that is on has to look on. Without this, "two up" and "dark" read
   exactly the same whether they are engaged or not, which is a control you can
   only understand by looking at the page it changed. */
.reader-tools .ghost.on {
  color: var(--bg);
  background: var(--green);
  border-color: var(--green);
}
/* The music and the rail, side by side. */
.reader-body {
  flex: 1;
  min-height: 0;
  display: flex;
}
/* Just the music. `position: relative` because the two edge tap slabs are
   positioned against this box rather than against the reader — against the
   reader, "right: 0" would put the forward tap under the rail. */
.reader-view {
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  position: relative;
}
.reader-stage {
  flex: 1;
  min-width: 0;
  min-height: 0;
  overflow: auto;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 4px;
}

/* --- the page-turn slabs down the edges ---
   Wes asked for buttons to tap as well as keys to press. Always visible and
   always dim rather than appearing on hover: his phone has no hover, and a
   tap that lands on a control nobody could see is a page turned by accident. */
.reader-tap {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 10%;
  min-width: 2.5rem;
  max-width: 4rem;
  border: 0;
  border-radius: 0;
  /* A gradient, not a slab. These lie over white paper as often as over the
     dark app, so they have to be visible on both — but a flat tint across a
     tenth of the page grays out whatever is written under it, and on a chart
     that is a chord symbol. Solid at the very edge, gone by the inner side,
     so the affordance is obvious and the music underneath is not touched. */
  color: hsl(0 0% 100% / 0.95);
  text-shadow: 0 0 4px hsl(220 25% 7% / 0.9);
  font-size: 2rem;
  line-height: 1;
  /* Above the music, below the rail. */
  z-index: 1;
}
.reader-tap.prev {
  left: 0;
  justify-content: flex-start;
  background: linear-gradient(to right, hsl(220 25% 7% / 0.45), transparent);
}
.reader-tap.next {
  right: 0;
  justify-content: flex-end;
  background: linear-gradient(to left, hsl(220 25% 7% / 0.45), transparent);
}
/* The glyph rides at the edge with the opaque end of its gradient, not in the
   middle of the strip where the gradient has already faded out under it. */
.reader-tap { display: flex; align-items: center; padding: 0 0.35rem; }
.reader-tap[hidden] { display: none; }
.reader-tap.prev:hover, .reader-tap.prev:focus-visible {
  background: linear-gradient(to right, hsl(220 25% 7% / 0.75), hsl(220 25% 7% / 0.15));
}
.reader-tap.next:hover, .reader-tap.next:focus-visible {
  background: linear-gradient(to left, hsl(220 25% 7% / 0.75), hsl(220 25% 7% / 0.15));
}
/* While drawing on the page the slabs would swallow the start of a highlight
   aimed at the left margin, which is exactly where a chord symbol is. */
.reader-view:has(.reader-pages.marking) .reader-tap { pointer-events: none; opacity: 0.15; }

/* --- the rail: click controls over a thinned set list --- */
.reader-rail {
  width: clamp(11rem, 22%, 17rem);
  flex: none;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding: 0.5rem;
  border-left: 1px solid var(--line);
  background: var(--panel);
  overflow-y: auto;
  z-index: 2;
}
.reader-rail[hidden] { display: none; }
.rail-bpm {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 0.3rem;
  font-size: 2rem;
  line-height: 1;
  color: var(--amber);
  font-variant-numeric: tabular-nums;
}
/* The tempo itself. It takes its size and color from `.rail-bpm` above and only
   needs to not shrink — but it is stated rather than left to inheritance,
   because this is the number read from two meters away and a later rule landing
   on `.rail-bpm > *` would take it with everything else. */
.rail-tempo { flex: none; }
.rail-bpm .unit { font-size: 10px; }
.rail-name { font-size: 12px; text-align: center; color: var(--fg); overflow-wrap: anywhere; }
/* One dot per beat of the bar, sitting directly under the tempo it belongs to.
   Wes had the beat number here as well and took it off: "this is visible from
   the beat circles already." */
.rail-dots {
  display: flex;
  justify-content: center;
  gap: 0.3rem;
  min-height: 1.1rem;
  margin-bottom: 0.1rem;
}
.rail-pos {
  font-size: 10px;
  text-align: center;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}
/* "next · <song>". Empty on the last song of the set, and an empty element has
   no height, so it costs nothing there. */
.rail-next { font-size: 10px; text-align: center; color: var(--cyan); }
.reader-rail .stand-flags { font-size: 10px; text-align: center; color: var(--amber); }
.reader-rail .stand-flags[hidden] { display: none; }
.rail-transport { display: flex; gap: 0.25rem; }
.rail-transport .ctl { flex: 1; min-width: 0; font-size: 12px; padding: 0.35rem 0.2rem; }
/* Which song's chart is on screen, moved without touching the click. Its own
   row and its own words, because "next page" and "next song's chart" are
   different things and a stand that confuses them is a stand that loses you. */
.rail-chart {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
}
.rail-chart .cap { flex: 1; min-width: 0; }
.rail-chart button { min-width: 2rem; font-size: 12px; padding: 0.2rem; }
/* The reader is showing a different song's chart to the one the click is on.
   Amber, because "the music in front of me is not the song that is playing" is
   the single most dangerous state this whole mode can be in. */
.rail-detached {
  font-size: 11px;
  color: var(--amber);
  text-align: center;
  text-transform: none;
  letter-spacing: normal;
}
/* The stand's key hints, under the set list.

   The footer carries these on the ordinary screen and the reader covers the
   footer (`position: fixed; inset: 0`, opaque), so with a chart up this is the
   only place they exist. `flex: none` and `margin-top: auto` put it at the foot
   of the rail whatever the set list does: the list above takes the slack, and
   an empty set list must not leave the hints floating in the middle of a column
   of nothing. `overflow-wrap` because a rail is 11rem at its narrowest and the
   strip has to wrap rather than push the rail wider. */
.rail-keys {
  flex: none;
  margin-top: auto;
  padding-top: 0.4rem;
  border-top: 1px solid var(--line);
  font-size: 11px;
  line-height: 1.6;
  color: var(--dim);
}
/* A hint is one unbreakable thing: "D close" split across two lines reads as
   two hints, neither of which is true. */
.rail-keys .hint { white-space: nowrap; }

/* The set list is its own scroller, and that is the fix for Wes's *"make stand
   mode attempt to keep the selected song in the top 1/3rd of the set list
   scroll window when navigating with arrow keys."* It had no overflow of its
   own, so the scroll the app wrote to it went nowhere — and scrolling the rail
   instead is worse, because it takes the tempo, the beat circles and the
   transport off the top of a stand mid-song.
   `flex: 1 1 auto` with `min-height: 0` so it takes whatever the controls above
   it leave and no more; `position: relative` so a row's offsetParent chain
   stops here, which is how `keepRowInBand` measures it. */
.rail-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  position: relative;
}
.rail-list li {
  display: flex;
  align-items: baseline;
  gap: 0.35rem;
  padding: 0.3rem 0.35rem;
  border: 1px solid transparent;
  border-radius: var(--radius);
  font-size: 12px;
  cursor: pointer;
}
.rail-list li:hover { background: var(--well); }
.rail-list li .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-list li .song-key { color: var(--cyan); font-size: 11px; }
/* A break in the service — a prayer, the sermon, the offering. Wes asked for
   these to be visible in stand mode; before this the rail ran one song straight
   into the next with no sign that twenty minutes sat between them. Drawn the
   way the main set list draws them (indented, dashed, dim) so the two lists
   read as the same list, and not clickable, because there is nothing to load. */
.rail-list li.marker-row {
  margin-left: 0.7rem;
  padding: 0.2rem 0.35rem;
  border-style: dashed;
  border-color: color-mix(in srgb, var(--line) 80%, transparent);
  color: var(--dim);
  font-size: 11px;
  font-style: italic;
  cursor: default;
}
.rail-list li.marker-row:hover { background: none; }
/* The song the click is on. */
.rail-list li.active { border-color: var(--green); background: color-mix(in srgb, var(--green) 16%, var(--panel)); }
/* The song whose chart is being read, when it is not the one playing. */
.rail-list li.reading { border-color: var(--amber); }

/* A rail down the side needs a side to be down. Below this the reader is
   narrower than about two phone-widths, and 11rem of set list left the music
   at 34% — unreadable, on the one device most likely to be an actual music
   stand. So it goes under the music instead, as a short strip: everything is
   still reachable, and it costs the page a few rem of height rather than half
   its width. Measured at 390px, which is his iPhone. */
@media (max-width: 700px) {
  .reader-body { flex-direction: column; }
  .reader-rail {
    width: auto;
    max-height: 9.5rem;
    flex: none;
    border-left: 0;
    border-top: 1px solid var(--line);
    padding: 0.35rem 0.5rem;
  }
  /* The song name is already at the top of the reader's own bar, and the
     floating player carries it too. Three copies on a 390px screen is two too
     many. */
  .reader-rail .rail-name { display: none; }
  .rail-bpm { font-size: 1.4rem; }
  /* Under the music the rail is a short strip, and a set list allowed to shrink
     to nothing between the transport and the bottom edge is a set list nobody
     can reach. Two rows minimum; the strip itself scrolls past that. */
  .reader-rail .rail-list { min-height: 2.6rem; }
  /* Under the music there is no height to spare, and the bar/beat position is
     the least urgent of the readouts — the dots say where you are faster. */
  .reader-rail .rail-pos { display: none; }
}
.reader-pages { display: flex; gap: 16px; align-items: flex-start; }
.reader-pages[hidden] { display: none; }
.reader-canvas { display: block; background: #fff; }
/* Dark mode. A filter rather than a re-render, so it toggles instantly — and
   deliberately on the pages rather than on .reader, because the mini player
   floats above this and must stay the color the app is. hue-rotate after the
   invert keeps a red chord marking red instead of turning it cyan. */
.reader-pages.inverted .reader-canvas { filter: invert(1) hue-rotate(180deg); }
/* --- marks on the page --- */
/* Absolutely positioned over the canvas and NOT under its filter, so a yellow
   highlight stays yellow in dark mode. Transparent to the pointer unless
   marking is on, or a highlight would swallow a press meant for the page. */
.reader-page { position: relative; }
.reader-marks { position: absolute; inset: 0; pointer-events: none; }
.reader-pages.marking .reader-marks { pointer-events: auto; cursor: crosshair; touch-action: none; }
.reader-mark {
  position: absolute;
  pointer-events: none;
  border-radius: 2px;
}
.reader-pages.marking .reader-mark { pointer-events: auto; }
/* Low alpha on purpose: this goes over the notes you are reading, and a
   highlight you cannot read through is a highlight that hid the music. */
.reader-mark.highlight { background: hsl(48 100% 50% / 0.32); }
.reader-pages.inverted .reader-mark.highlight { background: hsl(48 100% 55% / 0.22); }
/* The words scale with the box, which scales with the page — so a note stays
   the same size *relative to the music it annotates* at any zoom, and making
   one note wider is how you make that one note bigger. `noteFontPx` in
   core/pdfreader.js writes the actual size into the node's inline style; the
   11px here is what a note falls back to if it is ever drawn without one, and
   it is the size the default note lands at on a fitted letter page anyway.
   Everything else in the box is in `em` so it follows. */
.reader-mark.note {
  display: flex;
  align-items: flex-start;
  gap: 0.25em;
  padding: 0.12em 0.28em;
  background: hsl(38 92% 62% / 0.92);
  color: hsl(220 30% 10%);
  font-family: 'Fira Code', 'SF Mono', ui-monospace, monospace;
  font-size: 11px;
  line-height: 1.3;
  box-shadow: 0 1px 4px hsl(220 40% 10% / 0.4);
}
.reader-mark-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.reader-mark-ghost { outline: 1px dashed hsl(48 100% 50% / 0.8); }
/* --- the pen --- */
/* The stroke fills its mark's box exactly, and the box is what a drag or a
   resize moves — so the ink needs no arithmetic of its own at any zoom. Red
   rather than the highlight's yellow: a scribble is a correction, and it has to
   read as ink on top of the music rather than as a wash behind it. */
.reader-stroke { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.reader-stroke polyline {
  stroke: hsl(2 80% 55%);
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* A red line on an inverted page comes out cyan under the canvas filter — but
   the marks layer is deliberately NOT under that filter, so the ink keeps its
   color and only needs lifting to stay legible on black. */
.reader-pages.inverted .reader-stroke polyline { stroke: hsl(6 100% 68%); }
/* The stroke being drawn, before the pen lifts: same ink, spanning the whole
   page rather than a box that grows under it. */
.reader-stroke-ghost { pointer-events: none; }
/* A pen mark's box is the ink's own extent, which for a scribble is mostly
   empty — so the box itself takes the pointer for dragging, and shows its edges
   only while marking, or there would be nothing to grab. */
.reader-mark.pen { background: none; }
.reader-pages.marking .reader-mark.pen { outline: 1px dashed hsl(2 80% 55% / 0.5); }
/* --- moving and resizing a mark --- */
.reader-pages.marking .reader-mark { cursor: move; }
.reader-mark-handle {
  position: absolute;
  /* Wes: *"make the corners to drag to resize whatever highlight, note, or pen
     mark in the file viewing mode be much smaller. They are large and obnoxious
     right now."* They were 14px of solid white on each corner, which on a
     highlight over one line of lyrics is most of the mark.
     The dot you see is 6px; what your finger hits is the ::before below, which
     is still 20px. Making the target smaller as well would have traded one
     complaint for a worse one — a corner you cannot grab on a touchscreen. */
  width: 6px;
  height: 6px;
  margin: -3px;
  border: 1px solid hsl(220 40% 12%);
  border-radius: 50%;
  background: hsl(0 0% 100% / 0.9);
  touch-action: none;
}
/* The hit area, invisible and centered on the dot. A press anywhere in here
   reports the handle itself as the event target (a pseudo-element cannot be
   one), so the reader's `closest('.reader-mark-handle')` still finds it. */
.reader-mark-handle::before {
  content: '';
  position: absolute;
  inset: -7px;
}
.reader-mark-handle.nw { left: 0; top: 0; cursor: nwse-resize; }
.reader-mark-handle.ne { left: 100%; top: 0; cursor: nesw-resize; }
.reader-mark-handle.sw { left: 0; top: 100%; cursor: nesw-resize; }
.reader-mark-handle.se { left: 100%; top: 100%; cursor: nwse-resize; }
/* --- the note editor --- */
/* Centered over the music rather than anchored to the note it edits: a popover
   pinned beside a note near the right edge of a fitted page hangs off the
   screen, and the mark being edited is already the lit one. */
.reader-note-editor {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 4;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  width: min(24rem, calc(100% - 2rem));
  padding: 0.6rem;
  background: var(--panel, hsl(220 24% 12%));
  border: 1px solid var(--line, hsl(220 20% 30%));
  border-radius: 4px;
  box-shadow: 0 6px 24px hsl(220 40% 4% / 0.6);
}
/* display:flex would beat the hidden attribute, which is the trap
   .field[hidden], a.button-link[hidden] and #help[open] were each bitten by. */
.reader-note-editor[hidden] { display: none; }
.reader-note-input { width: 100%; resize: vertical; }
.reader-note-row { display: flex; justify-content: flex-end; gap: 0.4rem; }
.reader-mark-edit {
  flex: none;
  min-height: auto;
  padding: 0 0.2em;
  font-size: 11px;
  line-height: 1.2;
  color: hsl(220 30% 10%);
  background: hsl(0 0% 100% / 0.6);
  border: 0;
  border-radius: 2px;
  cursor: pointer;
}
.reader-mark-kill {
  flex: none;
  min-height: auto;
  padding: 0 0.2em;
  font-size: 11px;
  line-height: 1.2;
  color: hsl(220 30% 10%);
  background: hsl(0 0% 100% / 0.6);
  border: 0;
  border-radius: 2px;
  cursor: pointer;
}
/* On a note the two buttons ride its scaled text: an 11px × beside 24px words
   is a target that shrank while everything around it grew. A highlight and a
   pen have no font-size of their own, so they keep the fixed 11px above. */
.reader-mark.note .reader-mark-edit,
.reader-mark.note .reader-mark-kill { font-size: 0.95em; }
/* A highlight and a pen stroke have no flow to put a button in — the × goes in
   the top-right corner of the box rather than over the middle of the ink. */
.reader-mark.highlight .reader-mark-kill,
.reader-mark.pen .reader-mark-kill { position: absolute; top: 0; right: 0; }

.reader-msg { margin: 2rem auto; max-width: 32rem; text-align: center; }
.reader-msg[hidden] { display: none; }
/* Fit-to-width scrolls down the page, so the pages stop being centered
   vertically and start at the top of a stage that can grow past the box. */
.reader.fit-width .reader-stage { align-items: flex-start; }
/* Music stand mode opened on a song that has no chart. The line sat at the top
   of an otherwise empty full-height stage, which reads as a page that failed to
   draw rather than as a page there was never anything to draw on — so it is
   centered in the space instead, which is what "deliberately blank" looks like. */
.reader-stage:has(> .reader-msg:not([hidden])) { align-items: center; }

/* Walking this song's other charts, beside "open a file" — Wes, 2026-09-19:
   "cycle through different files for a given song after opening a file
   without having to close that file". Drawn as one control rather than three,
   so the two arrows and the position read as belonging to each other and not
   to the page arrows further up the bar. */
.reader-files {
  display: inline-flex;
  align-items: center;
  gap: 0.15rem;
}
.reader-files[hidden] { display: none; }
.reader-file-btn { min-width: 1.6rem; padding: 0.2rem 0.3rem; }
.reader-file-at {
  min-width: 2.2rem;
  text-align: center;
  font-size: 11px;
  /* Tabular so 9/12 and 10/12 do not shift the arrows sideways under a
     finger that is already reaching for one. */
  font-variant-numeric: tabular-nums;
}

@media (max-width: 640px) {
  /* The bar wrapped to three rows on a phone and ate a third of the screen,
     which on the one device most likely to be a music stand is the screen you
     came for. Tighter chrome, and the tab link goes down to the one word that
     matters — a label that long is a label that forces its own row. */
  .reader-title { flex-basis: 100%; text-align: center; font-size: 12px; }
  .reader-bar { gap: 0.25rem; padding: 0.3rem; row-gap: 0.25rem; }
  .reader-bar .ghost { font-size: 11px; padding: 0.2rem 0.35rem; min-height: 1.7rem; }
  .reader-pos { min-width: 6.5rem; font-size: 11px; }
  .reader-page-btn { min-width: 1.8rem; }
  /* Qualified with .reader-bar so it beats `.reader-bar .ghost` above — a
     bare `.reader-tab` loses on specificity and the label stays full length,
     which is how this first rendered as "open in a new tabtab". */
  .reader-bar .reader-tab { font-size: 0; }
  .reader-bar .reader-tab::after { content: 'tab'; font-size: 11px; }
  /* Same treatment, same specificity trap — see the note above. */
  .reader-bar .reader-file { font-size: 0; }
  .reader-bar .reader-file::after { content: 'file'; font-size: 11px; }
  .reader-file-at { min-width: 1.9rem; }
}

/* --- Planning Center charts and audio --- */

#pco-files { max-width: 40rem; width: min(40rem, 92vw); }

.pco-files-head {
  margin: 1rem 0 0.3rem;
  font-size: 12px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
}
.pco-files-head:first-of-type { margin-top: 0.25rem; }
.pco-files-empty { margin: 0.2rem 0; font-size: 13px; }
/* The door out to Planning Center's own page for this plan. Above the lists,
   with its sentence beside it rather than under it — it is one row of the panel
   and must not read as a heading for what follows. */
.pco-files-plan {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem 0.6rem;
  margin-bottom: 0.6rem;
  padding-bottom: 0.6rem;
  border-bottom: 1px solid var(--line);
}
.pco-files-plan .hint { font-size: 11px; }
.pco-files-summary { margin: 1rem 0 0; font-size: 12px; }
.pco-files-clear { margin-top: 0.35rem; }

.pco-files-list { list-style: none; margin: 0; padding: 0; }
.pco-files-list li {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.35rem 0.4rem;
  border: 1px solid transparent;
  border-radius: var(--radius);
}
.pco-files-list li + li { margin-top: 0.15rem; }
.pco-files-list .row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pco-files-list .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pco-files-list .sub { font-size: 12px; }
/* The reason an unavailable row is unavailable has to wrap — it is a sentence,
   not a label, and truncating it leaves the row looking broken for no reason. */
.pco-files-list .sub.warn { white-space: normal; }
.pco-files-list li.picked {
  border-color: color-mix(in srgb, var(--green) 45%, transparent);
  background: color-mix(in srgb, var(--green) 8%, transparent);
}
.pco-files-list li.unavailable .name { color: var(--dim); }
.pco-files-list li.busy { opacity: 0.6; }
/* min-width:0 on the nested flex container as well as on the child: a flex
   box's min-content floor comes from its children's specified widths, so the
   buttons would otherwise push the filename off the row at phone width. */
.pco-files-actions { display: flex; gap: 0.3rem; flex-shrink: 0; min-width: 0; }


/* The per-row way into the charts. Small and quiet: it sits beside a row whose
   job is to be scanned on a stage, and it must not compete with the song name. */
.row-files {
  flex-shrink: 0;
  opacity: 0.65;
  padding: 0.1rem 0.3rem;
  line-height: 1;
}
.row-files:hover { opacity: 1; }

/* "This one's numbers are right" on an imported row. Amber, matching the amber
   tempo it clears — the button and the mark are one thing, so they are one
   color. It only exists while the row is flagged, so there is never a tick to
   press on a row that has already been confirmed. */
.row-confirm {
  flex-shrink: 0;
  padding: 0.1rem 0.35rem;
  line-height: 1;
  color: var(--amber);
  border-color: color-mix(in srgb, var(--amber) 45%, transparent);
}
.row-confirm:hover, .row-confirm:focus-visible {
  color: var(--bg);
  background: var(--amber);
  border-color: var(--amber);
}

@media (max-width: 480px) {
  /* The buttons drop under the name rather than squeezing it to nothing —
     a filename is how you tell "Lead G" from "Chords G", so it is the part
     that must survive. */
  .pco-files-list li { flex-wrap: wrap; }
  .pco-files-actions { width: 100%; justify-content: flex-end; }
}

/* --- the walkthrough ---------------------------------------------------- *
 *
 * A spotlight and a bubble, both fixed to the viewport and both children of
 * <body>. See the markup in index.html for why this is not a <dialog>.
 *
 * The dimming is done by the HOLE, not by a separate backdrop: one absurdly
 * large spread on a box-shadow paints everything outside the element's
 * rectangle and nothing inside it, which is a cut-out without needing a mask,
 * an SVG or four separate dimming panels that never quite meet at the corners.
 */
#tour-hole {
  position: fixed;
  z-index: 90;
  border: 1px solid var(--green);
  border-radius: var(--radius);
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.66);
  /* The spotlight is scenery. Clicks belong to whatever is underneath it, so
     the app stays usable while the tour is explaining it. */
  pointer-events: none;
  transition: top 120ms ease, left 120ms ease, width 120ms ease, height 120ms ease;
}

/* The opening step points at nothing in particular, so there is no rectangle
   to cut — just the dimming. Zero size with the shadow still on would draw a
   1px scar in the middle of the screen. */
#tour-hole.tour-nowhere {
  border-color: transparent;
  border-radius: 0;
}

#tour-bubble {
  position: fixed;
  z-index: 91;
  width: min(22rem, calc(100vw - 1rem));
  padding: 0.7rem 0.8rem;
  border: 1px solid var(--green);
  border-radius: var(--radius);
  background: var(--panel);
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
#tour-bubble h2 {
  margin: 0 0 0.35rem;
  font-size: 14px;
  color: var(--green);
}
#tour-bubble p {
  margin: 0 0 0.6rem;
  font-size: 12px;
  line-height: 1.5;
}
.tour-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}
.tour-foot #tour-count { font-size: 11px; white-space: nowrap; }
.tour-buttons { display: flex; gap: 0.3rem; }
.tour-buttons button { padding: 0.2rem 0.5rem; font-size: 12px; }

/* The dimmed page must not also be a scrolling one: the spotlight is placed in
   viewport coordinates and a scroll under it would leave it pointing at empty
   space until the next reposition. */
body.touring { overflow: hidden; }

@media (max-width: 480px) {
  /* On a phone the bubble is most of the width, so the buttons get their own
     line rather than crushing the step counter out of existence. */
  .tour-foot { flex-wrap: wrap; }
  .tour-buttons { margin-left: auto; }
}
