/* Artist dashboard (all /dashboard/ pages).

   NO RAW COLOUR, with three deliberate exceptions, each argued in place: the
   QR canvas plate, the artist-logo backing plate, and the print sheet. All
   three are cases where a fixed white is the FUNCTION, not a style choice.
   Everything else names a role and lets the theme decide.

   NOTHING IS DISTINGUISHED BY HUE ALONE. Where a colour carries meaning here
   — status text, chart series, selected options — a glyph, a shape, a dash
   pattern or a luminance step carries it as well, and the colour is the
   redundant channel rather than the only one.

   Two local ladders below, mirroring the ones Gig Mode declares in shell.css.
   Every size in this file used to be hard px, so nothing could respond to a
   theme at all. Sizes come off --db-t-*, vertical rhythm off --db-pad /
   --db-gap / --db-row, and nothing branches on theme. All three themes
   currently ship --font-scale 1 and --density 1 — Stage differs by COLOUR
   ONLY — but the levers exist, so changing the type scale is a token edit
   rather than a sweep through this file.                                     */


/* THIS FILE CAN BE LIVE BEFORE ITS TOKENS ARE. See the long note at the top of
   css/shell.css: the five stylesheets ship as media="print" and js/css-boot.js
   promotes each one AS IT LOADS, so css/dashboard.css can be applying while
   css/themes.generated.css is not. In that window every var(--radius-lg) in
   here is unresolvable, every border-radius falls back to its initial value of
   0, and `.vm` — every modal in the app — is drawn with square corners.
   Reproduced with the token sheet delayed 2600ms: `.vm border-radius` read 0px
   at t=235ms and 18px at t=2790ms.

   Registering the shape and rhythm tokens gives them an initial value, so a
   var() reference to them can never be invalid. Repeated from shell.css rather
   than imported, because the whole point is that neither file depends on
   another network resource in order to draw itself. Themes still win: any
   author declaration beats a registered property's initial value.

   The ramps below consume --font-scale and --density, so those two matter as
   much as the radii — an unresolvable --density takes --db-pad with it and
   every panel and modal in this file loses its padding at the same moment. */
@property --radius        { syntax: '<length>'; inherits: true; initial-value: 12px; }
@property --radius-lg     { syntax: '<length>'; inherits: true; initial-value: 18px; }
@property --density       { syntax: '<number>'; inherits: true; initial-value: 1; }
@property --font-scale    { syntax: '<number>'; inherits: true; initial-value: 1; }
@property --line-height   { syntax: '<number>'; inherits: true; initial-value: 1.5; }
@property --weight-normal { syntax: '<number>'; inherits: true; initial-value: 400; }
@property --weight-medium { syntax: '<number>'; inherits: true; initial-value: 500; }
@property --weight-strong { syntax: '<number>'; inherits: true; initial-value: 600; }

:root {
  /* Also declared in css/shell.css, for the same reason and with the same
     value — nothing else defines it and one control in the shell asks for it.
     See the note there. */
  --radius-md: calc(var(--radius) - 4px);
}

:root {
  /* Type ramp. Eight steps, not the seventeen ad-hoc values this file had. */
  --db-t-micro: calc(11px * var(--font-scale));  /* uppercase labels, chips */
  --db-t-small: calc(12px * var(--font-scale));  /* meta, column units */
  --db-t-hint:  calc(13px * var(--font-scale));  /* hints, secondary rows */
  --db-t-body:  calc(14px * var(--font-scale));
  --db-t-mid:   calc(15px * var(--font-scale));  /* row figures, field text */
  --db-t-head:  calc(20px * var(--font-scale));  /* panel headings */
  --db-t-big:   calc(22px * var(--font-scale));  /* chart headline figures */
  --db-t-fig:   calc(26px * var(--font-scale));  /* the numbers that count */

  /* Vertical rhythm. One unit, scaled by the theme's density. */
  --db-gap:      calc(12px * var(--density));
  --db-pad:      calc(20px * var(--density));    /* panel padding */
  --db-row:      calc(40px * var(--density));    /* dense row height */
  --db-row-pad:  calc(7px * var(--density));
  --db-field:    calc(48px * var(--density));    /* form control height */
  --db-field-sm: calc(44px * var(--density));
  --db-control:  calc(36px * var(--density));    /* inline control height */
}

/* Panels separate from the page by a real luminance step (bg -> surface, ~5
   L* in every theme), so they are no longer outlined as well. Borders here
   are now reserved for things that need a hit target or that sit on a plane
   the ladder barely separates — which on Light is anything using
   --surface-sunken, since sunken and surface are 3 L* apart by design. */
.panel {
  background: var(--surface);
  border-radius: var(--radius-lg);
  padding: var(--db-pad);
  margin-top: var(--db-pad);
  /* Dense rows size their columns against the panel rather than the window,
     because in the two-column dashboard grid those are very different numbers. */
  container-type: inline-size;
}

.panel h2 { margin: 0 0 4px; font-size: var(--db-t-head); }
.panel .sub { margin: 0 0 12px; font-size: var(--db-t-hint); }

/* ---- the two fixed rows -----------------------------------------------------
 *
 * The view's own grid (css/shell.css) is `repeat(auto-fit, minmax(330px, 1fr))`
 * shared with three other screens, so the number of columns it lands on is
 * whatever the window happens to allow. These two rows need exact shapes, so
 * they span the full width and lay themselves out inside it:
 *
 *   .dash-trio   Overview · Worth learning · Most requested
 *   .dash-foot   Past gigs (2/3) · Venues (1/3)
 *
 * MEASURED AGAINST THE VIEW, NOT THE WINDOW. This app has three chromes — a
 * bottom bar on a phone, a left rail on a tablet on a mic stand, a sidebar on
 * a desktop — so the same window width leaves three different content widths.
 * A media query would put three cards across a 1100px window that only has
 * 820px of room for them. So these ask a container how much room there is.
 *
 * THE CONTAINER IS THE SECTION, NOT #dashboardView. It used to be the view
 * itself, which is also the grid box (`repeat(auto-fit, minmax(330px, 1fr))`,
 * see css/shell.css). Putting inline-size containment on a grid container
 * whose track count is derived from its own inline size is asking two
 * different sizing mechanisms to agree about the same number, and engines do
 * not agree about it — it measured correctly in headless Chromium and
 * collapsed the cards to min-content on the live site.
 *
 * section[data-view] is the view's parent, a plain block box, and it is
 * exactly the same width (1160px at a 1440 window, 782px at 834). So the
 * queries below answer identically without anything having to resolve its own
 * width from its own contents. */
section[data-view] { container-type: inline-size; }

/* The single-column default is EXPLICIT, not inherited from the absence of a
   template. If every query below fails to match — an engine without
   @container, a container that resolves to nothing — this is what is left,
   and it has to be the mobile layout rather than whatever intrinsic sizing
   would otherwise choose. `minmax(0, …)` because a long unbroken song title
   must never be able to widen a track. */
.dash-trio, .dash-foot {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--db-pad);
  margin-top: var(--db-pad);
}
.dash-trio > .panel, .dash-foot > .panel { margin: 0; min-width: 0; }

/* Middle: Overview takes the first row whole, then the two song lists sit side
   by side beneath it. Where you are, then what to do about it — the same
   reading order as the desktop row, folded rather than reordered. The two
   lists are also the pair that benefit from being read against each other, so
   they are the pair that stays together when the row has to break. */
@container (min-width: 660px) {
  .dash-trio { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .dash-trio > :nth-child(1) { grid-column: 1 / -1; }
}

/* Wide: the row as designed. .dash-foot splits here and not before — a third
   of a 900px view is 290px, and the venue table wants more than that. */
@container (min-width: 1000px) {
  .dash-trio { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .dash-trio > :nth-child(1) { grid-column: auto; }
  .dash-foot { grid-template-columns: 2fr 1fr; align-items: start; }
}

/* THE THREE CARDS ARE ONE HEIGHT. Grid stretches them to the tallest — which
   is a five-row list — and Overview would otherwise sit in a box of its own
   height with a band of nothing under it. Its four tiles take up the slack
   instead, so the row reads as three cards rather than two cards and a gap. */
.dash-trio > .panel { display: flex; flex-direction: column; }
.dash-trio .stat-strip { flex: 1; align-content: stretch; }
.dash-trio .stat-tile { display: flex; flex-direction: column; justify-content: center; }

/* ---- the venue modal --------------------------------------------------------
 *
 * Everything about one room. The SONG LISTS COME FIRST and are the only things
 * here set at body size — the averages, the comparison and the nights are all
 * context for a decision, and the decision is which songs. If the numbers ever
 * start pulling the eye first, they are too big.
 *
 * Same scrim token as the About dialog, so the app has one idea of "a thing
 * over the page". */
/* THE SHEET NEVER RUNS OFF ANY EDGE OF THE SCREEN.
 *
 * The overlay is a fixed, full-viewport grid that pads itself by its own
 * margin plus the safe-area insets, so on a notched phone the card starts
 * below the notch and ends above the home indicator. On a desktop every
 * env(safe-area-inset-*) resolves to 0, so the insets cost nothing there;
 * they are not a media query and do not need to be.
 *
 * THE CAP IS `100%`, AND THAT IS THE WHOLE FIX.
 *
 * It used to be min(85dvh, 900px). dvh measures the VIEWPORT, and the grid
 * area the card actually sits in is the viewport MINUS the padding above. So
 * the two were being counted against different boxes, and every pixel of
 * safe-area inset ate into the margin between them: on a landscape phone,
 * where the bottom inset is real, 85dvh plus 16px plus the inset came within a
 * pixel or two of the screen. It fit by luck rather than by construction, and
 * anything that grew the padding would have pushed the card's top off screen
 * where a centred grid item cannot be scrolled back to.
 *
 * `100%` of the grid area is the grid area, which is the viewport already less
 * the padding. It cannot exceed the screen whatever the insets do, at any
 * size, and it needs no unit that browsers disagree about. The 900px ceiling
 * stays so the card does not become a column of text on a big monitor.
 *
 * The card is only ever as tall as its content: max-height is a ceiling, not a
 * height. What changed for the better on a short laptop window is that it may
 * now use the full available height rather than 85% of it, which is several
 * more rows before anybody has to scroll. */
/* AND THREE GUARDS AGAINST THE CARD BEING DRAWN ABOVE THE TOP EDGE, where
 * nothing can scroll it back and the header and close button go with it.
 *
 * 1. THE OVERLAY IS A CHILD OF <body>. js/modal-portal.js moves it there.
 *    `position: fixed; inset: 0` only means "the viewport" while no ancestor
 *    establishes a containing block for fixed descendants, and this app has
 *    one: section[data-view] carries container-type, which implies
 *    contain: layout. Where that applies, inset:0 resolves against a section
 *    as tall as the whole dashboard, and place-items:center then centres the
 *    card in the middle of the DOCUMENT. Scrolled down to the Venues card,
 *    which is near the foot of a long page, that puts it off the top.
 *
 *    Engines disagree, which is why this was hard to see. Measured 11 August
 *    with a minimal probe (3000px section with container-type, fixed inset:0
 *    child, scrolled 400px): Chromium reported the overlay viewport-sized and
 *    NOT trapped. WebKit historically does trap, and the app runs in Safari.
 *    Rather than reason about which engine is right, remove the ancestor.
 *
 * 2. `place-items: safe center`. Centring is what makes an overflowing item
 *    unreachable. `safe` means "centre it, unless that pushes it past the
 *    start edge, in which case align to start". The plain `center` above is
 *    the fallback for engines that do not understand `safe`, so the order of
 *    the two declarations matters.
 *
 * 3. THE SCRIM ITSELF SCROLLS. A backstop for anything the other two miss:
 *    a card taller than its space stays reachable. It should never fire. */
.vm-overlay {
  position: fixed;
  inset: 0;
  z-index: var(--layer-modal);
  display: grid;
  place-items: center;
  place-items: safe center;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding:
    calc(16px + var(--safe-top, 0px))
    calc(16px + var(--safe-right, 0px))
    calc(16px + var(--safe-bottom, 0px))
    calc(16px + var(--safe-left, 0px));
  background: var(--surface-overlay);
}
.vm-overlay[hidden] { display: none; }

/* A QUESTION, NOT A PANEL. The venue modal is a page of figures and wants all
   560px of .vm; this is one sentence and two buttons, and at full width the
   buttons end up a hand's width apart from the words they answer. */
.vm-ask { width: min(420px, 100%); }
.vm-ask-actions {
  display: flex;
  /* THREE ANSWERS NOW, not two: Cancel, Remove it, Add it. Wrapping rather
     than shrinking, because a button that has been squeezed narrower than its
     label is a button somebody misses. */
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 10px;
  padding: 16px;
  border-top: 1px solid var(--border);
}

/* WHAT REMOVE ACTUALLY DOES, under the buttons.
   Below rather than above because it answers a decision somebody has already
   started making, and quiet because Add is the answer this card is for. It is
   still full body size: it is the one line that stops "Remove" being read as
   "delete", and that is not a footnote. */
.vm-ask-note {
  margin: 0;
  padding: 0 16px 16px;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  line-height: var(--line-height);
}
/* Stacked on a narrow phone, and the confirm goes on TOP once they stack: the
   thumb rests at the bottom of the screen, and the bottom of a stack is where
   an accidental press lands. Cancel takes that position. */
/* Stacking order is source order reversed, so it reads Add it, Remove it,
   Cancel from the top — the two that do something above the one that does not,
   and Cancel on the thumb rest. */
@media (max-width: 380px) {
  .vm-ask-actions { flex-direction: column-reverse; flex-wrap: nowrap; }
  .vm-ask-actions > button { width: 100%; }
}

.vm {
  width: min(560px, 100%);
  max-height: min(100%, 900px);
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: var(--surface);
  /* THE CARD'S OWN EDGE, against its own scrim — the same fault that was fixed
     on .gm-modal in css/shell.css and missed here, because the sweep that found
     it went looking for modals in the shell and this one lives in the
     dashboard. The card is separated from the PAGE by the luminance ladder, but
     the scrim darkens the page and not the card, so what the eye actually has
     to find is the card against the wash: 1.17:1 on Dark and 1.13 on Stage.
     --border-strong measures 4.02 and 3.90 against those same scrims, which is
     what WCAG 1.4.11 asks of a boundary you need in order to read the thing. */
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  /* AND A CLIP, WITHOUT WHICH THE BOTTOM TWO CORNERS RENDER SQUARE.
   *
   * Reported as "rounded top corners and square bottom corners", which ruled
   * out the stylesheet-loading race diagnosed earlier — that flattens all four
   * at once. Measured off the actual pixels: the border ARC is drawn correctly
   * on all four corners, and the BACKGROUND paints straight past it at the
   * bottom two.
   *
   * The cause is .vm-body, which is `overflow-y: auto`. A scroll container
   * gets its own paint layer, and a composited descendant is NOT clipped by an
   * ancestor's border-radius unless that ancestor establishes a clip of its
   * own. The top corners survived because .vm-head sits there and does not
   * scroll; the bottom two are where .vm-body reaches. Every modal in the app
   * is a .vm with a .vm-body, so every modal had it.
   *
   * overflow: hidden is the clip. It does not stop .vm-body scrolling — that
   * is its own scroller — and it does not break the sticky actions bar, which
   * sticks within .vm-body rather than within this box. */
  overflow: hidden;
  box-shadow: var(--shadow-2);
  container-type: inline-size;
}
/* FIXED, AND IT CARRIES THE WAY OUT. Nobody should have to scroll a modal to
   find the button that closes it. flex: none against a scrolling body is what
   pins it; the ✕ is inside it for the same reason. */
.vm-head {
  flex: none;
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: var(--db-pad) var(--db-pad) 12px;
  border-bottom: 1px solid var(--border);
}
.vm-head-text { min-width: 0; }
.vm-title {
  margin: 0;
  font-size: var(--db-t-head);
  overflow-wrap: anywhere;
}
.vm-sub { margin: 3px 0 0; font-size: var(--db-t-hint); color: var(--text-secondary); }
.vm-x {
  margin-left: auto;
  flex: none;
  /* 44px, which is the smallest thing a thumb hits reliably. It was 34. */
  width: 44px;
  height: 44px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--db-t-body);
  cursor: pointer;
}
.vm-x:hover { color: var(--text-primary); border-color: var(--border-strong); }
/* min-height: 0 is what lets a flex child actually shrink and scroll; without
   it the body sizes to its content and the whole card overflows the cap above.
   -webkit-overflow-scrolling for momentum on older iOS.

   IT HAS TO LOOK SCROLLABLE, NOT JUST BE SCROLLABLE.
   macOS and iOS draw overlay scrollbars: nothing is on screen until you are
   already scrolling, and the gutter is zero pixels wide. So a body with nine
   more rows below the fold looked exactly like a body that had been cut off,
   and on a short laptop window five of fourteen rows show. That is the whole
   of the "cropped" complaint: it scrolled perfectly well and gave no sign
   that it did.

   The two gradients below are the classic pure-CSS scroll shadow and they
   need no JavaScript and no scroll listener. The pair with
   background-attachment: local sits at the top and bottom of the CONTENT and
   scrolls away with it; the pair attached to the scroller sits at the top and
   bottom of the BOX and stays put. Where content overflows, the fixed shadow
   is uncovered and you see a soft edge; where it does not, the local one sits
   on top of it and hides it. Self-correcting, and it costs one paint. */
.vm-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  /* The scroll stays in the sheet rather than handing off to the page behind
     it at the ends. Belt and braces with the body lock in js/scroll-lock.js. */
  overscroll-behavior: contain;
  padding: 0 var(--db-pad) var(--db-pad);

  background:
    linear-gradient(var(--surface) 30%, transparent) top / 100% 24px no-repeat local,
    linear-gradient(transparent, var(--surface) 70%) bottom / 100% 24px no-repeat local,
    radial-gradient(farthest-side at 50% 0,
      color-mix(in srgb, var(--text-primary) 16%, transparent), transparent)
      top / 100% 12px no-repeat scroll,
    radial-gradient(farthest-side at 50% 100%,
      color-mix(in srgb, var(--text-primary) 16%, transparent), transparent)
      bottom / 100% 12px no-repeat scroll;
}
/* A FOCUSED FIELD STAYS IN VIEW WHEN THE KEYBOARD COMES UP.
   No .vm sheet has a text input today (the venue modal is read-only and the
   song sheet is three buttons), so this is provision rather than a fix for
   something observed. The browser scrolls a focused field into the nearest
   scrollable ancestor, which is .vm-body; the margin keeps it clear of the
   sticky header rather than tucked under it. */
.vm-body :is(input, textarea, select):focus { scroll-margin: 16px 0 96px; }

.vm-block { padding-top: calc(18px * var(--density)); }
.vm-block + .vm-block { border-top: 1px solid var(--border); margin-top: calc(14px * var(--density)); }
.vm-h { margin: 0 0 2px; font-size: var(--db-t-body); font-weight: var(--weight-strong); }
.vm-note, .vm-empty, .vm-more {
  margin: 0 0 8px;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  line-height: var(--line-height);
}
.vm-empty, .vm-more { margin: 6px 0 0; }

.vm-row {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 7px 0;
  border-bottom: 1px solid var(--border);
  font-size: var(--db-t-body);
}
.vm-row:last-of-type { border-bottom: 0; }
.vm-song { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vm-n {
  flex: none;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  font-size: var(--db-t-hint);
}
.vm-unit { font-size: var(--db-t-small); }
.vm-nights {
  margin-left: 8px;
  padding-left: 8px;
  border-left: 1px solid var(--border);
  font-size: var(--db-t-small);
  color: var(--text-muted);
}

/* Deliberately quieter than the lists above it: smaller figures, no plane of
   its own, and last but for the nights. It answers "is this a good room",
   which is not the question the modal was opened to answer. */
.vm-compare .vm-figs {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
  margin-top: 8px;
}
.vm-figs span { display: flex; flex-direction: column; min-width: 0; font-size: var(--db-t-small); color: var(--text-secondary); }
/* One step BELOW the song rows, not level with them. Equal size and a muted
   colour still reads as a second headline; a smaller figure reads as a
   footnote, which is what this is. */
.vm-figs b { font-size: var(--db-t-hint); color: var(--text-primary); font-variant-numeric: tabular-nums; }
.vm-figs i { font-style: normal; color: var(--text-muted); }
@container (max-width: 420px) {
  .vm-compare .vm-figs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* WHAT THE ROOM HAS GIVEN. Two figures, so two columns at every width — the
   pair only means anything read together, and stacking them would read as two
   separate findings. The total leads and is a step larger than the figures in
   the comparison block above, because on the card for ONE room this is a
   headline rather than a footnote: it is the number the venue list deliberately
   does not carry. */
.vm-earned .vm-figs {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
  margin-top: 8px;
}
.vm-earned .vm-figs b { font-size: var(--db-t-head); }
/* Only drawn while a tip at this room is still settling, which on a finished
   gig is never. Quiet, because it is a note about a few pence rather than a
   problem to act on. */
.vm-pending {
  margin: 8px 0 0;
  font-size: var(--db-t-hint);
  line-height: 1.35;
  color: var(--text-secondary);
}

/* Bars, not a line: each is one night, and a line between them would imply a
   reading in between that nobody took. */
.vm-spark {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 34px;
  margin-top: 14px;
}
.vm-spark i { flex: 1; min-width: 2px; background: var(--action); border-radius: 2px 2px 0 0; opacity: .55; }
.vm-spark i:last-child { opacity: 1; }
.vm-spark-l { margin: 5px 0 0; font-size: var(--db-t-small); color: var(--text-muted); }

/* The venue name is the way in. It has to look like one without turning the
   table into a page of buttons, so it takes the row's own hover and nothing
   else until it is pointed at. */
.drow-open {
  background: transparent;
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.drow-open .drow-title { text-decoration: underline; text-decoration-color: transparent; }
.drow-open:hover .drow-title, .drow-open:focus-visible .drow-title {
  text-decoration-color: var(--border-strong);
  text-underline-offset: 3px;
}

/* ---- the venue filter on a song card ----
 *
 * Not the .filter-row used by Past gigs: that carries a visible "Venue" label
 * above the control, which is right on a card with two filters side by side
 * and wasteful on a card a third of the page wide with one. "All venues" as
 * the resting value says what the control is. */
.venue-pick { margin: 0 0 10px; }
.venue-pick select {
  width: 100%;
  min-height: var(--db-control);
  padding: 0 10px;
  border-radius: var(--radius);
    /* --border-strong on a SELECT, because the box is the only thing saying this
     is a control: no fill, no underline, and the native arrow is drawn in the
     text colour rather than as a boundary. --border measures 1.36:1 against
     the surface on Dark, 1.93 on Light and 1.58 on Stage. */
  border: 1px solid var(--border-strong);
  background: var(--surface-raised);
  color: var(--text-primary);
  font-size: var(--db-t-hint);
}

/* ---- phase 3: the three controls on each song card, and the Everyone view --
 *
 * The tab is the Songs tab's own segmented control (.songs-mode-seg), so one
 * control means one thing across the app. The two selects share one row: the
 * dropdown takes the room, the time filter what it needs, and at 320px both
 * still fit without a scrollbar, which tests/venue-cards.mjs holds. */
.song-controls { display: grid; gap: 8px; margin: 0 0 10px; }
.song-tabs { display: inline-flex; justify-self: start; }
.song-tabs .song-tab[aria-selected="true"] {
  background: var(--action);
  border-color: var(--action);
  color: var(--action-text);
}
.song-filters {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 6px;
  margin: 0;
}
/* THE TWO DROPDOWN BUTTONS. Drawn as the selects they replaced were: the
   same box, border and height, a caret in the text colour. What they open is
   js/float-menu.js, over the page, never pushing it down. */
.song-pick {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  min-width: 0;
  min-height: var(--db-control);
  padding: 0 10px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
  color: var(--text-primary);
  font: inherit;
  font-size: var(--db-t-hint);
  text-align: left;
  cursor: pointer;
}
.song-pick[aria-expanded="true"] { border-color: var(--action); }
.song-pick:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.song-pick-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song-pick-caret {
  flex: none;
  width: 0;
  height: 0;
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-top: 6px solid currentColor;
}

/* THE LIST ITSELF, on <body>. Fixed, measured per open by js/float-menu.js:
   width, which way it drops, and a height cap it scrolls inside. */
.float-menu {
  position: fixed;
  z-index: var(--layer-dropdown);
  padding: 6px;
  overflow-y: auto;
  overscroll-behavior: contain;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
  box-shadow: var(--shadow-2);
}
.float-menu-group + .float-menu-group {
  margin-top: 4px;
  padding-top: 4px;
  border-top: 1px solid var(--border);
}
.float-menu-head {
  margin: 2px 8px 0;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.float-menu-option {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  /* 40px, item 85: compact, and the same as the other list in the product
     (the live queue's sort menu). Above the 36px the dropdown's own button
     and the Mine or Everyone tabs hold. */
  min-height: 40px;
  padding: 4px 8px;
  border: 0;
  border-radius: calc(var(--radius) - 3px);
  line-height: 1.25;
  background: transparent;
  color: var(--text-primary);
  font: inherit;
  font-size: var(--db-t-body);
  text-align: left;
  cursor: pointer;
}
.float-menu-option:hover { background: var(--action-quiet, var(--surface)); }
.float-menu-option:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: -3px; }
.float-menu-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.float-menu-option .pro-pill { flex: none; }
/* The tick keeps its room whether shown or not, so choosing does not shift the
   names sideways. */
.float-menu-tick { flex: none; opacity: 0; color: var(--action-on-surface); }
.float-menu-option.is-on .float-menu-tick { opacity: 1; }

/* A NOTE IS A PLAIN LINE, like an empty list's: no box, no dashes, no colour.
   Where I am's reading, the sharing switch being off, the daily limit. */
.song-note {
  display: grid;
  gap: 8px;
  padding: 14px 2px;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
}
.song-note p { margin: 0; }
.song-note .btn-quiet { justify-self: start; }

/* ---- Pro, blurred rather than hidden ----
 *
 * The rows are grey bars of made-up widths (dashboard.js, blurRows), never
 * data, so the blur hides a shape and nothing else. The overlay is a centred
 * column: the line now, and an empty .song-blur-action that an upgrade button
 * can drop into later without a redesign. */
.song-blur { position: relative; }
.song-blur-more { margin-top: 2px; }
.song-blur-shape {
  filter: blur(2.5px);
  opacity: 0.9;
  pointer-events: none;
  user-select: none;
}
.song-blur-row { min-height: 52px; }
.blur-bar {
  display: block;
  height: 12px;
  border-radius: 6px;
  background: var(--text-secondary);
  opacity: 0.6;
}
.blur-bar-sm { height: 9px; opacity: 0.4; }
.blur-bar-n { width: 22px; height: 16px; }
.blur-line { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.blur-arrow {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 1.5px solid var(--text-secondary);
  opacity: 0.6;
}
.song-blur-over {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 8px;
  padding: 8px;
  text-align: center;
}
.song-blur-line {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
  color: var(--text-primary);
  font-size: var(--db-t-hint);
  font-weight: var(--weight-medium);
}
.song-blur-action:empty { display: none; }
.song-pro-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 0; }
.song-pro-actions .btn-quiet[aria-pressed="true"] {
  border-color: var(--action);
  color: var(--action-on-surface);
}
.song-ten {
  margin: 0 0 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.song-ten-head { margin: 0 0 6px; font-weight: var(--weight-medium); }
.song-ten ol { margin: 0; padding-left: 1.4em; display: grid; gap: 3px; }
.song-find { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; margin: 0 0 8px; }
.song-find input { min-width: 0; min-height: var(--db-control); padding: 0 10px; }
.song-find-results { display: grid; gap: 4px; margin: 0 0 8px; }
.song-find-pick {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  min-height: 44px;
  padding: 6px 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: transparent;
  color: var(--text-primary);
  text-align: left;
  font: inherit;
  cursor: pointer;
}
/* ---- chips and arrows that explain themselves, item 86 ----
 *
 * Every one is a button. The chip stays small so the row stays tight; the
 * hit area is 44px square, drawn by ::before and costing no layout, the same
 * trick as the live queue's sort chip. Outlined, never filled, so each looks
 * like something to press and none looks like an alert. */
.song-explain {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.song-explain::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: max(100%, 44px);
  height: 44px;
  transform: translate(-50%, -50%);
}
.song-explain:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; border-radius: 999px; }
.song-explain[aria-expanded="true"] { border-color: var(--action); }
.drow-yours,
.song-chip {
  padding: 0 7px;
  white-space: nowrap;
  border-radius: 999px;
  border: 1px solid var(--border-strong);
  font-size: var(--db-t-small, 12px);
  color: var(--text-secondary);
}
/* THE ARROWS: no words, a round outlined button with the arrow in the text
   colour the app already uses for good and bad figures. Green up, red down,
   neither filled: a coloured ring and a stroke, not a badge. */
.drow-move {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 1px solid currentColor;
}
.drow-move-up { color: var(--positive-text); }
.drow-move-down { color: var(--negative-text); }
.drow-arrow { display: block; }
.song-chip-pro { border-radius: 999px; }
/* The gig count in the line under the heading, tappable (item 90). */
.song-chip-gigs { vertical-align: baseline; margin-left: 2px; }
.song-thin { margin-top: 10px; }
/* On a narrow phone a chip would squeeze the act's name down to a letter.
   It wraps under the name instead: the name is worth more than the row's
   height, and only a row that carries a chip grows. */
/* Only the worded chip wraps; an arrow is 20px and sits on the same line as
   the act's name, which gives way with an ellipsis, so an arrow never makes
   a row taller. */
.drow-second:has(> .song-explain) { column-gap: 12px; }
.drow-second:has(> .drow-yours) { flex-wrap: wrap; row-gap: 3px; }
.drow-second:has(> .drow-yours) > .drow-by { flex: 0 1 auto; max-width: 100%; }
.drow-second:has(> .drow-move) > .drow-by { flex: 0 1 auto; min-width: 0; }

/* The note a chip opens: one or two sentences, over the page. */
.float-note { padding: 10px 12px; }
.float-note-text {
  margin: 0;
  color: var(--text-primary);
  font-size: var(--db-t-hint);
  line-height: var(--line-height, 1.45);
}

/* -------------------------------------------------------- welcome card --- */

/* THE FIRST THING ON THE SCREEN AND THE LARGEST THING ON IT.
 *
 * Everything below this card is retrospective. This is the only part of the
 * Dashboard that answers the question someone opening the app on a Friday
 * evening actually has, so it is allowed to take the room it needs. No border
 * and no flag ring: it does not need marking out as special when it is the
 * only object above the fold. */
/* THE CARD OWNS ITS OWN RHYTHM, RATHER THAN EACH LINE GUESSING.
 *
 * Every child carried its own margin - 6px here, 10px there, 2px on two of
 * them - which worked only while the card held four short lines of text. A
 * 44px button was added to the middle of it for the summary accordion with no
 * vertical margin at all, and the whole card collapsed into itself: a heading,
 * a control and three lines of copy stacked with almost nothing between them.
 *
 * A flex column with a base gap, and extra margin only where a real section
 * break belongs. The three last-gig lines stay tight to each other because
 * they are one block read on the way past; the button and Go Live get air
 * because they are things you press. */
/* TIGHTER OUTSIDE, ROOMIER INSIDE, which is the whole of Mark's ask: the card
   should take less height without becoming cramped.
 *
 * Two different measurements were doing the work and only one of them was
 * wasted. The PADDING was --db-pad x 1.3, which at the default density is 26px
 * on all four sides — more than any panel on the screen, and it is the outer
 * ring that pushes everything down the page. The GAP between the items inside
 * was 5px, which is not a separation at all: the greeting, the wrap line and
 * the summary ran together as one block.
 *
 * So the padding comes down and the gap goes UP. The card loses height overall
 * and the things in it are further apart than they were, which is what stops a
 * tighter card being a cramped one.
 *
 * Both figures are on the existing scale: --db-pad is the panel padding every
 * other card uses, and 10px is the gap the stat strip uses between its tiles. */
.welcome-card {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: calc(8px * var(--density));
  background: var(--surface);
  border-radius: var(--radius-lg);
  padding: var(--db-pad);
  margin-top: var(--db-pad);
}

.wc-greet {
  margin: 0;
  font-size: var(--db-t-head);
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-tight);
}

.wc-sub {
  margin: 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}

/* The twenty minutes after a gig ends. Sized between the greeting and the
   supporting line, because "how did that go" is the question being answered
   and the greeting above it is only manners. */
.wc-wrap-line {
  margin: 4px 0 0;
  font-size: calc(19px * var(--font-scale));
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-tight);
}

/* FULL WIDTH, AND DELIBERATELY OVER-PADDED.
 *
 * This is tapped one-handed, sometimes in the dark, usually by someone holding
 * an instrument, and it is the only control on this screen that has to be hit
 * first time. 60px of height is roughly three times Apple's minimum target and
 * that is the point — the size IS the affordance, and a musician who has to
 * look for it has already lost the argument.
 *
 * The accent fill is flat. docs/THEMING.md names a soft accent halo as the one
 * look to avoid, and on a dark theme a glow around a large accent slab is also
 * the worst halation source in the app. */
/* THE WIDTH QUESTION, AND WHY IT STAYS FULL WIDTH ON A PHONE.
 *
 * Mark's concern is that this feels too heavy, particularly on wider screens.
 * Measured, he is right about the wider screens and wrong about the phone: at
 * 390px the card is 358px wide and a full-width button is simply a button; at
 * 1280px the welcome card spans the page and the same rule produced a 1200px
 * slab of accent colour to start a gig.
 *
 * So it shrinks to its own width ABOVE the point where the card stops being
 * roughly phone-shaped, and stays full width below it. See the rule further
 * down. Nothing about its height, padding or wording changes at any width, and
 * it never goes below 60px, so it is not harder to find or to hit — it stops
 * being a wall.
 *
 * THE MARGIN IS GONE ENTIRELY, and that is where most of the height came from.
 * It was 20px stacked on top of the card's 5px gap, compensating for a gap too
 * small to separate anything. The gap is now 8px and does that job for every
 * item in the card, so a second and larger per-item margin is only the old
 * problem written twice. */
.wc-golive {
  display: block;
  width: 100%;
  padding: 19px 20px;
  border-radius: var(--radius-lg);
  background: var(--action);
  color: var(--action-text);
  font-size: calc(18px * var(--font-scale));
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-normal);
  text-align: center;
}
.wc-golive:hover { background: var(--action-hover, var(--action)); }
.wc-golive:active { background: var(--action-active, var(--action)); }

/* The live face. The venue is the headline because it is the fact the artist
   is checking — "am I still on, and where" — and the greeting steps back to
   make room for it. */
.wc-live-line {
  display: flex;
  align-items: center;
  gap: 9px;
  margin: 10px 0 0;
}
.wc-venue {
  font-size: calc(21px * var(--font-scale));
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-tight);
}
/* Same pulse as the header status light, and for the same reason: colour is
   the third cue, after the word "live" and after the movement. */
.wc-dot {
  flex: none;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--positive);
  animation: livePulse 1.8s ease-in-out infinite;
}
.wc-elapsed {
  font-weight: var(--weight-medium);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
.wc-since::before { content: ' · '; }

/* How long is left, and when it ends. Two facts on one line because they are
   read together: the countdown decides whether there is time for another song,
   the clock time is what you say to the landlord. */
.wc-clockline {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 9px;
  margin: 12px 0 0;
}
.wc-remaining {
  font-size: calc(17px * var(--font-scale));
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}
.wc-until {
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* THE LAST FIFTEEN MINUTES, in the app's existing urgency language rather than
   a new one. --notice is the amber this codebase already uses for "you should
   know about this, nothing has gone wrong" — it is what the extend prompt sits
   on. Not --negative, which means something is broken, and not the accent,
   which means something is tappable. The change is weight and colour on a
   figure that was already there; nothing moves and nothing appears. */
.wc-remaining.is-low { color: var(--notice-text); }

/* Past the planned end and not yet closed. A short window in practice — the
   shell re-checks every twenty seconds — but never a negative number. */
.wc-remaining.is-over { color: var(--text-secondary); font-weight: var(--weight-medium); }

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

/* Rings rather than borders, so flagging a panel cannot shift its layout. */
.panel-flag { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--action) 45%, var(--border)); }

/* Several people asked for the same song — one card, one tap. Quiet, not
   accent-filled: it is a count, not an action, and a list peppered with
   accent pills spends the one accent on the least important thing in it. */
.count-badge {
  display: inline-block;
  margin-left: 7px;
  padding: 2px 9px;
  border-radius: 999px;
  background: var(--action-quiet);
  color: var(--action-quiet-text);
  font-size: var(--db-t-hint);
  font-weight: var(--weight-medium);
  font-variant-numeric: tabular-nums;
  vertical-align: middle;
}

/* My songs */
.switch {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 14px 0;
  font-weight: var(--weight-normal);
  font-size: var(--db-t-body);
  color: var(--text-secondary);
  cursor: pointer;
}
.switch input {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  accent-color: var(--action);
  cursor: pointer;
}

/* ===========================================================================
   THE SONGS TAB
   ===========================================================================
   Rebuilt rather than extended. The page used to be one card called "My
   songs" with a search box, an add form and a flat list. It now has to carry
   the strict/open decision as well, and songbooks after that, and bolting
   those onto a single card produces something you have to read rather than
   look at.

   Three sizes, and they are genuinely different jobs:

     mobile          one column, standing up, one thumb. Primary case.
     tablet landscape an iPad on a mic stand, read at arm's length. Vertical
                     space is scarce and the nav is already a left rail, so
                     the win is columns, not a stretched phone layout.
     desktop         initial setup and bulk work with a mouse and keyboard.
                     Long lists must be comfortable, and line length has to
                     stay readable rather than running the width of a 27in
                     monitor.

   Everything below is written in the semantic tokens. No colour is declared
   here that themes.generated.css does not own, so Dark, Light and Stage all
   come out right without this file knowing which one is on. */

/* --------------------------------------------------------- what people get */

/* The strict/open decision. A slim strip rather than a full card: it is one
   choice, it is made once, and giving it the same visual weight as the song
   list would suggest it needs attention every visit. */
.songs-mode-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px var(--db-gap);
}
/* inline-flex sizes to its content, so on a 320px handset the two labels —
   "Anything" and "Only what's on my list" — pushed the segment 0.6px past the
   viewport and gave the whole dashboard a horizontal scrollbar. max-width lets
   it shrink; the buttons give up their side padding before their words. */
.songs-mode-seg {
  display: inline-flex;
  max-width: 100%;
  padding: 3px;
  gap: 3px;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.songs-mode-seg button {
  min-height: var(--db-control);
  min-width: 0;
  padding: 0 16px;
  border: 1px solid transparent;
  border-radius: calc(var(--radius) - 3px);
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--db-t-body);
  font-weight: var(--weight-medium);
  cursor: pointer;
  white-space: nowrap;
}
.songs-mode-seg button:hover { color: var(--text-primary); }
/* aria-pressed is what carries the state to a screen reader; this is the
   matching visual. Filled rather than merely tinted, because on Stage a tint
   this small disappears under coloured lighting. */
.songs-mode-seg button[aria-pressed="true"] {
  background: var(--action);
  border-color: var(--action);
  color: var(--action-text);
}

/* THE PAGE SLIDE IS ALREADY DEFINED, further down this file, as
   .pg-slide-next / .pg-slide-prev over the pgNext / pgPrev keyframes. The
   dashboard's paged cards have used it since they were built.
   A duplicate pair was written here before that was noticed, with the same
   CLASS names and different keyframe names — so the two rules stacked, the
   later one won, and the earlier keyframes sat unused looking authoritative.
   One definition. js/keep-place.js adds those same two classes, so every
   paged list in the app now slides the same way. */

/* THE ALPHABET JUMP ON MY SONGS.
 *
 * One row, scrollable sideways rather than wrapping: twenty-seven small
 * targets that reflow into three lines on a phone stop being an alphabet and
 * become a wall. Sideways it is one gesture and it reads as an index, which is
 * what it is.
 *
 * This replaced a segmented sort control and a row of decade chips. Both are
 * gone from this screen — see the note in js/views/songs.js. */
.songs-alpha {
  display: flex;
  gap: 2px;
  margin-top: 8px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.songs-alpha::-webkit-scrollbar { display: none; }
.songs-alpha[hidden] { display: none; }
.songs-alpha button {
  flex: 0 0 auto;
  /* 30px wide and 34 tall is under the 44px guidance and deliberately so: this
     is a jump-to, not a destination. Getting the wrong letter costs one more
     tap on a control that is still on screen, and twenty-seven 44px targets
     would be 1188px of row. The two controls where a mis-tap actually costs
     something — Played and Skip — are 44px and stay that way. */
  min-width: 30px;
  height: 34px;
  padding: 0 4px;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  font-weight: var(--weight-medium);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
}
.songs-alpha button:hover { color: var(--text-primary); background: var(--surface-sunken); }
.songs-alpha button:focus-visible { outline: 3px solid var(--focus-ring); }
.songs-alpha button[aria-pressed="true"] {
  background: var(--action);
  border-color: var(--action);
  color: var(--action-text);
}
/* The audit chip at the end of the alphabet. Wider than a letter because it
   carries words, and set apart by a border so the alphabet still reads as an
   alphabet rather than as a row with one odd item in it. */
.songs-alpha .alpha-nolink {
  min-width: 0;
  margin-left: 8px;
  padding: 0 10px;
  border-color: var(--border);
  white-space: nowrap;
}
.alpha-n { opacity: .7; font-variant-numeric: tabular-nums; }
.songs-alpha .alpha-nolink[aria-pressed="true"] .alpha-n { opacity: .85; }
.songs-mode-note {
  margin: 0;
  flex: 1 1 16rem;
  min-width: 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  line-height: var(--line-height);
}

/* ------------------------------------------------------------- panel head */

/* Heading and count on one line, controls under it. The count is the point:
   "how many songs do I know" is the question this page exists to answer, so
   it is a figure beside the title rather than a line of prose below it. */
.songs-head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
}
.songs-head h2 { margin: 0; }
.songs-count {
  font-size: var(--db-t-hint);
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------ clearable search
 *
 * Safari on iOS does not draw the native clear button for `type="search"`, so
 * on the device this app is used on, emptying a box meant holding backspace
 * down. This draws one. Where the native button IS drawn — desktop WebKit —
 * it is switched off, because two crosses in one field is worse than none.
 *
 * SHOWN OFF `:placeholder-shown`, not off a class this file would have to keep
 * in step with the value. That is what makes it correct on first paint, after
 * `input.value = ''` in code, and after a browser restores a form on
 * back-navigation. The cost is that the field must have a placeholder; every
 * one of ours does, and `searchField()` in js/lib.js requires it. */
.search-field { position: relative; display: block; }
.search-field input[type="search"] {
  width: 100%;
  /* Room for the button, so a long query runs under it rather than behind. */
  padding-right: calc(var(--db-field-sm) + 2px);
}
.search-field input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.search-clear {
  position: absolute;
  top: 50%;
  right: 2px;
  transform: translateY(-50%);
  /* A real tap target. The icon is 16px; the button is not. */
  width: var(--db-field-sm);
  height: var(--db-field-sm);
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-secondary);
  cursor: pointer;
}
.search-clear svg { width: 16px; height: 16px; fill: currentColor; }
.search-clear:hover { color: var(--text-primary); }
.search-field input[type="search"]:placeholder-shown ~ .search-clear { display: none; }

.songs-tools {
  display: flex;
  gap: 10px;
  margin-top: 14px;
  flex-wrap: wrap;
}
.songs-tools .search-field {
  flex: 1 1 14rem;
  min-width: 0;
}
.songs-tools input[type="search"] {
  min-width: 0;
  height: var(--db-field);
  border-radius: var(--radius);
  font-size: var(--db-t-mid);
}
.songs-tools button {
  height: var(--db-field);
  padding: 0 18px;
  border-radius: var(--radius);
  font-size: var(--db-t-body);
  white-space: nowrap;
}

/* ------------------------------------------------------------- the drawers
 *
 * Add and Paste both open into the same sunken plane below the toolbar, one
 * at a time. Sunken: on Light this is #FFFFFF against a #F7F6F3 surface, a
 * deliberate 3 L* step, so these blocks keep a real border rather than
 * relying on a ladder step only two of the three themes have. */
.songs-drawer {
  display: none;
  margin-top: 14px;
  padding: 15px;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.songs-drawer.show { display: block; }
/* A CAPTION FOR A FIELD — AND ONLY THAT.
 *
 * The :not() is not defensive tidiness, it is a fix. The file importer's
 * picker is a <label> carrying .btn-primary, because an <input type="file">
 * cannot be styled to match and the label has to BE the button. This rule is
 * `.songs-drawer label`, which is one class and one element, and that beats a
 * single class — so it reached inside the button role and repainted it:
 * measured, "Choose a file" came out --text-secondary (#ADA69F) on the filled
 * --action plane, which is 2.3:1 and unreadable, at 13px instead of 14, as a
 * block instead of a centred flex line.
 *
 * Excluding the button roles here rather than patching .import-choose fixes it
 * for the next label-as-control too. Anything that has declared itself a
 * button gets to be one; everything else is still a caption. */
.songs-drawer label:not(.btn-primary, .btn-quiet, .btn-ghost, .btn-danger) {
  display: block;
  margin-bottom: 6px;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
.songs-drawer textarea {
  font-size: var(--db-t-mid);
  background: var(--surface);
  line-height: var(--line-height);
}
.songs-drawer input {
  height: var(--db-field-sm);
  border-radius: var(--radius);
  font-size: var(--db-t-mid);
  background: var(--surface);
}
/* Two fields side by side once there is room, stacked on a phone. Title is
   the required one and gets the larger share. */
/* THE LEAD NEEDS ROOM FOR THE FIELD'S FOCUS RING. The gap above this was
   measured at exactly 0, and the ring is drawn 2px OUTSIDE the border box —
   so tabbing into the title field struck a purple line through the descenders
   of the sentence above it. It read as a rendering fault, not as tight
   spacing. */
.songs-drawer-fields { display: grid; gap: 10px; grid-template-columns: 1fr; margin-top: 10px; }
@media (min-width: 560px) {
  .songs-drawer-fields { grid-template-columns: 1.3fr 1fr; }
}
/* ============ A BUTTON IS NOT SQUEEZED BELOW ITS OWN LABEL ============
 *
 * THE FAULT, MEASURED AT 390px on an open songbook: three buttons in this row
 * were each given 98 pixels, so "Add songs to this book" set on THREE lines,
 * "Download spreadsheet" on FOUR, inside a box with a fixed height. The type
 * was outside its own button.
 *
 * `flex: 1` is `flex: 1 1 0%`: a basis of nothing, which lets an item shrink
 * past its content and never wrap, because there is nothing left to wrap. Two
 * buttons in a row got away with it; three did not.
 *
 * Three rules together, and each covers a case the others do not:
 *
 *   flex-wrap, so a row that cannot hold its items becomes two rows.
 *   min-width, so an item is never narrower than its label. Capped at 100%,
 *     because a label longer than the whole panel has to be allowed to wrap
 *     rather than push the panel sideways.
 *   min-height instead of height, so on the one screen narrow enough to force
 *     that wrap the button GROWS rather than clipping. A fixed height is what
 *     turned a wrapped label into type outside a box. */
.songs-drawer-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
/* `.import-choose` IS IN THIS SELECTOR NOW, AND WAS NOT.
   It is a <label>, because an <input type="file"> cannot be styled to match
   the app — so the input is visually hidden and the label carries .btn-primary.
   That works for colour and geometry, which come from class rules, and fails
   completely for every rule in this file that says `button`. Measured by the
   review: "Choose a file" was 123px against "Read this list" at 176px, at every
   width, in the one place on the screen that promises two matched slots. Every
   other primary button in this panel is 176px or wider; this was the only one
   under the floor, and it was under it because of a tag name. */
.songs-drawer-actions button,
.songs-drawer-actions .import-choose {
  flex: 1 1 auto;
  min-width: min(100%, max-content);
  min-height: var(--db-field-sm);
  height: auto;
  padding-top: 6px;
  padding-bottom: 6px;
  border-radius: var(--radius);
  font-size: var(--db-t-body);
}

/* The review area under a paste. Today it renders one list; the three-bucket
   version drops straight in as three .songs-bucket blocks with no change to
   anything around it. */
.songs-review {
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}
.songs-review[hidden] { display: none; }
.songs-bucket + .songs-bucket { margin-top: 14px; }
.songs-bucket-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin: 0 0 6px;
  font-size: var(--db-t-body);
  font-weight: var(--weight-strong);
}
.songs-bucket-n {
  font-size: var(--db-t-hint);
  font-weight: var(--weight-normal);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
/* 267x21 ON A PHONE, and it is the only way to reach any of the rows behind it
   — three of them on a re-import. The text stays where it is; the target grows
   around it. */
@media (pointer: coarse) {
  .songs-bucket > .songs-bucket-head {
    min-height: 44px;
    align-items: center;
    padding: 4px 0;
  }
}
/* NO INNER SCROLLER. THE PAGE SCROLLS, AND THAT IS THE ONLY THING THAT DOES.
 *
 * "Scrolling regions inside scrolling regions. I am working in a small window
 *  inside a page with no indication of what moves and what stays."
 *
 * This was `max-height: 240px; overflow-y: auto`, and it was measured doing
 * exactly that: a 240px window holding 757px of rows, inside a document that
 * was ALSO scrolling, at 375, 768, 1024 and 1440 alike. Two scrollbars, no way
 * to tell which one a flick will move, and a list you cannot see the shape of.
 *
 * A cap like this is right when a list is a fixed part of a screen you must not
 * lose the rest of. A review step is not that: the list IS the step, there is
 * nothing below it worth keeping on screen, and the step already limits how
 * much is in the list. So it grows, and the page scrolls, once. */
/* minmax(0, 1fr) AND NOT A BARE GRID, which cost 14px of horizontal overflow at
   375 the moment this stopped being an overflow container. An implicit grid
   column is `auto`, and the MINIMUM of an auto track is the item's min-content
   width — so a row holding an unbreakable Ultimate Guitar URL widened the track
   past its own container and the page scrolled sideways on a phone. The 0
   minimum is what lets the row shrink and its own ellipsis do the work. */
.songs-bucket-list { display: grid; grid-template-columns: minmax(0, 1fr); }

/* "You already have all 3 of these." Positive rather than a warning: nothing
   has gone wrong, the file simply had nothing new in it. Sized like a bucket
   heading because it stands where the buckets would have been. */
.songs-nothing-new {
  margin: 0 0 14px;
  padding: 12px 14px;
  background: color-mix(in srgb, var(--positive-text) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--positive-text) 35%, transparent);
  border-radius: var(--radius);
  color: var(--text-primary);
  font-weight: var(--weight-strong);
}
@supports not (background: color-mix(in srgb, red 10%, transparent)) {
  .songs-nothing-new { background: var(--surface-sunken); border-color: var(--border); }
}
/* A FLEX ROW WITH AN UNBREAKABLE URL IN IT IS NOT A ROW, IT IS A COLUMN.
 *
 * Measured on the duplicates shelf at 375: one title rendered as a 665px
 * vertical stack of single letters, 38 lines tall, because the second cell held
 * an Ultimate Guitar URL that cannot break, `flex: none` said it must never
 * shrink, and the only thing left that could give was the title — whose own
 * min-content width is one character.
 *
 * A grid with an explicit `minmax(0, 1fr) auto` and a URL that is allowed to
 * wrap: the title keeps a real column, and the thing that cannot be shortened
 * is the thing that wraps. Below 480 they stack, because two columns on a phone
 * for a title and a 90-character URL is not two columns. */
.import-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: baseline;
  gap: 4px 12px;
  padding: var(--db-row-pad) 0;
  border-bottom: 1px solid var(--border);
  font-size: var(--db-t-body);
}
.import-row:last-child { border-bottom: 0; }
.import-row .song-title { min-width: 0; overflow-wrap: anywhere; }
.import-row .song-artist {
  margin-top: 0;
  text-align: right;
  min-width: 0;
  max-width: 100%;
  overflow-wrap: anywhere;
}
@media (max-width: 480px) {
  .import-row { grid-template-columns: minmax(0, 1fr); }
  .import-row .song-artist { text-align: left; }
}

/* ---------------------------------------------------------------- the list
 *
 * ONE COLUMN ON A PHONE, TWO FROM 760px UP.
 *
 * Two, not three. At 1194x834 — the iPad on the mic stand — two columns give
 * roughly 545px a side: long titles do not wrap, and each row stays a target
 * you can hit at arm's length without looking. Three would fit, and every one
 * of them would be 363px with "Rocket Man (I Think It's Going to Be a Long,
 * Long Time)" wrapped over three lines.
 *
 * It also solves the desktop line-length problem for free. The shell caps
 * .shell-main at 1240px, so two columns is about 590px each — a readable
 * measure — rather than one list stretched across the whole window. */
.songs-list {
  display: grid;
  grid-template-columns: 1fr;
  column-gap: var(--db-pad);
  margin-top: 4px;
}
@media (min-width: 760px) {
  .songs-list { grid-template-columns: 1fr 1fr; }
}

/* DENSITY: A LIST YOU CAN RUN YOUR EYE DOWN.
 *
 * Measured before this: 69.5px a row at 375px, so SIX songs on a phone screen
 * and none at all without scrolling, because the list starts 952px down the
 * page. The reference is how Ultimate Guitar lists songs — rows and rows,
 * tight, scannable — and this was nowhere near it.
 *
 * Where the 69.5px went: 22px of padding, a 24px line box for a 16px title
 * (--line-height is 1.5, which is a rule for PARAGRAPHS and wrong for a
 * one-line label), 3px of margin, and a 19px artist line. The text was 43px of
 * it and the rest was air.
 *
 * So: 12px of padding instead of 22, and a line-height of 1.25 on the two
 * lines that are labels rather than prose. The type does not shrink — the
 * title is still 16px and still the heaviest thing on the row, which is the
 * one thing that must not change. */
.song-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: calc(6px * var(--density)) 0;
  border-bottom: 1px solid var(--border);
  min-width: 0;
}
.song-main { min-width: 0; flex: 1; }
.song-title {
  font-weight: var(--weight-strong);
  overflow-wrap: anywhere;
  line-height: 1.25;
}
.song-artist {
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  margin-top: 1px;
  line-height: 1.25;
  overflow-wrap: anywhere;
}

/* One control per row rather than two.
 *
 * At 324 songs, an Edit and a Delete on every row is 648 buttons, and on a
 * stand-mounted screen the destructive one is a thumb's width from the one
 * you meant. A single overflow opens the row's actions in the sheet the
 * dashboard already uses, where Delete can be a full-width target that is
 * hard to hit by accident — and where "Add to books" has somewhere to live
 * without this row growing a third control. */
.song-more {
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: var(--radius);
  border: 1px solid transparent;
  background: transparent;
  color: var(--text-secondary);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
}
.song-more:hover { background: var(--action-quiet); color: var(--text-primary); border-color: var(--border); }

/* The row's actions, inside the shared .vm sheet. */
.song-act {
  display: block;
  width: 100%;
  min-height: var(--db-field);
  margin-top: 8px;
  padding: 0 14px;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text-primary);
  font-size: var(--db-t-body);
  font-weight: var(--weight-medium);
  text-align: left;
  cursor: pointer;
}
.song-act:hover { background: var(--action-quiet); }
/* Quiet, not filled. --negative-surface is the ALERT plane — a pale tint meant
   to sit behind dark text in a message — and using it as a button fill made
   Light's #F7D8D4 a pastel slab, which light.js forbids outright. The word,
   the outline and the negative text carry it; the red arrives at the confirm
   step, where it means something.
   --negative-text, not --negative: the fill role is tuned to carry ink on top
   of it and lands at 4.1:1 as text on Light, which is a fail. */
.song-act.danger {
  color: var(--negative-text);
  border-color: var(--negative);
}
.song-act.danger:hover { background: var(--negative-surface); }

/* ------------------------------------------------------------ empty states
 *
 * The old page deliberately had none: the list fills itself as gigs happen,
 * so prompting was noise. That reasoning stops holding the moment strict mode
 * exists, because an empty list plus strict is a request page that can accept
 * nothing. So: quiet, one line, and it says the thing that is actually true —
 * it fills itself, and here are the two ways to hurry it up. */
.songs-empty {
  padding: calc(26px * var(--density)) 4px;
  text-align: center;
  color: var(--text-secondary);
  font-size: var(--db-t-body);
  line-height: var(--line-height);
}
.songs-empty strong {
  display: block;
  margin-bottom: 6px;
  color: var(--text-primary);
  font-size: var(--db-t-mid);
  font-weight: var(--weight-strong);
}
.songs-empty.is-search { padding: calc(20px * var(--density)) 4px; }

/* ---- messages ----
 *
 * Success and failure appeared in the same place, at the same weight, with no
 * icon and no border — identical in greyscale and to a protanope, which for
 * the one line that says whether a save worked is not acceptable. Each state
 * now carries a glyph, a tinted plane and a coloured edge, so all three of
 * shape, luminance and hue say the same thing.
 *
 * Left-aligned rather than centred: a message with a leading glyph and a rule
 * down its left edge reads from the left, and every one of these sits under
 * the control it is about. */
.toast {
  display: none;
  margin-top: 12px;
  padding: 10px 12px;
  border-left: 3px solid transparent;
  border-radius: var(--radius);
  text-align: left;
  font-size: var(--db-t-body);
  font-weight: var(--weight-medium);
}
.toast::before {
  display: inline-block;
  width: 1.3em;
  font-weight: var(--weight-strong);
}
.toast.success {
  color: var(--positive-text);
  background: var(--positive-surface);
  border-left-color: var(--positive);
}
.toast.success::before { content: '✓'; }
.toast.error {
  color: var(--negative-text);
  background: var(--negative-surface);
  border-left-color: var(--negative);
}
.toast.error::before { content: '✕'; }
.toast.warning {
  color: var(--notice-text);
  background: var(--notice-surface);
  border-left-color: var(--notice);
}
.toast.warning::before { content: '!'; }

/* Profile page: form column + sticky live preview */
.profile-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 300px;
  gap: 22px;
  align-items: start;
}
@media (max-width: 900px) {
  .profile-layout { grid-template-columns: 1fr; }
  .preview-pane { order: -1; }
  .preview-sticky { position: static !important; }
  .phone { margin: auto; }
}

.preview-sticky { position: sticky; top: 20px; }
.preview-title { font-size: var(--db-t-mid); margin: 20px 0 10px; color: var(--text-secondary); }

/* THE PHONE MOCK-UP AND EVERYTHING IN IT NOW LIVE AT THE END OF THIS FILE.
   Search for "THE PREVIEW IS THE AUDIENCE PAGE". It was moved rather than
   edited in place because it is no longer a set of small hand-picked sizes
   that belong next to the pickers; it is css/public.css restated at full
   scale, and it reads as one thing or as nothing. */

/* ---- pickers ----
 *
 * All three of these signalled the current choice with a coloured ring and
 * nothing else — and the accent dots were coloured circles wearing a coloured
 * ring, which is the worst version of it. Every one of them now carries a
 * tick, so the choice survives greyscale and every dichromat. */
.theme-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 10px;
  margin-top: 8px;
}
.theme-swatch {
  position: relative;
  border: 2px solid;
  border-radius: var(--radius);
  padding: 9px;
  display: block;
  text-align: left;
}
.theme-swatch.selected { box-shadow: 0 0 0 2px var(--action); }
/* Drawn in the APP's action colour, not the previewed theme's: it is the
   dashboard saying "this one", not part of the thing being previewed, and it
   is the one pair guaranteed to contrast whatever the swatch is showing. */
.theme-swatch.selected::after {
  content: '✓';
  position: absolute;
  top: 6px; right: 6px;
  width: 18px; height: 18px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: var(--action);
  color: var(--action-text);
  font-size: var(--db-t-micro);
  font-weight: var(--weight-strong);
}
.swatch-card {
  display: block;
  border-radius: 7px;
  padding: 8px;
  margin-bottom: 7px;
}
/* No opacity: these already carry the previewed theme's own ink and muted
   colours inline, and dimming them showed the artist a duller theme than the
   one they are choosing. */
.swatch-line { display: block; height: 4px; border-radius: 2px; }
.swatch-line.short { width: 60%; margin-top: 4px; }
.swatch-pill { display: block; height: 8px; border-radius: 4px; margin-top: 7px; }
.swatch-name { font-size: var(--db-t-small); font-weight: var(--weight-medium); }

.accent-row { display: flex; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
/* A labelled swatch, not a bare circle. The dot alone made this the only
   unlabelled option set in the product, on the one screen whose entire job is
   picking a colour — identity carried purely by hue, measuring 1.10:1 apart in
   greyscale. The name does the identifying; the dot shows the colour. */
.accent-swatch {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 7px 12px 7px 8px;
  border-radius: 999px;
  background: transparent;
  border: 2px solid var(--border);
  color: var(--text-primary);
  font-size: var(--db-t-hint);
  font-weight: var(--weight-medium);
}
.accent-dot {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  flex: none;
  border: 1px solid var(--border-strong);
}
/* Neutral, never the accent: a ring in the accent around a dot of the accent
   disappears the moment hue stops working. */
.accent-swatch.selected {
  border-color: var(--text-primary);
  background: var(--surface-raised);
}
.accent-swatch.selected .accent-name::after {
  content: ' \2713';
  font-weight: var(--weight-strong);
}

.file-input { height: auto; padding: 12px; font-size: var(--db-t-body); }

/* Shown when a logo's measured luminance clashes with the chosen theme. */
.logo-warning {
  margin-top: 10px;
  padding: 12px 14px;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--notice) 12%, var(--surface));
  border: 1px solid var(--notice);
}
.logo-warning p { margin: 0; font-size: var(--db-t-hint); line-height: var(--line-height); }

/* RAW WHITE, DELIBERATE (1 of 3). This is the plate the artist can put BEHIND
   their own artwork when a dark logo lands on a dark theme. It is backing for
   somebody else's image, not app chrome, so it must not follow the app theme:
   the whole point is that it stays white while everything around it changes.
   The punter page draws the same plate for real; this is its preview. */
.pv-logo.has-plate {
  background: #FFFFFF;
  padding: 6px 9px;
  border-radius: 8px;
}
.crop-row { display: flex; gap: 14px; margin-top: 8px; }
.crop-option { display: flex; align-items: center; gap: 7px; font-weight: var(--weight-normal); font-size: var(--db-t-body); cursor: pointer; }
.crop-option input { width: 18px; height: 18px; accent-color: var(--action); }

/* Links editor */
.link-rows { display: grid; gap: calc(9px * var(--density)); margin-top: 8px; }
.link-row {
  display: flex;
  align-items: center;
  gap: 9px;
  /* So the clipped keyboard controls have something to be positioned against. */
  position: relative;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: calc(9px * var(--density)) calc(11px * var(--density));
}
/* ---- reordering links ----
 *
 * PRESS AND HOLD ANYWHERE ON A CARD. There is no grip any more: a grip was a
 * small target on a phone and the only place a drag could start.
 *
 * NOTHING MAY BE SELECTED WHILE A CARD IS BEING HELD, and this is the part that
 * made the old version unpleasant. Holding a row selected the text inside it,
 * raised the iOS callout, and flashed the tap highlight, all before anything
 * had been picked up. Four properties, because the four behaviours are separate
 * and every one of them had to go.
 *
 * The FIELD is exempt: it is text somebody types into and has to stay
 * selectable. js/views/profile.js will not start a drag from it either. */
.link-row {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
  transition: transform 140ms ease, box-shadow 140ms ease;
}
.link-row input,
.link-row textarea {
  user-select: text;
  -webkit-user-select: text;
  -webkit-touch-callout: default;
}

/* THE MOMENT IT LIFTS. A finger cannot feel a state change, so it has to be
   visible: off the page, scaled slightly, and above its neighbours. */
.link-row.is-dragging {
  transition: none;
  position: relative;
  z-index: 2;
  transform-origin: center;
  scale: 1.02;
  box-shadow: 0 10px 26px rgb(0 0 0 / .34);
  cursor: grabbing;
}
/* While a card is up, nothing else on the row should answer a finger that
   strays over it. */
.link-rows.is-reordering .link-row:not(.is-dragging) { pointer-events: none; }
.link-rows.is-reordering { cursor: grabbing; }

/* THE KEYBOARD PATH, HIDDEN UNTIL IT IS NEEDED.
 *
 * Visible arrows cluttered every row for the benefit of the few people who
 * cannot drag, and looked like a second way of doing the same thing. They are
 * still here, still reachable by Tab, and they appear when the row they belong
 * to has keyboard focus inside it. Clipped rather than display:none, because a
 * control that is display:none is not focusable and the path would be gone. */
.link-move {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
/* THE TRIGGER IS THE ARROW ITSELF BEING FOCUSED, and neither of the two
   obvious selectors works.
     :focus-within fires when somebody taps the address field with a finger,
     which is precisely the case the arrows are hidden for.
     :has(:focus-visible) fails the same way, because a text field matches
     :focus-visible whenever it is focused by any means at all, mouse and
     finger included. That is the spec, not a quirk.
   A row is "reached by keyboard" when one of its arrows has focus, and the
   only way to focus a clipped control is to tab to it. */
.link-row:has(.link-move button:focus) .link-move {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip-path: none;
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex-shrink: 0;
}
.link-move button {
  width: 26px;
  height: 21px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  color: var(--text-secondary);
  font-size: 9px;
  line-height: 1;
  cursor: pointer;
}
.link-move button:disabled { opacity: .35; cursor: default; }
.link-move button:focus-visible { outline: 2px solid var(--action); outline-offset: 1px; }

@media (prefers-reduced-motion: reduce) {
  .link-row { transition: none; }
  .link-row.is-dragging { scale: 1; }
}
.link-glyph { width: 17px; height: 17px; fill: var(--action-on-surface); flex-shrink: 0; }
.link-name { font-size: var(--db-t-hint); font-weight: var(--weight-medium); width: 92px; flex-shrink: 0; }
.link-entry {
  display: flex;
  align-items: center;
  flex: 1;
  min-width: 0;
  height: 40px;
  overflow: hidden;
  border: 1px solid var(--border-strong);
  border-radius: 9px;
  background: var(--surface);
}
.link-entry:focus-within {
  border-color: var(--action);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--action) 24%, transparent);
}
.link-affix {
  flex: none;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  white-space: nowrap;
}
.link-affix:first-child { padding-left: 10px; }
.link-affix:last-child { padding-right: 10px; }
.link-url {
  flex: 1;
  min-width: 4ch;
  height: 100%;
  padding-inline: 10px;
  border: 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  font-size: var(--db-t-body);
}
.link-url:focus { outline: 0; box-shadow: none; }
.link-url[aria-invalid="true"] { color: var(--negative-text); }
.link-entry:has(.link-url[aria-invalid="true"]) { border-color: var(--negative); }
.link-highlight {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  min-height: 40px;
  padding: 0 10px;
  border: 1px solid var(--border-strong);
  border-radius: 9px;
  background: var(--surface-raised);
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  font-weight: var(--weight-medium);
}
.link-highlight[aria-pressed="true"] {
  border-color: var(--action);
  background: var(--action-quiet);
  color: var(--action-quiet-text);
}
.link-highlight:disabled { opacity: .5; }
.link-error {
  display: block;
  flex-basis: 100%;
  margin-left: 136px;
  color: var(--negative-text);
  font-size: var(--db-t-hint);
}
.link-error:empty { display: none; }
.link-toggle input { width: 18px; height: 18px; accent-color: var(--action); cursor: pointer; }
/* Same reasoning as .delete-song: the alert plane is not a button fill. */
.link-remove {
  background: transparent;
  color: var(--negative-text);
  border: 1px solid var(--negative);
  border-radius: 8px;
  padding: 7px 10px;
  font-size: var(--db-t-hint);
}
.link-remove:hover { background: var(--negative-surface); }
.link-add { margin-top: 12px; }
.link-add select { height: var(--db-field-sm); border-radius: var(--radius); font-size: var(--db-t-body); }

@media (max-width: 650px) {
  .link-row { flex-wrap: wrap; }
  .link-name { width: auto; }
  .link-entry { flex-basis: 100%; order: 2; }
  .link-highlight { margin-left: auto; }
  .link-toggle, .link-remove { margin-left: 0; }
  .link-error { order: 3; margin-left: 0; }
}

/* Profile page */
.profile-grid { display: grid; gap: calc(14px * var(--density)); margin-top: 8px; }
.profile-grid label { display: block; font-size: var(--db-t-body); }
.profile-grid input { height: var(--db-field); margin-top: 6px; border-radius: var(--radius); font-size: var(--db-t-mid); }

/* D2. A SMALL DISCLOSURE UNDER THE NAME, WITH A FULL-SIZED TAP TARGET.
 * The options expand in normal document flow. Nothing floats over the sticky
 * preview, because seeing that preview while changing a deliberately risky
 * colour/shadow combination is the control's safety mechanism. */
.typed-name-edit {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 44px;
  margin: 2px 0 -7px;
  padding: 6px 2px;
  border: 0;
  background: transparent;
  color: var(--action-on-surface);
  font: inherit;
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  cursor: pointer;
}
.typed-name-edit svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform .16s ease;
}
.typed-name-edit[aria-expanded="true"] svg { transform: rotate(180deg); }
.typed-name-purpose { display: inline; margin-left: 6px; }
.typed-name-panel {
  margin-top: calc(14px * var(--density));
  padding: calc(16px * var(--density));
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-sunken);
}
.typed-name-panel[hidden], .typed-name-font-list[hidden] { display: none; }
.typed-name-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: calc(16px * var(--density));
}
.typed-name-grid label,
.typed-name-grid .field-label {
  display: block;
  margin-bottom: 6px;
  color: var(--text-primary);
  font-size: var(--db-t-body);
}
/* ------------------------------------------- THE THREE ALIGNMENT BUTTONS ---
 *
 * SELECTED IS LEGIBLE WITHOUT COLOUR, and that is a requirement rather than a
 * nicety: a chosen state told only by a purple tint is not told at all to
 * somebody who cannot separate purple from grey, and it survives no greyscale
 * print or screenshot. Three things change together, and two of them are not
 * hue: the button FILLS (light box becomes dark box, or the reverse in a light
 * theme), its border DOUBLES from 1px to 2px, and an inset ring separates the
 * fill from the border so the shape reads as pressed rather than merely tinted.
 * Turn the screen to greyscale and the chosen one is still the obvious one.
 *
 * 44px minimum on both axes, which is the floor everything tappable in this app
 * is held to. They share the row equally rather than being 44px each, so on a
 * phone the three targets are as large as the field is wide. */
.typed-name-align {
  display: flex;
  gap: 6px;
}
.typed-name-align-btn {
  display: inline-flex;
  flex: 1 1 0;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
  color: var(--text-secondary);
  cursor: pointer;
}
.typed-name-align-btn svg {
  width: 20px;
  height: 20px;
  fill: currentColor;
}
.typed-name-align-btn[aria-checked="true"] {
  border: 2px solid var(--action);
  background: var(--action);
  box-shadow: inset 0 0 0 2px var(--surface-raised);
  color: var(--action-text);
}

.typed-name-grid select,
.typed-name-font-button {
  width: 100%;
  min-height: var(--db-field);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
  color: var(--text-primary);
  font-size: var(--db-t-mid);
}
.typed-name-range-label {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}
.typed-name-range-label output {
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  font-variant-numeric: tabular-nums;
}
#typedNameScale { width: 100%; min-height: 32px; margin: 0; accent-color: var(--action); }
/* Still a positioning context for the chevron below, which is drawn on the
   FIELD rather than inside the button. The list is no longer positioned against
   it — it is on <body> now — so nothing here decides where the menu lands. */
.typed-name-font-field { position: relative; }
.typed-name-font-button {
  padding: 8px 38px 8px 12px;
  overflow: hidden;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
  cursor: pointer;
}
.typed-name-font-field::after {
  content: '';
  position: absolute;
  top: calc(var(--db-field) / 2 + 22px);
  right: 14px;
  width: 7px;
  height: 7px;
  border: solid var(--text-secondary);
  border-width: 0 2px 2px 0;
  transform: translateY(-50%) rotate(45deg);
  pointer-events: none;
}
/* --------------------------------------------- THE FONT PICKER, AS AN OVERLAY
 *
 * position: fixed, and the coordinates come from js/views/profile.js because
 * only script can know where the trigger is and which way there is room to
 * open. CSS cannot flip a menu.
 *
 * The element is moved to <body> before it is shown (js/modal-portal.js), so
 * `fixed` here means the viewport in every engine and no ancestor's
 * overflow: hidden can clip it. Both halves of that are load-bearing and both
 * are explained where the moving happens.
 *
 * z-index 60 puts it over the card and the sticky preview and under the
 * dashboard's modals (.vm-overlay is 90): a full-screen dialog opening on top
 * of a menu should cover it.
 *
 * It scrolls inside itself rather than growing past the screen; the height cap
 * is set per open, from the room actually available. */
.typed-name-font-list {
  position: fixed;
  z-index: var(--layer-dropdown);
  display: grid;
  gap: 6px;
  margin: 0;
  padding: 8px;
  overflow-y: auto;
  overscroll-behavior: contain;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
  box-shadow: var(--shadow-2);
}
.typed-name-font-option {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  min-height: 48px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
  color: var(--text-primary);
  text-align: left;
  cursor: pointer;
}
.typed-name-font-option.selected { border: 2px solid var(--action); }
.typed-name-font-option span { overflow-wrap: anywhere; }
.typed-name-font-option small {
  flex: none;
  color: var(--text-secondary);
  font-family: var(--font);
  font-size: var(--db-t-small);
}
.typed-name-colour-row { display: flex; align-items: center; gap: 8px; }
#typedNameColour {
  flex: none;
  width: 52px;
  height: var(--db-field);
  padding: 4px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
  cursor: pointer;
}
.typed-name-colour-row .btn-quiet { min-height: var(--db-field); }
.typed-name-colour-row .btn-quiet[aria-pressed="true"] {
  border-color: var(--action);
  color: var(--action-on-surface);
}

@media (max-width: 650px) {
  .typed-name-purpose { display: block; margin: -2px 0 0; }
  .typed-name-grid { grid-template-columns: 1fr; }
}

@media (prefers-reduced-motion: reduce) {
  .typed-name-edit svg { transition: none; }
}

/* The logo and header keep their established order and controls, but the
   header starts on a new visual beat. The logo-only grid item above collapses
   completely when there is no logo, so this boundary follows either the short
   logo picker or the full set of logo controls without leaving a ghost gap. */
.profile-image-header {
  margin-top: calc(6px * var(--density));
  padding-top: calc(18px * var(--density));
  border-top: 1px solid var(--border);
}

/* A field hint that is only ever green or red is a hue-only answer to "is
   this slug free?". The glyph is the answer; the colour agrees with it. */
.field-hint { font-size: var(--db-t-small); color: var(--text-secondary); margin-top: 5px; min-height: 15px; }
.field-hint.ok { color: var(--positive-text); }
.field-hint.bad { color: var(--negative-text); }
.field-hint.ok::before, .field-hint.bad::before {
  margin-right: .4em;
  font-weight: var(--weight-strong);
}
.field-hint.ok::before { content: '✓'; }
.field-hint.bad::before { content: '✕'; }

/* A checkerboard, not a theme surface.

   This backs the artist's own uploaded artwork, which is usually a transparent
   PNG and may be any colour. It used --surface-sunken, which on Light is
   #FFFFFF by design (light themes keep their inputs white) — so a white logo
   on a white plate rendered as a blank rectangle and the artist could not see
   the file they had just uploaded. Any single colour has the same failure for
   artwork of that colour, so this shows transparency AS transparency, the way
   every image editor does, and belongs to no palette. */
.logo-preview {
  max-width: 200px;
  max-height: 80px;
  object-fit: contain;
  display: block;
  margin-top: 8px;
  background-color: #FFFFFF;
  background-image:
    linear-gradient(45deg, #CFCFCF 25%, transparent 25% 75%, #CFCFCF 75%),
    linear-gradient(45deg, #CFCFCF 25%, transparent 25% 75%, #CFCFCF 75%);
  background-size: 16px 16px;
  background-position: 0 0, 8px 8px;
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  padding: 8px;
}
.save-profile { width: 100%; height: calc(52px * var(--density)); margin-top: 16px; }

.help-details {
  margin-top: 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 4px 14px;
  background: var(--surface-sunken);
}
.help-details summary { cursor: pointer; font-weight: var(--weight-medium); padding: 10px 0; font-size: var(--db-t-body); }
.help-details p, .help-details li { font-size: var(--db-t-hint); color: var(--text-secondary); line-height: var(--line-height); }

/* ---- QR page ---- */
.qr-panel { text-align: center; }

/* RAW WHITE, DELIBERATE (2 of 3). A QR code is read by a camera looking for
   dark modules on a light field with a clear quiet zone. Theming this plate
   would make the code fail to scan on two of the three themes, which is the
   whole feature gone. So it stays white in every theme and the padding stays
   part of the code rather than decoration around it. This is the one place in
   the app where "legible" and "consistent with the theme" genuinely conflict,
   and legible wins outright — a themed QR code is not a design decision, it
   is a broken QR code. The canvas itself is painted #fff/#000 in js/views/qr.js
   for the same reason. */
.qr-canvas-wrap {
  background: #FFFFFF;
  display: inline-block;
  padding: 18px;          /* the quiet zone; scanners need it */
  border-radius: var(--radius);
  margin-top: 14px;
  max-width: 100%;
}
#qrCanvas { max-width: 100%; height: auto; }
@media (max-width: 380px) {
  .qr-canvas-wrap { padding: 10px; }
}
.qr-actions { display: flex; gap: 10px; justify-content: center; margin-top: 16px; flex-wrap: wrap; }
/* No colour here: these are .btn-primary and .btn-quiet, and forcing
   --text-primary onto them put off-white text on the accent fill at 2:1. */
.qr-actions button, .qr-actions a {
  padding: 13px 20px;
  border-radius: var(--radius);
  font-size: var(--db-t-body);
  font-weight: var(--weight-strong);
  text-decoration: none;
}
.qr-url { margin-top: 12px; font-size: var(--db-t-body); overflow-wrap: anywhere; }

/* Phone: the search box and the two buttons stop sharing a line. Search takes
   the full width because it is the one you reach for on a list of 300, and
   Add and Paste split the row under it. */
@media (max-width: 650px) {
  .songs-tools { display: grid; grid-template-columns: 1fr 1fr; }
  /* The wrapper is the grid item now, not the input inside it. */
  .songs-tools .search-field { grid-column: 1 / -1; }
}

/* RAW WHITE AND BLACK, DELIBERATE (3 of 3). Paper is white, and carrying a
   dark app theme onto a sheet would empty an ink cartridge to produce
   something less legible than the default. Ink on paper is maximum contrast
   for free.
 *
 * This used to hide everything except a print-only table talker that the old
 * Share screen printed with window.print(). That screen is retired and the
 * talker with it: printed signage is now real PDFs at exact page sizes, drawn
 * by js/print/, which is a better answer to the same problem than asking
 * somebody to find "Margins: None" in a print dialogue. What is left is the
 * sensible default for anybody who prints a dashboard page anyway. */
@media print {
  body { background: #FFFFFF; color: #000000; }
}

/* ---- font picker ----
 *
 * Each option is set in the face it selects, because a list of font names all
 * rendered in the same face tells you nothing. The group is a real radiogroup
 * in the markup, so it now looks like one: an empty ring that fills with a
 * tick. Selection used to be an accent border and a tint, both of which are
 * hue. */
.font-list { display: grid; gap: 10px; margin-top: 8px; }
.font-option {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  padding: 14px 16px;
  text-align: left;
  background: var(--surface-sunken);
  border: 2px solid var(--border);
  border-radius: var(--radius);
  color: var(--text-primary);
}
.font-option::before {
  content: '';
  flex: none;
  align-self: center;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2px solid var(--border-strong);
  box-sizing: border-box;
  /* The button is set in the typeface it is offering; the tick is not. */
  font-family: var(--font);
}
/* No tint. A 10% accent wash over the sunken plane came out as pale lilac on
   Light — a pastel accent, which light.js names as a defect because it is the
   first thing glare takes. The row already carries a filled ✓ badge and a
   2px accent border, so the wash was decoration doing no work. */
.font-option.selected { border-color: var(--action); }
.font-option.selected::before {
  content: '✓';
  display: grid;
  place-items: center;
  border-color: var(--action);
  background: var(--action);
  color: var(--action-text);
  font-size: var(--db-t-small);
  font-weight: var(--weight-strong);
  line-height: 1;
}
.font-sample { font-size: 19px; font-weight: var(--weight-strong); flex: 1 1 auto; min-width: 0; }
.font-name {
  font-family: var(--font);
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
  flex-shrink: 0;
}

/* ------------------------------------------------------------ Past gigs --- */

/* ---- dense rows -----------------------------------------------------------
 *
 * Everything outside Gig Mode is read sitting down and scanned, not tapped
 * mid-song, so rows here are a single tight line with the numbers aligned in
 * columns down the right. Gig Mode keeps its big separated targets; this is
 * the deliberate opposite of that, for the opposite job.
 *
 * One row = one line. The title truncates; the numbers never do, because a
 * column of figures that shifts position is not a column. */
.drow {
  display: flex;
  align-items: center;
  gap: var(--db-gap);
  width: 100%;
  min-height: var(--db-row);
  padding: var(--db-row-pad) 0;
  border-bottom: 1px solid var(--border);
  background: transparent;
  color: var(--text-primary);
  text-align: left;
  font-size: var(--db-t-body);
}
.drow:last-child { border-bottom: 0; }
.drow-btn { cursor: pointer; border-radius: 0; }
.drow-btn:hover { background: var(--surface-raised); }
.drow-head {
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  min-height: 0;
  padding: 4px 0 6px;
}
.drow-main {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: baseline;
  gap: 8px;
  overflow: hidden;
}
/* A SONG IS TWO LINES: what it is, and who did it. Stacked in a column of its
   own inside the row's flex, so the artist sits UNDER the title rather than
   trailing it — trailing put two competing pieces of text on one baseline and
   the eye had to choose which was the song. The pair still behaves as a single
   item to the row, keeping the title's baseline against the count beside it. */
.drow-song {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.drow-title {
  font-weight: var(--weight-medium);
  line-height: 1.15;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.drow-rank { color: var(--text-secondary); font-variant-numeric: tabular-nums; min-width: 18px; }
.drow-caret { color: var(--text-secondary); font-size: var(--db-t-micro); width: 10px; flex: none; }
.drow-when, .drow-for {
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* ============ THE HEADINGS MAY WRAP. THE FIGURES MAY NOT ============
 * white-space: nowrap below is right for a row of numbers: a figure broken
 * over two lines is not a figure. It is wrong for a heading, and it is why
 * "Avg earned" overflowed its column by 31px instead of sitting on two lines,
 * which is what sent the column hunting for width it had to take off the venue
 * name. Headings wrap; nothing else does. */
.drow-head .drow-nums { white-space: normal; }
.drow-nums {
  flex: none;
  /* A GRID, NOT A FLEX ROW. See the note on .col-n: the heading row and the
     body rows are separate elements, so the only way a heading can be over its
     own figures is for both to use the same track sizes. */
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(calc(36px * var(--font-scale)), max-content);
  align-items: baseline;
  gap: 10px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ============ THE TRACKS, PER TABLE ============
 *
 * Fixed widths, so the head and the body agree by construction. Each is the
 * width of the widest thing that goes in it, which for these columns is
 * always the HEADING: the figures are one to five characters and the words
 * are Played, Earned, Reqs, Avg, Gigs.
 *
 * Named in one place per table rather than per cell, because the fault this
 * replaces was exactly what happens when each cell decides for itself. */
#pastGigList :is(.drow-head, .drow-btn) .drow-nums {
  /* Time, Length, Reqs, Played, Earned. */
  grid-template-columns:
    calc(78px * var(--font-scale))
    calc(52px * var(--font-scale))
    calc(40px * var(--font-scale))
    calc(52px * var(--font-scale))
    calc(56px * var(--font-scale));
  grid-auto-columns: auto;
}
#pastVenues .drow-nums {
  /* Gigs, Reqs, Avg, Avg earned, Last played, and the delete control's own
     space.
     THE FOURTH IS 62 RATHER THAN 56 because its heading is now "Avg earned",
     which says which kind of number the column holds: the past gigs table has
     an "Earned" of its own and that one is a single night.
     IT IS NOT 90, AND THAT WAS THE FIRST ANSWER AND A BAD ONE. The heading
     needs 87px on ONE line, so the column was widened to hold it, and the 28px
     came off the venue name: "Private F...", "The Free...", "The Far..." down
     the whole list, with the (i) clipped off the heading. Trading the name for
     the heading is the wrong trade, because the name is what somebody is
     looking for. The heading wraps to two lines instead, which costs no
     horizontal space at all, and the column only has to hold the figure and
     "Avg /gig" underneath it. */
  grid-template-columns:
    calc(38px * var(--font-scale))
    calc(40px * var(--font-scale))
    calc(38px * var(--font-scale))
    calc(62px * var(--font-scale))
    calc(78px * var(--font-scale))
    26px;
  grid-auto-columns: auto;
}
/* ONE TAP, ON THE ROW IT BELONGS TO.
 *
 * OUTLINED AND NOT FILLED. A filled control here would be the loudest thing on
 * the card and would read as the card's purpose; the purpose is the list, and
 * this is what you do to one line of it. Same reasoning the live screen's chart
 * button follows, where filled is reserved for Played and Skip.
 *
 * align-self centre because .drow-nums aligns on the baseline for the numbers,
 * and a button on a text baseline sits low. */
.drow-add {
  align-self: center;
  flex: none;
  min-height: 32px;
  padding: 4px 12px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: var(--surface-raised);
  color: var(--text-primary);
  font: inherit;
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  cursor: pointer;
}
.drow-add:hover { border-color: var(--action-on-surface); color: var(--action-on-surface); }
.drow-add:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* THE WORTH LEARNING CONTROL: BOTH ANSWERS, BEFORE THE TAP.
 *
 * It said "Add", which meant the remove half was invisible until somebody
 * tapped a button named after the other thing. A tick and a cross show what the
 * two answers are without naming either, and the pill opens the modal where
 * they are named.
 *
 * THE SLASH IS DOING WORK. Without it the two marks read as two controls, and a
 * thumb landing between them would be a fifty-fifty guess about which one it
 * hit. With it they read as one either/or, which is what it is: neither half is
 * separately tappable, and the whole pill opens the same modal.
 *
 * SHAPE FIRST, COLOUR SECOND. A tick and a cross are different shapes, so the
 * pair survives greyscale and every dichromat before the colour helps at all —
 * which is the rule docs/THEMING.md sets and the reason this is not two
 * coloured dots. The inks are the -text roles rather than the fill roles,
 * because these are marks on a plane rather than fills. */
.drow-decide {
  align-self: center;
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 32px;
  padding: 4px 12px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: var(--surface-raised);
  font: inherit;
  font-size: var(--db-t-body);
  font-weight: var(--weight-strong);
  line-height: 1;
  cursor: pointer;
}
.drow-yes { color: var(--positive-text); }
.drow-no { color: var(--negative-text); }
/* Quiet, and it is punctuation rather than a third mark. */
.drow-slash { color: var(--text-muted); font-weight: var(--weight-medium); }
.drow-decide:hover { border-color: var(--action-on-surface); }
.drow-decide:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.drow-decide[disabled] { opacity: .5; cursor: default; }

/* THE PAIR, IN THE MODAL. Mark asked for a green Add and a red Remove.
 *
 * NOT .btn-danger, and the difference matters. That class is documented two
 * hundred lines below as "red is reserved for actions that delete something the
 * artist cannot get back", and it carries a ⚠ glyph to say so. Removing a song
 * from Worth learning deletes nothing — that is the whole constraint the
 * feature was built under, and the line under these buttons says so in words.
 * Dressing it as destruction would contradict the sentence directly above it.
 *
 * So red here means "the no of a yes/no pair" rather than "irreversible", which
 * is what a tick and a cross already mean on the row this modal opens from. No
 * warning glyph. The distinction is noted on .btn-danger itself. */
.btn-yes {
  background: var(--positive-surface);
  color: var(--positive-text);
  border: 1px solid var(--positive);
}
.btn-yes:hover:not(:disabled) {
  background: color-mix(in srgb, var(--positive) 22%, var(--positive-surface));
}
.btn-no {
  background: var(--negative-surface);
  color: var(--negative-text);
  border: 1px solid var(--negative);
}
.btn-no:hover:not(:disabled) {
  background: color-mix(in srgb, var(--negative) 22%, var(--negative-surface));
}
/* Held between the tap and the answer. It never comes back enabled: on success
   the row goes, and on failure the row is re-rendered from scratch. */
.drow-add[disabled] { opacity: .5; cursor: default; }
.drow-n { font-weight: var(--weight-strong); font-size: var(--db-t-mid); }
.drow-unit, .drow-sub { color: var(--text-secondary); font-size: var(--db-t-small); }

/* WHO RECORDED IT. Present on every song row, and deliberately quiet: it is
   there to settle "which Landslide?", not to compete with the title. */
/* The venue modal's own song rows carry the artist too — same quiet second
   line, scoped to that list's tighter type. */
/* Tight leading and no gap above, here and on .drow-by below. The two lines
   have to read as ONE thing: give them comfortable spacing and a song stops
   looking like a song and starts looking like two list entries. */
.vm-by {
  display: block;
  font-size: var(--db-t-small);
  line-height: 1.1;
  color: var(--text-secondary);
}

.drow-by {
  font-size: var(--db-t-small);
  line-height: 1.1;
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* ============ A HEADING SITS OVER ITS OWN FIGURES ============
 *
 * THE FAULT, MEASURED at 390, 430, tablet portrait and desktop: on Past gigs
 * the headings were up to twenty pixels off the numbers under them, and on
 * Venues about three. Nobody could tell which heading belonged to which
 * figure, which is the whole point of a column.
 *
 * THE CAUSE was min-width. Every cell was sized by its own content with a
 * floor under it, so "Played" made the HEADER cell 53px wide while the "1"
 * beneath it made the BODY cell 36, and the two rows drifted apart cell by
 * cell. Two rows of flex items are not a table; they are two rows that happen
 * to look like one when the content is a similar width.
 *
 * THE FIX IS A SHARED GRID. .drow-nums is a grid on both rows with the same
 * tracks, so a cell is in a column because the layout says so rather than
 * because two strings happened to measure the same. The tracks are declared
 * per table below, because the two tables carry different columns.
 *
 * grid-auto-columns rather than a fixed template on the base rule: a row with
 * an unexpected cell still lays out rather than stacking everything into track
 * one, which is what a template with too few tracks does. */
.col-n { text-align: right; font-variant-numeric: tabular-nums; }
.col-w { text-align: right; font-size: var(--db-t-small); }
.col-n.strong { font-weight: var(--weight-strong); }

/* ============ THE MONEY COLUMN ============
 *
 * Wider than a bare count, because "£412" is four characters and a venue that
 * has been given nothing shows a dash rather than a zero: a zero in a money
 * column reads as a measurement and a dash reads as no measurement, and on a
 * room played once with no tips the second is the true one.
 *
 * NOT COLOURED. Green is the played column's, and a green money figure would
 * read as a verdict on a room. This is what the musician EARNED there, after
 * Stripe's fee and ours, which is the figure that reconciles with their bank
 * and is not a judgement about the venue. */
.col-n.earned {
  min-width: calc(52px * var(--font-scale));
  font-variant-numeric: tabular-nums;
}
/* Kept for the past-gig rows, which still carry a secondary money figure. */
.col-n.earned.muted { font-size: var(--db-t-small); }
/* "a gig", beside the amount. Small and quiet: it qualifies the figure rather
   than competing with it, and the column header above already says what kind
   of amount it is. */
.col-per {
  display: block;
  font-size: var(--db-t-hint);
  font-style: normal;
  font-weight: var(--weight-normal, 400);
  line-height: 1.1;
  color: var(--text-secondary);
}

/* THE PLAYED FIGURE. A bare green numeral sitting immediately beside a bare
 * bold numeral is two numbers separated by hue and nothing else — in the gig
 * list "12  5" is Reqs then Played, and greyscale makes those the same thing.
 * The tick names it. Every cell in the column carries one, so the column of
 * figures still lines up. */
.col-n.ok, .drow-sub.ok, .detail-tally .ok { color: var(--positive-text); }
.col-n.ok { font-weight: var(--weight-medium); }
.col-n.ok::before, .drow-sub.ok::before, .detail-tally .ok::before {
  content: '✓';
  margin-right: .25em;
  font-size: .85em;
  font-weight: var(--weight-strong);
}

/* Narrow: the wide time column is the first thing to go, then the secondary
   figure. The counts that are the point of the row always stay.

   Keyed to the PANEL, not the viewport. Panels sit in `minmax(330px, 1fr)`
   tracks, so on a 1180px desktop a panel is about 440px wide and a viewport
   query would never fire, which is how a venue name ends up truncated to
   "Th..." on a large screen. The container is what the row has to fit in, so
   the container is what decides.

   Which column goes is stated per column, not inferred, and the order is by
   what cannot be worked out from the rest of the row. `.col-opt` goes first
   because it is the derivable one (a venue's total requests is just its gigs
   times its average). `.col-w` goes next. Anything left stays, because a table
   that sheds every column but one is not a table, which is what "hide anything
   not bold" did to the venue list. */
/* THE GIG DATE IS THE ONE THING THAT DOES NOT GET DROPPED.
 *
 * It used to. `.drow-when { display: none }` went with `.col-w` below 430px,
 * on the same reasoning as the columns above — narrow row, too many items on
 * one line, shed the derivable ones. But a date is not derivable from anything
 * else in the row, and dropping it left a phone showing a list of venue names
 * with no way to tell one night at The Anchor from the next. The rule was
 * shedding the SECOND most important fact on the row.
 *
 * SO IT MOVES INSTEAD OF GOING. .drow-gig is display:contents while there is
 * room, which takes it out of the layout entirely and leaves .drow-title and
 * .drow-when as direct children of .drow-main's flex — a wide panel renders
 * exactly as it did. Below the breakpoint the wrapper becomes a column and the
 * date drops onto its own line under the venue, which is where the song rows
 * have always put their second line (see .drow-song / .drow-by) so the two
 * lists still read as the same kind of thing.
 *
 * SAME SIZE AND COLOUR AS EVERY OTHER SECOND LINE ON THE DASHBOARD:
 * --db-t-small in --text-secondary, which .drow-when already had, and the
 * tight 1.1 leading .drow-by uses so the pair reads as one row rather than
 * two entries. */
.drow-gig { display: contents; }

/* THE MONEY COLUMN NO LONGER SHEDS AT ALL, and the rule that dropped it below
   620px has gone with the column it was written for. It said the per-gig figure
   was the derivable one, because the row carried the total and the number of
   gigs beside it. The row now carries the per-gig figure and NOT the total, so
   there is nothing left to derive it from: dropping it on a phone would take
   the whole answer off the screen most musicians read this on. */
/* ============ AND THE TEMPLATE FOLLOWS WHAT IS HIDDEN ============
 *
 * A `display: none` grid item does not take a track, so hiding the optional
 * column without shortening the template leaves an empty track at the end and
 * every heading a column to the left of its figures. The two have to move
 * together, which is the whole reason the tracks are declared in one place. */
@container (max-width: 560px) {
  .drow .col-opt { display: none; }
  #pastGigList :is(.drow-head, .drow-btn) .drow-nums {
    grid-template-columns:
      calc(78px * var(--font-scale))
      calc(52px * var(--font-scale))
      calc(52px * var(--font-scale))
      calc(56px * var(--font-scale));
  }
  #pastVenues .drow-nums {
    grid-template-columns:
      calc(38px * var(--font-scale))
      calc(38px * var(--font-scale))
      calc(62px * var(--font-scale))
      calc(78px * var(--font-scale))
      26px;
  }
}
@container (max-width: 430px) {
  .drow .col-w { display: none; }
  .drow-gig { display: flex; flex-direction: column; min-width: 0; }
  .drow-gig .drow-when { line-height: 1.1; }
  /* col-w is Time on the gig list and Last played on the venues, and both are
     the FIRST or LAST track. Same rule as above: what is hidden comes out of
     the template too. */
  #pastGigList :is(.drow-head, .drow-btn) .drow-nums {
    grid-template-columns:
      calc(52px * var(--font-scale))
      calc(52px * var(--font-scale))
      calc(56px * var(--font-scale));
  }
  /* ============ THE EARNED COLUMN IS THE WIDEST WORD IN THE ROW ============
     It was 56px, which holds the figure and holds "Avg /gig" underneath it,
     and does NOT hold the heading: "Avg earned" measured 87px in a 56px track
     and overflowed by 31 rather than wrapping. That is the squash that was
     reported. 90 is what the heading needs, measured rather than guessed at:
     84 left three pixels of it still over the edge. It comes off the venue
     name,
     which truncates with an ellipsis and can afford it. The figure is not
     shrunk to make room, which was the other way this could have gone and the
     wrong one: the number is the thing being read. */
  #pastVenues .drow-nums {
    grid-template-columns:
      calc(38px * var(--font-scale))
      calc(38px * var(--font-scale))
      calc(62px * var(--font-scale))
      26px;
  }
}
/* Fallback where container queries are unsupported: the viewport is at least a
   ceiling on how wide a panel can be. */
@media (max-width: 560px) {
  .drow .col-opt { display: none; }
  #pastGigList :is(.drow-head, .drow-btn) .drow-nums {
    grid-template-columns:
      calc(78px * var(--font-scale))
      calc(52px * var(--font-scale))
      calc(52px * var(--font-scale))
      calc(56px * var(--font-scale));
  }
  #pastVenues .drow-nums {
    grid-template-columns:
      calc(38px * var(--font-scale))
      calc(38px * var(--font-scale))
      calc(62px * var(--font-scale))
      calc(78px * var(--font-scale))
      26px;
  }
}
@media (max-width: 430px) {
  .drow .col-w { display: none; }
  .drow-gig { display: flex; flex-direction: column; min-width: 0; }
  .drow-gig .drow-when { line-height: 1.1; }
  #pastGigList :is(.drow-head, .drow-btn) .drow-nums {
    grid-template-columns:
      calc(52px * var(--font-scale))
      calc(52px * var(--font-scale))
      calc(56px * var(--font-scale));
  }
  /* ============ THE EARNED COLUMN IS THE WIDEST WORD IN THE ROW ============
     It was 56px, which holds the figure and holds "Avg /gig" underneath it,
     and does NOT hold the heading: "Avg earned" measured 87px in a 56px track
     and overflowed by 31 rather than wrapping. That is the squash that was
     reported. 84 is what the heading needs, and it comes off the venue name,
     which truncates with an ellipsis and can afford it. The figure is not
     shrunk to make room, which was the other way this could have gone and the
     wrong one: the number is the thing being read. */
  #pastVenues .drow-nums {
    grid-template-columns:
      calc(38px * var(--font-scale))
      calc(38px * var(--font-scale))
      calc(62px * var(--font-scale))
      26px;
  }
}

/* ---- overview strip ----
 *
 * Two by two, or four across when there is genuinely room. Never three, which
 * is what auto-fit chose in a half-width panel: it left the fourth tile alone
 * on a row of its own with two thirds of the row empty beside it. */
/* FIVE STATS, AND FIVE IS THE AWKWARD NUMBER.
 *
 * Two columns leaves one stranded on the last row; four columns leaves one
 * stranded on the second. So the fifth tile SPANS THE ROW whenever the column
 * count does not divide five, which turns the orphan into a full-width tile —
 * a deliberate shape rather than a gap.
 *
 * Container queries, not viewport ones: this panel is full width on a phone,
 * full width again in the middle layout, and roughly a third of the row on a
 * desktop. Only the panel knows how much room it has. */
.stat-strip {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: calc(10px * var(--density));
  margin-top: 12px;
}
/* THE ODD ONE OUT SPANS. It was :nth-child(5), which meant "the fifth thing in
   the box" and stopped meaning "the last tile" the moment a period selector was
   added above them — the fourth tile started spanning and the fifth sat half a
   row wide. It names the tile it means now. */
.stat-strip > .stat-tile:last-child { grid-column: 1 / -1; }

/* THE TABLET SHAPE: TWO, THEN THREE. This is the middle layout, where Overview
   spans the whole trio and there is room for more than two across.
 *
 * Five across fitted, but fitting is not the same as reading well: five narrow
 * columns of different-length labels gave five ragged text blocks with no
 * shared edge, and the card stopped looking like a set of related facts.
 *
 * Two then three, on a SIX-column grid — the first pair at span 3, the last
 * three at span 2. Both rows reach the full width, so neither has a hole in it
 * and every tile in a row is exactly as wide as its neighbours.
 *
 * The split falls where the meaning does. Gigs played and total requests are
 * the size of the thing; different people, different songs and the played
 * share are all readings OF that thing. Two lines of grouping the eye gets for
 * free, without a heading to say so. */
@container (min-width: 700px) {
  .stat-strip { grid-template-columns: repeat(6, minmax(0, 1fr)); }
  /* `.stat-tile:nth-child(n)`, not a bare `:nth-child(n)`. Two things are going
     on. The `:nth-child(n)` is there for specificity: the narrow layout spans
     its last tile across the row at 0,3,0, and a bare `*` scores lower and
     loses, so this has to match that weight and come later. The `.stat-tile` is
     there for correctness: dropping it made this match EVERY child, and the
     period selector is child one — so the selector took the first wide slot and
     every tile shifted one place, which put the 2-then-3 grouping below on the
     wrong pair. The tiles are children two to six; the offsets say so. */
  .stat-strip > .stat-tile:nth-child(n) { grid-column: span 2; }
  .stat-strip > .stat-tile:nth-child(2),
  .stat-strip > .stat-tile:nth-child(3) { grid-column: span 3; }
}

/* The narrowest phones: one per row, so nothing is ever half a label wide. */
@media (max-width: 380px) {
  .stat-strip { grid-template-columns: minmax(0, 1fr); }
}
/* One step up the ladder from the panel, and left unoutlined: the tile is a
   plane, not a form field. */
.stat-tile {
  background: var(--surface-raised);
  border-radius: var(--radius);
  padding: calc(12px * var(--density)) calc(14px * var(--density));
  min-width: 0;
}
.stat-n { display: block; font-size: var(--db-t-fig); font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; }
.stat-l { display: block; font-size: var(--db-t-small); color: var(--text-secondary); margin-top: 2px; }


/* The totals above legitimately exceed the gig list below when a gig has been
   deleted. Said plainly, in place, so it reads as an explanation rather than
   an arithmetic bug. */
.reconcile {
  margin: 12px 0 0;
  padding: 10px 12px;
  border-left: 2px solid var(--border-strong);
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
}

/* ---- chart -----------------------------------------------------------------
 *
 * FIVE LINES: the four series (Total, Played, Skipped, Tip taps) and, drawn
 * under them, the same window one period back.
 *
 * THIS BLOCK ONLY DESCRIBED TWO OF THEM. A rebuild on 10 August 2026 cut the
 * card down to Total and its comparison and rewrote these rules to match; the
 * four series came back the next day and the CSS did not. Played, Skipped and
 * Tip taps were left with no rule at all, so they inherited currentColor and
 * drew as three near-black lines on Light and three near-white ones on Dark —
 * which is what "the colour has gone" meant. The definitions below are the
 * repair. Same for .lg-toggle, which had been rendering as a bare user-agent
 * button since the same commit.
 *
 * NO LINE IS TOLD APART BY COLOUR ALONE. In greyscale four mid-toned strokes
 * are four grey lines, and greyscale is not a corner case — it is roughly what
 * a protanope already sees, and it is exactly what a phone in direct sun
 * gives you. So each series carries:
 *   - its own DASH PATTERN on the line (solid / dashed / dotted / dash-dot),
 *   - its own MARKER SHAPE on the point (circle / square / triangle / diamond),
 *   - its own STROKE WEIGHT, heaviest for Total because Total is the line you
 *     are meant to read first,
 *   - and the key draws all three at the size they appear in the plot, so it
 *     actually keys rather than being a colour chip beside a word.
 * Colour is the fourth channel and the only redundant one.
 *
 * Marker sizes are not equal: a square reads heavier than a circle of the same
 * width and a triangle lighter, so they are sized to the same visual WEIGHT
 * rather than the same number.
 *
 * WEIGHTS ARE DELIBERATELY LIGHT. Five lines at 2px and above filled the plot
 * with ink and left the peaks looking like a barcode; at 1.75/1.25/1 the same
 * five lines separate on shape and hue with room to see between them. */
.range-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 14px; }
/* ONE DEFINITION, TWO RANGE CONTROLS. Mark: the Overview card's period pills
 * must EXACTLY match these, reusing the styling rather than copying it. They
 * were a separate block that agreed with this one in spirit and differed in
 * every value - 44px against 30, --border-strong against --border, --db-t-small
 * against --db-t-hint, and a padding shorthand that set height a second way.
 * Two range controls on one screen that look different are two things to learn.
 *
 * min-height rather than padding alone, on both: 30px was under the 44px floor
 * every other control on these cards meets, and these are worked one-handed.
 * The trend card's buttons get that too, which they should always have had. */
.range-btn,
.strip-period,
.venue-sort {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 5px 14px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-family: inherit;
  font-size: var(--db-t-hint);
  cursor: pointer;
}
.range-btn.is-on,
.strip-period.is-on,
.venue-sort.is-on {
  background: var(--action);
  border-color: var(--action);
  color: var(--action-text);
  font-weight: var(--weight-medium);
}

#pastChart {
  /* The x labels have to line up under the plot, and the plot starts after
     the y axis plus the flex gap. Stated once, consumed by both. */
  --chart-y-w: calc(26px * var(--font-scale));
  --chart-gap: 8px;
  /* The plot keeps its height while its contents are replaced. Without this
     the page briefly shortens on every range change and the browser clamps
     the scroll position to the top. */
  min-height: calc(330px * var(--density));
}

/* MOBILE FIRST. On a phone the headline and its comparison stack, and the
   three secondary figures sit in a row below them. From 520px up the
   secondaries move out to the right so the whole head is one band. */
.chart-head {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-bottom: 12px;
}
.chart-lead { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; margin: 0; }
.chart-big { font-size: var(--db-t-big); font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; }

/* THE COMPARISON, AND WHY IT IS NOT A COLOURED ARROW.
   Up is not automatically good and down is not automatically bad — a quiet
   month can be a month with fewer gigs in it — so this is deliberately not
   painted green and red. The direction is in the WORD ("Up 12% on the 30 days
   before"), which survives greyscale, colour blindness and being read out. The
   tone classes exist so a future decision to tint them has somewhere to go. */
.chart-compare {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 8px;
  margin: 0;
  font-size: var(--db-t-hint);
  color: var(--text-primary);
}
.chart-compare .cmp-raw { font-size: var(--db-t-small); color: var(--text-muted); }
/* THE ROW HOLDS ITS HEIGHT WITH NOTHING IN IT. All time has no preceding
   period, so this line had nothing to render and the card lost 20px, which
   made switching range reflow the page under the reader's thumb. Two lines'
   worth, because on a narrow screen in a wide-fonted theme the sentence and
   its raw figure wrap onto two, and a card that changes height between THEMES
   is the same fault wearing different clothes. */
.chart-compare { min-height: 1.35em; }
/* Two lines' worth on a narrow screen, where the sentence and its raw figure
   wrap in some themes and not others. A card that changes height between
   THEMES is the same fault as one that changes height between ranges. */
@media (max-width: 620px) {
  .chart-compare { min-height: calc(2 * 1.35em); }
}
.chart-compare.is-empty { visibility: hidden; }

.chart-side {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 18px;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}
.chart-side b {
  color: var(--text-primary);
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
}
@media (min-width: 520px) {
  .chart-head { flex-direction: row; align-items: flex-start; justify-content: space-between; gap: 16px; }
  .chart-side { flex: none; flex-direction: column; text-align: right; gap: 3px; }
}

/* Said once, above the plot, when there is nothing in it. An empty chart with
   no words is indistinguishable from a chart that failed to load. */
.chart-none {
  margin: 0 0 10px;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}

/* THE KEY IS ALSO THE CONTROLS: each entry switches its line. Four lines on
   one plot needs that, and it is the only way to read one series cleanly.
   The comparison is not among them — it is the same metric as Total shifted
   back a period, so it appears as a plain key entry, not a switch. */
.chart-legend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: calc(6px * var(--density)) calc(14px * var(--density));
  margin-bottom: 12px;
}
/* NO PILL. Four bordered capsules across a 390px phone wrapped onto two lines
   and put more chrome on screen than plot. The swatch already draws the line
   it stands for, so the border was drawing a box around a box. */
.lg-toggle {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  /* Vertical padding only — the row's own gap does the horizontal spacing, and
     the height keeps the tap target honest at ~30px without a visible edge. */
  padding: calc(5px * var(--density)) 0;
  border: 0;
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  cursor: pointer;
}
/* A hidden series was once signalled by opacity alone, which is not a cue —
   it is the same thing at lower contrast, and on a phone in a bright room it
   reads as "greyed out" rather than "switched off". The label is struck
   through and the marker leaves the swatch; the swatch KEEPS its colour and
   its dash, so it still says which line you have put away. */
.lg-toggle.is-off {
  color: var(--text-muted);
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}
.lg-toggle.is-off .sw::after { display: none; }
.lg-key {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  color: var(--text-muted);
  font-size: var(--db-t-small);
}

/* ONE DEFINITION PER SERIES, consumed by the swatch, the line and the dots —
   colour, dash, marker shape, marker size and stroke weight stated together so
   a series cannot end up half-described the way three of these just did.
 *
   Colour comes from --series-N, which every theme file supplies and which is
   solved at the 3:1 bar WCAG 1.4.11 asks of a GRAPHICAL object rather than the
   4.5:1 a text token carries. Borrowing text tokens is what made every line
   near-black on Light. The dash and the marker shape are what carry greyscale
   and colour blindness, so the colour is free to be vivid rather than dark.
 *
   A CONTRAST RATIO IS NOT WHAT KEEPS TWO LINES APART, and believing it was is
   how a green Played and a near-neutral Skipped shipped as the same line: the
   pair measured 1.11:1 against each other, which is a fact about their
   luminance and nothing to do with whether they look alike. The four tokens
   are separated in CIEDE2000 now, at least 20 apart in every theme, and
   scripts/check-contrast.mjs holds that floor and the 3:1 one together.

   The four channels below are still the load-bearing ones. Dash, marker shape,
   marker size and stroke weight all differ per series and all four survive a
   black and white printout, so do not flatten any of them to make room for a
   fifth series. Colour sits on top of that system, never in place of it. */
.ln-total {
  color: var(--series-1);
  --ln-dash: none;
  --marker: circle(50%);
  --marker-size: 10px;
  --ln-weight: 2.25;
  --key-line: currentColor;
}
.ln-played {
  color: var(--series-2);
  --ln-dash: 7 4;
  --marker: inset(0 round 1.5px);
  --marker-size: 9px;
  --ln-weight: 1.5;
  --key-line: repeating-linear-gradient(to right, currentColor 0 7px, transparent 7px 11px);
}
/* Skipped is the near-neutral of the set on purpose: it is the thing that did
   NOT happen, and it should not compete with Played for attention. That costs
   it chroma, so it leans hardest of the four on its secondary channels — the
   only dotted line and the only triangle.

   It is now the quietest of the four by luminance as well, sitting just above
   the 3:1 floor in all three themes. That was the missing half: a near-neutral
   at the TOP of the ladder is still the brightest line on the plot, which is
   what it was on Dark, where it was a pale warm grey at 8.7:1 while Played sat
   below it. Costing it chroma and then handing it the brightness back is not
   restraint, it is the same loud line in a different coat. */
.ln-skipped {
  color: var(--series-3);
  --ln-dash: .5 4;
  --marker: polygon(50% 0%, 100% 100%, 0% 100%);
  --marker-size: 11px;
  --ln-weight: 1.5;
  --key-line: repeating-linear-gradient(to right, currentColor 0 2px, transparent 2px 5px);
}
.ln-tips {
  color: var(--series-4);
  --ln-dash: 9 3 2 3;
  --marker: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
  --marker-size: 11px;
  --ln-weight: 1.5;
  --key-line: repeating-linear-gradient(to right,
    currentColor 0 9px, transparent 9px 12px, currentColor 12px 14px, transparent 14px 17px);
}
/* MARKER SIZES ARE THE ONES ABOVE, AND THEY GREW ON PURPOSE. A marker carries
   the non-colour half of the distinction: circle, square, triangle, diamond,
   so the four series can be told apart in greyscale and by somebody who cannot
   separate the hues. At 6 to 8px with a 2px surface ring around it, the shape
   inside was three or four pixels across, which is a smudge: the shapes were
   legible in the key, where they sit at rest beside their names, and not on
   the plot, where the work actually has to happen. The stroke weights moved
   with them, and further apart than they were, because Total is the primary
   series and 1.75 against 1.25 was not enough to say so at a glance. */

/* THE PERIOD BEFORE. The faintest line on the plot and the thinnest, because
   it is context rather than a reading: it is there to be glanced past on the
   way to this period's shape. It is drawn first so all four series cross over
   the top of it, and it is deliberately NOT tinted up or down — direction of
   change is carried by the WORD above the plot ("Up 64% on the month before"),
   which survives greyscale, colour blindness and being read out loud. */
.ln-prev {
  /* ITS OWN TOKEN, BECAUSE A MIX OF --text-muted LANDED ON TOP OF SKIPPED.
     This was color-mix(--text-muted 70%, --surface), which is a grey, and
     Skipped is the near-neutral of the series set, which is also a grey. They
     measured 4.1 dE2000 apart on Dark, 2.2 on Light and 6.7 on Stage, against
     the 20 the palette holds every other pair to — so the only thing telling a
     real metric from the comparison ghost was dotted against dashed. See the
     note beside --series-prev in js/themes/app/*.js for what replaced it and
     why it sits in Total's hue family. */
  color: var(--series-prev);
  --ln-dash: 6 4;
  --marker: inset(0 round 1.5px);
  --marker-size: 5px;
  --ln-weight: 1;
  --key-line: repeating-linear-gradient(to right, currentColor 0 6px, transparent 6px 10px);
}

/* The key: a length of the actual line with the actual marker sitting on it. */
.sw {
  position: relative;
  flex: none;
  display: inline-block;
  width: 22px;
  height: 12px;
  vertical-align: middle;
}
.sw::before, .sw::after { content: ''; position: absolute; box-sizing: border-box; }
/* The key draws the line at the WEIGHT it is drawn in the plot, not at a
   convenient 2px. Weight is one of the four channels telling the series
   apart, so a key that flattens it is keying something the chart does not do. */
.sw::before {
  left: 0; right: 0; top: 50%;
  height: calc(var(--ln-weight, 1.25) * 1px);
  margin-top: calc(var(--ln-weight, 1.25) * -.5px);
  background: var(--key-line, currentColor);
}
.sw::after {
  left: 50%; top: 50%;
  width: var(--marker-size, 7px);
  height: var(--marker-size, 7px);
  margin: calc(var(--marker-size, 7px) / -2) 0 0 calc(var(--marker-size, 7px) / -2);
  background: currentColor;
  clip-path: var(--marker, none);
}
/* "Not actioned" and "Gigs" in the breakdown are not series — they have no
   line in the plot, so their key is an empty marker and no rule. */
.sw.sw-none { color: var(--text-muted); }
.sw.sw-none::before { display: none; }
.sw.sw-none::after {
  width: 9px; height: 9px; margin: -4.5px 0 0 -4.5px;
  background: transparent;
  border: 1px solid currentColor;
  border-radius: 2px;
  clip-path: none;
}

.chart { display: flex; gap: var(--chart-gap); height: calc(200px * var(--density)); }
.chart-y {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  font-size: var(--db-t-micro);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  text-align: right;
  min-width: var(--chart-y-w);
}
/* ONE GRIDLINE PER TICK, AND THEY ARE THE FAINTEST THING ON THE CARD.
   Without a rule to sit on, the axis figures are numbers floating in space and
   the eye has to guess the height of a peak. Painted as a background rather
   than added to the markup so they are unambiguously chrome: nothing in the
   plot can be mistaken for data, and the SVG needs no extra elements the tests
   would have to account for. Solid hairlines, never dashed — a dashed grid
   reads as a threshold.

   THE SPACING COMES FROM THE DATA NOW. This was two hardcoded rules at 0% and
   50%, which was correct only while the y axis always printed exactly three
   figures. niceScale() in js/views/dashboard.js picks the tick count from the
   range, so the step is handed in as --grid-step and the gradient repeats at
   whatever that is. A chart with five ticks gets four rules; one with two gets
   one. The bottom rule is the axis itself, below. */
.chart-plot {
  position: relative;
  flex: 1;
  min-width: 0;
  /* THE AXIS RAIL HAS TO BE READ, NOT SENSED. It was --border at 70% towards
     the surface, which measured 1.23:1 on Dark, 1.56 on Light and 1.35 on
     Stage — under every one of them the rail the whole chart is read against
     was fainter than the gridlines it anchors. WCAG 1.4.11 asks 3:1 of a
     graphical object needed to understand the content, and nothing on this
     card needs understanding more than where nought is. Mixed from the text
     tokens rather than the border token so it clears it in every theme. */
  border-bottom: 1px solid color-mix(in srgb, var(--text-secondary) 65%, var(--surface));
  background-image:
    repeating-linear-gradient(to bottom,
      var(--grid) 0 1px, transparent 1px var(--grid-step, 50%));
  background-size: 100% 100%;
  background-repeat: no-repeat;
  --grid: color-mix(in srgb, var(--border) 55%, var(--surface));
}

/* The hover columns sit UNDER the lines now, not over them, so a picked
   column can be an opaque plane rather than a transparent wash. Nothing above
   them takes pointer events, so they still receive every click. */
/* NO width/height HERE, AND THAT IS THE WHOLE OF IT.
   This carried `width: 100%; height: 100%` alongside the inset. The inset
   already sizes the box — left and right of 6px make it twelve narrower than
   the plot — and the width then overrode that back to the plot's FULL width
   while leaving the 6px left offset in place. So the SVG began 6px in and ran
   6px past the right-hand end of the axis, and its 0-100 coordinate space was
   twelve pixels wider than .chart-dots below, which has the same inset and no
   width. Lines and markers were therefore drawn on two different scales: they
   agreed near the middle and drifted apart towards the right, which is the end
   a musician reads first. At the last point the marker sat about twelve pixels
   left of the line's own final vertex, floating clear of it wherever the line
   was steep, and every line overran the plot frame into the card padding.
   Found by an adversarial review of the rendered card, not by any assertion.
   tests/chart-faults.mjs now measures both boxes and fails if they differ.

   AND THE WIDTH IS SPELLED OUT RATHER THAN DELETED, which is the second half
   of the lesson. Removing width and height outright looked like the obvious
   fix and it wrecked the plot: an <svg> is a REPLACED element, so width:auto
   and height:auto resolve to its intrinsic size rather than to the box that
   left/right/top/bottom describe, and the lines were drawn against a viewBox
   that no longer matched the plot. .chart-dots gets away with the inset alone
   because it is an ordinary div. So the width is stated, and stated as the
   inset box: the same 100% minus two edges that left and right already
   describe, which keeps this layer and the marker layer on one scale without
   relying on how a replaced element resolves auto. */
.chart-lines {
  position: absolute;
  inset: 0 var(--chart-edge, 6px);
  width: calc(100% - 2 * var(--chart-edge, 6px));
  height: 100%;
  z-index: 2;
  overflow: visible;
  pointer-events: none;
}
/* Weight is a channel, so it is stated per series beside the dash and the
   marker rather than as a pile of overrides here. Round caps and joins:
   at these weights a mitred corner on a spike is a visible spur. */
.chart-lines polyline {
  fill: none;
  stroke: currentColor;
  stroke-width: var(--ln-weight, 1.25);
  stroke-linejoin: round;
  stroke-linecap: round;
  stroke-dasharray: var(--ln-dash, none);
}

/* Markers are HTML rather than SVG circles: the plot is stretched to fit its
   box (preserveAspectRatio="none"), which would turn a circle into an oval.
 *
 * THE MARK IS THE ::after; THE <i> ITSELF IS A RING IN THE SURFACE COLOUR.
 * Where four lines converge — which on this card is most of the zero line —
 * markers land on top of each other and on top of the strokes, and the pile
 * reads as one blob. A two-pixel gap of surface around each one separates
 * them without drawing a border, which would be adding ink that is not data.
 * The ring cannot be a box-shadow or an outline: clip-path clips both, and
 * the clip is what gives each series its shape. It can only be a second box
 * behind the first, wearing the same clip. */
/* THE MARKERS ON THE FIRST AND LAST POINTS SAT HALF OUTSIDE THE PLOT.
   Points are positioned by percentage across the box, so the last one lands at
   left:100% and its own negative margin pulls only half of it back inside. All
   five of the right-hand markers measured outside the plot box.

   Inset by half a marker so 0% and 100% land a marker-radius in from each
   edge. The LINES are inset by the same amount on the same axis, so the dots
   stay exactly on the polyline they belong to; insetting one and not the other
   is how you get markers floating off their own line. The tap columns are
   deliberately NOT inset: they should still cover the full width, because the
   edges of a chart are where a thumb lands. */
.chart-dots {
  position: absolute;
  inset: 0 var(--chart-edge, 6px);
  z-index: 3;
  pointer-events: none;
}
.chart-dots .dot {
  position: absolute;
  width: calc(var(--marker-size, 7px) + 4px);
  height: calc(var(--marker-size, 7px) + 4px);
  margin: calc((var(--marker-size, 7px) + 4px) / -2) 0 0 calc((var(--marker-size, 7px) + 4px) / -2);
  background: var(--surface);
  clip-path: var(--marker, none);
}
.chart-dots .dot::after {
  content: '';
  position: absolute;
  inset: 2px;
  background: currentColor;
  clip-path: var(--marker, none);
}

.chart-hover { position: absolute; inset: 0; z-index: 1; display: flex; }
.chart-col {
  flex: 1 1 0;
  min-width: 0;
  background: transparent;
  border: 0;
  border-radius: 0;
  padding: 0;
  cursor: pointer;
}
/* NO BACKGROUND ON EITHER STATE ANY MORE. Both used to paint a full-height
   block across the hovered or selected column — a 30px-wide slab of colour
   behind a 2px line, which is a lot of ink to say "this one". The scrubber
   below says it with a rule instead. The buttons stay: they are the tap
   targets, and they are as tall as the plot so a point can be hit on a phone
   without aiming at a 9px dot. */
.chart-col { background: transparent; }
/* The focus ring is the one thing that still draws on the column itself, and
   it has to, because a keyboard user has nothing else to go on. Inset so it is
   not clipped by the plot's own edge. */
.chart-col:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: -3px; }

/* THE SCRUBBER. One rule, two pixels wide, snapped to the point it names.
 *
 * It replaces the highlighted block for the reason above, and it is drawn in
 * --action because that is the colour this app already uses for "the thing you
 * have chosen" — the selected range pill, the selected period pill. A reader
 * who has learned that on one control does not have to learn it again here.
 *
 * BEHIND THE LINES, IN FRONT OF THE GRID. z-index 0 puts it above the plot's
 * background and below .chart-lines, so it never cuts across a series or a
 * marker: the data stays on top of the pointer, which is the right way round.
 *
 * `left` is a custom property written by placeScrub(), so moving it is one
 * style write on one absolutely positioned element. It cannot reflow anything,
 * which is what lets the pointer handler run unthrottled.
 *
 * THE INSET ARITHMETIC IS THE SAME ONE THE LINES AND THE AXIS LABELS USE, and
 * it has to be. .chart-lines and .chart-dots are inset by --chart-edge on both
 * sides, so a point at "50% of the plot" is NOT at 50% of this element's box:
 * a rule positioned against the outer box drifts from the points it names,
 * furthest apart at the two ends. --scrub-r is the plain 0-to-1 ratio and the
 * inset is applied here, exactly as .chart-x > span does it for the dates.
 *
 * margin-left: -1px centres the 2px rule on its point rather than hanging it to
 * the right, so the first and last buckets get a line centred on the very edge
 * of the plot rather than half of one outside it. */
.chart-scrub {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--chart-edge, 6px)
    + (100% - 2 * var(--chart-edge, 6px)) * var(--scrub-r, 0.5));
  z-index: 0;
  width: 2px;
  margin-left: -1px;
  background: var(--action);
  opacity: 0;
  pointer-events: none;
}
.chart-scrub.is-on { opacity: 0.85; }
/* The snap is instant for a mouse moving between adjacent points and would be
   a smear at 200ms. Short enough to read as movement rather than a redraw. */
@media (prefers-reduced-motion: no-preference) {
  .chart-scrub { transition: left 90ms linear, opacity 90ms linear; }
}

/* ONE SLOT PER BUCKET, most of them empty. The slots are what put a label
   under the point it names; renderChart decides how many of them get to carry
   a word, because that is a question about how wide this card happens to be. */
/* THE LABELS SIT UNDER THEIR OWN POINTS, which they did not.
   This was a flex row of equal bands with the text centred in each, so label k
   landed at (k + 0.5) / n of the width while point k is drawn at k / (n - 1).
   Those two agree near the middle of the plot and diverge towards both ends.
   Measured on the week range at desktop width, "Fri" sat 47 CSS pixels right
   of Friday's own point and "Tue" 47 to the left of Tuesday's, which is about
   a quarter of a day column; on the year range the 66-request July peak was
   visibly to the right of the tick that named it. A chart whose axis names the
   wrong column is worse than one with no axis at all.
   Positioned against the same inset box the plot uses, so a label and its
   point cannot come apart again. */
.chart-x {
  position: relative;
  min-height: 1.3em;
  margin: 8px 0 0 calc(var(--chart-y-w) + var(--chart-gap));
  font-size: var(--db-t-micro);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
.chart-x > span {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  white-space: nowrap;
}
/* The end labels sit on the very edges of the plot, so centring them hangs
   half of each off the card — "13 Jul" arrived as "3 Jul". They are the only
   two that can align to an edge, and they are the two that must. */
.chart-x > span.is-first { transform: none; }
.chart-x > span.is-last { transform: translateX(-100%); }
/* Recessive, but not so recessive that the axis stops being readable: the
   named ticks step up one rung, the unnamed ones draw nothing at all. */
.chart-x > span.is-major { color: var(--text-secondary); }

/* THE LAST POINT IS A PART PERIOD AND THE CHART HAS TO SAY SO. It always sits
   below the ones before it, because it always covers less time than they do,
   and drawn identically to a complete reading that is indistinguishable from a
   collapse. Quiet, under the axis, in the same voice as the rest of the
   card's hints. */
.chart-partial {
  margin: 6px 0 0 calc(var(--chart-y-w) + var(--chart-gap));
  font-size: var(--db-t-hint);
  color: var(--text-muted);
}

/* ---- pagers, shared by every dense list ---- */
.pg-pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding-top: 10px;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  font-variant-numeric: tabular-nums;
}
.pg-pager button {
  min-width: 34px;
  min-height: 30px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-primary);
  font-size: var(--db-t-mid);
  cursor: pointer;
}
/* Stated in the ladder rather than as opacity, so "there is no next page"
   stays readable instead of fading towards the background. */
.pg-pager button:disabled {
  opacity: 1;
  color: var(--text-muted);
  border-color: color-mix(in srgb, var(--border) 45%, var(--surface));
  cursor: default;
}

/* ---- paging: a window the pages slide through -----------------------------
 *
 * The window clips and holds the height; the track carries the rows and slides
 * in from the side you came from. Ghost rows pad every page to its full
 * complement so the card is the same height empty, half full, or on its last
 * page — which is what makes the page layout judgeable at all. */
.pg-window { overflow: hidden; }
.pg-track { display: block; }
.pg-slide-next { animation: pgNext .24s cubic-bezier(.3, 0, .2, 1); }
.pg-slide-prev { animation: pgPrev .24s cubic-bezier(.3, 0, .2, 1); }
@keyframes pgNext {
  from { transform: translateX(28%); opacity: 0; }
  to   { transform: translateX(0);   opacity: 1; }
}
@keyframes pgPrev {
  from { transform: translateX(-28%); opacity: 0; }
  to   { transform: translateX(0);    opacity: 1; }
}
/* Belt and braces. js/keep-place.js already declines to add the class when
   reduced motion is asked for — and the SCROLL still happens, because without
   it you are looking at the wrong part of the page — but a stylesheet that can
   animate is one that will. */
@media (prefers-reduced-motion: reduce) {
  .pg-slide-next, .pg-slide-prev { animation: none; }
}
/* Inert height-holders. Quiet enough to read as empty slots in a list rather
   than as rows that failed to load — mixed into the surface they sit on, not
   faded towards whatever is behind them. */
.drow-ghost {
  border-bottom-color: color-mix(in srgb, var(--border) 35%, var(--surface));
  pointer-events: none;
}
.drow-blank {
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  justify-content: center;
  text-align: center;
  border-bottom-color: color-mix(in srgb, var(--border) 35%, var(--surface));
}
@media (prefers-reduced-motion: reduce) {
  .pg-slide-next, .pg-slide-prev { animation: none; }
}

/* ---- a point's breakdown, AT the point ----
 *
 * Anchored to the column it describes and floated above the plot, so the
 * reading and the thing being read sit together. It flips side near the right
 * edge and is pulled upward so a finger on the point does not cover it.
 *
 * One step up the ladder from the panel it floats over, which is what says
 * "on top of" — the accent border it used to wear said "important", which it
 * is not; the column highlighted underneath it already says which point. */
.chart-tip {
  position: absolute;
  bottom: 12px;
  z-index: 5;
  /* THE TOOLTIP MUST NEVER RECEIVE THE POINTER.
   *
   * It sits over the plot, so without this it swallowed every move the moment
   * it appeared: the chart stopped responding, and the next point to the right
   * became almost unreachable because the reading for the current one was in
   * the way. A tooltip is something to read, never something to point at.
   *
   * Only while it floats. Below the plot on a narrow screen it is in normal
   * flow, covering nothing, and the links inside it have to stay usable. */
  pointer-events: none;
  width: max-content;
  max-width: min(280px, 78vw);
  padding: 10px 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
  box-shadow: var(--shadow-2);
}
.chart-tip.is-right { transform: translateX(10px); }
.chart-tip.is-left { transform: translateX(-100%) translateX(-10px); }
/* BELOW THE CHART, IN FLOW, ON A PHONE. Not positioned, so there is nothing it
   can cover: the card grows while a point is open and shrinks when it closes.
   The floating version measured 77% of the plot's width and 91% of its height
   on a 390px screen, which is the whole chart. See pointDetail() in
   js/views/dashboard.js for why the phone is the case that has to work. */
/* THE CARD MUST NOT SCROLL SIDEWAYS ON A PHONE.
 *
 * The dot on the last data point is centred on the right-hand edge of the
 * plot, so half of it sits in the card's padding and about two pixels of it
 * past the card. Two pixels is enough: the card became horizontally
 * scrollable, which on a touchscreen means a chart that slides under the
 * finger when somebody is trying to scroll the page.
 *
 * Clipped rather than repositioned, and only at the width where the reading is
 * already in normal flow below the plot. Above this width the reading floats
 * inside the plot and clipping the card could cut it off, which would be a
 * worse fault than the one being fixed. */
@media (max-width: 620px) {
  #pastChart { overflow-x: clip; }
}

.chart-tip.is-below {
  pointer-events: auto;
  position: static;
  width: auto;
  max-width: none;
  margin-top: 12px;
  transform: none;
  box-shadow: none;
}
.pt-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.pt-when { font-weight: var(--weight-strong); font-size: var(--db-t-mid); }
/* --border-strong, NOT --border. This is an icon-only control: there is no
   label, no fill and no underline, so the box IS the affordance. --border
   measures 1.36:1 against the surface on Dark and 1.58 on Stage, which is a
   hairline you have to already know is there. WCAG 1.4.11 asks 3:1 of the
   visual boundary of a control, and this is the case it was written for. */
.pt-close {
  margin-left: auto;
  width: 26px; height: 26px;
  border-radius: 8px;
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  cursor: pointer;
}
/* NO ✕ ON A READING THAT FLOATS, since 28 September 2026. It could not be
   pressed. The floating reading is pointer-events: none so it never swallows a
   move across the chart, which is right, and its ✕ inherited that: a click on
   it went through to the column underneath and pinned a different point.
   Letting the ✕ alone take the pointer did not help either, because the
   reading follows the pointer: moving towards the ✕ crosses other columns and
   the ✕ moves away. round5-ux found it, timing out on the click.
   A floating reading closes the way the handler already offers, Escape or a
   click anywhere else. Below the chart, on a phone, it is in normal flow, it
   takes taps, and its ✕ stays. */
.chart-tip:not(.is-below) .pt-close { display: none; }
/* ONE COLUMN, ALWAYS. Two rows that are meant to be read against each other
   have to be stacked, or the eye compares across a gap instead of down a
   column. The old auto-fit grid put them side by side on a wide screen. */
.pt-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 6px;
}
.pt-item {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
/* Which day the figure belongs to. The comparison value is from a different
   date, and a number in a tooltip that does not say which day it is from is
   worse than no number. */
.pt-item em {
  font-style: normal;
  font-size: var(--db-t-small);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
.pt-item b {
  margin-left: auto;
  color: var(--text-primary);
  font-size: var(--db-t-mid);
  font-variant-numeric: tabular-nums;
}
.pt-note { margin: 10px 0 0; font-size: var(--db-t-hint); color: var(--text-secondary); }

/* ---- deletion ---- */
/* One venue or one gig plus its question, boxed together, so it is never in
   doubt WHICH thing is about to go. */
.del-card {
  border: 1px solid var(--negative);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--negative-surface) 70%, var(--surface));
  margin: 8px 0 12px;
  overflow: hidden;
}
.del-card > .drow { padding-left: 12px; padding-right: 12px; border-bottom: 1px solid var(--negative); }
.del-body { padding: calc(12px * var(--density)) calc(14px * var(--density)); }
.del-q { margin: 0 0 8px; font-size: var(--db-t-mid); font-weight: var(--weight-strong); }
.del-facts {
  margin: 0 0 12px;
  padding-left: 18px;
  font-size: var(--db-t-hint);
  line-height: calc(var(--line-height) * 1.15);
  color: var(--text-secondary);
}
.del-facts b { color: var(--text-primary); font-variant-numeric: tabular-nums; }
/* Each outcome is a button that states its own consequence, rather than a
   checkbox modifying a single Delete. */
.del-choices { display: grid; gap: 8px; }
.del-choice {
  display: block;
  width: 100%;
  text-align: left;
  padding: 10px 12px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-primary);
  cursor: pointer;
}
.del-choice:hover { border-color: var(--text-secondary); }
.del-choice.is-danger { border-color: color-mix(in srgb, var(--negative) 60%, var(--surface)); }
.del-choice.is-danger:hover { border-color: var(--negative); background: color-mix(in srgb, var(--negative) 12%, var(--surface)); }
.del-choice-t { display: block; font-weight: var(--weight-medium); font-size: var(--db-t-body); }
/* The word is already "Delete everything"; the colour agrees rather than
   carrying it, and the -text role is the one that clears AA on this plane. */
.del-choice.is-danger .del-choice-t { color: var(--negative-text); }
.del-choice-d { display: block; margin-top: 2px; font-size: var(--db-t-small); line-height: var(--line-height); color: var(--text-secondary); }
.del-cancel { display: flex; justify-content: flex-end; margin-top: 10px; }
@media (min-width: 620px) {
  .del-choices { grid-template-columns: 1fr 1fr; }
  .del-choices > :only-child { grid-column: 1 / -1; max-width: 340px; }
}

.btn-small { min-height: 32px; padding: 4px 12px; font-size: var(--db-t-hint); }
.row-x {
  flex: none;
  width: 26px;
  height: 26px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  line-height: 1;
  cursor: pointer;
}
.row-x:hover { color: var(--negative-text); border-color: var(--negative); }
.row-x-space { flex: none; width: 26px; }

/* ---- filters ---- */
.filter-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 10px;
  margin: 0 0 12px;
}
.filter { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.filter > span {
  font-size: var(--db-t-micro);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}
.filter select {
  min-height: var(--db-control);
  max-width: 200px;
  padding: 0 10px;
  border-radius: var(--radius);
    /* --border-strong on a SELECT, because the box is the only thing saying this
     is a control: no fill, no underline, and the native arrow is drawn in the
     text colour rather than as a boundary. --border measures 1.36:1 against
     the surface on Dark, 1.93 on Light and 1.58 on Stage. */
  border: 1px solid var(--border-strong);
  background: var(--surface-raised);
  color: var(--text-primary);
  font-size: var(--db-t-body);
}
.filter-clear {
  min-height: var(--db-control);
  padding: 0 12px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  cursor: pointer;
}

/* ---- an expanded night: structure, then contents ----
 *
 * The venue row and the songs under it were the same weight, so an open gig
 * read as more rows of the gig list. The detail is now inset behind a rule and
 * introduced by a heading, which makes the nesting visible at a glance. The
 * rule is neutral: nesting is structure, and the accent is spent on the one
 * panel that should catch the eye and on the controls that change state. */
.gig-detail {
  margin: 2px 0 10px 10px;
  padding: 2px 0 10px 18px;
  border-left: 2px solid var(--border-strong);
}
.detail-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px 14px;
  padding: 8px 0 6px;
}
.detail-label {
  font-size: var(--db-t-micro);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}
/* FOUR PHRASES, AND ON A NARROW SCREEN THEY WRAP. "7 requests, 4 played,
   1 skipped, 1 asked again" does not fit 320px on one line, and inline-flex
   with no wrap ran it past the edge of the panel instead of onto a second
   line. */
.detail-tally {
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px 10px;
  min-width: 0;
}
.detail-tally b { color: var(--text-primary); font-variant-numeric: tabular-nums; }
/* ============ A SONG ROW IS NOT A GIG ROW ============
 *
 * 27 September 2026. Opening a past gig on a phone was illegible, and the
 * cause was one selector. `#pastGigList .drow-nums` declared the gig list's
 * column widths, and the song rows inside an opened gig are also inside
 * #pastGigList, so they took the same template: Reqs, Played and Earned, a
 * fixed 180px, for a cell that holds status CHIPS and no figures at all.
 *
 * Measured at 320px before this, in Chromium: the row was 202px wide, the
 * chips took their 180, and the song title was left with NOTHING. Not narrow,
 * not truncated: zero pixels. 375px gave it five, 390px gave it nine, and
 * every one of the six songs and every "who asked for it" was clipped, while
 * the chips ran off the right-hand edge of the panel.
 *
 * The template is now scoped to the rows it was written for, the heading and
 * the gig buttons. A song row sizes to its own content.
 *
 * NOT nowrap, which .drow-nums sets for figures and is right for them: chips
 * are words, there can be three of them, and they are allowed to fall onto a
 * second line rather than push the song off the row. */
.drow-detail { font-size: var(--db-t-hint); }
.drow-detail .drow-title { font-weight: var(--weight-normal); }
.drow-detail .drow-nums {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 4px 6px;
  white-space: normal;
  max-width: 62%;
}

/* ============ AND ON A PHONE THE SONG GETS THE WHOLE ROW ============
 * Scoping the template back gave the title real width at every size. It is
 * still only about half a narrow screen, and a song title is the thing being
 * read, so below the same breakpoint the gig list already uses the row becomes
 * a column: song and who asked on top, chips underneath. Same shape the gig
 * rows above take when the date drops under the venue, so the two still read
 * as one list. */
@container (max-width: 430px) {
  .drow-detail { flex-direction: column; align-items: stretch; gap: 3px; }
  .drow-detail .drow-main { width: 100%; flex-wrap: wrap; }
  /* THE SONG TAKES THE LINE AND "for Steph" DROPS UNDER IT. Side by side, a
     name of any length was taking the title's width off it: measured at 320px,
     "Bohemian Rhapsody" had 43 pixels while "for Rachel from the party" had the
     rest. The name is the smaller fact and it goes where every other second
     line on this dashboard goes. The x2 badge stays on the title's line, which
     is the row it is counting. */
  .drow-detail .drow-song { flex: 1 1 auto; }
  .drow-detail .drow-for { flex: 1 0 100%; }
  .drow-detail .drow-nums { justify-content: flex-start; max-width: 100%; }
}
@media (max-width: 430px) {
  .drow-detail { flex-direction: column; align-items: stretch; gap: 3px; }
  .drow-detail .drow-main { width: 100%; flex-wrap: wrap; }
  /* THE SONG TAKES THE LINE AND "for Steph" DROPS UNDER IT. Side by side, a
     name of any length was taking the title's width off it: measured at 320px,
     "Bohemian Rhapsody" had 43 pixels while "for Rachel from the party" had the
     rest. The name is the smaller fact and it goes where every other second
     line on this dashboard goes. The x2 badge stays on the title's line, which
     is the row it is counting. */
  .drow-detail .drow-song { flex: 1 1 auto; }
  .drow-detail .drow-for { flex: 1 0 100%; }
  .drow-detail .drow-nums { justify-content: flex-start; max-width: 100%; }
}

.gig-detail-tools { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 8px 0 2px; }
.gig-venue-editor {
  margin: 8px 0 12px;
  padding: calc(12px * var(--density));
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
}
.gig-venue-field { display: grid; gap: 5px; }
.gig-venue-field > span {
  font-size: var(--db-t-micro);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}
/* At least 16px prevents iOS from zooming the whole dashboard on focus. */
.gig-venue-field input {
  width: 100%;
  min-height: var(--db-control);
  padding: 0 10px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  font: inherit;
  font-size: max(16px, var(--db-t-body));
}
.gig-venue-hint {
  margin: 6px 0 10px;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  line-height: var(--line-height);
}

/* ============ THE VENUES FOUND, CORRECTING A PAST GIG ============
 *
 * 27 September 2026. Rows, not a dropdown: a dropdown inside an accordion
 * inside a scrolling card is three layers of positioning to get wrong, and a
 * musician on a phone wants a tappable list, not a select.
 *
 * TWO LINES PER RESULT, name over town, because the town is what tells four
 * pubs called The Red Lion apart and it has to be readable at a glance rather
 * than trailing the name and getting truncated first.
 *
 * SCROLLS AT SIX RESULTS, so a search never pushes the buttons under it off
 * the screen. It scrolls DOWN, never sideways: every line inside wraps. */
.gig-venue-found:empty { display: none; }
.gig-venue-hits {
  display: grid;
  max-height: calc(6 * 54px);
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}
.gig-venue-hit {
  display: grid;
  gap: 1px;
  width: 100%;
  min-height: 48px;
  padding: 7px 10px;
  border: 0;
  border-bottom: 1px solid var(--border);
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.gig-venue-hit:last-child { border-bottom: 0; }
.gig-venue-hit:hover { background: var(--surface-raised); }
.gig-venue-hit:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.gig-venue-hit-name {
  font-size: var(--db-t-body);
  font-weight: var(--weight-medium);
  overflow-wrap: anywhere;
}
.gig-venue-hit-where {
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  overflow-wrap: anywhere;
}
.gig-venue-hit-tag {
  margin-left: 6px;
  padding: 1px 6px;
  border-radius: 999px;
  border: 1px solid var(--border);
  font-size: var(--db-t-micro);
  white-space: nowrap;
}
/* The name they typed, when nothing in the seed is it. Set apart by a rule
   above it rather than a colour: it is a different KIND of answer, not a
   warning. */
.gig-venue-hit-own { border-top: 1px solid var(--border-strong); }
.gig-venue-say {
  margin: 8px 0 0;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  line-height: var(--line-height);
}
.gig-venue-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; }
.drow.is-asked .drow-title { color: var(--negative-text); }

/* ---- per-gig ---- */
.gig-item { border-bottom: 1px solid var(--border); }
.gig-item:last-child { border-bottom: 0; }
.gig-item .drow-btn { border-bottom: 0; }

/* A group can hold a mix, two played and one skipped, so each status gets its
 * own chip rather than one label standing in for all of them.
 *
 * The chips already carry their own word, so this is the mildest of the
 * hue-only cases — but three words in three colours at 11px is still three
 * colours doing the work at a glance. Each now leads with a glyph, and
 * "played" also sits on its own tinted plane, so the three separate by shape
 * and luminance before the word is read at all. `white-space: nowrap` and
 * `flex: none` guarantee the word is never truncated. */
.gig-chip {
  flex: none;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  font-size: var(--db-t-micro);
  font-weight: var(--weight-medium);
  white-space: nowrap;
}
.gig-chip::before { margin-right: .35em; font-weight: var(--weight-strong); }
.gig-chip.is-played {
  color: var(--positive-text);
  background: var(--positive-surface);
  border-color: color-mix(in srgb, var(--positive) 40%, var(--border));
}
.gig-chip.is-played::before { content: '✓'; }
.gig-chip.is-skipped { color: var(--text-secondary); }
.gig-chip.is-skipped::before { content: '–'; }
.gig-chip.is-new { color: var(--text-primary); }
.gig-chip.is-new::before { content: '•'; }
/* ASKED AGAIN, AND IT IS NOT A SKIP. The song was played; somebody who arrived
   later asked for it not knowing. Quiet like a skip, because it is not an
   outcome the musician needs to act on, but marked with the repeat glyph rather
   than the dash so it cannot be read months later as "did not know it". */
.gig-chip.is-already_played { color: var(--text-muted); }
.gig-chip.is-already_played::before { content: '↺'; }

/* ------------------------------------------------------------- Settings --- */

.settings-field { margin-top: calc(18px * var(--density)); }
.settings-field:first-of-type { margin-top: 12px; }
.settings-field label { display: block; font-size: var(--db-t-body); margin-bottom: 8px; }

.settings-row { display: flex; flex-wrap: wrap; gap: 10px; }
.settings-row input { flex: 1 1 200px; min-width: 0; height: var(--db-field); border-radius: var(--radius); font-size: var(--db-t-mid); }
.settings-row button { flex: 0 0 auto; padding: 0 18px; height: var(--db-field); border-radius: var(--radius); font-size: var(--db-t-body); }

.settings-copy { font-size: var(--db-t-body); line-height: calc(var(--line-height) * 1.08); margin: 0 0 12px; }
.settings-links { font-size: var(--db-t-body); margin: 0 0 14px; }
.settings-button { padding: 13px 18px; border-radius: var(--radius); font-size: var(--db-t-body); }
.settings-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
.settings-actions button { padding: 13px 18px; border-radius: var(--radius); font-size: var(--db-t-body); flex: 1 1 auto; }

/* ---- notifications toggle ----
 *
 * A real checkbox with the box moved off-screen rather than hidden, so it
 * keeps its keyboard behaviour, its focus ring and its announcement as a
 * switch. The track and knob are decoration painted from its :checked state.
 *
 * The disabled cases here are not errors — iOS before the app is installed, or
 * permission refused at the OS level — so the row dims and explains itself
 * rather than turning red. */
.notify-row { margin: 4px 0 2px; }
.notify-switch {
  display: flex;
  align-items: center;
  gap: 12px;
  cursor: pointer;
  /* Comfortably above the 44px minimum, because this is tapped in a pub. */
  min-height: 44px;
}
.notify-switch input {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
.notify-track {
  flex: 0 0 auto;
  position: relative;
  width: 50px; height: 30px;
  border-radius: 15px;
  background: var(--action-quiet);
  border: 1px solid var(--border-strong);
}
.notify-knob {
  position: absolute;
  top: 3px; left: 3px;
  width: 22px; height: 22px;
  border-radius: 50%;
  background: var(--text-secondary);
  transition: left .16s ease, background .16s ease;
}
.notify-switch input:checked + .notify-track {
  background: var(--action);
  border-color: var(--action);
}
.notify-switch input:checked + .notify-track .notify-knob {
  left: 25px;
  background: var(--action-text);
}
.notify-switch input:focus-visible + .notify-track {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
}
.notify-label { font-size: var(--db-t-body); font-weight: var(--weight-medium); }
.notify-row.is-unavailable { opacity: .55; }
.notify-row.is-unavailable .notify-switch { cursor: default; }
.notify-row.is-setting-up { opacity: .72; }
.notify-row.is-setting-up .notify-switch { cursor: wait; }
.notify-note {
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  line-height: calc(var(--line-height) * 1.05);
  margin: 6px 0 0;
}
.notify-note[hidden] { display: none; }

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

/* THE ESCAPE HATCH FROM A SETUP THAT DID NOT FINISH. Sits under the warning it
   belongs to, not beside the switch, because the switch is inert at the moment
   this appears and a control next to a dead control reads as part of it. */
.notify-retry { margin: 10px 0 0; }
.notify-retry[hidden] { display: none; }

/* --------------------------------------------------------------- checks ---

   NOTIFICATION CHECKS. A collapsed row directly under the switch.

   NOTHING IN HERE USES A CONTAINER QUERY, CSS NESTING, :has() OR color-mix().
   The device this section exists for runs iPadOS 15.8, which is Safari 15.6:
   container queries are Safari 16, nesting is 17.2, color-mix() is 16.2. A
   rule that does not apply there would make the one diagnostic we have look
   broken on the one device that needs it. Plain selectors and plain colours. */
.notify-checks {
  margin: 14px 0 0;
  border-top: 1px solid var(--border);
  padding-top: 10px;
}

/* A button, not a summary. See the note in js/views/settings.js: the section
   is opened from code by the deep link, and aria-expanded has to be able to
   say so. 44px because it is tapped on a stand, in a pub, in a hurry. */
.nc-summary {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  min-height: 44px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-secondary);
  font: inherit;
  font-size: var(--db-t-small);
  text-align: left;
  cursor: pointer;
}
.nc-summary:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: 6px;
}
/* Drawn from borders rather than from a glyph, so it needs no icon font and
   cannot arrive as a missing box. */
.nc-chevron {
  flex: 0 0 auto;
  width: 7px; height: 7px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(-45deg);
  transition: transform .15s ease;
  margin-left: 3px;
}
.nc-summary[aria-expanded="true"] .nc-chevron { transform: rotate(45deg); }

.nc-body { padding: 4px 0 6px; }
.nc-body[hidden] { display: none; }
.nc-intro {
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  line-height: calc(var(--line-height) * 1.05);
  margin: 0 0 10px;
}

/* ON SCREEN, NOT BEHIND THE BUTTON. Somebody whose clipboard is refused has to
   be able to photograph this, so it is readable at a normal distance and it
   scrolls sideways rather than wrapping a user agent string into porridge.
   `user-select: text` is explicit because the surrounding panel is a tap
   target and iOS suppresses selection inside one. */
.nc-readout {
  margin: 0 0 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1.55;
  /* ============ IT WRAPS, IT DOES NOT SCROLL SIDEWAYS ============
   *
   * 27 September 2026. This block was `white-space: pre` with `overflow-x:
   * auto`, so on a phone every long line was cut at the edge of the panel:
   * "app version: 2026.09.20-payout-n...", "user agent: Mozilla/5.0 (Macint...".
   *
   * That defeats the thing this element is FOR. The comment above it in
   * js/views/settings.js promises that a photograph of this is a complete
   * report, for somebody whose clipboard and textarea have both been refused,
   * and a photograph of a sideways scroller shows whatever happened to be in
   * view. It also put a sideways scroller inside the app, which is the one
   * thing Mark has said there must never be.
   *
   * pre-wrap keeps every newline, which is what makes it readable as a list,
   * and lets a long value fold onto the next line instead of leaving. */
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  -webkit-user-select: text;
  user-select: text;
}

.nc-actions {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 0;
  flex-wrap: wrap;
}
.nc-copied { font-size: var(--db-t-small); color: var(--text-secondary); }
.nc-copied[hidden] { display: none; }

/* Revealed only when the clipboard has refused. A textarea because a long
   press inside one offers Select All on every mobile browser, which a <pre>
   does not. */
.nc-fallback {
  display: block;
  width: 100%;
  margin: 10px 0 0;
  padding: 10px 12px;
  border: 1px solid var(--border-strong);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text-primary);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1.55;
  resize: vertical;
  -webkit-user-select: text;
  user-select: text;
}
.nc-fallback[hidden] { display: none; }
.nc-hint {
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  line-height: calc(var(--line-height) * 1.05);
  margin: 8px 0 0;
}
.nc-hint[hidden] { display: none; }

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

/* Destructive, and dressed like it: red is reserved for actions that delete
   something the artist cannot get back.
 *
 * .btn-no, further up this file, is also red and is NOT this. It is the "no" of
 * a yes/no pair on a control that deletes nothing, so it carries no warning
 * glyph. If a third red thing ever appears, decide which of the two it is
 * before styling it. */
.btn-danger {
  background: var(--negative-surface);
  color: var(--negative-text);
  border: 1px solid var(--negative);
}
/* A glyph, because in greyscale the red border measured RGB 28,28,28 against
   an ordinary card border's RGB 28,28,28 — identical. "Close my account" was
   pixel-indistinguishable from "Download my data" beside it. The words carried
   the meaning; nothing carried the affordance. */
.btn-danger::before {
  content: '\26A0';
  margin-right: 8px;
  font-weight: var(--weight-strong);
}
.btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--negative) 22%, var(--negative-surface)); }

.danger-panel { box-shadow: inset 0 0 0 1px var(--negative); }
.danger-list { margin: 0 0 12px; padding-left: 20px; font-size: var(--db-t-body); line-height: calc(var(--line-height) * 1.15); }
.danger-list strong { overflow-wrap: anywhere; }

#settingsCloseStep { margin-top: 14px; }
#settingsCloseStep label { display: block; font-size: var(--db-t-body); margin-bottom: 8px; }
#settingsCloseSlug { height: var(--db-field); border-radius: var(--radius); font-size: var(--db-t-mid); max-width: 280px; }

/* ============ SETTINGS AS A LIST THAT LEADS TO SCREENS ============
 *
 * 28 September 2026. A phone's own Settings: a title, groups of plain rows,
 * each row the full width of its group with a chevron at the end, and a gap
 * between groups. Every row is a 52px target from edge to edge, so it can be
 * hit with a thumb at 320px without aiming. The screens behind the rows are
 * the panels that were already here, with a way back above each.
 *
 * NOTHING MOVES SIDEWAYS: no transform, no slide, no fixed widths. A screen
 * appears in place of the list, the way every other route in this app does. */
.settings-title {
  margin: 4px 0 0;
  font-size: calc(var(--db-t-head) * 1.35);
  line-height: 1.15;
}
.settings-group {
  list-style: none;
  margin: var(--db-pad) 0 0;
  padding: 0;
  background: var(--surface);
  border-radius: var(--radius-lg);
  overflow: hidden;
}
.settings-group > li + li { border-top: 1px solid var(--border); }
.settings-item,
.settings-item:link,
.settings-item:visited {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 52px;
  padding: 0 var(--db-pad);
  color: var(--text-primary);
  text-decoration: none;
  font-size: var(--db-t-body);
  font-weight: var(--weight-medium);
}
.settings-item:hover { background: var(--surface-raised); }
.settings-item:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: -3px; }
.settings-item-label { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.settings-item--danger,
.settings-item--danger:link,
.settings-item--danger:visited { color: var(--negative-text); }
.settings-chev { flex: none; width: 20px; height: 20px; color: var(--text-secondary); }

/* THE WAY BACK. A 44px target at the top of every screen, saying where it
   goes. The browser's own back button does the same thing, because every
   screen is an address. */
.settings-screen-head { margin-top: 4px; }
.settings-back,
.settings-back:link,
.settings-back:visited {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  min-height: 44px;
  padding-right: 12px;
  margin-left: -4px;
  color: var(--action-on-surface);
  text-decoration: none;
  font-size: var(--db-t-body);
  font-weight: var(--weight-medium);
}
.settings-back .settings-chev { color: currentColor; }
.settings-back:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--radius); }
/* The panel under the way back sits close to it: they are one screen. */
.settings-screen-head + .panel { margin-top: 4px; }

/* The build actually running on this device. Deliberately the quietest thing
   on the Settings page — it is diagnostic, read once when somebody is checking
   whether an update landed, and never looked at again. See the comment beside
   it in js/views/settings.js for why it is there at all. */
.settings-build {
  margin: 18px 0 4px;
  text-align: center;
  font-size: 12px;
  letter-spacing: var(--tracking-wide);
}

/* ---------------------------------------------------- Add songs: the routes
 *
 * Route 1 is the primary control and looks it: full width, on screen always,
 * nothing to open first. Routes 2 to 4 are a row of quiet buttons under it,
 * in order of how quickly they fill a list. Manual is last and is never
 * disabled — someone playing their own material has an audience who will ask
 * for those songs, and none of them will ever be in the catalogue. */
.songs-primary { margin-top: 4px; }
.songs-primary input[type="search"] {
  width: 100%;
  height: var(--db-field);
  border-radius: var(--radius);
  font-size: var(--db-t-mid);
}
/* The catalogue box is the one that most needs emptying: you search, tap the
   song, and the results and the query are both still there for the next one. */
.songs-primary .search-field { display: block; }
.songs-hint {
  margin: 8px 0 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  line-height: var(--line-height);
}
/* NARROW: EACH PANEL OPENS UNDER ITS OWN BUTTON.
 *
 * These were three buttons in a row followed by three panels, so on a phone —
 * where the row wraps to a stack — tapping the first button opened its panel
 * below all three, two buttons further down than the thing you had just
 * tapped. Each button and its panel are one block now, stacked, so opening one
 * pushes the others down and the answer appears where the question was asked.
 *
 * A column is the ONLY layout in which a panel can sit directly under its own
 * button, so this is what a narrow screen gets. Where there is room for the
 * three labels on one line, the wide rules below turn the same markup into
 * tabs — see the note there for why the DOM does not change. */
.songs-routes {
  display: grid;
  gap: 10px;
  margin-top: 16px;
}
.songs-route { display: grid; }
/* ONLY the route buttons. `.songs-routes button` also matched every button
   inside the drawers — Read this list, Show me twenty songs, Add song, Cancel,
   and the review screen's type-it-myself links — and stretched all of them to
   the full width of the panel. */
.songs-route > button[data-route] {
  width: 100%;
  min-height: var(--db-field-sm);
  border-radius: var(--radius);
}
/* The drawer's own top margin would double the grid gap when it is open. */
.songs-route .songs-drawer { margin-top: 10px; }

/* WHICH ONE IS OPEN, SAID IN MORE THAN ONE WAY. At both widths, because in a
   column the open panel is the only marking there is and that is not much,
   and as tabs it is the whole point. Weight and a rule under the label as
   well as the fill: a fill alone is a colour difference, and this app is used
   under stage lighting by people who may not see colour the way the palette
   assumes. */
.songs-route > button[data-route][aria-expanded="true"] {
  background: var(--surface-sunken);
  border-color: var(--border-strong);
  color: var(--text-primary);
  font-weight: var(--weight-strong);
}

/* WIDE: ONE ROW OF LABELS, ONE PANEL UNDER THE ROW.
 *
 * THE DOM DOES NOT CHANGE BETWEEN THE TWO LAYOUTS, and that is the whole
 * design. `display: contents` on the wrapper promotes each button and its
 * panel to be grid items of `.songs-routes` itself, so the buttons land on
 * row 1 and whichever panel is open lands on row 2 spanning the full width —
 * with the elements never being moved, destroyed or recreated. A pasted
 * setlist half-reviewed, twenty picker cards ticked, a half-typed song title:
 * all of it survives a rotation or a window drag across the breakpoint,
 * because nothing happened to it.
 *
 * THE BREAKPOINT IS MEASURED, NOT A DEVICE NAME. Measured in the browser at
 * the shipped type scale, the three labels laid out in a row need:
 *
 *   Build my list        128px
 *   Paste a list         118px
 *   Add a song manually  192px
 *   two 8px gaps          16px
 *   ------------------------------
 *                        453px
 *
 * ...of the PANEL'S CONTENT BOX, which is what a container query measures
 * here (`.panel` is the nearest element with `container-type`). 500px is that
 * number with about a tenth spare, for a longer label later or a theme that
 * scales the type up. It works out at roughly a 580px window, so every phone
 * in portrait gets the column and every phone in landscape, every tablet and
 * every desktop gets the tabs.
 *
 * A container query rather than a media query because the answer depends on
 * the room the labels HAVE, not the size of the window — the same panel is
 * narrower when the dashboard puts it in a two-column grid, and a media query
 * cannot see that. And if the estimate is ever wrong the failure is soft:
 * `minmax(0, auto)` lets the labels wrap inside their own buttons rather than
 * pushing the row off the side of the panel. */
@container (min-width: 500px) {
  .songs-routes {
    /* THE FOURTH TRACK IS THE WHOLE POINT. With three auto tracks and
       `justify-content: start` the grid's tracks only add up to the width of
       the three BUTTONS — 142 + 186 + 200 + 16 of gap = 544px — and the free
       space to the right belongs to no track at all. So the drawer's
       `grid-column: 1 / -1` spanned 544px on a 1728px screen, and at 1194 it
       was 327px NARROWER than before the desktop pass. This is the identical
       bug as .songs-pane landing in one column of #songsView, one level down:
       1/-1 of the wrong box is still the wrong box. A trailing 1fr track gives
       the free space somewhere to live, so -1 means the edge of the panel. */
    grid-template-columns: repeat(3, minmax(0, max-content)) minmax(0, 1fr);
    column-gap: 8px;
    row-gap: 0;
  }
  .songs-route { display: contents; }
  .songs-route > button[data-route] {
    width: auto;
    grid-row: 1;
    padding: 0 18px;
  }
  /* Row 2, full width, whichever one it is. A closed panel is `display: none`
     and takes no grid slot at all, so there is never a gap where a shut one
     would have been.

     NO WIDTH CAP ON THE DRAWER ITSELF. It carried `width: min(100%, 46rem)`
     for one loop, which fixed the 544px bug and replaced it with a 704x832px
     empty rectangle at 1728 — the largest hole anywhere on the page, bigger
     than the 420x970 rail it had just replaced. A container is not the thing
     that needs a measure; the prose and the fields inside it are, and they are
     capped where they are defined. */
  .songs-route > .songs-drawer {
    grid-row: 2;
    grid-column: 1 / -1;
    margin-top: 12px;
  }
}

/* THE DRAWER USED TO DO THE TWO-COLUMN LAYOUT ITSELF, and that is why it never
 * looked composed. `#routePaste.show { display: grid }` at 1400px placed six
 * LOOSE ELEMENTS into two tracks by hand — drop zone in column 1, "or" in
 * column 2 row 2, AI note and textarea and button in column 3 — with
 * `align-items: start`. Nothing tied any of them together, so nothing lined up
 * with anything: measured at 1440, the left column's content stopped 255px
 * above the right column's and the word "or" floated level with neither.
 *
 * The columns are a property of the two CARDS now (.import-routes, further
 * down this file), not of the drawer, and they come from a container query on
 * the room the drawer actually has rather than from a window width. The drawer
 * is a plain block again at every size. */

/* Catalogue results. A whole row is the target, and it stays put after a tap
   so the next one is one more tap rather than a fresh search. */
.songs-cat { margin-top: 12px; display: grid; gap: 6px; }

/* AND THE OTHER WAYS IN DO NOT GET PUSHED OFF THE SCREEN BY THEM.
 *
 * Measured: the moment a catalogue search returns, the three route buttons —
 * build a list, paste one, add one by hand — moved 804px down the page and out
 * of the viewport at 1194, 1440 and 1728 alike. That is the worst possible
 * moment to lose them. The person who has just searched and not found what
 * they wanted is exactly the person who needs "paste a list" next, and it had
 * gone somewhere they would have to go looking for it.
 *
 * Capped so the results scroll inside their own box and everything below stays
 * where it was. `contain` so a trackpad flick that reaches the end of the
 * results does not then take the page with it.
 *
 * DESKTOP ONLY, deliberately. On a phone a nested scroller inside a scrolling
 * page is a worse problem than the one being fixed, and the routes are one
 * flick away rather than a screen and a half.
 *
 * THE NUMBER IS WHAT IS LEFT, NOT A FRACTION. 50vh was the first try and it
 * was not enough: at 1194x834 it still put the route buttons 92px below the
 * fold, because the heading, the mode bar, the search field and the panel
 * padding above the results come to about 36rem whatever the window is doing.
 * So the results take the height that is actually left over, and the floor
 * stops them collapsing to nothing on a very short window. */
/* #catResults ONLY, and that scoping is a fix. The rule was written against
   `.songs-cat`, which #bookAddList also carries — so the songbook picker, which
   lives in a drawer, has its own pager and pushes nothing off the screen, was
   capped too. Measured: it showed 11 rows at 375 and EIGHT at 1194, getting
   shorter as the screen got bigger, for a problem it does not have. */
@media (min-width: 1100px) {
  #catResults.songs-cat:not(:empty) {
    max-height: max(14rem, calc(100vh - 36rem));
    overflow-y: auto;
    overscroll-behavior: contain;
  }
}
@media (min-width: 760px) { .songs-cat { grid-template-columns: 1fr 1fr; column-gap: var(--db-pad); } }
.cat-row {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: var(--db-field);
  padding: 8px 12px;
  text-align: left;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-size: var(--db-t-body);
  cursor: pointer;
}
.cat-row:hover { border-color: var(--border-strong); }
.cat-main { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.cat-title { font-weight: var(--weight-strong); }
.cat-by { color: var(--text-secondary); }
.cat-mark {
  flex: none;
  width: 26px; height: 26px;
  display: grid; place-items: center;
  border-radius: 50%;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-size: 15px; line-height: 1;
}
/* ============ ALREADY ON THE LIST IS NOT A SUCCESS ============
 *
 * It used to be green: a positive border and a positive fill on every row
 * already in the book. Two things wrong with that. Green means PLAYED
 * everywhere else in this app, on the queue and on the past gig cards, and
 * spending it on "this is in a list" takes the meaning away from the place
 * that needs it. And a musician adding forty songs to a book ends up looking
 * at forty green rows, which is the screen shouting about a state they created
 * themselves on purpose.
 *
 * The tick is the signal. The plane behind it settles rather than lights up,
 * and the row quietens, which is what "done, move on" should look like. It is
 * never the fill alone, because a fill that reads on one theme is a smudge on
 * another under stage lighting. */
.cat-row.is-in {
  border-color: var(--border-strong);
  background: var(--surface-sunken);
}
.cat-row.is-in .cat-main { color: var(--text-secondary); }
.cat-row.is-in .cat-mark {
  background: var(--text-secondary);
  border-color: var(--text-secondary);
  color: var(--surface);
  font-weight: var(--weight-strong);
}

/* Build my list: twenty at a time. */
.songs-picker { display: grid; gap: 8px; margin: 12px 0; grid-template-columns: 1fr; }
@media (min-width: 620px) { .songs-picker { grid-template-columns: 1fr 1fr; } }
@media (min-width: 1040px) { .songs-picker { grid-template-columns: 1fr 1fr 1fr; } }
.pick-card {
  display: grid;
  gap: 2px;
  padding: 11px 13px;
  text-align: left;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  cursor: pointer;
}
.pick-card[aria-pressed="true"] {
  border-color: var(--positive);
  background: var(--positive-surface);
}
.pick-card[aria-pressed="true"] .pick-title::after { content: ' ✓'; color: var(--positive-text); }
.pick-title { font-weight: var(--weight-strong); font-size: var(--db-t-body); overflow-wrap: anywhere; }
.pick-by { font-size: var(--db-t-hint); color: var(--text-secondary); overflow-wrap: anywhere; }
.pick-why { font-size: var(--db-t-small); color: var(--text-muted); margin-top: 3px; }

/* Skips are permanent now, so there has to be a way back. Quiet: it is an
   undo, not an invitation. */
/* The style step's one line. Quiet: it is a note about what happened, not a
   control, and it disappears when there is nothing to say. */
.songs-style {
  margin: 10px 0 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
.songs-style[hidden] { display: none; }
.songs-style.is-working { color: var(--text-muted); }

.songs-skipped {
  margin: 10px 0 0;
  font-size: var(--db-t-hint);
  color: var(--text-muted);
}
.songs-skipped[hidden] { display: none; }
.link-quiet {
  background: none;
  border: 0;
  padding: 0 0 0 4px;
  color: var(--action);
  font: inherit;
  text-decoration: underline;
  cursor: pointer;
}

/* The duplicate flag on manual add. A notice, not an error: it never blocks. */
.songs-flag {
  margin: 10px 0 0;
  padding: 9px 12px;
  border-left: 3px solid var(--notice);
  border-radius: var(--radius);
  background: var(--notice-surface);
  color: var(--text-primary);
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
}
.songs-flag[hidden] { display: none; }

/* The `hidden` ATTRIBUTE ONLY SETS display:none IN THE UA STYLESHEET, so any
   author rule with a display of its own beats it. Both of these are flex, so
   hiding them from JS did nothing at all until these two lines existed — the
   empty Songs tab kept offering to filter a list with nothing in it, which is
   the exact thing the empty state was added to stop. */
.songs-tools[hidden],
.switch[hidden] { display: none; }

/* The three-bucket review. Nothing here is written until the button is
   pressed, so every row is a checkbox and the no-match bucket starts unticked
   — those are the ones that need a look, and the safe default is not to add
   something the catalogue has never heard of by accident. */
/* NO POINTER CURSOR ON THE ROW, BECAUSE THE ROW IS NOT A CONTROL.
   "Clicking a row title changes nothing" — measured, and true: the row carried
   `cursor: pointer` and no click handler, so the hand appeared over every inch
   of it and the tick was the only thing that answered. A cursor is a promise
   about what will happen when you press, and this one was making a promise the
   row could not keep. The label around the checkbox keeps its own. */
/* FINDING 7: ONE LIST, TWO LEFT EDGES.
   A flex row puts whatever comes first at the row's left edge, so a row WITH a
   checkbox indented its title by the tick plus the gap and a row WITHOUT one
   did not. Measured at 375 inside a single .songs-bucket-list: "Lovely Day" at
   titleLeft 54 and "Zzq Flow Probe Two" at 103, adjacent rows of the same step,
   a 49px step between them. Chart-link rows sat at 54 and ticked rows at 103
   across steps too.
   A grid with a reserved first column fixes both directions at once: the tick
   column exists whether or not a tick occupies it, so every title in every step
   shares an edge. The column is sized off the same custom property the coarse
   pointer bump uses, so it grows with the target rather than being a second
   number to keep in step with it. */
.review-row {
  --review-tick-col: 28px;
  display: grid;
  grid-template-columns: var(--review-tick-col) minmax(0, 1fr);
  align-items: start;
  gap: 10px;
  padding: var(--db-row-pad) 0;
  border-bottom: 1px solid var(--border);
  font-size: var(--db-t-body);
}
@media (pointer: coarse) {
  .review-row { --review-tick-col: 40px; }
}
.review-tick { grid-column: 1; }
.review-main { grid-column: 2; }
.review-row:last-child { border-bottom: 0; }

/* ================= THE STEPPER, AND THE WIDTH OF A ROW =================
 *
 * A review of 800 songs was five headed lists on one screen. It is now one kind
 * of decision per step, and this is where you are in them.
 *
 * THE ROWS ALSO STOP BEING A PHONE LAYOUT STRETCHED TO A DESKTOP. Measured by
 * review at 1440: a checkbox, a title and about a thousand pixels of nothing,
 * with a hairline running the full width under each one. A line of text is
 * readable to roughly 70 characters and unreadable well before 1088px, and the
 * eye has to travel the whole way back for the next row. The list is capped and
 * the step's own hint sits over it at the same measure, so the column reads as
 * a column rather than as a phone screen someone pulled sideways. */
/* WHERE YOU ARE, AT A SIZE THAT COUNTS AS BEING ON THE SCREEN.
   "Several steps, no indication of how many there are or how far through I am"
   was said of a screen that carried both — as a line of hint-sized secondary
   text over a row of 4px dots. Something that quiet is not information, it is
   decoration. Body size, primary ink, first thing in the panel. */
/* ONE ROW, NOT TWO. "Step 1 of 5" and the dots are one statement and were
   stacked, which cost 21px of a preamble that is repeated on every step. */
.review-steps {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 12px;
  /* FINDING 9: the preamble was 299px before the first row you can decide
     about at 375, and 197px after earlier work — 145px of content and 52px of
     air spread over four gaps. The CONTENT stays: the stepper is the "where am
     I" that was asked for, and the heading and its instruction are what the
     first review demanded. What gives is the space between them. */
  margin-bottom: 10px;
}
.review-steps-where {
  font-size: var(--db-t-body);
  color: var(--text-primary);
  font-weight: var(--weight-medium);
}

/* THE ARITHMETIC, WHERE IT CAN BE CHECKED. "12 songs read from its columns",
   then steps of 3 + 4 + 2, then "add 4 songs" — with the missing three
   explained only inside a shut accordion on the last step. One line, above the
   steps, on every step, naming everything that came off the total. Quiet,
   because it is reference and not an instruction, but present. */
/* ONE LINE, AND NO RULE OF ITS OWN. It carried a hairline that landed 50px
   above the step heading's 2px rule — two separators between the same two
   things — and with a separate "Reviewing flow.csv" box above it the preamble
   was 268px on a 375px phone, unchanged on every one of five steps. */
.review-tally {
  margin: 0 0 10px;
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-secondary);
}

/* ---- the step itself: a heading, a count, and room ----------------------
   "Cramped everywhere. Lists have no room, headings do not separate sections,
   and I do not know what a step is asking me until I read it twice." */
.review-step { margin-bottom: 4px; }
.review-step-head {
  display: flex;
  /* FINDING 10: baseline, and the heading wraps. At 375 the heading is two
     lines spanning y=206..264 while "1 song" sat at y=210 — baseline-locked to
     line one and marooned top-right above the heading's empty second line, on
     three of five headings. The count belongs to the whole heading, not to its
     first line, so it centres against the block instead. */
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 0;
  padding-bottom: 6px;
  border-bottom: 2px solid var(--border-strong);
  /* No --db-t-h3 token exists, so this states its own size rather than
     depending on a fallback nobody can see. A step heading is one rung above
     body and below the panel h2 it sits under. */
  font-size: calc(var(--db-t-body) * 1.15);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}
/* The count sits at the far end of the heading's rule rather than trailing the
   words. "Choose a chart link 3" reads as a heading with a stray numeral after
   it, and out loud it is worse, so it is a phrase and it is separated. */
.review-step-n {
  flex: none;
  font-size: var(--db-t-hint);
  font-weight: var(--weight-normal);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
/* THE INSTRUCTION, NOT A HINT. It is the sentence that says what to do with the
   rows underneath, so it gets read before them: full measure, own space, and
   the ink of something meant to be read rather than skimmed past. */
.review-step-ask {
  margin: 8px 0 10px;
  max-width: 46rem;
  font-size: var(--db-t-body);
  line-height: var(--line-height);
  color: var(--text-secondary);
}
/* A row on the chart-link step has no checkbox, so nothing needs to line up
   with one. */
/* A chart-link row has no tick, and no longer needs its indent removing: the
   reserved column above keeps its title on the same edge as every other row's
   rather than pulling it 49px left of them. */
.review-steps-dots { display: flex; gap: 6px; list-style: none; padding: 0; margin: 0; }
.review-step-dot {
  height: 4px;
  flex: 1 1 0;
  min-width: 12px;
  border-radius: 999px;
  background: var(--border);
}
/* Done and here are told apart by more than one thing, because a colour-only
   difference is no difference to a lot of people: the current step is twice the
   height of the rest as well as the action colour. */
.review-step-dot.is-done { background: var(--action-quiet); }
.review-step-dot.is-here { background: var(--action); height: 8px; margin-top: -2px; }

/* WHAT THE IMPORT DID, WHERE THE BUTTON WAS. Same list shape as the promise it
   answers, in the same place on the screen, and it does not dismiss itself. */
.review-done-list {
  margin: 12px 0 4px;
  max-width: 46rem;
}
.review-done-list li { color: var(--text-primary); }

.review-summary { margin: 4px 0 12px; }
.review-summary-head { font-weight: var(--weight-medium); margin-bottom: 4px; }
.review-summary-list { margin: 0; padding-left: 1.25em; display: grid; gap: 2px; }
.review-summary-list li { font-size: var(--db-t-body); color: var(--text-secondary); }

/* The measure, applied to the rows and to the sentence above them so they share
   an edge. max-width, not width, so nothing breaks on a phone. */
/* ONE MEASURE, NOT TWO.
   "At 1440 the heading's 2px rule is 1088px while the rows under it are 736px:
   it runs 352px past the hairlines it heads." The cap was on the list and the
   instruction only, so the heading's rule, the step bar, the count and the
   button row all kept the panel's full width and the step read as two columns
   of different widths stacked on each other. Everything in the review shares
   the same edge now. */
.songs-bucket-list, .songs-bucket > .songs-hint,
.songs-bucket > .songs-bucket-head, .songs-nothing-new,
.review-step-head, .review-steps, .review-tally, .review-summary,
.import-count, .review-actions { max-width: 46rem; margin-inline: auto; }
/* FINDING 8: THE CAP IS RIGHT AND THE ALIGNMENT WAS NOT. Capping every child to
   46rem and leaving them all flush left put 352px — 32% of a 1088px panel — of
   permanent nothing down the right at 1440, on every step. A measure of 46rem
   is a reading decision and stays; where the column SITS in the panel is a
   different decision and was never made. The column centres once the panel is
   wider than the measure, so the empty space is shared rather than piled on one
   side. Below that width nothing moves, because there is no slack to share.
   ON THE CAPPED ELEMENTS THEMSELVES, not on #pasteReview's children: the cap
   sits on .songs-bucket-list and friends, several of which are GRANDCHILDREN of
   the panel. Centring the child centred a full-width wrapper, which moves
   nothing, and the measured 292..1028 inside a 292..1380 panel did not budge. */
/* NOT A BANNER. This carried style="width:100%" inline, so at 1440 the button
   that finishes an import was 1088px of solid purple — six times the width of
   the "Read this list" button a hundred pixels above it, which is the same
   kind of action. Sized like every other primary button on the screen. */
/* AND "NEXT" IS THE SAME BUTTON AS "ADD THE TICKED SONGS".
   Measured: #pasteNext computed to 44px while Back and Discard beside it were
   48px, and by area "Discard this import" — the button that throws the work
   away — was 2.6 times the size of the one that carries it on, sitting
   immediately to its right. The step forward is the primary action on a
   decision step and is sized like one. */
.review-confirm,
.review-go {
  height: var(--db-field);
  min-width: 12rem;
  padding: 0 20px;
}
/* The commit and the way out, side by side. The discard is quiet and second:
   it must be findable without competing with the thing most people came to
   press. */
.review-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-top: 12px;
}
.review-actions .btn-quiet { height: var(--db-field); padding: 0 16px; }
.review-row input[type="checkbox"] {
  width: 20px; height: 20px; flex: none; margin-top: 2px;
  accent-color: var(--action); cursor: pointer;
}
/* minmax(0, 1fr) FOR THE SAME REASON AS .songs-bucket-list ABOVE, and this one
   was here all along: `min-width: 0` fixes the FLEX side of the box and does
   nothing about the GRID inside it. Measured at 375 with a long title: the row
   was 267px and its own grid track was 335px. A track whose minimum is
   min-content will not shrink for anything the row does. */
.review-main {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 2px;
  min-width: 0;
}
.review-title { font-weight: var(--weight-strong); overflow-wrap: anywhere; }
.review-by, .review-in { font-size: var(--db-t-hint); color: var(--text-secondary); overflow-wrap: anywhere; }
.review-note { font-size: var(--db-t-hint); color: var(--notice-text); }
/* WHAT WAS DECIDED LAST TIME, on the row it was decided about. A chip rather
   than another line of hint text: on a re-import most of the screen is these,
   and a paragraph on every row would be the wall this screen was rebuilt to
   remove. `justify-self: start` so it hugs its own text inside the grid rather
   than striping the full width. */
/* NOT A 999px RADIUS, BECAUSE IT IS NOT ALWAYS ONE LINE. At 375 the pill
   "Typed in last time: Zzq Typed By Hand — Zzq Typed Act" wraps to three lines
   and measures 194x63; a 999px radius clamps to half the height, so the curve
   cuts 18px in from the edge while the text sits at 9px and the first and last
   lines cross outside their own border. A radius that cannot exceed the corner
   it is drawn on, and padding that clears it. */
.review-recalled {
  justify-self: start;
  margin-top: 2px;
  padding: 2px 10px;
  border-radius: 10px;
  font-size: var(--db-t-hint);
  line-height: 1.5;
  color: var(--text-secondary);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  overflow-wrap: anywhere;
}
/* A memory that could not be honoured is a question, not a reassurance, so it
   takes the notice colours the rest of the app uses for "look at this one"
   rather than the quiet grey of a decision that held. */
/* ================= THE DUPLICATES ALREADY ON THE LIST =================
   Two sides of a pair, each with what is attached to it, because "keep this
   one" is not a decision anybody can make from two identical-looking titles.
   The set lists and the request count are what actually differ between them. */
.songs-dupes { margin-bottom: 12px; max-width: 46rem; }
.dupe-pair {
  display: grid;
  gap: 4px;
  padding: var(--db-row-pad) 0;
  border-bottom: 1px solid var(--border);
}
.dupe-pair:last-child { border-bottom: 0; }
.dupe-title { font-weight: var(--weight-strong); overflow-wrap: anywhere; }
.dupe-side {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding-left: 12px;
}
.dupe-credit { flex: 1 1 12rem; min-width: 0; overflow-wrap: anywhere; }
.dupe-notes { font-size: var(--db-t-hint); color: var(--text-secondary); }
.dupe-notes-empty { font-style: italic; }
/* Not a danger button. Pressing it KEEPS something; the removal is the other
   half of the same action and the confirm names it. Colouring this red would
   say the opposite of what it does. */
.dupe-keep { height: var(--db-control); padding: 0 14px; flex: none; }

/* The song this row duplicates. Notice colours, because unlike a remembered
   decision this is something to LOOK at rather than something already settled. */
.review-dupe {
  justify-self: start;
  margin-top: 2px;
  padding: 2px 10px;
  border-radius: 10px;
  font-size: var(--db-t-hint);
  line-height: 1.5;
  color: var(--notice-text);
  background: var(--notice-surface);
  border: 1px solid var(--notice);
  overflow-wrap: anywhere;
}
.review-recalled.is-stale {
  color: var(--notice-text);
  background: var(--notice-surface);
  border-color: var(--notice);
}
/* NAMED .import-pick, NOT .review-pick, AND THAT IS NOT A STYLE PREFERENCE.
   css/dashboard.css declares .review-pick twice — this select, and the gig
   review's row button 600 lines lower — and the later one wins, so the
   ambiguity dropdown computed `display: grid` with a three-column template it
   was never written for. Same collision on .review-count, which rendered the
   import's count line at 13px/600 in the gig review's weight. js/views/
   songbooks.js already carries a comment about walking into this one.
   AND IT IS A TARGET ON A TOUCH SCREEN. 36px, and it is the only control that
   answers the ambiguous step's question. */
/* AND IT IS SIZED FOR ITS ANSWER, NOT FOR THE MEASURE. At 1440 it stretched to
   the full 736px column to hold "Human by The Killers" — a form field where a
   question's answer should be. */
.import-pick {
  justify-self: start;
  margin-top: 4px;
  width: auto;
  max-width: 100%;
  height: var(--db-control);
  border-radius: var(--radius);
  font-size: var(--db-t-hint);
  background: var(--surface);
}
.import-pick:disabled { opacity: .5; }
@media (pointer: coarse) {
  .import-pick { height: var(--db-field-sm); }
}

/* A row the matcher could not settle is a decision, not a tick, so it loses
   the checkbox and the pointer cursor that goes with it. */
/* The tick is its own target, so tapping the label cannot be confused with
   tapping the row's edit link sitting next to it. */
.review-tick { display: flex; align-items: flex-start; padding-top: 2px; cursor: pointer; }

/* 27x30px, HOLDING A 20x20 CHECKBOX, ON A TOUCH SCREEN — and it is the ONLY
   control on the "Found in the catalogue" step, so on a phone the whole step is
   a column of targets a third of the size a thumb needs. The box stays 20px:
   what grows is the label around it, by padding, with the same amount taken off
   as negative margin so the checkbox does not move a pixel from where it was
   drawn beside the title. Only under a coarse pointer — a mouse does not need
   44px and the row would carry the empty space for nothing. */
@media (pointer: coarse) {
  .review-tick {
    min-width: 44px;
    min-height: 44px;
    padding: 9px 12px 12px;
    margin: -7px 0 -7px -12px;
  }
}

/* THE MANUAL ROUTE AS A LINK, NOT A BUTTON. It is the exception — most rows
   never need it — and three button-sized targets per row turned a hundred-line
   review into a wall you could not read down. */
.review-edit {
  align-self: start;
  /* Hugs its text. Without this it is a grid item stretching most of the row,
     which reads as a button however it is painted. The 44px min-height it
     inherits from the button rules stays — it is transparent, so it costs
     nothing visually and it is the difference between a comfortable thumb
     target and a 13px one. */
  justify-self: start;
  margin-top: 4px;
  padding: 4px 0;
  border: 0;
  background: none;
  color: var(--action);
  font: inherit;
  font-size: var(--db-t-hint);
  text-decoration: underline;
  cursor: pointer;
}
/* THE TYPING CARD IS A CARD, SO IT HAS EDGES. It inherited `padding: … 0` from
   .review-row, so its two inputs ran to x=321 inside a card that ended at
   x=321 — touching their own 12px rounded corner on both sides, with the
   corner cutting across the input's border. */
/* 85x28 UNDER A COARSE POINTER, 6px below a 44px search box in the same row.
   The comment above claims it "inherits" a 44px min-height from the button
   rules; nothing in this file gives it one. It has one now, the same way the
   tick and the Open link got theirs — by padding, with the block padding taken
   back as negative margin so the row does not grow. */
@media (pointer: coarse) {
  .review-edit {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    padding: 12px 0;
    margin-top: -4px;
    margin-bottom: -12px;
  }
}

.review-row.is-typing {
  background: var(--surface-raised);
  border-radius: var(--radius);
  padding-inline: 12px;
}

.import-count {
  margin: 12px 0 8px;
  font-size: var(--db-t-hint);
  font-weight: var(--weight-normal);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
/* Not printed at all on a step where nothing can be ticked; see
   renderReviewCount. Stated here because a rule above sets display on it. */
.import-count[hidden] { display: none; }

/* The manual route, on the review screen. Prefilled with what was read, so the
   common case is fixing one word rather than typing the pair again. */
.review-manual { display: grid; gap: 6px; margin-top: 6px; }
.review-manual input {
  height: var(--db-control);
  /* FINDING 6 is the @media below, not this line: --db-control is 36px, which
     is right with a mouse and wrong under a thumb. Every other control on this
     row already got a coarse-pointer bump — .review-tick, .review-edit,
     .import-pick, .review-links-pick — and these two, the only ones you TYPE
     into, did not. Measured at 375: two 194x36 fields against a 51x49 checkbox
     on the same row and a 218x44 search box on the row below. */
  padding: 0 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  font-size: var(--db-t-hint);
  min-width: 0;
}
/* AFTER the rule it overrides, and that is load-bearing rather than tidy. Both
   selectors are (0,2,0), so the later one in the file wins outright — with this
   block sitting above, the base 36px kept winning and the fields measured
   208x36 on a coarse pointer, unchanged. A media query raises specificity by
   nothing. Same trap as the container queries on the live card, twice. */
@media (pointer: coarse) {
  .review-manual input { height: var(--db-field-sm); font-size: var(--db-t-body); }
}

/* SAYING IT USES AI. Set as a notice rather than a hint: it is a fact about
   where the text goes, not advice about how to use the box. */
.songs-ai {
  margin: 8px 0 10px;
  padding: 9px 12px;
  border-left: 3px solid var(--notice);
  border-radius: var(--radius);
  background: var(--notice-surface);
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
}

/* THE LOCAL LINE-SPLITTER, MADE IMPOSSIBLE TO MISS.
 *
 * With no ANTHROPIC_API_KEY the dev server splits the pasted lines itself, so
 * the review screen can be driven in a browser. Nothing on screen said which
 * of the two had read the list, and this screen presents both with identical
 * confidence — so an impression of the parser could be formed from something
 * that is not the parser.
 *
 * Styled as the loudest thing this app has, deliberately: negative colours, a
 * solid rule, a mark, and the first line in the body weight rather than the
 * hint size everything else on the screen uses. A quiet notice is what this
 * WAS. It sits above every bucket so it cannot be scrolled past on the way to
 * the rows it is about.
 *
 * It can only ever appear where there is no key, which is a development
 * machine — nothing in api/ falls back to local parsing, and nothing may. */
.songs-stub {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  margin: 0 0 14px;
  padding: 12px 14px;
  border: 2px solid var(--negative);
  border-radius: var(--radius);
  background: var(--negative-surface);
  color: var(--negative-text);
  font-size: var(--db-t-body);
  line-height: var(--line-height);
}
.songs-stub strong { display: block; font-size: var(--db-t-mid); }
.songs-stub-mark {
  flex: none;
  width: 22px; height: 22px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--negative);
  color: var(--negative-ink);
  font-weight: var(--weight-strong);
  font-size: 14px; line-height: 1;
}
/* ...and the note above the box, which claimed the model read it. */
.songs-ai.is-stubbed {
  border-left-color: var(--negative);
  background: var(--negative-surface);
  color: var(--negative-text);
}

/* THE WAIT. A few seconds of nothing is indistinguishable from a dead page,
   so the stage is named and the dots move. */
.songs-working {
  display: flex;
  align-items: center;
  gap: 11px;
  margin-top: 12px;
  padding: 11px 13px;
  border-radius: var(--radius);
  background: var(--surface-raised);
}
.songs-working[hidden] { display: none; }
.songs-working-dots { display: inline-flex; gap: 5px; flex: none; }
.songs-working-dots i {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--action);
  animation: songs-working-pulse 1.1s ease-in-out infinite;
}
.songs-working-dots i:nth-child(2) { animation-delay: .16s; }
.songs-working-dots i:nth-child(3) { animation-delay: .32s; }
/* The working block is the app's own loading bar now (see .app-loading in
   css/shared.css), and these two lines sit beside it rather than beside three
   dots. Laid out here because the text is this screen's, not the component's. */
.app-loading.sb-working,
.songs-working {
  display: flex;
  align-items: center;
  gap: 11px;
}
.app-loading.sb-working { justify-items: stretch; padding: 11px 13px; }
.app-loading.sb-working .app-loading-bar { flex: none; width: 64px; }
.songs-working-text { display: grid; gap: 1px; min-width: 0; }
.songs-working-text strong { font-size: var(--db-t-body); color: var(--text-primary); }
.songs-working-text span { font-size: var(--db-t-hint); color: var(--text-secondary); }

@keyframes songs-working-pulse {
  0%, 80%, 100% { opacity: .25; transform: scale(.8); }
  40%           { opacity: 1;   transform: scale(1); }
}

/* Reduce motion: the dots stop moving but stay visible. The words carry the
   "still working" on their own, and a vanished indicator would read as done. */
@media (prefers-reduced-motion: reduce) {
  .songs-working-dots i { animation: none; opacity: .75; transform: none; }
}

/* ------------------------------------------------------------- songbooks
 *
 * A named subset of the repertoire. Not a setlist: no order, no positions, no
 * drag handles — see the note at the top of js/views/songbooks.js for why that
 * distinction is load-bearing rather than pedantic.
 *
 * ONE SECTION, TWO STATES: the list of books, or one book open. Deliberately
 * not an accordion. A book can hold three hundred songs, and a row that
 * expands to three hundred pushes every other book off the bottom of the
 * screen — which turns a list you were navigating into one you have to scroll
 * past. Opening replaces, and offers a way back. */
/* ============ A SHELF OF BOOKS, NOT A TABLE WITH A COLOURED EDGE ============
 *
 * This was six rows with a stripe down the left. A stripe down the left is a
 * SPINE, and a spine is what you see when a book is on a shelf facing away from
 * you. A list of six songbooks should look like six books.
 *
 * So each one is a cover: a portrait card in its own colour, with its own
 * device, and its name set large across it. In a grid, so a musician with a
 * Wedding set, a Sunday afternoon and a Function night sees three different
 * objects rather than three rows of text.
 *
 * NOBODY PICKS ANY OF IT. Colour and device are derived from the book's own id,
 * out of ten and six taken from different parts of one hash, which is sixty
 * combinations before two books look alike. See coverFor() and deviceFor() in
 * js/views/songbooks.js.
 *
 * AUTO-FILL RATHER THAN A FIXED COUNT, so the shelf is two across on a phone,
 * three or four on a tablet and five on a desktop without a media query per
 * step. 132px is the narrowest a cover can be and still carry a two-word name
 * at a readable size; measured, not guessed. */
.book-shelf {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
  gap: 12px;
  margin-top: 12px;
}

.book-cover {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 4px;
  /* A PAPERBACK, near enough. Nothing here is a photograph so the exact ratio
     is a matter of feel rather than of fitting anything; 3:4 is a book and
     16:9 is a video. */
  aspect-ratio: 3 / 4;
  padding: 12px 12px 12px 20px;
  overflow: hidden;
  text-align: left;
  border: 1px solid rgb(0 0 0 / .28);
  border-radius: 4px 8px 8px 4px;
  background: var(--book-ink, var(--action));
  color: #fff;
  cursor: pointer;
  /* Off the page a little, like something sitting on a shelf. */
  box-shadow: 0 2px 8px rgb(0 0 0 / .28);
  transition: transform 140ms ease, box-shadow 140ms ease;
}
@media (prefers-reduced-motion: reduce) {
  .book-cover { transition: none; }
}
.book-cover:hover {
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgb(0 0 0 / .34);
}
.book-cover:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 3px; }

/* THE SPINE, down the left, a shade darker than the cover. It is what makes a
   coloured rectangle read as a book rather than as a tile. */
.book-spine {
  position: absolute;
  inset: 0 auto 0 0;
  width: 12px;
  background: rgb(0 0 0 / .22);
  border-right: 1px solid rgb(255 255 255 / .14);
}

/* THE DEVICE. Drawn with gradients, so it costs no image and no request, and
   kept to the top two thirds so it never runs under the name. Six of them, and
   the sixth is deliberately nothing: a plain cover among patterned ones is
   itself a difference, and every cover carrying a pattern is a shelf of
   wallpaper. */
.book-art {
  position: absolute;
  inset: 0 0 38% 12px;
  pointer-events: none;
  opacity: .9;
}
.book-cover[data-device="0"] .book-art {
  background-image: repeating-linear-gradient(
    -45deg, rgb(255 255 255 / .20) 0 6px, transparent 6px 14px);
}
.book-cover[data-device="1"] .book-art {
  background-image: radial-gradient(rgb(255 255 255 / .26) 2px, transparent 2.2px);
  background-size: 14px 14px;
}
.book-cover[data-device="2"] .book-art {
  background-image: repeating-linear-gradient(
    to bottom, rgb(0 0 0 / .16) 0 5px, transparent 5px 13px);
}
/* A disc, off to one side, which is the only device that is a SHAPE rather
   than a texture and is the one that reads from furthest away. */
.book-cover[data-device="3"] .book-art {
  background-image: radial-gradient(circle at 72% 34%,
    rgb(255 255 255 / .26) 0 30%, transparent 31%);
}
.book-cover[data-device="4"] .book-art {
  background-image: linear-gradient(to bottom,
    rgb(255 255 255 / .18) 0 22%, transparent 22%),
    linear-gradient(to bottom, transparent 0 62%, rgb(0 0 0 / .16) 62% 68%, transparent 68%);
}

/* THE NAME, as large as a cover can carry it, wrapping rather than truncating:
   a truncated book name is a book you cannot tell from the other truncated
   one. Four lines is the ceiling, after which it clips, which on a 132px cover
   is about forty characters. */
.book-title {
  position: relative;
  font-size: var(--db-t-head);
  font-weight: var(--weight-strong);
  line-height: 1.12;
  letter-spacing: -.01em;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 4;
  line-clamp: 4;
  -webkit-box-orient: vertical;
  /* Legible over every device above, which is what a shadow is for here rather
     than decoration: the disc and the stripes both pass behind the top of a
     four-line name. */
  text-shadow: 0 1px 3px rgb(0 0 0 / .45);
}
.book-cover .book-n {
  position: relative;
  font-size: var(--db-t-small);
  color: rgb(255 255 255 / .82);
}

/* ============ TEN INKS ============
 * Chosen dark enough to carry white type at the app's contrast floor, and far
 * enough apart in hue that two neighbours on the shelf never read as the same
 * colour. No two are the same family. */
.book-cover[data-cover="0"] { --book-ink: #6C4BC7; }
.book-cover[data-cover="1"] { --book-ink: #1F7F74; }
.book-cover[data-cover="2"] { --book-ink: #C7451F; }
.book-cover[data-cover="3"] { --book-ink: #9A6B12; }
.book-cover[data-cover="4"] { --book-ink: #2A5FA8; }
.book-cover[data-cover="5"] { --book-ink: #A32F5C; }
.book-cover[data-cover="6"] { --book-ink: #3C7A34; }
.book-cover[data-cover="7"] { --book-ink: #4A4E63; }
.book-cover[data-cover="8"] { --book-ink: #0F6A8B; }
.book-cover[data-cover="9"] { --book-ink: #7A3B8F; }

.book-name {
  flex: 1;
  min-width: 0;
  font-weight: var(--weight-strong);
  font-size: var(--db-t-body);
  overflow-wrap: anywhere;
}
.book-n {
  flex: none;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  font-variant-numeric: tabular-nums;
}
.book-go { flex: none; color: var(--text-secondary); font-size: 18px; line-height: 1; }

/* ============ THE BAR AT THE FOOT OF THE ADD DRAWER ============
 *
 * Every tap writes to the book there and then, and the only sign of it used to
 * be a tick changing shape somewhere in a list of thirty. Nothing said what had
 * gone in, nothing said how many, and the book's own count sat at the top of
 * the screen where the open drawer had pushed it out of sight, so turning the
 * page looked exactly like losing your work even though nothing was ever lost.
 *
 * It sticks to the bottom of the drawer so it is under the thumb on every page
 * of results, which is also where the brief asks for an add control. */
.book-addbar {
  position: sticky;
  bottom: 0;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 2px 12px;
  margin-top: 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}
.book-addbar-counts {
  grid-column: 1;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 10px;
  font-size: var(--db-t-small);
}
.book-addbar-counts strong { color: var(--text-primary); font-variant-numeric: tabular-nums; }
.book-addbar-counts span { color: var(--action-on-surface, var(--action)); }
.book-addbar-last {
  grid-column: 1;
  margin: 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.book-addbar button { grid-column: 2; grid-row: 1 / span 2; }

.book-export { margin: 14px 0 10px; }
.book-export h4 { margin: 0 0 2px; font-size: var(--db-t-body); }
.book-export .songs-hint { margin: 0 0 8px; }
.book-export button[disabled] { opacity: .45; cursor: default; }

/* THE EXAMPLES ARE NOT BUTTONS, and that is the whole design of this state.
   A book that appears without being asked for is a book somebody has to
   delete before they can use the feature. These say what SORT of thing goes
   here and nothing more. */
.book-empty { display: block; }
.book-examples {
  margin: 10px 0;
  padding-left: 20px;
  display: grid;
  gap: 3px;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
}
.book-examples li { list-style: disc; }

.book-open { margin-top: 12px; }
.book-back {
  display: inline-block;
  min-height: var(--db-field-sm);
  margin-bottom: 4px;
  font-size: var(--db-t-body);
}
.book-title { margin: 0; font-size: var(--db-t-head); overflow-wrap: anywhere; }

/* ============ THE PENCIL ON THE BOOK'S NAME ============
 *
 * Renaming was a button at the very foot of the book, under every song in it.
 * A name is a property of the book, so the control sits on the name.
 *
 * align-items: center rather than the row's own baseline, because a 34px
 * square hanging off a baseline sits visibly low against the type it belongs
 * to. The count keeps the baseline by taking it back on itself. */
.book-head { align-items: center; }
.book-head .songs-count { align-self: baseline; }
.book-rename {
  flex: none;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}
.book-rename svg { width: 17px; height: 17px; }
.book-rename:hover { color: var(--text-primary); border-color: var(--text-secondary); }
.book-rename:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* ============ THE NAME BEING EDITED, WHERE THE NAME IS ============
 *
 * The field takes the heading's place and the book stays on screen underneath
 * it. Wraps on a phone: three controls on one 390px line would put Save at
 * about 70 pixels, which is the fault this file has just fixed elsewhere. */
.book-head-editing {
  align-items: center;
  gap: 8px;
}
.book-name-field {
  flex: 1 1 200px;
  min-width: 0;
  height: var(--db-field-sm);
  padding: 0 12px;
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  /* The size the heading it replaced was, so the name does not jump. */
  font-size: var(--db-t-head);
}
.book-name-field:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 1px; }
.book-name-save, .book-name-cancel {
  flex: 0 1 auto;
  min-width: max-content;
  height: var(--db-field-sm);
}
/* Full width under the row, so a message about the name is under the name. */
.book-name-note { flex: 1 1 100%; margin: 0; }
.book-tools { display: flex; gap: 10px; flex-wrap: wrap; margin: 4px 0 10px; }
.book-tools button {
  min-height: var(--db-field-sm);
  padding: 0 14px;
  border-radius: var(--radius);
  font-size: var(--db-t-hint);
}
.book-songs { margin-top: 12px; display: grid; gap: 6px; }
/* A real tap target, and the only destructive control on the row — so it is
   an X rather than a word, and it says what it removes in its aria-label
   rather than relying on the icon to carry it. */
.book-remove {
  flex: none;
  width: var(--db-field-sm);
  height: var(--db-field-sm);
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--radius);
  background: none;
  color: var(--text-secondary);
  font-size: 15px;
  cursor: pointer;
}
.book-remove:hover { background: var(--negative-surface); color: var(--negative-text); }

/* ---- the three manual routes into a songbook ----
 *
 * SELECT IS A MODE. A tick on every row all the time is three hundred controls
 * nobody asked for, and it makes the ordinary case — find a song, tap it, edit
 * it — a decision about which of two things a tap means. */
.songs-selectbar {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  margin-top: 10px;
  padding: 10px 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-sunken);
}
.songs-selectbar[hidden] { display: none; }
.songs-selected { flex: 1; min-width: 8rem; font-size: var(--db-t-hint); color: var(--text-secondary); }
.songs-selectbar .btn-primary { min-height: var(--db-field-sm); padding: 0 16px; border-radius: var(--radius); }
.songs-selectbar .btn-primary:disabled { opacity: .45; cursor: default; }

.song-row.is-selectable { cursor: pointer; }
.song-row.is-selected { background: var(--surface-sunken); border-color: var(--border-strong); }
/* The tick is a real target and a real box, so an unticked row still shows
   WHERE the tick will go — an empty space says nothing. */
.song-tick {
  flex: none;
  width: 24px; height: 24px;
  display: grid; place-items: center;
  margin-right: 10px;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--action-on-surface, var(--text-primary));
  font-size: 14px; line-height: 1;
}
.song-row.is-selected .song-tick { background: var(--action); color: var(--action-text); border-color: var(--action); }

/* The picker: which book, with what a song is already in shown as a tick and a
   mixed selection as a dash. */
.book-pick-list { display: grid; gap: 6px; }
.book-pick-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  min-height: var(--db-field);
  padding: 8px 12px;
  text-align: left;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-size: var(--db-t-body);
  cursor: pointer;
}
.book-pick-row:hover { border-color: var(--border-strong); }
.book-pick-mark {
  flex: none;
  width: 24px; height: 24px;
  display: grid; place-items: center;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  font-size: 14px; line-height: 1;
}
.book-pick-row.is-all .book-pick-mark { background: var(--action); color: var(--action-text); border-color: var(--action); }
.book-pick-row.is-some .book-pick-mark { color: var(--text-secondary); }
.book-pick-new { width: 100%; min-height: var(--db-field-sm); margin-top: 10px; border-radius: var(--radius); }

/* ------------------------------------------------- My songs | Songbooks
 *
 * The Songs page was three stacked cards with a fourth arriving. Songbooks is
 * its own view now rather than another card, so neither area makes the other
 * scroll longer.
 *
 * A SEGMENTED CONTROL, because this page already has one — "What people can
 * request" — and an app with two ways of saying "one of these two" has
 * neither. Both labels fit on one line at 320px, so there is no narrow
 * variant: it is the same control at every width, which is also what the Add
 * songs tabs settle to once there is room for them.
 *
 * Marked by weight and a rule as well as fill. A fill alone is a colour
 * difference, and this is read under stage lighting. */
/* .songs-views is now only a JS hook and its own spacing. Its looks come from
   .seg-tabs in css/shared.css, which IS this control, extracted so Tips could
   stop drawing its own. Spacing stays per-place: a shared component should not
   decide how much room it needs in somebody else's layout. */
.songs-views { margin: 0 0 var(--db-pad); }

/* How many books there are, so the tab is not a door with nothing written on
   it — you can see whether anything is behind it before tapping. */
.songs-views-n {
  min-width: 20px;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--border);
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  font-variant-numeric: tabular-nums;
}
.songs-views button[aria-selected="true"] .songs-views-n {
  background: var(--action);
  color: var(--action-text);
}
.songs-views-n:empty { display: none; }

/* THE TWO PANES ARE INVISIBLE TO THE LAYOUT.
 *
 * `section[data-view]` puts its panels in a grid — two columns once there is
 * room — so wrapping them in a pane div made the PANE the grid item and every
 * panel inside it half the width it should be. The Add songs labels stopped
 * fitting on one line and fell back to a column at 1024px, which is how this
 * was found: panel content box 382px where it had been 824px.
 *
 * `display: contents` puts the panels back in the grid directly, exactly as
 * they were before there were panes at all.
 *
 * AND THE `hidden` RULE HAS TO BE RESTATED, because the attribute only sets
 * display:none in the UA stylesheet and any author rule with a display of its
 * own beats it — the same trap already documented above for .songs-tools. */
.songs-pane { display: contents; }
.songs-pane[hidden] { display: none; }

/* AND THE CHILD SELECTORS HAVE TO REACH THROUGH THE PANE.
 *
 * css/shell.css lays #songsView out as a grid above 700px and uses DIRECT
 * child selectors to do it: `#songsView > .panel { margin: 0 }` and
 * `#songsView > .panel-wide { grid-column: 1 / -1 }`. With the panels now one
 * level deeper, inside a pane, neither matched — so every panel dropped into a
 * 330px auto-fit track and the Songs page became two narrow columns. Measured:
 * the panel's content box went from 824px to 382px at a 1024px window, which
 * took the Add songs labels below their 500px breakpoint and folded them back
 * into a column.
 *
 * `display: contents` on the pane is what keeps the panels as grid items; this
 * is what keeps them the right WIDTH. Both are needed and neither is enough
 * alone. Scoped here rather than by widening the selectors in shell.css,
 * because that file is being edited by another pass. */
@media (min-width: 700px) {
  /* THE PANE ITSELF SPANS THE ROW, which is the piece that was missing and the
     reason the whole page was narrow. #songsView is a two-column grid above
     700px; .songs-pane was landing in ONE of those columns, so at a 1440px
     window the entire Songs page — every panel, the search, the list — was
     living inside 570px. The panels were then told to span 1/-1, but 1/-1 of
     570px is still 570px. Measured before this line: #paneMine 570 wide at a
     1440 viewport. */
  #songsView > .songs-pane { grid-column: 1 / -1; }
  #songsView > .songs-pane > .panel { margin: 0; }
  #songsView > .songs-pane > .panel-wide { grid-column: 1 / -1; }
  /* The switch itself spans the row: it is the heading for both panes, not a
     card sitting beside one. */
  #songsView > .songs-views { grid-column: 1 / -1; }
}

/* The destructive half of the multi-select bar. Quiet until something is
   selected, and never the primary — Add to book is what most selections are
   for, and the one that cannot be undone should not be the one under the
   thumb by default. */
.songs-selectbar .btn-quiet.danger {
  min-height: var(--db-field-sm);
  padding: 0 14px;
  border-radius: var(--radius);
  color: var(--negative-text);
}
.songs-selectbar .btn-quiet.danger:hover { background: var(--negative-surface); }
.songs-selectbar .btn-quiet.danger:disabled { opacity: .45; cursor: default; }
.btn-primary.danger { background: var(--negative); color: var(--negative-ink); }
.btn-primary.danger:hover { filter: brightness(1.08); }

/* ===================== THE AI SONGBOOK BUILDER ======================
 *
 * The fork, the brief screen and the review screen all live inside the one
 * #bookCreate overlay, which is portalled to <body> like every other overlay
 * here (js/modal-portal.js explains why that is not optional).
 *
 * ---------------------------------------------------------------------------
 * THIS WHOLE BLOCK WAS WRITTEN IN A TOKEN VOCABULARY THIS APP DOES NOT HAVE.
 *
 * It asked for --line, --card, --muted, --accent and --accent-ink. Not one of
 * those five is defined by css/themes.generated.css, by any other stylesheet,
 * or by any JavaScript in the repo — thirty references, none with a fallback.
 * They look like tokens from an earlier palette and they were never renamed.
 *
 * AN UNDEFINED CUSTOM PROPERTY IS NOT A NO-OP. `var(--line)` with no fallback
 * makes its declaration invalid at computed-value time, and the fallback then
 * is the property's INITIAL value — not the value the rest of the cascade
 * would have given it. For a shorthand the whole shorthand goes, longhand by
 * longhand. So:
 *
 *   border: 1px solid var(--line)   ->  border-style: none.  NO BORDER.
 *   background: var(--card)         ->  transparent.         NO PLANE.
 *   color: var(--muted)             ->  color is INHERITED, so this one
 *                                       resolves to `inherit` — secondary
 *                                       text came out the same colour as
 *                                       primary text.
 *
 * Which is exactly the fault that was reported. Measured on 15 August 2026 in
 * the New songbook modal: .build-route computed `border: 0px none`, so the two
 * route buttons — "Pick songs myself" and "Describe the gig", the only two
 * controls on that screen — had no border, no background and body-text colour.
 * They were paragraphs. Same for the four .build-example buttons on the brief
 * screen, which sat under the words "Or start from one of these:" looking
 * exactly like the prose above them.
 *
 * Rewritten in the real semantic tokens rather than aliased, so there is one
 * vocabulary in this file and no second one waiting to be used again by
 * mistake. Where the choice was between --border and --border-strong the rule
 * at the top of this file decides it: a border on a HIT TARGET carries 3:1 for
 * WCAG 1.4.11 and --border does not, so every control here takes
 * --border-strong and only the separators take --border.
 *
 * The rem literals went with them. A theme that scales type or density moved
 * everything around these blocks and left them where they were.
 * ---------------------------------------------------------------------------
 */

/* The two routes at New book, drawn as blocks rather than two words: this is a
   fork somebody meets once per book, and the difference between them is not
   obvious from the verbs alone. */
.build-choice { display: grid; gap: 12px; margin: 8px 0 0; }
.build-route {
  display: grid; gap: 3px; text-align: left;
  min-height: var(--db-field);
  padding: 14px 16px;
  border: 1px solid var(--border-strong); border-radius: var(--radius);
  background: var(--surface-sunken); color: var(--text-primary); cursor: pointer;
}
.build-route:hover, .build-route:focus-visible { border-color: var(--action); }
.build-route-name { font-weight: var(--weight-strong); font-size: var(--db-t-mid); }
.build-route-note { font-size: var(--db-t-hint); color: var(--text-secondary); }

.build-lead { color: var(--text-secondary); margin: 0 0 12px; font-size: var(--db-t-hint); line-height: var(--line-height); }

/* PROMPTS, NOT FIELDS. Deliberately not styled as a form: no boxes, no labels
   with colons, nothing that looks answerable in place. */
.build-questions {
  margin: 0 0 14px; padding: 0 0 0 18px;
  color: var(--text-secondary); font-size: var(--db-t-hint); line-height: var(--line-height);
}
.build-questions li { margin: 2px 0; }

.build-field { margin: 0 0 16px; }
/* A LABEL IS A HEADER FOR THE FIELD UNDER IT. Weight alone left it reading as
   one more line of the paragraph above; the colour step is what separates it. */
.build-label {
  display: block; margin: 0 0 5px;
  font-size: var(--db-t-body); font-weight: var(--weight-strong);
  color: var(--text-primary);
}
.build-field textarea, #reviewName {
  width: 100%; padding: 10px 12px; border: 1px solid var(--border-strong);
  border-radius: var(--radius); background: var(--surface-sunken); color: var(--text-primary);
  font: inherit; font-size: var(--db-t-mid); line-height: var(--line-height); resize: vertical;
}
.build-note { margin: 6px 0 0; font-size: var(--db-t-hint); color: var(--text-secondary); }

/* The box holds the microphone, so it is the positioning context. */
.build-box { position: relative; }
.build-box textarea { padding-right: 42px; }
.build-mic {
  position: absolute; top: 7px; right: 7px;
  width: 32px; height: 32px; display: grid; place-items: center;
  border: 1px solid var(--border-strong); border-radius: var(--radius-md);
  background: var(--surface); color: var(--text-primary);
  cursor: pointer; font-size: var(--db-t-mid);
}
.build-mic.is-live { border-color: var(--action); background: var(--action); color: var(--action-text); }
@media (prefers-reduced-motion: no-preference) {
  .build-mic.is-live { animation: build-mic-pulse 1.4s ease-in-out infinite; }
  @keyframes build-mic-pulse { 50% { opacity: .55; } }
}

.build-examples-lead { margin: 0 0 7px; font-size: var(--db-t-hint); color: var(--text-secondary); }
.build-examples { display: grid; gap: 7px; margin: 0 0 16px; }
/* DASHED, WHICH IS THE POINT: these fill the box above rather than submitting
   anything, so they are marked as a different KIND of control. Dashed and
   invisible are not the same thing, and until --line was rewritten they were. */
.build-example {
  text-align: left; padding: 10px 12px;
  font-size: var(--db-t-hint); line-height: var(--line-height);
  border: 1px dashed var(--border-strong); border-radius: var(--radius);
  background: transparent; color: var(--text-secondary); cursor: pointer;
}
.build-example:hover, .build-example:focus-visible {
  border-style: solid; border-color: var(--action); color: var(--text-primary);
}

.build-actions {
  display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap;
  margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--border);
}

/* ---- the review screen ---- */
.review-why { margin: 0 0 5px; line-height: var(--line-height); }
.review-why-venue { color: var(--text-secondary); font-size: var(--db-t-hint); margin-bottom: 16px; }

.review-bar {
  display: flex; align-items: center; justify-content: space-between;
  gap: 8px; margin: 8px 0; flex-wrap: wrap;
}
.review-count { font-weight: var(--weight-strong); }
.review-bulk { display: flex; gap: 7px; flex-wrap: wrap; }

/* A WARNING, NOT A BARRIER. Deliberately not red and never next to Save. */
.review-short {
  margin: 0 0 10px; padding: 9px 12px;
  font-size: var(--db-t-hint); line-height: var(--line-height);
  border-left: 3px solid var(--action); background: var(--surface-sunken);
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
}

/* `.review-rows` lived here with `max-height: 46vh; overflow-y: auto` and
   nothing in js/, css/, tests/ or any html used it — confirmed by searching the
   whole tree for the class before deleting. Harmless as it sat, and exactly the
   rule shape this flow was rebuilt to remove: a scrolling region inside a
   scrolling page. Left in place it was a loaded gun for whoever reached for a
   plausible-looking class name next. */
.review-pick {
  display: grid; grid-template-columns: auto 1fr auto; align-items: center;
  gap: 10px; padding: 8px 10px; border-radius: var(--radius-md); cursor: pointer;
}
.review-pick:hover { background: var(--surface-sunken); }
.review-pick-by { color: var(--text-secondary); font-size: var(--db-t-hint); }
/* Ticked is the default state, so UNTICKED is what has to read as different —
   the row recedes rather than the ticked row shouting. */
.review-pick:not(.is-on) .review-pick-name,
.review-pick:not(.is-on) .review-pick-by { opacity: .5; }

.review-avoided { margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--border); }
.review-avoided h4 { margin: 0 0 4px; font-size: var(--db-t-mid); }
.review-restore {
  display: grid; grid-template-columns: 1fr auto auto; align-items: center;
  gap: 8px; width: 100%; text-align: left;
  min-height: var(--db-field-sm);
  padding: 8px 10px; margin: 3px 0;
  border: 1px solid var(--border-strong); border-radius: var(--radius);
  background: transparent; color: var(--text-primary); cursor: pointer;
}
.review-restore:hover, .review-restore:focus-visible { border-color: var(--action); }
.review-avoided-why { color: var(--text-secondary); font-size: var(--db-t-small); }
.review-restore-do { font-size: var(--db-t-hint); font-weight: var(--weight-strong); color: var(--action-on-surface); }

@media (max-width: 420px) {
  .review-restore { grid-template-columns: 1fr; gap: .15rem; }
  .build-actions { flex-direction: column-reverse; }
  .build-actions button { width: 100%; }
}

/* THE MID-GIG BOOK SWAP. One tap on a live screen, so it sits with the counts
   rather than behind a menu, and it is hidden entirely when the musician has
   no songbooks — a picker with one option is a control that cannot do
   anything. No confirmation dialogue anywhere near it: it changes what the
   next person sees, nothing else, and choosing again undoes it. */
/* In the status bar, inline, and it must add NO HEIGHT to the queue region.
   live-card-density holds the standard — four whole cards on a 375px screen —
   and this control broke it twice before it earned its place there.

   HEIGHT AND WIDTH BOTH HAVE TO BE SAID OUT LOUD. shared.css carries
   `input, select, textarea { width: 100%; height: 58px }` — a form-field size,
   right for a form field and 26px too tall for a status bar. Setting padding
   and font-size does nothing against it: an explicit height wins over padding
   every time, so the select stayed 58px through two rounds of fiddling with
   the padding while the queue lost a card. Say `height` or inherit 58. */
.gm-book {
  display: inline-flex; align-items: center; gap: .35rem;
  min-width: 0; flex: 0 1 auto; overflow: hidden;
}
/* On the narrowest screens the label goes — the select already reads
   "All songs" or the book's name, so "Showing" is the removable half. */
@media (max-width: 420px) {
  .gm-book-label { display: none; }
  .gm-book { max-width: 9.5rem; }
}
/* Same dead vocabulary as the builder above, and this one is on the LIVE
   screen: --line and --card left the mid-gig book selector with no border and
   no background, a bare word on the status bar that gave no sign it could be
   tapped. Read at half a metre under stage lighting, which is the worst place
   in the app for an invisible control. */
.gm-book-label { font-size: var(--db-t-small); color: var(--text-secondary); flex-shrink: 0; }
.gm-book-select {
  flex: 0 1 auto; min-width: 0; width: auto; max-width: 100%;
  height: 32px; line-height: 1;
  text-overflow: ellipsis;
  padding: 0 8px; font: inherit; font-size: var(--db-t-hint);
  border: 1px solid var(--border-strong); border-radius: var(--radius-md);
  background: var(--surface-sunken); color: var(--text-primary);
}
.gm-book[hidden] { display: none; }


/* ==========================================================================
   SONGS AND SONGBOOKS ON A BIG SCREEN
   ==========================================================================

   THE COMPLAINT: "a mobile layout stretched across 1400px is not a desktop
   layout... do not leave a column of full-width controls with 900px of nothing
   beside it."

   MEASURED BEFORE ANY OF THIS, at 1440x900, Songbooks tab:

     .book-list        1 column, 1120px wide (it was a list then; it is a shelf now)
     .book-row         1120px, with the name at x=0 and "9 songs" at x=1009
     #bookNew          1120px wide — a full-bleed New book button
     #songbooksBody    1120x192 in a 1160px column, ~700px of empty page below

   ...and on the Songs tab the catalogue search was a 1120px single-line input
   and the filter a 1030px one. Every one of those is a phone control that was
   never told the window got bigger.

   FOUR SIZES, AND TABLET PORTRAIT AND LANDSCAPE ARE NOT THE SAME PROBLEM.

     390x844    phone       one column. Untouched — it was already right, and
                            everything below is gated at 700px or above.
     834x1112   tablet Pt   tall and narrow-ish. Room for TWO columns of books
                            and songs; not enough for a side-by-side toolbar,
                            and no reason to save vertical space on a screen
                            with 1112px of it.
     1194x834   tablet Ls   the mic stand. Wide enough for THREE columns of
                            books, and 834px is the shortest of the four — so
                            this is the one size where vertical rhythm is the
                            scarce resource and gets tightened.
     1440x900   desktop     three columns of books, the My songs toolbar beside
                            its heading, and the two setup panels side by side.

   NOTHING HERE CHANGES THE MARKUP. Panels are selected with :has() on
   something they contain rather than by :nth-child, so a view file being
   edited in parallel can reorder its sections without silently moving a
   layout rule onto the wrong panel.                                          */


/* ---- 700px and up: the width starts being used ------------------------- */
@media (min-width: 700px) {

  /* THE SWITCH IS A CONTROL, NOT A BANNER. Two tabs stretched to 1160px gave
     each label a 578px target and read as a page header. Capped to the width
     two words actually need, left-aligned so it sits at the start of the
     content column like the heading of the thing it switches. */
  #songsView > .songs-views {
    max-width: 420px;
    margin-inline: 0;
  }

  /* THE SHELF SIZES ITSELF at every width, so there is nothing to do here that
     the auto-fill track above has not already done: a wider window simply
     fits more covers on the shelf. What DOES need saying at this width is
     where New book goes; see below. */

  /* AND NEW BOOK IS THE NEXT CELL, NOT A ROW OF ITS OWN.
   *
   * The books and the button that makes another one are siblings in the
   * markup — `.book-shelf` then `.songs-drawer-actions` — so laying the books
   * out in a grid left New book stranded on its own line under a row with two
   * empty cells beside it. The one place the eye is already looking for
   * "another one of these" is the end of the row.
   *
   * `display: contents` on the list promotes the book rows to be items of THIS
   * grid, so the button follows the last book in the same flow, in the same
   * track, at the same size — with the markup untouched. Same technique, and
   * for the same reason, as `.songs-route { display: contents }` further up.
   *
   * Guarded by :has(.book-shelf) so the OPEN-BOOK state — where #songbooksBody
   * holds one .book-open div and no list — is not turned into a grid. */
  #songbooksBody:has(> .book-shelf) {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
    gap: 12px;
    align-items: start;
  }
  #songbooksBody:has(> .book-shelf) > .songs-hint { grid-column: 1 / -1; margin-bottom: 4px; }
  #songbooksBody:has(> .book-shelf) > .book-shelf { display: contents; }
  /* AND THE ACTION STARTS ITS OWN ROW, or it becomes a songbook.
     `display: contents` on the shelf promotes every cover into this grid, so
     the New book action simply took the next free cell — same auto-fill track,
     same height, same rounded rect as a book cover, sitting on the same line as
     the last one. Measured at 1728: the button and the last book row both at
     y=294 with one, two, three, five, six or seven books; it only looked right
     at four and eight, where the row happened to be full. A primary action that
     is indistinguishable from the things it acts on, and whose shape depends on
     how many songbooks somebody owns. */
  #songbooksBody:has(> .book-shelf) > .songs-drawer-actions {
    grid-column: 1 / -1;
    margin-top: 0;
  }

  /* THE SONGS INSIDE AN OPEN BOOK get the same treatment the repertoire list
     already had: two columns from 760px, for the same reason (see .songs-list,
     which explains why it is two and not three). This list was left out when
     that was done. */
  .book-songs {
    display: grid;
    grid-template-columns: 1fr;
    column-gap: var(--db-pad);
  }

  /* AN ACTION BUTTON STOPS BEING A BANNER. `.songs-drawer-actions button` is
     `flex: 1`, which is right on a phone — a full-width primary action under a
     drawer — and absurd at 1120px. Scoped to `.panel` so the ROW OF ACTIONS AT
     THE FOOT OF A MODAL is untouched: those sit in a 560px card where filling
     the width is still the correct answer, and they are reached through
     `.vm-body`, never through `.panel`. */
  .panel .songs-drawer-actions { justify-content: flex-start; }
  /* And here too — see the note beside the base rule. A tag-name selector over
     a control that is deliberately a <label> is a rule that quietly does not
     apply, and this is the one that sets the 176px floor. */
  .panel .songs-drawer-actions button,
  .panel .songs-drawer-actions .import-choose {
    flex: 0 1 auto;
    min-width: 11rem;
  }

  /* LINE LENGTH is capped further down this file, in rem and over a longer
     list of selectors. It was stated here in `ch` first, which does not mean
     characters — see the note on the rule that replaced it. Two rules saying
     different numbers for the same thing is how the looser one survives. */

  /* THE EMPTY STATE ALIGNS WITH ITS PANEL, IT DOES NOT FLOAT IN THE MIDDLE.
     This used to be `max-width: 68ch; margin-inline: auto`, on the reasoning
     that centred text needs a centred box. The reasoning was fine and the
     result was not: 68ch computed to 600px, so `auto` margins pushed the block
     404px in from a heading, a hint and a button that all start at the panel's
     left edge. Measured on the empty Songbooks panel at 1728 — h2 at x=276,
     hint at 276, the New book button at 276, and the empty state at 680. Three
     alignments in one card, drifting further right the wider the window gets
     (260px at 1440, 137px at 1194).

     The cap stays, in rem like every other measure in this file, and the box
     starts where everything else in the panel starts. The TEXT inside it is
     still centred, which was the only part that ever needed to be. */
  .songs-empty {
    max-width: 34rem;
    margin-inline: 0;
  }

  /* A SEARCH FIELD IS NOT A TEXT COLUMN EITHER. The catalogue box was 1120px
     for a two-word query. Capped to something you can see both ends of; the
     RESULTS below it keep the full width and are already two columns. */
  .songs-primary .search-field { max-width: 40rem; }
}


/* ---- tablet portrait: two columns, full rhythm ------------------------- */
@media (min-width: 700px) and (orientation: portrait) {
  /* 1112px of height and no reason to crowd it. This exists to say explicitly
     that portrait is NOT getting the landscape tightening below, rather than
     leaving that as an accident of which query matched. */
  .book-songs { grid-template-columns: 1fr 1fr; }
}


/* ---- short and wide: the mic stand, and a laptop window ---------------- */
/* 1194x834 is the iPad this app is used on at gigs, and 1440x900 is a laptop
   with a browser open. Both are WIDE AND SHORT, and on both the scarce
   dimension is vertical: every 20px given back is another song row above the
   fold. Portrait is deliberately excluded — see above. */
@media (min-width: 1000px) and (orientation: landscape) and (max-height: 900px) {
  #songsView > .songs-views { margin-bottom: var(--db-gap); }
  #songsView > .songs-pane > .panel { padding-block: calc(var(--db-pad) * .8); }
  .book-songs { grid-template-columns: 1fr 1fr; }
}


/* ---- 1100px and up: the toolbar moves beside its heading --------------- */
@media (min-width: 1100px) {

  .book-songs { grid-template-columns: 1fr 1fr; }

  /* MY SONGS: TITLE LEFT, TOOLS RIGHT, ONE BAR.
   *
   * The panel was heading, then a 1030px filter field with a Select button
   * pushed against the far edge, then the list — three stacked full-width
   * rows where a desktop expects one. The panel becomes a two-track grid and
   * the two things that belong on a bar share row 1; everything else spans.
   *
   * SELECTED BY WHAT IT CONTAINS. `:has(> #songList)` is the repertoire panel
   * and can only ever be the repertoire panel. The blanket `> *` rule means a
   * child added to this section by the view file lands full width by default
   * rather than in whichever track was next, so this cannot break by someone
   * else adding a row. */
  #songsView .panel:has(> #songList) {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: end;
    column-gap: var(--db-pad);
  }
  #songsView .panel:has(> #songList) > * { grid-column: 1 / -1; }
  #songsView .panel:has(> #songList) > .songs-head { grid-column: 1; grid-row: 1; }
  #songsView .panel:has(> #songList) > .songs-tools {
    grid-column: 2; grid-row: 1;
    margin-top: 0;
    /* Wide enough to read a song title back, narrow enough that it is a tool
       and not the page. */
    width: min(30rem, 40vw);
  }
  /* The filter is now in a fixed-width track, so its own flex-basis of 14rem
     would leave the Select button floating away from it. */
  #songsView .panel:has(> #songList) > .songs-tools .search-field { flex: 1 1 auto; }

  /* SONGBOOKS: heading and count on the bar, the same as My songs. The
     songbooks panel is the only panel on its pane, so on a desktop it was a
     1160px card with 192px of content in the top-left corner of it. */
  #songsView .panel:has(#songbooksBody) > .songs-head { margin-bottom: 4px; }
}


/* ---- the two setup panels used to sit side by side here. They do not. ----
 *
 * `@media (min-width: 1400px) { ... grid-column: auto }` put "What people can
 * request" and "Add songs" in half-width tracks, on the reasoning that neither
 * needed 1160px and stacking them pushed the song list below the fold.
 *
 * The arithmetic in the note was right and the conclusion was wrong. What it
 * checked was whether the ROUTE LABELS still fitted in a half-width panel —
 * they did, at 530px of content box — and it never asked what happened to the
 * catalogue results underneath them. An independent review measured that: at
 * 1440 the results were 530px with 19 of 30 titles wrapping in a 191px text
 * box, while the same results on a 1194px iPad, below this breakpoint, were
 * 874px and wrapped nothing. The desktop was worse than the tablet at the
 * page's primary job, and this rule is why.
 *
 * IT IS DELETED RATHER THAN OVERRIDDEN, and that distinction cost a loop. The
 * pass that replaced this with a 22rem rail left the block in place and simply
 * won the specificity fight; when the rail was in turn removed, the halves
 * came straight back — measured at 570px panels on a 1440px window, the exact
 * fault that had supposedly been fixed twice. A rule that is wrong has to go,
 * not get out-ranked.
 * ----------------------------------------------------------------------- */


/* ==========================================================================
   THE FILE IMPORTER, inside the Paste a list drawer
   ==========================================================================

   Two routes into the same review screen: choose a file, or paste text. The
   drop zone is first because it is the one that takes a spreadsheet somebody
   already has; the textarea is still there under it for the case where they
   are copying out of a message.

   THE PICKER IS A <label>, NOT A <button>, and that is not a shortcut — an
   <input type="file"> is drawn differently by every browser and cannot be made
   to match the app, so the input is visually hidden (see .visually-hidden in
   css/shell.css, which had to be added: nothing on the dashboard defined it)
   and the label carries .btn-primary. The button system in shell.css gives a
   label the same geometry and the same pointer cursor as a real button, so it
   IS the button as far as anyone using it is concerned.                      */

.import-drop {
  display: grid;
  justify-items: center;
  align-content: center;
  gap: 8px;
  /* It IS the label now, so it takes the pointer the button used to. */
  cursor: pointer;
  padding: calc(20px * var(--density)) 16px;
  /* Dashed, and on its own plane: the drawer around it is already
     --surface-sunken, so a solid border on the same plane would read as one
     more field rather than as a place to drop something. */
  border: 2px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  text-align: center;
}
/* THE DRAG STATE HAS TO BE OBVIOUS WHILE A FILE IS UNDER THE CURSOR, when the
   pointer is somewhere else and the eye is on the file. Border AND plane, not
   a colour swap: on Stage the accent against the surface is a small step. */
/* THE FOCUS RING HAS SOMETHING TO DRAW ON. #importFile is visually hidden at
   1x1 with clip: rect(0,0,0,0), so tabbing to it drew a correct purple ring on
   nothing at all — the file route had two visible affordances and neither could
   show focus. :has() puts the ring on the zone the input belongs to. */
/* BOTH OF THESE SELECTORS WERE DEAD, and the review measured it: with the file
   input focused, outline-style was `none`. #importFile is a SIBLING of
   #importDrop, not a descendant, so `.import-drop:has(:focus-visible)` could
   never match; and the input is not inside .songs-drawer-actions either, so the
   second could not match. A keyboard user tabbing to the file route saw nothing
   at all — the route has two visible affordances and neither could show focus.
   The input comes first in the markup, so a sibling combinator reaches the drop
   zone. Only the drop zone takes the ring: two rings for one focused control
   reads as two things being focused.

   AND THE SELECTOR WAS ONLY HALF OF IT. With the combinator fixed, the ring
   still did not draw: `--focus-ring` is a COLOUR (#CFA3F7), so
   `outline: var(--focus-ring)` sets the colour and leaves outline-style at
   `none`. Measured with the input focused by a real Tab: :focus-visible
   matched, the selector matched, outline-style was still `none`.

   That idiom was in this file eight times and in css/public.css three more, so
   this was never one dead focus ring — it was ELEVEN, including three on the
   page the audience uses. All of them now name a style and a width. The one
   place that already got it right, `outline: 3px solid var(--focus-ring)`, is
   why the mistake was invisible: a grep for --focus-ring found plenty of hits
   and they all looked deliberate. */
#importFile:focus-visible + .import-drop {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
}
.import-drop.is-over {
  border-color: var(--action);
  border-style: solid;
  background: var(--action-quiet);
}
/* The label is already a .btn-primary; this only stops the grid stretching it
   to the full width of the zone. */
.import-choose { justify-self: center; }

/* ONE OF THESE TWO, NEVER BOTH. A phone was being shown "Drop a file here",
   which is not a thing you can do on a phone — and the one route that IS
   phone-native, photographing the list, was buried mid-sentence in the note
   above. Keyed on the pointer rather than on a width, because a tablet with a
   trackpad can drop and a wide touchscreen cannot. */
.import-drop-touch { display: block; }
.import-drop-note { display: none; }
@media (hover: hover) and (pointer: fine) {
  .import-drop-note { display: block; }
  .import-drop-touch { display: none; }
}
.import-drop-note {
  margin: 2px 0 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
.import-drop-kinds {
  margin: 0;
  font-size: var(--db-t-small);
  color: var(--text-muted);
  line-height: var(--line-height);
  max-width: 44ch;
}

/* THE ATTRIBUTE ONLY SETS display:none IN THE UA STYLESHEET, and any author
   rule with a display of its own beats it — the trap this file already
   documents for .songs-tools and .songs-pane. Both of these ship hidden and
   are shown from JS, so both need it restating. */
.import-chosen,
.import-problem {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin: 10px 0 0;
  padding: 9px 12px;
  border-radius: var(--radius);
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  overflow-wrap: anywhere;
}
.import-chosen[hidden],
.import-problem[hidden] { display: none; }

.import-chosen {
  background: var(--positive-surface);
  color: var(--positive-text);
  border-left: 3px solid var(--positive);
}
.import-problem {
  background: var(--negative-surface);
  color: var(--negative-text);
  border-left: 3px solid var(--negative);
}
/* NOTHING IS DISTINGUISHED BY HUE ALONE — the rule at the top of this file.
   A glyph as well as the colour, so the difference between "got your file"
   and "that did not work" survives greyscale, a dichromat and a stage wash. */
.import-chosen::before { content: '\2713'; flex: none; font-weight: var(--weight-strong); }
.import-problem::before { content: '\26A0'; flex: none; font-weight: var(--weight-strong); }

/* ===========================================================================
   IMPORT YOUR SONGS: TWO ROUTES, TWO CARDS.

   What this replaces, measured off the rendered screen at 1440: a drop zone in
   a left column ending at y=207, a text box in a right column starting at
   y=207, an AI warning floating at the top of the right column with 59px of
   nothing between it and the box it was about, and the words "or paste it in"
   at y=125 in the gutter — level with neither. More than half the left column
   was empty. Every element was technically placed and nothing was composed.

   The fix is not alignment, it is grouping. Each route is a card holding
   everything that route needs: what it is called, what it takes, the control,
   and the button that starts it. Two complete things with a divider between
   them, rather than six loose elements in two columns.
   ======================================================================== */

/* THE QUERY BELOW HAD NO CONTAINER OF ITS OWN, WHICH IS WHY IT FIRED EARLY.
   Its comment says "720px of drawer, not a window width", and that was the
   intention — but nothing between .import-routes and the panel carried
   `container-type`, so the nearest container was section[data-view] and the
   query was measuring the whole view. The review measured the consequence: two
   columns began at an 820px WINDOW with 326px cards, barely above the 300px a
   previous round had already condemned, with "or a photo / of your list"
   orphaning a word and the paste footnote running to four lines against the
   file card's three right across the 820-1260 band.
   This makes the code do what its comment always claimed. */
#routePaste { container-type: inline-size; }

.import-routes {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
  margin-top: 12px;
}

/* ================= THE UPLOAD CARD GOES QUIET ONCE IT HAS DONE ITS JOB =====
 *
 * "After a file is read the page does not move: the first thing you see is the
 *  upload card, still green, with 'Choose a file' as the largest filled button
 *  on screen — the control that would restart what you just started."
 *
 * Two faults in one: the page did not go to the review (fixed with
 * bringIntoView in js/views/songs.js), and even once it does, the two upload
 * cards sit above it as a live invitation to begin again. They fold away while
 * a review is open, and one quiet line says which file is on the screen.
 *
 * NOT REMOVED FROM THE DOM: #importFile is the input the drop handler and every
 * test drive the import through, and #pasteText holds the pasted list. They are
 * hidden, not destroyed, and the way back is Discard — which is on every step
 * of the review. */
.import-drawer.is-reviewing .import-routes,
.import-drawer.is-reviewing > .songs-hint { display: none; }
/* AND THE RULE THAT SEPARATED THE REVIEW FROM THEM GOES WITH THEM. .songs-review
   carries a border-top, 14px of margin and 12px of padding to divide it from the
   two upload cards — and with those folded away it opened the card with 26px of
   empty band and a full-width line above "Step 1 of 5", dividing nothing from
   nothing, at all four widths. */
.import-drawer.is-reviewing .songs-review {
  border-top: 0;
  margin-top: 0;
  padding-top: 0;
}
/* The file's name is on the front of .review-tally now: a box of its own above
   the tally said "flow.csv" twice in adjacent sentences and cost 77px of a
   phone screen on every step. */

.import-card {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  min-width: 0;
}
.import-card-head {
  margin: 0;
  font-size: var(--db-t-body);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}
.import-card .import-drop {
  /* THE CONTROLS MATCH AT EVERY WIDTH, not only where the cards sit side by
     side. This lived inside the container query, so below 720px of drawer the
     file card's control collapsed to 64px against the textarea's 184px — a
     9.6:1 dashed strip with three words in it, which reads as a divider that
     went wrong rather than as a place to drop a file. The "two cards, same
     slots" claim was true of the markup and false of the screen at exactly the
     widths where the cards are stacked one above the other and easiest to
     compare. */
  min-height: 184px;
}
/* AND IT IS NOT 184px ON A DEVICE THAT CANNOT DROP A FILE.
   The rule above was right about matching the textarea and wrong to apply it
   everywhere: the review measured a 616x184 empty box on a tablet and 239x184
   on a phone — the largest element in the card, dashed, containing one line of
   text, on devices where dropping is not a gesture that exists. A 64px strip
   read as a divider that went wrong; this reads as a control that failed to
   load. On a coarse pointer it is a tap target, and a tap target is sized like
   one. */
@media (pointer: coarse) {
  .import-card .import-drop { min-height: 88px; }
}
/* IT SHOWS THAT IT TOOK THE FILE. It was identical at rest, after a successful
   read, and directly above "That file could not be read." — a control that had
   accepted something and gave no sign of it, with the only feedback in a
   separate slot below. The class is set alongside #importChosen, so the two
   cannot disagree. */
.import-drop.is-chosen {
  border-style: solid;
  border-color: var(--positive);
  background: var(--positive-surface);
}
.import-drop.is-chosen .import-drop-note,
.import-drop.is-chosen .import-drop-touch { color: var(--positive-text); }
/* AND THE FOOTNOTES TAKE THE SAME ROOM. They are the only slot whose height
   depends on how the words happen to wrap — two lines in one card, three or
   four in the other — and everything below them drifts by the difference,
   which is why the two buttons were never level. Measured before this: the
   pair was 13px apart at 1194 and 32px apart at 900, sign flipping across
   widths. Three lines of hint text, so the common cases match exactly. */
.import-card .import-card-ai {
  min-height: calc(3lh);
}
.import-card-note {
  margin: 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  line-height: var(--line-height);
}
/* The AI sentence sits at the FOOT of its card, under the control it describes
   — it is what happens when you press the button, so it belongs beside the
   button and not above the thing you are filling in. `margin-top: auto` pins
   it there however tall the card's control grows. */
/* THE AI SENTENCE IS A FACT, NOT A WARNING, and inside a card it has to stop
   dressing like one. `.songs-ai` is a --notice panel with an amber edge, which
   was right when it was one flagged line on an otherwise plain screen. Two of
   them, one per card, turned a screen where nothing is wrong into a screen
   with two warnings on it. Same words, same place, no alarm: a rule above it
   and quiet text, which is what a footnote looks like. */
.import-card .import-card-ai {
  margin: 0;
  margin-top: auto;
  /* NO RULE. It had a border-top, which landed 8px under the drop zone's own
     dashed edge at the same width — two hairlines a hair apart, which reads as
     a border somebody forgot to delete rather than as a footnote. Muted colour
     and space is what a footnote is made of. */
  padding: 10px 0 0;
  border-left: 0;
  border-top: 0;
  border-radius: 0;
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  /* No prose cap here, and the selector carries .import-card so it can win.
     The readable-measure rule further down this file names .songs-ai at the
     same specificity and comes later, so a bare .import-card-ai lost and the
     footnote's rule stopped 60px short of its own card edge — which reads as a
     broken border rather than as a measure. A footnote is sized by the card it
     is in. */
  max-width: none;
}
/* The stubbed-parser banner still shouts, because that one IS a warning: it
   says the list was split by a development stub rather than read by a model. */
.import-card-ai.is-stubbed {
  padding: 9px 12px;
  border-top: 0;
  border-left: 3px solid var(--negative);
  border-radius: var(--radius);
  background: var(--negative-surface);
  color: var(--negative-text);
}
.import-card .songs-drawer-actions { margin-top: 0; }
.import-card #pasteText { width: 100%; }

/* THE DIVIDER IS A DIVIDER. It was a paragraph with a rule on each side, which
   is right when it sits between two stacked blocks and wrong when it is beside
   them. It is now the middle track of the grid: a rule down the gap at wide
   widths, a rule across at narrow ones, with the word sitting on it. */
.import-or {
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  text-transform: lowercase;
}
.import-or::before,
.import-or::after {
  content: '';
  flex: 1;
  background: var(--border);
  height: 1px;
}

/* ---- side by side, once there is genuinely room for two columns ----------
   720px of drawer, not a window width: this drawer sits in a panel inside a
   grid, so the only number that means anything is how much room the drawer
   HAS.
   540 was the first try and it was too eager. Two columns began at about a
   640px window with 244px cards, and at 768 they were 300px — every phrase
   breaking mid-sentence, "or a photo / of your list", footnotes running to
   four lines. Stacked at 768 each card is 644px and nothing wraps at all. The
   review called 768 the worst width on the screen and it was right: a
   two-column layout that has to be squeezed is worse than one column. */
/* AND THE NUMBER GOES UP, now that it means what it says. 720px of drawer is
   about 340px per card, which is the crowding this was supposed to avoid — the
   figure was chosen back when it was accidentally measuring a much larger box,
   so it was never really tested at 720. 940 gives roughly 440px a card, which
   is where the file card's own sentence stops breaking mid-phrase. Below it,
   stacked, each card gets the full drawer and nothing wraps at all. */
@container (min-width: 940px) {
  .import-routes {
    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
    gap: 16px;
  }
  /* EACH CARD IS AS TALL AS WHAT IS IN IT, and the two controls are sized to
     each other so that comes out close anyway.

     Two earlier attempts at this were worse. Stretching the DROP ZONE to fill
     the card grew a dashed box to 250px to hold one button, which made the
     emptiness the screen was rejected for taller. Forcing the CARDS to equal
     height instead moved the same slack to a single 85px gap above the
     footnote, which reads as a hole rather than as spacing. Neither is fixed
     by adjusting the number: a card padded out to match its neighbour looks
     padded out. A card snug around its contents does not, even when it is
     shorter — the border is what makes it read as one complete thing, not the
     height. The divider still spans the taller of the two, so the pair reads
     as a fork rather than as two unrelated boxes. */
  .import-routes { align-items: start; }

  /* THE DIVIDER STOPS SPANNING, AND THAT IS THE WHOLE FIX FOR THE VOID.
   *
   * It was `align-self: stretch` with `flex: 1` rules, so the rule ran the full
   * height of the grid row — which is the height of the TALLER card. That is
   * fine in the resting state, where the two are within 6px of each other, and
   * wrong in every state anybody actually reaches. Measured by the review:
   *
   *     state              1440     1194
   *     rest               6.5px    3.5px
   *     empty submit       49px     59px
   *     bad file           62px     52px
   *     file read OK       62px     71.5px      <- the success path is worst
   *
   * The overhang is a rule ending in mid-air beside nothing, bounded by a card
   * border on one side — and this file already records the finding that such a
   * void is MORE conspicuous than the plain gap it replaced. The cause is that
   * the two cards have asymmetric message slots (#importChosen and
   * #importProblem in one, #pasteProblem and #pasteAllowance in the other) and
   * none of them reserves height, so whichever card is speaking grows.
   *
   * Reserving height in both cards would mean permanent empty boxes. Forcing
   * equal card heights was tried and moved the slack to an 85px hole above a
   * footnote. So: the divider is a fixed, centred mark rather than a span. It
   * still reads as a fork — a word with a stroke above and below it, on the
   * axis between the two cards — and it cannot hang into anything, because it
   * never claims to reach either end. The cards are free to be different
   * heights, which is what they honestly are. */
  .import-or {
    flex-direction: column;
    align-self: center;
    gap: 10px;
  }
  .import-or::before,
  .import-or::after {
    width: 1px;
    height: 32px;
    flex: none;
  }
}

/* ---- which chart link, when the row has a choice to offer ----------------
 *
 * Both of these are rare on any given row and must stay quiet: a hundred-line
 * import that raises a hundred questions is worse than one that picks. They
 * are small, they sit under the title, and they carry the answer that doing
 * nothing would give. */
/* A LIST, NOT A DROPDOWN. Two chords transcriptions of one song both showed
   as "Chords", so the control offered a choice between two identical words —
   and a <select> cannot be opened in a new tab, so neither could be looked at
   before choosing. Each version is a row now: a radio, its kind and tab
   number, and the URL as a real link. */
/* ================= THE CHART-LINK ROW IS THE RADIO GROUP =================
 *
 * "Row 1 has EIGHT interactive elements and is 306-346px tall against 57-58px
 *  on the step beside it."
 *
 * Three of the eight were no-match apparatus that had no business on this step
 * and are gone from the markup (see reviewRow). Of the five that were left,
 * three were "open in a new tab" anchors — one per option, beside a radio that
 * already named the option. One anchor per ROW now, on the title line, pointed
 * at whichever version is currently chosen: no extra height, and it follows the
 * choice instead of asking you to find the right one of three.
 *
 * The options themselves are a wrapping row of chips rather than a stacked
 * list, which is what takes the row from 306px to about 100. A chip is 44px
 * under a coarse pointer, because a radio is the only control on this step and
 * a 16px one on a phone is the fault this whole pass is about. */
/* THE TITLE AND THE CREDIT ON ONE LINE. Two grid rows on a chart-link row is
   38px of a 120px budget spent saying one thing, and this step's rows are the
   ones that had none to spare. */
.review-line {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 10px;
  min-width: 0;
}

/* FINDING 4 — THE CHART-LINK ROW AGAINST ITS NEIGHBOURS.
   Measured at 375 before this: the row 190px against 63px on the step beside
   it, made of a 62px title line and a 106px chooser holding two 232x44 pills
   that stack because neither fits beside the other in a 217px column.
   What is trimmed here is the space between and around them. What is NOT
   trimmed is the pills' 44px height or their labels: the height is the
   coarse-pointer target every other control on this screen was raised to, and
   the labels are the only thing telling two chords transcriptions apart. A row
   holding a three-way choice costs more than a row holding one checkbox, and
   the remaining difference is that, not slack. */
.review-links {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 8px;
  margin-top: 4px;
}
.review-links-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.review-links-row { display: flex; min-width: 0; }
.review-links-pick {
  display: flex;
  align-items: center;
  gap: 6px;
  min-height: 32px;
  padding: 3px 10px 3px 8px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  cursor: pointer;
  font-size: var(--db-t-hint);
}
/* The chosen one is told apart by its border and its ground as well as by the
   dot, because a 16px radio dot is not a state anybody reads at arm's length. */
.review-links-pick:has(input:checked) {
  border-color: var(--action);
  background: var(--action-quiet);
}
.review-links-pick:has(input:focus-visible) { outline: 3px solid var(--focus-ring); }
.review-links-pick input { width: 16px; height: 16px; flex: none; accent-color: var(--action); }
.review-links-kind { white-space: nowrap; font-weight: var(--weight-medium); }
/* The tab number is what distinguishes two links of the same kind, so it is
   the one part of the URL that is never abbreviated away. */
.review-links-tab { color: var(--text-secondary); font-variant-numeric: tabular-nums; }
/* 44px TALL AND NO WIDER THAN IT HAS TO BE. */
@media (pointer: coarse) {
  .review-links-pick { min-height: 44px; padding-inline: 10px 12px; }
}
/* AND ON A PHONE IT IS A LIST, NOT A RAGGED WRAP. Real Ultimate Guitar labels
   with 7-digit tab ids measure 132-185px; the column is 267px, so two of them
   share a line only by accident and the group came out as a stack of chips of
   six different widths with random gaps at the ends. Below 480 the options are
   full-width rows with their left edges aligned, which is the same height and
   reads as one control instead of six. */
@media (max-width: 480px) {
  .review-links-list { flex: 1 0 100%; flex-direction: column; align-items: stretch; }
  .review-links-row, .review-links-pick { width: 100%; }
}
/* ONE PER ROW, AT THE END OF THE CHIPS, so it shares their last line whenever
   there is room and costs the row no height of its own. */
.review-links-open {
  flex: none;
  font-size: var(--db-t-hint);
  color: var(--action-on-surface);
  white-space: nowrap;
  text-decoration: underline;
}
.review-links-open:focus-visible { outline: 3px solid var(--focus-ring); }
/* 33x20px, and it is the only way to find out which chart a chip means, on the
   step whose chips were deliberately grown to 44px for exactly that reason.
   Grown by padding with the block padding taken back as negative margin, so the
   title line it sits on does not get taller. */
@media (pointer: coarse) {
  .review-links-open {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    padding: 12px 6px;
    margin-block: -12px;
  }
}

/* The separate "replace the link" tick is gone; picking one of the file's
   versions IS the instruction to replace. Its rules went with it. */

/* ---- finding a song the parser could not match --------------------------
 *
 * Sits inside a .review-row that is already a flex row, so it takes the whole
 * width under the title rather than trying to share the line with it. */
.review-find {
  display: grid;
  gap: 6px;
  margin-top: 8px;
  width: 100%;
  min-width: 0;
}
.review-find-label {
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
.review-find-box {
  width: 100%;
  min-width: 0;
  height: var(--db-field-sm);
  padding: 0 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  font-size: var(--db-t-body);
}
.review-find-busy,
.review-find-none {
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}

/* NO SCROLLER HERE EITHER, AND THIS ONE WAS THE SAME FAULT WEARING A HAT.
 *
 * `max-height: 15rem; overflow-y: auto` reproduced .songs-bucket-list's crime
 * exactly, one search box lower: measured, a 240px window over 337px of results
 * inside a 4147px document, the moment anybody used the search. The sixth
 * result was sliced horizontally through its own text, with no scrollbar drawn
 * and no fade to say there was more.
 *
 * The list is capped by RESULT COUNT instead — search_catalogue is called with
 * max_rows: 6 — so it can never be long enough to need clipping, and the page
 * is the only thing that scrolls. */
.review-find-list {
  display: grid;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
/* A WHOLE ROW IS THE TARGET. Same shape as .cat-row and .book-pick-row, which
   are the other two "tap a result to take it" lists in this app — a person
   who has used one has used all three. */
.review-find-pick {
  display: grid;
  gap: 1px;
  width: 100%;
  min-height: var(--db-field-sm);
  padding: 7px 12px;
  text-align: left;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  font-size: var(--db-t-body);
  cursor: pointer;
}
.review-find-pick:hover,
.review-find-pick:focus-visible { border-color: var(--action); }
/* A RESULT THAT WOULD ADD NOTHING, said on the result rather than found out
   afterwards from a number in the confirmation. */
.review-find-pick.is-held { border-style: dashed; }
.review-find-have {
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}

/* Two columns of results once there is room, because this list sits inside a
   panel that is 1120px wide on a desktop and a single column of 40px rows
   down the left of it is the fault this whole pass is about. */
@media (min-width: 900px) {
  .review-find-list { grid-template-columns: 1fr 1fr; }
}

/* ===========================================================================
   THE CHART LINK ON A REPERTOIRE ROW.

   The live card's version of this control lives in css/shell.css beside the
   rest of Gig Mode, with the reasoning for its size and position. Everything
   that was briefly duplicated here has been removed: dashboard.css loads after
   shell.css, so a second opinion about .gm-chart in this file silently wins,
   and the copy here was the wrong one — it put the control in the names line,
   which wraps on a 375px phone and cost the queue a card.
   ========================================================================= */

.song-chart {
  flex: 0 0 auto; align-self: center;
  padding: 6px 10px; margin-right: 4px;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: transparent; color: var(--text-secondary);
  font-size: var(--db-t-small); text-decoration: none; white-space: nowrap;
}
.song-chart.has-link { border-color: var(--action); color: var(--action-on-surface); }
.song-chart:hover { background: var(--action-quiet); }
.song-chart:focus-visible { outline: 3px solid var(--focus-ring); }

/* The chart field in the row sheet. */
.song-chart-label {
  display: block; margin-bottom: 6px;
  font-size: var(--db-t-hint); color: var(--text-secondary);
}
/* The search link sits UNDER the field it feeds, not beside it: the order on
   screen is the order of the job — go and find it, come back, paste, save. */
.song-chart-find {
  display: inline-block;
  margin-top: 8px;
  font-size: var(--db-t-hint);
  color: var(--action-on-surface);
}
.song-chart-find:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.song-chart-edit { display: flex; gap: 8px; }
.song-chart-edit input {
  flex: 1 1 auto; min-width: 0; height: var(--db-field);
  padding: 0 12px; font-size: var(--db-t-body);
  border: 1px solid var(--border-strong); border-radius: var(--radius);
  background: var(--surface-sunken); color: var(--text-primary);
}
.song-chart-edit .song-act { flex: 0 0 auto; width: auto; padding: 0 16px; }


/* ===========================================================================
   FIVE FAULTS FROM THE INDEPENDENT REVIEW PASS.

   The reviewer did not write any of this and was told not to be generous. All
   five are its findings, measured off the rendered page rather than described.
   ========================================================================= */

/* 1. "NOT SURE WHAT TO SAY?" WAS NOT A CONTROL. Measured on the AI brief
      screen: padding 0, border 0, background transparent, 16px, contrast
      13.5:1 — identical to body text, and LARGER than the real field labels
      beside it. It is a <summary>, it is first in the modal's tab order, and
      it had no CSS rule anywhere. This is precisely "a control that could be
      mistaken for text". */
.sb-hints { margin: 0 0 10px; }
.sb-hints-summary {
  display: inline-flex; align-items: center; gap: 8px;
  min-height: 40px; padding: 8px 14px;
  border: 1px solid var(--border-strong); border-radius: var(--radius);
  background: var(--surface-sunken);
  color: var(--action-on-surface);
  font-size: var(--db-t-body); font-weight: var(--weight-medium);
  cursor: pointer; list-style: none;
}
.sb-hints-summary::-webkit-details-marker { display: none; }
.sb-hints-summary::before {
  content: '▸'; font-size: .9em; transition: transform .15s ease;
}
.sb-hints[open] > .sb-hints-summary::before { transform: rotate(90deg); }
.sb-hints-summary:hover { border-color: var(--action); }
.sb-hints-summary:focus-visible { outline: 3px solid var(--focus-ring); }

/* 2. AND OPENING IT PUT THE PRIMARY ACTION BELOW THE FOLD. "Build the book"
      measured 63px past a 900px viewport on desktop and 213px past an 844px
      one on a phone. The modal body does scroll, but there is no affordance,
      so it reads as if the buttons vanished. Sticky, on the body's own
      scroller — NOT a second scroller inside the first, which is the fault
      that was just removed from the review screen. */
.sb-actions {
  position: sticky; bottom: 0; z-index: 1;
  margin-top: 16px; padding: 12px 0 4px;
  border-top: 1px solid var(--border);
  background: var(--surface);
}

/* 3. THE EMPTY SONGBOOKS LIST HAD ITS BULLETS STRANDED. .songs-empty centres
      its text; the <ul> inside kept list-style-position: outside and a 20px
      indent, so each disc painted at the li's left edge while the words sat in
      the middle — measured at 84px adrift on a phone and 209px on desktop.
      Five markers floating in space, on the first screen somebody sees. */
.songs-empty ul {
  list-style-position: inside;
  padding-left: 0;
  margin-inline: auto;
  max-width: 42ch;
  text-align: left;
}

/* 4. THE DATE AXIS PRINTED ON TOP OF ITSELF at 390px: "17 Jul" and "20 Jul"
      overlapped by 22px of a 33px label, rendering as "1720JuJul". The labels
      are absolutely positioned with no minimum spacing, so a narrow plot
      collides them. Below 480px only the first and last are kept — they are
      the two that carry the range, they are the two that already align to an
      edge rather than to a centre, and two readable dates beat five unreadable
      ones. */
@media (max-width: 480px) {
  .chart-x > span:not(.is-first):not(.is-last) { display: none; }
}

/* A BUCKET WITH NO DECISION IN IT, closed to a single line.
   On a re-import "Already in your list" is the biggest group and the least
   interesting — a scrolling list of songs the musician already has buries the
   twenty that need looking at. It opens if they want to check. */
.songs-bucket-quiet > .songs-bucket-head {
  cursor: pointer;
  list-style: none;
  display: flex; align-items: center; gap: 8px;
}
.songs-bucket-quiet > .songs-bucket-head::-webkit-details-marker { display: none; }
.songs-bucket-quiet > .songs-bucket-head::before {
  content: '▸'; font-size: .85em; color: var(--text-secondary);
  transition: transform .15s ease;
}
.songs-bucket-quiet[open] > .songs-bucket-head::before { transform: rotate(90deg); }
.songs-bucket-quiet > .songs-bucket-head:focus-visible { outline: 3px solid var(--focus-ring); }
@media (prefers-reduced-motion: reduce) {
  .songs-bucket-quiet > .songs-bucket-head::before { transition: none; }
}

/* REMOVING A SONG FROM A BOOK: the row leaves, an undo takes its place.
   The same movement Played and Skipped make in Gig Mode — the gap closes
   rather than snapping shut — and the same rule about motion: somebody who
   has asked for less of it still gets the removal and still gets the undo,
   they just do not get the travel. */
.book-songs .song-row.is-leaving {
  overflow: hidden;
  transition: height .2s ease, opacity .2s ease;
}
.book-undo {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 10px 12px; margin: 2px 0;
  border: 1px dashed var(--border-strong); border-radius: var(--radius);
  background: var(--surface-sunken);
  animation: book-undo-in .2s ease;
}
.book-undo-said { color: var(--text-secondary); font-size: var(--db-t-body); min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.book-undo-go {
  flex: 0 0 auto; min-height: 36px; padding: 6px 14px;
  border: 1px solid var(--action); border-radius: var(--radius);
  background: transparent; color: var(--action-on-surface);
  font-size: var(--db-t-body); font-weight: var(--weight-strong); cursor: pointer;
}
.book-undo-go:hover { background: var(--action-quiet); }
.book-undo-go:focus-visible { outline: 3px solid var(--focus-ring); }
.book-undo.is-going { opacity: 0; transition: opacity .2s ease; }
.book-songs .song-row.is-returning { animation: book-undo-in .2s ease; }
@keyframes book-undo-in { from { opacity: 0; } to { opacity: 1; } }

@media (prefers-reduced-motion: reduce) {
  .book-songs .song-row.is-leaving,
  .book-undo, .book-undo.is-going,
  .book-songs .song-row.is-returning { transition: none; animation: none; }
}

/* ===========================================================================
   THE SONGS PAGE ON A BIG SCREEN, RE-DECIDED.

   An independent review measured the previous attempt and the verdict was that
   it was "a phone layout that has learned two tricks". The measurements that
   matter, and what each one says:

     · At 1440 the catalogue results were 530px — 36.8% of the viewport — with
       19 of 30 titles wrapping in a 191px text box, and a 620x1000px hole
       beside them. At 1194 the SAME results were 874px and NOTHING wrapped.
       The desktop was worse than the tablet at the page's primary job, and the
       cause was the two-up rule: it split the row into equal halves, so the
       panel you actually work in got half a page while a segmented control
       with three buttons in it got the other half.
     · 1194 — the iPad on the mic stand — was gated out of the desktop layout
       entirely at min-width 1400, leaving three 914px full-bleed phone panels,
       1.82 screens tall, with four song rows visible.
     · Panels at 1194 and 1440 were 20% TIGHTER than the same panels on a
       phone, because a landscape-and-short rule dropped padding-block to .8.
       "Spacious" was not merely unachieved, it was inverted.

   So: the setup row is no longer two halves of anything. Every section on this
   page is full width at every desktop size, because every section is full-width
   work — a search and its results, a paste box, a list. What changes with the
   window is how many columns the LIST runs and how much air sits between the
   sections, and nothing else. The breakpoint is 1100, because the thing the old
   one was reasoning from (a button row's minimum width) is not a reason to
   leave a 1194px screen on the phone layout.
   ========================================================================= */

/* THERE IS NO RAIL. THE SECOND REVIEW KILLED IT, AND IT WAS RIGHT.
 *
 * The first attempt at this put "What people can request" in a 22rem rail with
 * the working panel beside it. Measured, that turned a 620x1000px hole into a
 * 420x970px hole: the rail panel is 177px tall and never grows, the panel
 * beside it is 750-970px tall, and the difference is empty. Nothing was found
 * to put in the column, because there is nothing to put there — so the column
 * was not made narrower, it was deleted.
 *
 * IT IS A SETTING, NOT A WORKSPACE. "What people can request" is two buttons
 * and a sentence, changed once and then left alone for months. A setting reads
 * as a bar across the top of the thing it governs; it does not read as a
 * sidebar, and it certainly does not earn a third of a 1440px window. As a bar
 * it is about 80px tall instead of 177, and the panel where the work actually
 * happens gets the whole 1160px rather than 776.
 *
 * AND THIS IS WHY THE SONGBOOKS TAB CAME BACK. The rule that made the rail was
 * `#songsView > .songs-pane:has(...):has(...) { display: grid }` — specificity
 * (1,3,0) — against `.songs-pane[hidden] { display: none }` at (0,2,0). The ID
 * won, `hidden` stopped hiding, and above 1360px clicking Songbooks showed you
 * My songs with the songbooks panel 1803px down the page. The documented trap
 * two hundred lines up in this very file ("any author rule with a display of
 * its own beats it") caught the author of the note. Adding :not([hidden]) would
 * have patched it; having no display rule to fight with is why it cannot come
 * back. */

/* AIR BETWEEN THE SECTIONS. One --db-pad from 390px to 1728px meant the gap
   between two panels was the same as the gap inside one, so the rhythm did not
   read at any width.

   ONE gap, not two. The rail version had grid `gap: 1.6x` AND this margin, so
   the measured space above "My songs" was 64px where the comment claimed 32.
   With the grid gone there is one rule left and it is this one. */
@media (min-width: 1100px) {
  #songsView > .songs-pane > .panel { margin-bottom: calc(var(--db-pad) * 1.6); }
  #songsView > .songs-pane > .panel:last-child { margin-bottom: 0; }

  /* The setting, as a bar: heading and control on one line, the note under it
     rather than squeezed beside it. */
  .panel:has(> .songs-mode-row) {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    column-gap: var(--db-pad);
    row-gap: 4px;
  }
  .panel:has(> .songs-mode-row) > h2 { margin: 0; }
  .songs-mode-row {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    column-gap: var(--db-pad);
    row-gap: 4px;
    margin: 0;
  }
  .songs-mode-note { margin: 0; }
}

/* AND THE PANELS GET THEIR HEIGHT BACK. The landscape-and-short rule was
   written for a phone held sideways and caught every laptop and the iPad with
   it. Restricted to screens that are genuinely short. */
@media (min-width: 1100px) and (orientation: landscape) and (max-height: 900px) {
  #songsView > .songs-pane > .panel { padding-block: var(--db-pad); }
}

/* ---- the controls that were still full-bleed ---------------------------- */

@media (min-width: 900px) {
  /* A single-line input does not get 1120px because it is in a wide panel.
     #bookFilter was the identical full-bleed field shell.css's own header
     claims to have fixed — the cap was scoped to .songs-primary and this field
     is not in it. */
  #bookFilter, #bookFilter.search-field, .book-open .search-field { max-width: 34rem; }
  /* 830px of flex child to hold "Tap songs to select them."

     And then the void moved rather than went: shrinking the count to its text
     left every control clumped in the left 530px with 588px of empty bar to
     the right of them. `margin-inline-end: auto` on the count is the whole
     fix — the count keeps the left edge, the actions take the right, and when
     the bar wraps on a phone an auto margin against a wrap is simply nothing. */
  #songSelectCount { flex: 0 1 auto; margin-inline-end: auto; }
  #songSelectBar { justify-content: flex-start; gap: 16px; }
}

/* ---- prose, measured rather than assumed -------------------------------- */

/* 68ch DID NOT MEAN 68 CHARACTERS. `ch` is the width of a zero, measured here
   at 8.22px against an average glyph of 6.52px, so `max-width: 68ch` was 559px
   and about 86 characters — 26% looser than it claimed. Stated in rem, which
   is a length rather than a guess about a typeface.

   27rem, not 32. The unit was right and the number was still wrong: 32rem is
   512px, and at the measured ~5.8px per glyph that is about 88 characters —
   the same line length, restated in a better unit. 27rem is 432px and lands at
   roughly 74, which is what the rule was always trying to say. Re-measured
   after the change rather than derived: longest rendered line 71 characters. */
.songs-hint, .songs-mode-note, .book-examples,
#pasteAi, .songs-ai, .import-drop-kinds, .sb-ai-note {
  max-width: 27rem;
}
/* ...EXCEPT INSIDE A CARD, and this needs the ID to win against the ID above.
   `.import-card .import-card-ai` is (0,2,0) and `#pasteAi` is (1,0,0), so the
   cap survived on ONE of the two twinned footnotes — 432px against its twin's
   492px, side by side, doing the same job. A footnote is sized by its card. */
.import-card #pasteAi.import-card-ai { max-width: none; }

/* ---- the list, for a mouse and a wide screen ---------------------------- */

/* HOW MANY COLUMNS, DECIDED BY THE ROW RATHER THAN BY THE WINDOW.
 *
 * Three columns at 1440 and four at 1700 were set from window widths, against
 * an asserted "titles need 380px" that nobody had measured. Measured now, over
 * all 324 titles in a real repertoire at the shipped type scale:
 *
 *     p50 112px   p75 155px   p90 200px   p95 241px   p98 367px   max 500px
 *
 * ...so 380 was roughly the p98, and sizing the common case to the rarest one
 * is how the list ended up with four columns whose titles wrapped MORE than
 * one column did. The review measured the result: a 179px title box at 1728
 * with 9 of 60 wrapping and 636px of dead air, because a grid row is as tall
 * as its tallest cell and one long title inflates the whole band across every
 * column.
 *
 * THE FURNITURE IS THE OTHER HALF OF IT, and it is why the old numbers stopped
 * being true. Each row now carries "Find chords" (90px) and the ⋯ button
 * (40px) with two 12px gaps: 154px that used to belong to the title. The
 * column count was never revisited when the chart link was added.
 *
 * So the minimum is a measurement plus that furniture: a 250px title box fits
 * 95.4% of real titles on one line, and every 160px past it buys three more
 * percentage points at the cost of a whole column. 25rem is 400px.
 *
 * auto-fill rather than a count, so the answer is right at widths nobody
 * tested. Measured after the change — the share is of ALL 324 titles against
 * the rendered title box, not of whichever twenty happen to be on page one:
 *
 *      390   1 col   156px box    75.6% on one line   (a phone; unavoidable)
 *      768   1 col   518px box   100%
 *      900   1 col   650px box   100%
 *     1024   2 col   258px box    95.4%
 *     1194   2 col   269px box    95.7%   <- the iPad
 *     1440   2 col   392px box    98.8%
 *     1728   3 col   298px box    95.7%
 *
 * The step from 900 to 1024 is where a second column starts fitting, and it is
 * abrupt because that is what a floor is. Below it one roomy column beats two
 * cramped ones, which is the whole principle of the rule; 24rem would buy the
 * 900px window a second column at a 236px box, and that is the shape being
 * fixed, one size lower down.
 *
 * `min(100%, 26rem)` and not a bare 26rem: a minmax floor larger than the
 * container produces a TRACK larger than the container, so a bare 26rem would
 * put a 416px column in a 358px phone and push the page sideways. min(100%)
 * makes the floor stand down when there is not room for it. */
#songList { grid-template-columns: repeat(auto-fill, minmax(min(100%, 25rem), 1fr)); }
@media (min-width: 1100px) and (pointer: fine) {
  /* 70px is a two-line thumb row. A mouse does not need it, and the shorter
     row is what turns 10 visible rows into 14. */
  #songList .song-row { padding-block: 7px; }
}

/* ===========================================================================
   TIPS  (/payments)

   Step one renders the Setup tab; the other three carry honest placeholders.
   Mobile first, because this is set up on a phone and read on a phone.

   Everything here sizes off the same tokens as the rest of the dashboard so
   the Stripe components, which inherit font family and background from their
   parent container, land on the app's own surface rather than a white slab.
   =========================================================================== */

.pay-wrap {
  max-width: 720px;
  margin: 0 auto;
  padding-bottom: var(--db-pad);
}

/* The notification banner sits above the tabs and only takes space when Stripe
   has actually put something in it. An empty bordered box above every tab
   would read as something broken. */
.pay-banner:not(:empty) { margin-bottom: 12px; }

/* The tab row is .seg-tabs, shared with the Songs page and defined once in
   css/shared.css. The bespoke underline tabs that used to live here are gone:
   they were a fourth way of drawing the same control. Only the spacing is
   local. */
.pay-wrap .seg-tabs { margin-bottom: var(--db-pad); }

.pay-block {
  background: var(--surface);
  border-radius: var(--radius-lg);
  padding: var(--db-pad);
  margin-bottom: var(--db-pad);
}
.pay-block h3 {
  margin: 0 0 8px;
  font-size: var(--db-t-head);
  color: var(--text-primary);
}

.pay-explainer,
.pay-note {
  margin: 0 0 12px;
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-secondary);
}
.pay-explainer {
  background: var(--surface-sunken);
  border-radius: var(--radius);
  padding: 12px 14px;
}
.pay-note-warn { color: var(--negative); }

/* THE WALLET WARNING, AND WHY IT IS NOT PAINTED RED.
 *
 * Nothing is broken. Card payments work, money can already reach the musician,
 * and the only thing missing is two extra buttons. A --negative panel would say
 * "your payments have failed" to somebody whose payments have not failed, and
 * the one thing worse than a musician not knowing about this is a musician
 * panicking about it mid gig.
 *
 * --notice is the token for "worth your attention, not your alarm", and the
 * tint is the same 12% blend the state nudge and duplicate hint already use, so
 * this reads as one more member of a family rather than a new kind of box. */
.pay-block-warn {
  border: 1px solid color-mix(in oklab, var(--notice) 40%, var(--border));
  background: color-mix(in oklab, var(--notice) 12%, var(--surface));
}
.pay-block-warn h2 { color: var(--notice-text); }

.pay-label {
  display: block;
  margin-bottom: 6px;
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  color: var(--text-primary);
}

.pay-input {
  width: 100%;
  box-sizing: border-box;
  min-height: 44px;
  padding: 10px 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-size: var(--db-t-body);
  /* Uppercased on screen because that is genuinely how it appears on a
     statement. The value is uppercased in js/stripe-descriptor.js as well, so
     this is presentation agreeing with data rather than doing the work. */
  text-transform: uppercase;
}

/* THE PREVIEW IS THE POINT. "22 characters" means nothing to anybody; a
   rendered line does. Monospace and letter-spaced so it reads as a machine
   printed it, which is what a bank statement looks like. */
.pay-preview {
  margin-top: 10px;
  padding: 12px 14px;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-sunken);
}
.pay-preview-caption {
  display: block;
  font-size: var(--db-t-small);
  color: var(--text-muted);
  margin-bottom: 4px;
}
.pay-preview-line {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--db-t-body);
  letter-spacing: 0.06em;
  color: var(--text-primary);
  /* A long descriptor must never push the page sideways. */
  overflow-wrap: anywhere;
}

/* The Stripe sign-in explanation. Sunken rather than tinted: it belongs to the
   panel it sits in and it is not an alert. See signInNote() for why that
   distinction is the whole point of the thing. */
/* Closed by default, so it costs one line on a screen that is already dense.
   Still sunken rather than tinted: it is a description of how Stripe works, not
   an alert, and a coloured panel would turn "this is normal" into "something is
   wrong". */
.pay-signin {
  margin: 0 0 14px;
  padding: 10px 14px;
  border-radius: var(--radius);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
}
.pay-signin-summary {
  /* 44px of target from the padding above and below, without a button's worth
     of visible chrome. */
  min-height: 24px;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  cursor: pointer;
  list-style-position: inside;
}
.pay-signin-summary:hover { color: var(--text-primary); }
.pay-signin[open] .pay-signin-summary {
  margin-bottom: 8px;
  color: var(--text-primary);
  font-weight: var(--weight-medium);
}
.pay-signin-body {
  margin: 0;
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-secondary);
}

.pay-hint {
  margin: 8px 0 0;
  font-size: var(--db-t-small);
  line-height: var(--line-height);
  color: var(--text-secondary);
  min-height: 1em;
}
.pay-hint.is-warn { color: var(--negative); }

.pay-action {
  margin-top: 14px;
  width: 100%;
  min-height: 48px;
}
/* THE DEFAULT STATE OF THIS BUTTON IS DISABLED, until the box is ticked, so it
   is the one most people read first. The app-wide treatment is opacity .5,
   which on this purple leaves near-black text at roughly 2.6:1 and is hard
   work under stage lighting. Given the label is the only thing explaining why
   nothing happens when it is pressed, it is worth staying legible: a flat
   surface with secondary text rather than a faded fill. */
.pay-action:disabled {
  opacity: 1;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  color: var(--text-secondary);
  cursor: not-allowed;
}

.pay-loading {
  margin: 0;
  padding: 18px 0;
  font-size: var(--db-t-hint);
  color: var(--text-muted);
}

/* THE STRIPE PANEL, DELIBERATELY LIGHT AND DELIBERATELY FRAMED.
 *
 * The components are forced to a light palette in js/views/payments.js, because
 * on a dark card Stripe's default dark-grey headings sat on our near-black and
 * a musician was entering bank details half blind. Stripe INHERITS the
 * background from this container, so this container has to be white or the
 * inheritance undoes the palette.
 *
 * A white card inside a dark app looks like a bug unless it is obviously on
 * purpose. So it gets a border, a radius and a caption naming Stripe above it:
 * it reads as a panel belonging to another product, which is what it is. Same
 * treatment in Dark, Light and Stage; there is no theme in which unreadable
 * money screens are the right trade. */
.pay-stripe {
  margin-top: 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: #ffffff;
}
.pay-stripe-cap {
  margin: 0;
  padding: 8px 14px;
  background: #eef0f3;
  border-bottom: 1px solid #d7d7d7;
  /* Stripe's own text colour, not a theme token: this strip sits on the light
     panel and must stay readable when the app around it is black. */
  color: #4b5563;
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.pay-stripe-surface {
  padding: 10px;
  background: #ffffff;
}

/* ON A PHONE, STRIPE'S PANEL BREAKS OUT OF THE CARD.
 *
 * MEASURED at a 375px viewport: Stripe's components were being handed 277px.
 * The budget was 375, less 36 of shell gutter, less 40 of .pay-block padding,
 * less 2 of border, less 20 of surface padding. Stripe's embedded components
 * lay themselves out for something nearer 320, so their own inputs overflowed
 * their container and `overflow: hidden` — which is there to keep the rounded
 * corners — CLIPPED the edges off. That is the cropping, and it is invisible as
 * a cause: nothing is broken, the fields are simply wider than the hole.
 *
 * TWO OPTIONS AND THIS IS THE ONE. The alternative was overflow-x: auto, which
 * turns somebody's identity form into a horizontally scrolling strip — worse
 * than clipping, because now they have to discover the scroll. Giving the panel
 * the card's full width is what Mark suggested and it is right: Stripe's
 * component is a guest with its own layout rules, and squeezing it inside our
 * padding is us imposing a constraint it does not honour.
 *
 * It recovers 60px, to 327. The panel goes edge to edge within the card, loses
 * its corners and side borders because a rounded box half-off the card reads as
 * a mistake, and keeps its caption so it still announces itself as Stripe's.
 *
 * overflow goes to visible here as well. With the radius gone there is nothing
 * left to clip FOR, and if Stripe ever needs more room than this the content
 * should spill visibly rather than disappear — a bug somebody can see is worth
 * more than one they cannot.
 *
 * 480px rather than 375: the squeeze is real on every phone, not only the
 * narrowest one, and there is nothing between 375 and 480 that wants the inset
 * treatment more than it wants readable fields. */
@media (max-width: 480px) {
  .pay-block > .pay-stripe {
    margin-left: calc(var(--db-pad) * -1);
    margin-right: calc(var(--db-pad) * -1);
    border-left: 0;
    border-right: 0;
    border-radius: 0;
    overflow: visible;
  }
  .pay-block > .pay-stripe > .pay-stripe-surface { padding: 10px 6px; }
}

/* ONE CONTINUOUS WAIT. Painted immediately and removed only when Stripe has
   drawn something of its own, so the panel never goes blank in between. */
.pay-skeleton { display: block; padding: 8px 4px 16px; }
.pay-skeleton-text {
  display: block;
  margin-bottom: 14px;
  color: #6b7280;
  font-size: var(--db-t-hint);
}
.pay-skeleton-bar {
  display: block;
  height: 14px;
  margin-bottom: 10px;
  border-radius: 7px;
  background: linear-gradient(90deg, #eceff3 25%, #f6f8fa 37%, #eceff3 63%);
  background-size: 400% 100%;
  animation: pay-shimmer 1.4s ease-in-out infinite;
}
.pay-skeleton-bar.short { width: 55%; }
@keyframes pay-shimmer {
  0% { background-position: 100% 0; }
  100% { background-position: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
  .pay-skeleton-bar { animation: none; }
}

/* The completion and under-review states. A tick and a heading, because this is
   the moment a musician finds out whether it worked. */
.pay-done { text-align: center; }
.pay-done-mark {
  margin: 0 0 6px;
  font-size: 34px;
  line-height: 1;
  color: var(--positive, #6fd08c);
}
.pay-done h2 { margin-bottom: 6px; }

.pay-list {
  margin: 0;
  padding-left: 20px;
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-secondary);
}
.pay-list li { margin-bottom: 8px; }

/* Stripe's components are block elements that take the full width of their
   parent and grow with their content. Nothing here constrains their height:
   an onboarding form clipped to a fixed box is an onboarding form that cannot
   be completed. */
#payOnboarding,
#payManagement,
#payBanner { width: 100%; }

@media (min-width: 768px) {
  .pay-action { width: auto; min-width: 220px; }
}

/* The permanence warning and its tick box. Weighted deliberately: this is the
   one irreversible decision in the app, and a hint-sized line under a button
   is how it got missed the first time. */
/* The permanence warning that used to live here is gone, and the reason is
   worth keeping: it claimed the bank statement name could never be changed, and
   Stripe's own account management form let a musician change it in the same
   session. There is nothing left to warn about, because there is nothing we can
   promise. See descriptorMarkup() in js/views/payments.js. */

.pay-confirm {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  margin-top: 14px;
  /* The whole row is the target, not the 16px box. One-handed, on a phone, in
     bad light. */
  min-height: 44px;
  padding: 10px 0;
  cursor: pointer;
  font-size: var(--db-t-body);
  line-height: var(--line-height);
  color: var(--text-primary);
}
.pay-confirm input {
  /* Bigger than a default checkbox for the same reason. */
  width: 22px;
  height: 22px;
  margin: 0;
  flex: 0 0 auto;
  accent-color: var(--action);
}

/* The four tip amounts on the profile page. Four across matches what the
   audience sees, so a musician editing them is looking at the same shape the
   room will be looking at. */
/* TWO BY TWO, NOT FOUR ACROSS. Four steppers in a row at 390px gives each one
   about 90px, and two 34px buttons leave 22px for the field, which is what the
   first version actually rendered: a clipped mess. The audience sees four
   across because their chips have no steppers on them. */
.tip-preset-row {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 8px;
  margin-bottom: 6px;
}
@media (min-width: 620px) {
  .tip-preset-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
/* SMALL, PLAIN, AND RIGHT BESIDE THE FIELD.
 *
 * The first version made these 34 by 44 pixel styled buttons in a two-by-two
 * grid, which is a lot of furniture for nudging a number by a pound. Mark's
 * verdict: far too big. They are now plain text targets tight against the
 * field, and the field itself is three digits wide rather than built for
 * 12345.67.
 *
 * All four fit on one row again because they are narrow now. */
.tip-preset-row {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 6px;
  margin-bottom: 6px;
}
.tip-preset {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2px;
}
.tip-preset-step {
  width: 20px;
  height: 44px;
  flex: 0 0 auto;
  border: 0;
  background: none;
  color: var(--text-secondary);
  font-size: 17px;
  line-height: 1;
  padding: 0;
}
.tip-preset-step:hover { color: var(--text-primary); }
.tip-preset-field {
  display: flex;
  flex: 1 1 auto;
  min-width: 0;
  align-items: center;
  padding: 0 2px;
  min-height: 40px;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.tip-preset input {
  width: 100%;
  min-width: 0;
  border: 0;
  background: none;
  color: var(--text-primary);
  font-size: 16px;
  padding: 0;
  text-align: center;
}

/* The worked example. Figures, not a paragraph about figures. */
.tip-worked {
  margin: 10px 0 4px;
  padding: 12px 14px;
  border-radius: var(--radius);
  background: color-mix(in oklab, var(--action) 8%, var(--surface));
}
.tip-worked-line { margin: 0 0 6px; font-size: 14px; line-height: 1.45; }
.tip-worked-line:last-of-type { margin-bottom: 0; }
.tip-worked-note {
  margin: 8px 0 0;
  font-size: 12px;
  line-height: 1.4;
  color: var(--text-secondary);
}

/* NOT SET UP. The card is inert and says so once. */
/* THE THIRD TIME THIS EXACT TRAP HAS SHIPPED, and the reason Mark kept being
   told his tips were not set up while fully onboarded. `hidden` is
   `[hidden] { display: none }` in the user agent stylesheet, at the lowest
   possible specificity, and a class setting `display: flex` beats it. The
   JavaScript was setting hidden="" correctly every time; the banner simply
   ignored it. Same as .tip-dialog eating every tap on the page, and
   .tip-pay-veil eating every tap on Apple Pay. */
.tip-editor-locked[hidden] { display: none; }
.tip-editor-locked {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  padding: 14px 16px;
  margin-bottom: 16px;
  border-radius: var(--radius);
  background: color-mix(in oklab, var(--notice) 12%, var(--surface));
  border: 1px solid color-mix(in oklab, var(--notice) 40%, var(--border));
}
.tip-editor-locked p { margin: 0; flex: 1 1 200px; color: var(--text-primary); }
/* THE WHOLE CARD IS OFF, not a notice sitting on a working form. Dimmed,
   inert, and nothing inside it can be reached by a pointer or by a tab. */
.tipcard-editor.is-locked #tipEditorBody {
  opacity: .4;
  pointer-events: none;
  filter: grayscale(.6);
}

/* THE SETUP STEP. At the top of the dashboard, and it stays until it is done.
   Not a banner, not a modal, and not dismissible: a musician without Stripe has
   the only revenue channel in the product switched off, usually by inertia. */
.setup-step {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  padding: var(--db-pad);
  margin-bottom: var(--db-pad);
  background: color-mix(in oklab, var(--action) 10%, var(--surface));
  border: 1px solid color-mix(in oklab, var(--action) 35%, var(--border));
  border-radius: var(--radius-lg);
}
.setup-step-text { flex: 1 1 220px; min-width: 0; }
.setup-step h2 { margin: 0 0 4px; font-size: var(--db-t-head); }
.setup-step p { margin: 0; color: var(--text-secondary); line-height: var(--line-height); }
/* ONE SHAPE FOR "SET UP TIPS", WHEREVER IT APPEARS.
 *
 * There are three of these — on the Dashboard, on the profile tip card, and
 * under Go Live — and each had invented its own metrics: 48px tall with 20px of
 * padding, 44px with 18px, and a bare underlined link in a sentence. Three
 * sizes for one action is why they read as bolted on rather than as part of the
 * app.
 *
 * The cause is structural rather than careless: .btn-primary and .btn-quiet in
 * css/shared.css set COLOUR ONLY, and every caller in the app supplies its own
 * height and padding. So there is no house size to inherit and each site
 * improvised one. This does not try to fix that everywhere — twenty-four call
 * sites is a separate job — it gives this one action a single definition and
 * uses it in all three places.
 *
 * 44px, because that is the floor this app uses everywhere a thumb has to find
 * something, and the same radius and weight as every other button so it sits in
 * the same family. The colour still comes from .btn-primary or .btn-quiet
 * alongside it, so the two on a card stay primary and the one inside a sentence
 * on the live screen stays quiet. */
.setup-cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  min-height: 44px;
  padding: 0 18px;
  border-radius: var(--radius);
  font-size: var(--db-t-body);
  font-weight: var(--weight-medium);
  text-decoration: none;
  white-space: nowrap;
}

/* One line under Go Live. Factual, not a warning: nothing is broken, a musician
   simply has not turned tips on, and they are about to play to a room that
   would otherwise have had the option. */
.gm-tips-off {
  margin: 12px 0 0;
  font-size: 14px;
  line-height: 1.5;
  color: var(--text-secondary);
  text-align: center;
}
.gm-tips-off a { color: var(--action-on-surface); }

/* THE AMOUNT BESIDE THE COIN, NOT INSIDE IT.
   At 16 to 18px the inside of the disc is about 11px across, which is not
   enough for "£5" let alone "£12.50" without the ring disappearing to make
   room. Measured on a phone and an iPad: it reads as a smudge on both. */
.gm-tipped { display: inline-flex; align-items: center; gap: 3px; }
.gm-tipped-amount {
  font-size: 12px;
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

/* ===========================================================================
   OVERVIEW AND THE TIP HISTORY

   Two screens with one job between them: Overview answers "how am I doing this
   month", the history answers "what happened to that £5". The visual difference
   is deliberate and it is the thing stopping them converging into the same
   screen twice: Overview is two large figures and no controls, the history is a
   list with a selector on top.

   Everything sizes off the dashboard tokens, so all seven themes get this for
   free rather than each needing a rule.
   =========================================================================== */

/* THE HEADLINE. One figure large enough to read across a table in a pub, and
   everything else quiet beside it. tabular-nums so that £312.50 and £330.90
   line their decimal points up instead of shimmering against each other. */
.tip-ov-period {
  margin: 0 0 10px;
  font-size: var(--db-t-head);
  color: var(--text-primary);
}
.tip-ov-label {
  margin: 0;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}
.tip-ov-big {
  margin: 2px 0 0;
  font-size: clamp(2.25rem, 12vw, 3rem);
  line-height: 1.05;
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
/* WHERE THE ONE FIGURE CAME FROM, 19 September 2026: tips and request sales
   are one total, and this is the breakdown under it. Small and secondary, in
   the quiet text colour, so it never competes with the figure it explains.
   Drawn only when some of the money came from requests. */
.tip-ov-split,
.tip-period-split,
.tip-totals-split {
  margin: 4px 0 0;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
.tip-totals-split { margin: 0 0 6px; }
.tips4w-split,
.vm-split {
  margin: 4px 0 0;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

.tip-ov-second {
  margin: 10px 0 0;
  font-size: var(--db-t-body);
  color: var(--text-secondary);
}
.tip-ov-second b {
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
/* The measured claim in automatic mode, and the plain difference line in
   selection mode. Same slot, same treatment, because they are the same sentence
   doing the same job for two different settings. */
.tip-ov-covered {
  margin: 12px 0 0;
  padding: 10px 12px;
  border-radius: var(--radius);
  background: var(--surface-sunken);
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-secondary);
}

.tip-ov-rate-line {
  margin: 0 0 4px;
  font-size: var(--db-t-body);
  color: var(--text-primary);
}
.tip-ov-rate .pay-note { margin-bottom: 0; }

.tip-ov-payout {
  margin: 0;
  font-size: var(--db-t-body);
  line-height: var(--line-height);
  color: var(--text-secondary);
}
.tip-ov-payout b {
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------------- the rows
 *
 * `tiph-` FOR THE HISTORY, AND THE PREFIX IS NOT DECORATION.
 *
 * These started life as .tip-row, .tip-row-sub and .tip-back, and css/tip.css
 * already owns all three: they are the AUDIENCE-facing tip control, a purple
 * gradient card. The dashboard loads that stylesheet, so every row in the
 * musician's tip history was painting itself as a giant tip button, with
 * secondary text at 1.15:1 on the action purple. It was found by measuring
 * rather than by looking, because on a dark theme a purple slab reads as a
 * deliberate highlight until you try to read the words on it.
 *
 * Anything new here that names a row, a back control or a subtitle takes the
 * tiph- prefix. `tip-ov-`, `tip-totals-` and `tip-period-` are safe: they exist
 * nowhere else.
 */

.tip-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.tiph-row {
  border-bottom: 1px solid var(--border);
}
.tiph-row:last-child { border-bottom: 0; }

/* The whole row is the target, not a chevron at the end of it. 44px minimum so
   it is reachable with a thumb on a phone held in one hand. */
.tiph-row-open,
.tiph-row-static {
  display: block;
  width: 100%;
  min-height: 44px;
  box-sizing: border-box;
  padding: 10px 0;
  border: 0;
  background: none;
  text-align: left;
  font: inherit;
  color: inherit;
}
.tiph-row-open { cursor: pointer; }
.tiph-row-open:hover .tiph-row-when { color: var(--text-primary); }

.tiph-row-main {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}
.tiph-row-when {
  font-size: var(--db-t-body);
  color: var(--text-secondary);
}
.tiph-row-amount {
  font-size: var(--db-t-head);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.tiph-row-amount.is-refunded {
  text-decoration: line-through;
  color: var(--text-muted);
}
.tiph-row-sub {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 2px;
  font-size: var(--db-t-small);
  color: var(--text-muted);
}
.tip-badge {
  padding: 1px 7px;
  border-radius: 999px;
  border: 1px solid var(--border-strong);
  background: var(--surface-sunken);
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  white-space: nowrap;
}
/* ONLY THE LIVE ONE IS LOUD. A refunded tip and a dispute already decided are
   both history; a dispute still running has a deadline attached to it and is
   the one thing in this list somebody has to act on. --notice rather than
   --negative, because nothing has gone wrong yet: it is the token for "worth
   your attention, not your alarm", the same one the wallet warning uses. */
.tip-badge.is-alarm {
  border-color: color-mix(in oklab, var(--notice) 55%, var(--border));
  background: color-mix(in oklab, var(--notice) 14%, var(--surface));
  color: var(--notice-text);
  font-weight: var(--weight-medium);
}
.tip-detail-dispute > span:last-child { color: var(--notice-text); }

/* THE OPENED TIP. The only place in the product where our fee sits beside
   Stripe's, which is why it is a small ledger rather than a paragraph: somebody
   opening this is checking arithmetic, and arithmetic reads in columns. */
/* IT GROWS WHERE IT STANDS. The height is animated from a measured pixel value
   and then handed back to the stylesheet, which is the only way to transition
   to auto; overflow:hidden is what makes the intermediate heights read as a
   panel unrolling rather than as text being clipped.

   The padding is on an inner wrapper's worth of margin instead of the box
   itself, because padding on a box whose height is being animated to 0 leaves a
   stubborn few pixels at the end of the collapse. */
.tip-detail {
  padding: 0;
  margin: 4px 0 0;
  overflow: hidden;
  transition: height .24s cubic-bezier(.4, 0, .2, 1);
}
.tip-detail > :last-child { margin-bottom: 14px; }

/* ASKED NOT TO BE MOVED ABOUT. The panel still opens and closes; it simply
   arrives at its final size immediately. js/views/payments.js checks the same
   query before it measures anything, so nothing is animated from script
   either. */
@media (prefers-reduced-motion: reduce) {
  .tip-detail { transition: none; }
}
.tip-detail-line {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 5px 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
.tip-detail-line > span:last-child {
  font-variant-numeric: tabular-nums;
  text-align: right;
  color: var(--text-primary);
}
.tip-detail-total {
  margin-top: 4px;
  padding-top: 9px;
  border-top: 1px solid var(--border);
  font-size: var(--db-t-body);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}
.tip-detail-total > span:last-child { font-weight: var(--weight-strong); }
.tip-detail .pay-note { margin: 8px 0 0; }

/* NONE OF THE btn-* CLASSES CARRY THEIR OWN SIZE. They set colour, border and
   nothing else, and every caller in the app supplies padding and height. The
   first version of this leant on a `.btn-secondary` that does not exist
   anywhere in the stylesheet, and measured 122x20: a twenty-pixel-high button
   for the one control on this screen that moves money. */
.tip-refund-btn,
.tip-seeall {
  min-height: 44px;
  padding: 10px 16px;
  border-radius: var(--radius);
}
.tip-refund-btn { margin-top: 12px; }
.tip-seeall { margin-top: 12px; width: 100%; }

/* --------------------------------------------------------- other periods */

.tip-periods {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--db-pad);
}
.tip-period-label {
  margin: 0 0 6px;
  font-size: var(--db-t-small);
  color: var(--text-muted);
}
.tip-period-figure {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  margin: 0 0 3px;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
.tip-period-figure b {
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
.tip-period-quiet { color: var(--text-muted); }
.tip-period-quiet span:last-child { font-variant-numeric: tabular-nums; }


/* ------------------------------------------------------------- the history */

/* A PUSHED VIEW, NOT A FIFTH TAB. Overview stays lit in the tab strip above and
   this replaces the panel beneath it, so the back control has to be obvious or
   there is no way out that looks like one. */
.tip-history-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: var(--db-pad);
}
.tip-history-head h2 {
  margin: 0;
  font-size: var(--db-t-head);
  color: var(--text-primary);
}
.tiph-back {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  min-height: 44px;
  padding: 0 10px 0 4px;
  border: 0;
  background: none;
  font: inherit;
  font-size: var(--db-t-hint);
  color: var(--action);
  cursor: pointer;
}
.tiph-back:hover { text-decoration: underline; }

/* NOT UPPERCASED. This borrows .pay-input for its box, and that class carries
   `text-transform: uppercase` because it was written for the bank statement
   descriptor, where the uppercase IS the data. Here it turned a plain menu into
   LAST FOUR WEEKS, which reads as shouting. */
.tip-range {
  margin-bottom: var(--db-pad);
  text-transform: none;
}

.tip-totals-line {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 5px 0;
  font-size: var(--db-t-body);
  color: var(--text-secondary);
}
.tip-totals-line > span:last-child {
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}
/* "You received" is the largest and comes first, because it is the number they
   came for. The other three are context to it. */
.tip-totals-big {
  font-size: var(--db-t-head);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}
.tip-totals-big > span:last-child { font-weight: var(--weight-strong); }
.tip-more { margin: 12px 0 0; }

/* THE DISPUTES HEADING SAT FLUSH ON THE LIST ABOVE IT. Every other card on this
   screen is separated by --db-pad, and this one had nothing: .pay-block carries
   a margin-BOTTOM only, and the tip list above it carries no margin at all, so
   the two boxes met edge to edge. Measured at 375px. */
.tip-stripe-head { margin-top: var(--db-pad); }

/* THE DISPUTES CARD IS ONE ELEMENT, NOT TWO STACKED.
 *
 * Our heading and explanation were a .pay-block, and Stripe's frame a separate
 * .pay-stripe underneath it, each with its own background, its own corners and
 * a gap down the middle. Two cards, reading as two unrelated things, when the
 * second one only makes sense as the answer to the first.
 *
 * THE WRAPPER OWNS THE EDGE. It takes the border and the radius that .pay-stripe
 * used to carry and both children give theirs up, so there is one outline rather
 * than a box inside a box. overflow: hidden is what clips their now-square
 * corners back to the wrapper's curve.
 *
 * THE COLOUR CHANGE IS DRAWN RATHER THAN LEFT TO HAPPEN. The header sits on
 * --surface and Stripe's panel is white, because Stripe's iframe is light
 * whatever the app around it is doing. That seam exists either way, so it gets a
 * deliberate 1px rule instead of being an accident of two backgrounds meeting.
 *
 * NOTHING HERE REACHES INSIDE THE IFRAME, and nothing could. This styles the box
 * we put Stripe's component in and stops at its edge. */
.pay-merge {
  margin-bottom: var(--db-pad);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--surface);
}
.pay-merge > .pay-merge-head {
  margin: 0;
  border-radius: 0;
}
/* The note carries a bottom margin for the stacked case. Inside the card that
   is dead space sitting above the divider. */
.pay-merge > .pay-merge-head > :last-child { margin-bottom: 0; }
.pay-merge > .pay-merge-body {
  margin: 0;
  border: 0;
  border-top: 1px solid var(--border);
  border-radius: 0;
}

/* MONEY OWED. The one place on this screen where --negative is right: every
   other warning in the app is "worth your attention" and takes --notice, and
   this is money that has gone the wrong way.

   A tinted panel with a red edge, not a red slab. The figure is the loud part
   and everything under it is ordinary readable text, because this is the block
   somebody has to actually read rather than glance at. */
.pay-owed {
  border: 1px solid color-mix(in oklab, var(--negative) 45%, var(--border));
  background: color-mix(in oklab, var(--negative) 10%, var(--surface));
}
.pay-owed h3 { color: var(--negative-text); }
.pay-owed-figure {
  margin: 2px 0 12px;
  font-size: clamp(2rem, 10vw, 2.6rem);
  line-height: 1.05;
  font-weight: var(--weight-strong);
  color: var(--negative-text);
  font-variant-numeric: tabular-nums;
}
.pay-owed-line {
  margin: 0 0 8px;
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-secondary);
}
.pay-owed-line:last-of-type { margin-bottom: 0; }
.pay-owed-see {
  margin-top: 14px;
  width: 100%;
  min-height: 44px;
  padding: 10px 16px;
  border-radius: var(--radius);
}

.tip-history .tip-list {
  background: var(--surface);
  border-radius: var(--radius-lg);
  padding: 4px var(--db-pad);
}

/* The refund facts, one per line, because they are four separate things a
   person has to take in and a paragraph would hide the third one. */
.tip-refund-facts {
  margin: 0 0 12px;
  padding-left: 20px;
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-secondary);
}
.tip-refund-facts li { margin-bottom: 6px; }

/* At 375px two columns of period figures is too tight for the labels, so they
   stack. Measured, not assumed. */
@media (max-width: 400px) {
  .tip-periods { grid-template-columns: 1fr; gap: 14px; }
}

/* ===========================================================================
   WAITING ON STRIPE

   Two states that used to be static text on a screen that had, in fact,
   stopped: the "Setting up" button, and the panel that sits there while Stripe
   decides. Both now show that something is happening, and both eventually offer
   a way out.
   =========================================================================== */

/* THE DOTS. Three, staggered, on the button that is waiting for account
   creation. aria-hidden in the markup, so a screen reader hears "Setting up"
   once rather than a label that mutates three times a second.

   Opacity rather than transform or scale: the button is a fixed size and
   anything that moves the dots moves the label with them. */
.btn-dots {
  display: inline-flex;
  gap: 3px;
  margin-left: 7px;
  align-items: center;
}
.btn-dots i {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: currentColor;
  opacity: .28;
  animation: pay-dot 1.1s ease-in-out infinite;
}
.btn-dots i:nth-child(2) { animation-delay: .18s; }
.btn-dots i:nth-child(3) { animation-delay: .36s; }

@keyframes pay-dot {
  0%, 70%, 100% { opacity: .28; }
  35%           { opacity: 1; }
}

/* ASKED NOT TO BE MOVED ABOUT. The dots hold at their dim state rather than
   vanishing: three faint dots after "Setting up" still read as a wait, where
   nothing at all reads as a button that has finished. */
@media (prefers-reduced-motion: reduce) {
  .btn-dots i { animation: none; }
}

/* THE SAFETY NET. It appears twenty seconds in, under whatever is already on
   screen, and it is deliberately the quietest thing in the panel: a fallback
   that looks like the main path is a fallback people take by default.

   No border, no tint, no icon. A sentence and a text button. */
.pay-settle-net {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px 10px;
  margin: 14px 0 0;
  padding-top: 12px;
  border-top: 1px solid var(--border);
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-muted);
}
.pay-settle-refresh {
  flex: 0 0 auto;
  min-height: 32px;
  padding: 5px 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--text-primary);
  font-size: var(--db-t-hint);
  font-weight: var(--weight-medium);
  cursor: pointer;
}
.pay-settle-refresh:hover:not(:disabled) { background: var(--surface-sunken); }
.pay-settle-refresh:disabled { opacity: 1; color: var(--text-muted); cursor: default; }

/* ---- the account number, at the top of Tips -> Documents ----
 *
 * MOVED OFF PROFILE. It was a bordered card beside the act name and the page
 * link, on the reasoning that a reference is identity rather than money. The
 * only place it is ever actually used is on a statement, so it now sits on the
 * screen the statements are on — as ONE LINE above them, not a card, because it
 * is a caption for what follows rather than an item in its own right.
 *
 * The value is tabular-nums and slightly tracked out, because this is a
 * reference somebody reads aloud down a phone or copies into an email, and
 * proportional digits in a short code are genuinely harder to transcribe. */
.pay-account {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  /* WRAPS RATHER THAN TRUNCATES. At 375px the label and the value together are
     close to the full width; if a longer entity name ever makes them wider, the
     value drops to its own line and stays readable, which a nowrap would not. */
  flex-wrap: wrap;
  margin: 0 0 var(--db-pad);
  padding: 0 2px;
}
.pay-account-label {
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
.pay-account-value {
  font-size: var(--db-t-mid);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  letter-spacing: .04em;
}

/* ==================================================== FEE STATEMENTS ==
 *
 * TWO SUPPLIERS ON ONE SCREEN. Our statements and Stripe's tax invoices sit in
 * blocks of the same shape on the Documents tab, so that a musician reads them
 * as two documents rather than as one real one and one odd one. Ours are these
 * classes; Stripe's are inside an iframe we do not style.
 */

.fs-list { display: grid; gap: 8px; margin-top: 12px; }

/* A DOCUMENT ROW, AND IT IS A BUTTON. Whole-row target, 60px tall, because this
   is opened with a thumb. */
.fs-item {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 60px;
  padding: 12px 14px;
  text-align: left;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text-primary);
  font: inherit;
  cursor: pointer;
}
.fs-item:hover { border-color: var(--border-strong); }
.fs-item-doc { flex: none; width: 22px; height: 22px; color: var(--text-secondary); }
.fs-item-doc svg { width: 100%; height: 100%; }
.fs-item-text { flex: 1 1 auto; min-width: 0; }
.fs-item-title {
  display: block;
  font-size: var(--db-t-mid);
  font-weight: var(--weight-medium);
}
.fs-item-ref {
  display: block;
  margin-top: 2px;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
.fs-item-figure { flex: none; text-align: right; }
.fs-item-label {
  display: block;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}
.fs-item-amount {
  display: block;
  font-size: var(--db-t-mid);
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
}

/* ---- the statement itself ----
 *
 * IT LOOKS LIKE THE PAPER, NOT LIKE THE APP, AND THAT IS DELIBERATE. This is
 * the one surface in the product that is a facsimile of something that gets
 * printed and sent to an accountant, so it keeps the sheet's own proportions
 * and its own restraint. It still takes its colours from the theme — a white
 * rectangle dropped into Dark is a torch in a dark room, and the person reading
 * this is often doing it in bed after a gig. */
.fs-block { padding-top: 8px; }

.fs-sheet {
  padding: clamp(18px, 5vw, 32px);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.fs-entity {
  font-size: clamp(20px, 5vw, 26px);
  font-weight: var(--weight-strong);
  letter-spacing: -0.01em;
}
.fs-meta {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-top: 6px;
}
.fs-kind, .fs-period { font-size: var(--db-t-body); color: var(--text-secondary); }
.fs-ref {
  font-size: var(--db-t-body);
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
  letter-spacing: .03em;
  text-align: right;
  /* The reference is the thing somebody copies into an email. It must not wrap
     in the middle on a 375px screen. */
  white-space: nowrap;
}
.fs-who {
  margin: 26px 0 0;
  font-size: clamp(16px, 4vw, 19px);
  font-weight: var(--weight-strong);
}

.fs-figures { margin-top: 22px; }
.fs-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding: 5px 0;
  font-size: var(--db-t-mid);
}
.fs-label { min-width: 0; }
/* TABULAR FIGURES, ALWAYS. This is a column somebody adds up with their eye,
   and proportional digits put the decimal points in different places. */
.fs-amount { flex: none; font-variant-numeric: tabular-nums; white-space: nowrap; }
.fs-total { font-weight: var(--weight-strong); }
.fs-result {
  margin-top: 4px;
  font-size: clamp(17px, 4.4vw, 20px);
  font-weight: var(--weight-strong);
}
/* OVER THE FIGURES ONLY, never the full width. A rule under the label as well
   says the label is being totalled. */
.fs-rule {
  height: 1px;
  margin: 6px 0 6px auto;
  width: min(180px, 55%);
  background: var(--border-strong);
}
.fs-gap { height: 18px; }

.fs-note {
  margin: 26px 0 0;
  font-size: var(--db-t-hint);
  line-height: var(--line-height);
  color: var(--text-secondary);
}

/* "NOTHING TO PAY" IS THE ANSWER TO THE ONLY QUESTION THIS DOCUMENT RELIABLY
   PROVOKES, so it is not small print. It was a plain paragraph under two other
   plain paragraphs first, which is exactly where a reader's eye does not go on
   something shaped like an invoice. */
.fs-nothing {
  margin-top: 18px;
  padding: 14px 16px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.fs-deducted { margin: 0; font-size: var(--db-t-hint); color: var(--text-secondary); }
.fs-pay {
  margin: 6px 0 0;
  font-size: clamp(15px, 3.8vw, 17px);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}

/* THE ADDRESS IS TINY AND AT THE FOOT, ON PURPOSE. It has to be on the document
   and it has no business competing with the figures. */
.fs-foot {
  margin-top: 26px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}

.fs-actions { margin-top: 16px; }
.fs-download-note { margin: 8px 0 0; }

/* ---- the eye on a Most Requested row ----
 *
 * TAKES A SONG OUT OF THE LIST THE AUDIENCE SEES, and nothing else. The row
 * stays, the count stays, the song stays searchable and requestable. So the
 * hidden state is a DIMMING and a word, never a strikethrough: a line through a
 * title says deleted, and this is the opposite of deleted.
 */
.eye {
  flex: none;
  /* CENTRED IN THE ROW, NOT ON ITS BASELINE. .drow-nums is align-items:
     baseline, which is right for a number beside its unit and wrong for a
     44px square button: it hung the button below the row and pushed the row
     taller. */
  align-self: center;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin: -10px 0 -10px 2px;
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  background: none;
  color: var(--text-secondary);
  cursor: pointer;
}
.eye svg { width: 20px; height: 20px; }
.eye:hover { background: var(--surface-sunken); color: var(--text-primary); }
.eye.is-off { color: var(--text-primary); }

/* The row itself, once hidden. Dimmed enough to be told apart down a list and
   not so far that the count stops being readable — the figures are the reason
   the card exists and they are still true. */
.drow.is-hidden-song .drow-song,
.drow.is-hidden-song .drow-nums { opacity: .55; }
/* The second line of a song row: who recorded it, and any badge. A row rather
   than two stacked children, so a badge sits beside the artist instead of
   pushing the row a line taller. */
.drow-second {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.drow-second .drow-by {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.drow-hidden {
  flex: none;
  padding: 1px 7px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  white-space: nowrap;
}

/* The line under the card. Always there, not only once something is hidden:
   the eye looks like it removes a song, and a musician who believes that will
   never press it and never find out otherwise. */
.drow-note {
  margin: 12px 2px 0;
  font-size: var(--db-t-small);
  line-height: var(--line-height);
  color: var(--text-secondary);
}

/* ---- a real sliding switch ----
 *
 * NOT THE .switch ABOVE, which is a checkbox with a label beside it. That is
 * right for "put a plate behind my logo", a modifier on something already being
 * done. This is for a setting that turns a whole block of somebody's public
 * page on and off, and a switch that SLIDES says on-or-off at a glance in a way
 * a tick does not.
 *
 * THE INPUT IS STILL A REAL CHECKBOX, moved off screen rather than set to
 * display:none — a hidden input is not focusable and not reachable by a screen
 * reader, and this control has to work by keyboard. The visible track is
 * painted from :checked and :focus-visible on that input, so the accessibility
 * comes from the element rather than from ARIA bolted on afterwards. */
.toggle {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 14px 0 0;
  cursor: pointer;
}
/* display:flex above beats the browser's own [hidden] rule, so a switch the
   code had switched off stayed on screen — the browse switch was showing in
   strict mode, where there is nothing to choose. Anything that sets a display
   has to say what hidden means. */
.toggle[hidden] { display: none; }
.toggle input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.toggle-track {
  flex: none;
  position: relative;
  width: 50px;
  height: 30px;
  border-radius: 999px;
  background: var(--surface-sunken);
  border: 1.5px solid var(--border-strong);
  transition: background .16s ease, border-color .16s ease;
}
.toggle-knob {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 21px;
  height: 21px;
  border-radius: 50%;
  background: var(--text-secondary);
  transition: transform .16s ease, background .16s ease;
}
.toggle input:checked + .toggle-track {
  background: var(--action);
  border-color: var(--action);
}
.toggle input:checked + .toggle-track .toggle-knob {
  transform: translateX(20px);
  background: var(--action-text, #FFFFFF);
}
/* The focus ring goes on the TRACK, because the input it belongs to is off
   screen and an invisible focus ring is the same as none. */
.toggle input:focus-visible + .toggle-track {
  outline: 2px solid var(--action);
  outline-offset: 2px;
}
.toggle-text {
  font-size: var(--db-t-body);
  color: var(--text-primary);
}
@media (prefers-reduced-motion: reduce) {
  .toggle-track, .toggle-knob { transition: none; }
}

/* ---- who pays the fees: two cards, not a dropdown ----
 *
 * A dropdown shows one option at a time. Choosing between these two meant
 * opening it, reading one sentence, closing it and opening it again to read the
 * other — and this is the setting that decides whether a musician's £5 chip
 * means £5 to them or £4.52. Both answers have to be readable at once.
 *
 * SIDE BY SIDE WHERE THERE IS ROOM, STACKED WHERE THERE IS NOT. auto-fit with a
 * min track does that without a media query, and the min is 220px because
 * below that the second line wraps to four lines and the pair stops reading as
 * a comparison.
 *
 * The selected state is a filled tick and a 2px accent border — the same pair
 * .font-option uses, and for the reason recorded there: a tint alone is hue,
 * and hue alone fails in daylight and for anybody who cannot see it. */
/* ============ AND THE SAME CARD UNDER A NAME THAT IS NOT ABOUT FEES ========
 *
 * .choicecard is the same radio card: a tick, a title and a line under it. The
 * request-limit control on the profile page is exactly this shape and nothing
 * to do with money, and giving it the class .feemode would have meant somebody
 * restyling the fee cards silently restyling a setting about how many requests
 * a phone may send. One set of declarations, two names, and neither is a lie.
 *
 * IT USED TO SAY "AND A QUIET NOTE UNDER IT", and that third line has gone from
 * the request-limit cards; see .choicecards-row further down, which is where
 * the differences between the two users of these rules are now kept. */
.feemode-grid,
.choicecards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 10px;
  margin-top: 8px;
}
.feemode,
.choicecard {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: 14px 16px 14px 44px;
  position: relative;
  text-align: left;
  background: var(--surface-sunken);
  border: 2px solid var(--border);
  border-radius: var(--radius);
  color: var(--text-primary);
  font: inherit;
  cursor: pointer;
}
.feemode::before,
.choicecard::before {
  content: '';
  position: absolute;
  left: 14px;
  top: 15px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2px solid var(--border-strong);
  box-sizing: border-box;
}
.feemode.selected,
.choicecard.selected { border-color: var(--action); }
.feemode.selected::before,
.choicecard.selected::before {
  content: '✓';
  display: grid;
  place-items: center;
  border-color: var(--action);
  background: var(--action);
  color: var(--action-text, #FFFFFF);
  font-size: 13px;
  line-height: 1;
}
.feemode-title,
.choicecard-title {
  font-size: var(--db-t-body);
  font-weight: var(--weight-medium);
}
.feemode-line,
.choicecard-line {
  font-size: var(--db-t-small);
  line-height: var(--line-height);
  color: var(--text-secondary);
}
.feemode:disabled,
.choicecard:disabled { cursor: default; }

/* ---- the friction line on a fee-mode card ----
 *
 * WHAT THIS MODE COSTS THE PERSON TIPPING, on every card including the one a
 * musician is most likely to pick. A settings screen that listed only
 * advantages would be a sales page.
 *
 * .choicecard-note SHARED THESE DECLARATIONS AND HAS GONE, 18 September 2026.
 * The request-limit cards dropped the explaining sentence under each number
 * (docs/COPY-FOR-APPROVAL.md, RL6), nothing emits that class any more, and a
 * selector matching nothing reads as an invitation to write the markup back.
 * .feemode-friction keeps every declaration it had, so the fee cards are
 * exactly as they were. */
.feemode-friction {
  font-size: var(--db-t-small);
  line-height: var(--line-height);
  color: var(--text-secondary);
  opacity: .85;
}
.feemode.selected .feemode-friction { opacity: 1; }

/* ---- FOUR IN ONE ROW, AND ONLY WHERE ONE ROW IS THE POINT ----
 *
 * A MODIFIER, NOT A CHANGE TO .choicecards, because the two users of these
 * declarations want opposite layouts and both are right. The fee modes are two
 * cards carrying several lines each and a worked example of what a tip costs;
 * they want the wrapping auto-fit grid above, which stacks them the moment
 * there is not 220px apiece. The request limits are four cards carrying a name
 * and a number, and they are a SCALE: 2, 4, 8 and then anything you like. A
 * scale that wraps to two rows stops being one, because the eye no longer runs
 * along it. Restyling the shared class to suit this panel would have moved the
 * setting that decides whether a musician's five pounds is five pounds.
 *
 * minmax(0, 1fr) RATHER THAN 1fr. A grid item's automatic minimum is its
 * min-content width, which for these is the longest word: "Balanced",
 * "requests", "Anything". Four of those plus the gaps do not fit a 390px phone,
 * so with a bare 1fr the row overflows its panel and the page scrolls
 * sideways. minmax(0, ...) lets the tracks go narrower than their words and
 * the words wrap instead, which is the behaviour that was wanted.
 *
 * ============ WHAT GIVES WAY AT 390px, DELIBERATELY ============
 *
 * Measured on an iPhone-width viewport, 390x844: the panel gives the row 312px,
 * so four tracks and three 6px gaps make each card 73.5px wide, and 7px of
 * padding a side leaves about 59px for words.
 *
 * THE TICK CANNOT STAY BESIDE THE TITLE, because the shared padding that makes
 * room for it is 44px on the left and 16px on the right, and 60px of padding
 * does not fit inside a 73.5px card at all. Every word would have been squeezed
 * into what was left.
 *
 * SO THE TICK MOVES ABOVE THE TEXT rather than shrinking or going away. It is
 * the thing that says which card is chosen, and it does that WITHOUT relying on
 * the accent border being visible, which is the whole argument recorded over
 * .font-option: hue alone fails in daylight and for anybody who cannot see it.
 * Taking the 44px gutter back gives every card its full width for words, and
 * the tick keeps its full size and contrast.
 *
 * THE TICK IS IN ONE PLACE AT EVERY WIDTH, above the text. There is room on a
 * laptop to put it back beside the title, and doing it would mean one control
 * drawn two ways and a musician who sets this on a laptop meeting a
 * different-looking card on their phone. What changes with the width is only
 * how many cards share a row, below.
 *
 * ============ TWO BY TWO ON A PHONE, FOUR ACROSS FROM 620px ============
 *
 * It was four across at every width, and on 18 September 2026 Mark ruled that
 * too tight on a phone. Measured at 390x844 in that layout: each card 73.5px
 * wide, three of the four running to four lines, Custom to three, all four
 * stretched to 145px tall. The number and the window it applies over ARE the
 * setting, so the fix was never going to be shorter wording. It is more room.
 *
 * 620px IS NOT A NEW BREAKPOINT. It is the one .tip-preset-row already uses on
 * this same page, for the same problem: four presets, two by two below it and
 * four across from it, argued in the comment over that rule with the same
 * arithmetic at 390px. One page, one answer to "when do four presets fit in a
 * row", so a musician who narrows the window watches the tip amounts and the
 * request limit fold at the same moment rather than a few pixels apart.
 *
 * Written mobile first, as .tip-preset-row is: two columns is the rule and four
 * is the exception a wide screen earns. minmax(0, 1fr) in both, or the
 * automatic min-content floor of "Balanced" pushes the grid wider than the
 * panel and the page scrolls sideways.
 *
 * THE ORDER IS UNCHANGED BY THE FOLD. A grid fills row by row, so two by two
 * reads Tight, Balanced / Relaxed, Custom: the same order as the row of four,
 * left to right and then down, with the three presets rising in number before
 * the one that is not a preset. */
.choicecards-row {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 6px;
}
@media (min-width: 620px) {
  .choicecards-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.choicecards-row .choicecard {
  padding: 10px 7px 12px;
  gap: 3px;
  /* A NAME MUST NOT SPILL OUT OF ITS CARD AT ANY FONT SCALE. --font-scale is
     the musician's. This was written for a 74px card, which stopped existing
     on 18 September 2026 when the phone layout went two by two: the narrowest
     card now is 118px, at 320px. It is kept as the net under the largest font
     scale, where a long word can still outgrow a card. Breaking a word is ugly
     and legible; overflowing the border is neither. */
  overflow-wrap: break-word;
}
/* THE NAME IS TOLD APART FROM THE NUMBER BY WEIGHT AND COLOUR, NOT BY SIZE.
   First done because "Balanced" at the shared 14px hung over the border of a
   74px card at 390px. That card is gone: since the two by two layout the cards
   are 153px at 390px and 118px at 320px, so the size is no longer what makes
   the name fit. It is kept because the pair still reads as a heading and a
   value this way, the name in the strong weight and primary colour, the line
   under it regular and secondary, and changing it now would be a visual change
   nobody asked for. Only inside this modifier, so the fee-mode titles keep
   the size they have. */
.choicecards-row .choicecard-title {
  font-size: var(--db-t-small);
  font-weight: var(--weight-strong);
}
/* THE FREE LIMIT WHILE PAID REQUESTS ARE ON: visibly not in use, and the
   statement box under it (.paid-statement, css/shared.css) says why. Dimmed rather than hidden, so a musician can still see
   the value that comes back when paid requests go off. */
#requestLimitChoices.is-paused { opacity: 0.5; }
#requestLimitChoices.is-paused .choicecard { cursor: not-allowed; }

/* ============ PAID REQUESTS, INSIDE THE REQUEST LIMITS SECTION ============
 *
 * The "credits card": one bordered block carrying the Pro accent as the
 * product's single Pro mark (.pro-mark and .pro-pill, css/shared.css). An
 * accent and not a redesign: the edge is an inset shadow so it moves nothing,
 * the background is the section's own, and no body text changes colour. Only
 * the heading carries the pill.
 *
 * The two fields sit side by side at every width. At 320px each is a little
 * over 100px, which holds a four-digit price or a two-digit count with room to
 * spare; they are short fields and a stacked layout would only make the card
 * longer on the screen where length costs most. */
.credits-block {
  margin-top: 18px;
  padding: 14px 14px 14px 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

/* THE ACCOUNT-WIDE TIPS SWITCH, the same card as the Paid requests one below it
   because they are the same kind of thing: one Pro question about money, with the
   Pro accent edge on the left and the pill in the heading. Shares .credits-switch
   and .credits-h with it rather than being given its own, so the two cannot drift
   apart. Only the outer spacing is its own, and only because this one is FIRST in
   its section and has a section subtitle above it rather than a form. */
.tipson-block {
  margin: 4px 0 18px;
  padding: 14px 14px 14px 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.tipson-block[hidden] { display: none; }
.credits-h { margin: 0 0 4px; font-size: var(--db-t-body); font-weight: var(--weight-strong); }
.credits-block .sub { margin: 0 0 12px; }
/* TWO NAMES, ONE RULE. .tipson-switch is the account-wide Take tips switch and
   .credits-switch is the Paid requests one; they are the same object and are
   declared together so they cannot drift apart. They are not ONE name because
   `label.credits-switch` is the selector two suites use to click the credits
   switch, and a second element answering to it broke both of them. */
.credits-switch,
.tipson-switch {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 44px;
  cursor: pointer;
}
.credits-switch-label { font-weight: var(--weight-medium); }
/* HOW PAID REQUESTS WORK, five short lines above the controls. Body text in
   the section's own colours: the Pro mark is the edge and the pill, never the
   text. */
.credits-how {
  margin: 8px 0 14px;
  padding-left: 1.2em;
  display: grid;
  gap: 6px;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  line-height: 1.45;
}
.credits-how li::marker { color: var(--text-muted); }
.credits-fields {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  margin-top: 12px;
}
.credits-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.credits-field label { font-size: var(--db-t-small); color: var(--text-secondary); }
.credits-field input { width: 100%; min-width: 0; }
/* The free allowance is two digits at most, on its own row before the price. */
.credits-allowance { margin-top: 12px; }
.credits-allowance input { max-width: 6em; }
.credits-allowance .field-hint { margin: 0; }
.credits-money { display: flex; align-items: center; gap: 6px; }
.credits-summary { margin: 12px 0 0; font-weight: var(--weight-medium); }
.credits-summary:empty { display: none; }
/* A WARNING, NOT AN ERROR: the musician may set this price and it is saved.
   So it is notice, the colour the product uses for "worth knowing", and not
   negative, which would read as refused. */
.credits-warn { margin: 6px 0 0; color: var(--notice-text); font-size: var(--db-t-small); }
.credits-problem { margin: 6px 0 0; color: var(--negative-text); font-size: var(--db-t-small); }

.choicecards-row .choicecard::before {
  position: static;
  /* flex: none, or the column's stretch would pull the circle into an oval as
     wide as the card. */
  flex: none;
  align-self: flex-start;
  margin-bottom: 3px;
}

/* ---- the typed number under Custom, on the request-limit panel ----
 *
 * NARROW ON PURPOSE. A field that takes at most two digits and is drawn the
 * full width of a card reads as a place to write a sentence, and on a phone it
 * puts a 30-pixel value in a 340-pixel box. 7ch is two digits, a spinner and
 * room to breathe.
 *
 * It is only in the document when Custom is the choice (the wrapper carries
 * `hidden`), so there is no state where these rules apply to something a
 * musician cannot use. */
.rl-custom { margin-top: 12px; }
.rl-custom label { display: block; }
.rl-custom input { width: 7ch; }

/* ---- the line about the live screen, set apart from the rest ----
 *
 * IT IS THE SECOND .field-hint IN THIS PANEL and it is not about the same
 * thing as the first. The other one explains what may be typed into the Custom
 * box; this one sends a musician to a different screen entirely, mid-gig, and
 * names the button to press when they get there. Two identical-looking grey
 * lines a few pixels apart read as one block of small print, which is exactly
 * how a sentence stops being read.
 *
 * So it gets air above it and the page's ordinary text colour instead of the
 * hint grey. It stays at hint SIZE, because it is still a footnote to the
 * control above it and promoting it to body text would have it competing with
 * the cards. It is not a warning and does not want a colour of its own; it
 * wants to look like a sentence somebody meant to write. */
.rl-giglink {
  margin-top: 14px;
  color: var(--text-primary);
}

/* ==================== THE FLOW MOCK-UP UNDER THE FEE MODES ====================
 *
 * A DRAWING OF THE SCREENS, NOT A DESCRIPTION OF THEM. The difference between
 * the three modes is not a fact about money — the worked example below already
 * gives the figures — it is how many screens somebody has to get through and
 * what each one says. That is a shape, and a shape is quicker to look at than
 * to read.
 *
 * Small on purpose, and aria-hidden: it is a diagram, and each option card
 * carries the same information in words for anybody listening. Making it
 * tappable would invite a musician to try to pay themselves on a settings page.
 */
.feemock { margin-top: 16px; }
.fm-cap {
  margin: 0 0 8px;
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
}
/* THE SCREENS SIT IN A ROW AND SCROLL IF THEY MUST. Three screens at 375px is
   tight, and a wrapped flow stops reading as a sequence — so the row scrolls
   sideways inside its own box rather than folding. */
.fm-flow {
  display: flex;
  align-items: stretch;
  /* 6px, not 8: three screens plus two gaps has to clear 375px inside the
     panel's own padding, and at 8px the selection flow overflowed by 5px —
     measured, and 5px of sideways scroll is the worst amount to have because
     it looks like a rendering fault rather than like a scroller. */
  gap: 6px;
  /* Kept as a scroller anyway. It is the honest fallback at 320px and at large
     text sizes, where three screens genuinely cannot fit. */
  overflow-x: auto;
  padding-bottom: 4px;
  scrollbar-width: thin;
}
.fm-screen {
  flex: 1 0 90px;
  min-width: 90px;
  display: flex;
  flex-direction: column;
  gap: 5px;
}
/* The arrow between screens is drawn by the step label rather than by an
   element, so there is nothing to align and nothing to hide at the ends. */
.fm-screen + .fm-screen .fm-step::before { content: '→ '; opacity: .6; }
.fm-step {
  font-size: 10px;
  font-style: normal;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}
.fm-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: 8px;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
/* Two by two inside a 96px screen: this is a picture of four chips, not four
   chips. The real ones are one row at 375px — see css/tip.css. */
.fm-chips { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.fm-chip {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 4px 2px;
  border-radius: 4px;
  background: var(--surface);
  font-size: 10px;
  font-weight: var(--weight-medium);
  color: var(--text-primary);
  line-height: 1.2;
}
.fm-chip i { font-style: normal; font-size: 8px; opacity: .75; }
.fm-opts { display: grid; gap: 4px; }
.fm-opt {
  display: flex;
  flex-direction: column;
  padding: 5px 6px;
  border-radius: 4px;
  background: var(--surface);
  border: 1px solid var(--border);
  font-size: 10px;
  font-weight: var(--weight-medium);
  line-height: 1.2;
}
.fm-opt.is-bright { border-color: var(--action); }
.fm-opt i { font-style: normal; font-size: 8px; opacity: .75; }
.fm-total {
  text-align: center;
  font-size: 12px;
  font-weight: var(--weight-strong);
  min-height: 14px;
}
/* The pay buttons are blocks rather than words: at this size a label would be
   unreadable, and what the diagram is saying is "and then you pay", not which
   wallet. */
.fm-pay {
  height: 14px;
  border-radius: 4px;
  background: var(--text-primary);
  opacity: .8;
}
.fm-pay.is-card { background: var(--surface); border: 1px solid var(--border-strong); opacity: 1; }
.fm-steps {
  margin: 8px 0 0;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}

/* ------------------------------------------------------ the gig summary */

/* HOW THE LAST GIG WENT. A screen rather than a card, because it is what the
   app opens ON when a gig has finished and has not been looked at.
 *
 * Centred and capped rather than full-bleed: it is read standing still, often
 * on a phone in one hand while packing down, and a list that runs the width of
 * an iPad is a list nobody finishes. */
/* THE PAGE ENDS, RATHER THAN STOPPING. Every round shrank the actions and
   every round the void below them grew: 217px, then 275px, then 358px at
   tablet-portrait, where more than a third of the screen was blank and the
   content sat jammed into the top two-thirds. Centred where there is room to
   centre. Guarded by a height query rather than `safe center` so that a screen
   too short to hold the content is never centred into a clipped top. */
@media (min-height: 760px) {
  .wrap-screen { min-height: 100dvh; justify-content: center; }
}
.wrap-screen {
  width: min(560px, 100%);
  margin: 0 auto;
  padding:
    calc(24px + var(--safe-top, 0px))
    calc(20px + var(--safe-right, 0px))
    calc(24px + var(--safe-bottom, 0px))
    calc(20px + var(--safe-left, 0px));
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.wrap-head { display: flex; flex-direction: column; gap: 6px; }

/* A BREADCRUMB, NOT A HEADLINE.
   It was the largest type on the page, and measured against the figure it
   introduces it carried 29% MORE INK — so the eye met a status label before it
   met the subject. The subject is the money. This says what happened, where and
   how long, in one muted line, and gets out of the way. */
.wrap-title {
  margin: 0;
  font-size: var(--db-t-body, 15px);
  line-height: 1.3;
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
  letter-spacing: 0;
}
.wrap-crumb { color: var(--text-muted); font-weight: var(--weight-normal); }
.wrap-sub { margin: 0; color: var(--text-secondary); }

/* THE LEAD FIGURE, AND IT IS THE SUBJECT OF THE SCREEN.
 *
 * The first attempt made it a stat tile at two and a half times the others:
 * figure over label, same rail, same tone, same construction. One component
 * three times is a form, which is what the previous version was accused of
 * being. So this is built differently from everything below it — the figure and
 * its label share a baseline, the label tucked beside it rather than stacked
 * under it, and the figure carries the only colour on the page.
 *
 * A FLAT TINT, NOT A GRADIENT. The AI treatment and the tip control each own a
 * gradient and a third would dilute both. --action-on-surface is the accent
 * already solved for ink on a plane in every theme.
 *
 * NO TRACKING ON THE LABEL. It was the only tracked text on the screen, which
 * is a tic rather than a decision, and it slows word recognition in poor light. */
.wrap-hero {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
}
.wrap-hero b {
  color: var(--action-on-surface);
  font-size: calc(var(--db-t-head) * 3.2);
  line-height: 1;
  font-weight: var(--weight-strong);
  letter-spacing: calc(var(--tracking-tight) * 1.5);
  margin-left: -0.02em;
}
.wrap-hero span {
  color: var(--text-secondary);
  font-size: var(--db-t-body, 15px);
  letter-spacing: 0;
}
.wrap-hero.is-waiting b { color: var(--text-muted); }
/* THE BREAKDOWN UNDER A COMBINED FIGURE: tips, and requests sold. Small and
   quiet, on its own line directly under the figure it explains, never
   competing with it. Inside .wrap-hero, so it wraps under the figure rather
   than taking the layout's gap. */
.wrap-hero .wrap-hero-split {
  flex-basis: 100%;
  margin-top: 2px;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* THE AIR IS THE DESIGN, AND IT WAS INVERTED. The figure had 29px above it on a
   phone — less than its own digit height — while 55px of nothing sat above the
   buttons. Least space where the subject is, most where nothing is. */
.wrap-head { gap: 0; }
/* THE SUBJECT OWNS THE LARGEST GAP ON THE PAGE. It had the smallest of the
   three: 51 above and 53 below, against 60 around the quiet line and 55 to 62
   above the actions. A flat plateau of gaps is evenness by another name, and
   evenness is what round one failed for. Big, medium, small - so the eye knows
   where it is meant to stop. */
.wrap-head .wrap-hero { margin: 72px 0 76px; }

/* THE FOUR THAT SUPPORT IT, READ IN POOR LIGHT WHILE PACKING DOWN.
   Fixed columns rather than auto-fit: the count is known, and auto-fit once
   gave six columns and orphaned the seventh onto a line of its own at every
   size from a tablet up. */
.wrap-stats {
  margin: 0;
  display: grid;
  /* FOUR IN A ROW AT EVERY WIDTH, 375 INCLUDED. Mark's call, and it overrides
     both the 2x2 that was here and the design review that asked for it.
   *
     The 2x2 was put in because three of the four labels wrapped to two lines at
     375 and one did not, leaving a ragged bottom edge. That is answered by
     reserving two lines on every label whether or not it needs them, which the
     rule below already does - so the edge is straight either way. The 2x2's own
     cost was worse: its column rails were inherited by the quiet row beneath,
     and it read as a table rather than a row of facts.
   *
     6px of column gap rather than 8: at 375 the four tracks are 78px each and
     "different" sets at 46px, so the labels fit with room and the figures never
     touch.
   *
     10px, not 6. At 6 the gap BETWEEN two statistics measured the same as the
     gap between two words of one of them - 6px against a 5-6px word-space - so
     four tiles read as one run-on string. A boundary has to beat the spacing
     inside the things it separates or it is not a boundary. The label drops a
     step below 480px to pay for it, which keeps Mark's four labels intact:
     the instruction was one row, and shortening his words is not the lever. */
  grid-template-columns: repeat(4, 1fr);
  gap: 14px 10px;
}
@media (max-width: 480px) {
  .wrap-stats .wrap-stat span { font-size: calc(var(--db-t-small) * 0.92); }
}
/* ============ AND ONE STEP FURTHER ON THE NARROWEST PHONES ============
 *
 * 27 September 2026. On a 320px screen the four tracks are 47px each and
 * "unplayed" sets at 49, so overflow-wrap broke it in half and the summary
 * read "unplaye / d". A word cut down the middle, on the one panel that is a
 * summary of somebody's night.
 *
 * FOUR IN A ROW IS MARK'S CALL AND IT STANDS, so this is the same lever the
 * 480px rule above already pulls rather than a 2x2 by the back door: the label
 * drops one more step and all four words fit whole. Measured in Chromium at
 * 320px: at 0.92 the widest label is 49px in a 47px track, at 0.86 it is 46.
 * The figures above the labels do not change size, because those are what is
 * being read. */
@media (max-width: 359px) {
  .wrap-stats .wrap-stat span { font-size: calc(var(--db-t-small) * 0.86); }
}

/* WHAT MATTERS LESS, AND SAYS SO IN TONE AS WELL AS SIZE.
   It recedes by a third tone rather than by a rule. The rule measured 1.76:1
   against its background on Stage, where under stage lighting it simply is not
   there — so the two rows collapsed into one seven-cell grid, which is the
   opposite of receding without vanishing. Air separates them instead, and air
   does not depend on contrast. */
.wrap-quiet {
  /* ONE STRATUM WITH THE BAND ABOVE IT, not a sibling of it. At 36px it was
     spaced like a section break, which is what separates whole blocks on this
     page - so the supporting counts read as two things rather than as two lines
     of one thing. 24px binds them. */
  margin: 24px 0 0;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 8px;
  color: var(--text-muted);
  font-size: var(--db-t-small);
}
.wrap-quiet b { font-weight: var(--weight-medium); }
/* The separator is an <i> and not a bare character so it can be hidden from a
   screen reader, which otherwise reads "five played middle dot two skipped".
   Not styled as italic; the element was chosen for being short and inline. */
.wrap-quiet i { font-style: normal; color: var(--border-strong); }

.wrap-stat { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.wrap-stat b {
  color: var(--text-primary);
  font-size: calc(var(--db-t-head) * 1.35);
  line-height: 1.02;
  font-weight: var(--weight-strong);
  /* NOT tabular-nums. At this size the even advance separated the digits of 11
     enough to read as two figures — "1 1" — which is the opposite of what
     tabular numerals are for. Nothing here is a column that needs to align. */
  letter-spacing: var(--tracking-tight);
}
.wrap-stat.is-minor b {
  font-size: calc(var(--db-t-head) * 0.85);
  color: var(--text-muted);
}
.wrap-stat.is-minor span { color: var(--text-muted); }
.wrap-stat span {
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  /* WRAPS, AND USED NOT TO. The labels are two words each, and the old rule was
     nowrap with an ellipsis on a 72px track — which truncated "people asked" to
     "people ask…" on the Stage theme, whose type runs a hundred weight heavier
     than Dark's. Tuning the track to fit Dark is what hid that. */
  line-height: 1.15;
  /* break-word, not anywhere: two-word labels wrap at the space. `anywhere`
     would give "peopl / e asked". */
  overflow-wrap: break-word;
  /* ROOM FOR TWO LINES ON EVERY TILE, whether or not this one needs them. One
     label wrapping while its neighbours do not left the row ragged under the
     first figure, which is the one the eye lands on. 2.15 rather than 2.3
     because on a 667px phone every reserved pixel comes off the bottom of the
     screen, and 2.3 was part of what pushed the Done button off it. */
  min-height: 2.15em;
}

/* THE WIDE SIZES EARN SOMETHING. Below this the screen is a phone column, which
   is right. Above it the column widened but nothing used the room: the same
   519px of content sat in the middle of a 1440px window. */
/* 760, not 900. iPad portrait is 768 and sat just under the old breakpoint, so
   the commonest tablet width got nothing and ended up the emptiest screen of
   the five.
   AND ALL SEVEN ON ONE LINE, which is what the extra width was for. 679px over
   seven tiles is about 89px each, enough for "still waiting" at 12px. It spends
   the room, removes the second row, and takes the misalignment with it. */
@media (min-width: 760px) {
  /* 100% - 96px so there is still a side margin at exactly 768, where a flat
     720px container is effectively full bleed. */
  .wrap-screen { width: min(720px, 100% - 96px); }
  /* The lead figure earns more air where there is room for it. */
  .wrap-hero { margin-top: 30px; }
  .wrap-hero b { font-size: calc(var(--db-t-head) * 3.4); }
}

/* A BOUNDED GAP, NOT A FULL-HEIGHT PUSH. min-height:100dvh with margin-top:auto
   on the actions did not remove the dead space, it moved it from below the
   composition into the middle of it — a 433px hole between Add songs and the
   footer at tablet portrait, 42% of the viewport, with a rule drawn under it.
   Trailing space at the foot of a short screen reads as "the page ended". A
   hole in the middle reads as "something failed to render".

   THE -20px IS NOT COSMETIC. The clamp ADDS to the section gap already in this
   column, so a plain 6vh spent 40px on a 667px phone that had no spare height
   to give — and pushed Done off the bottom, leaving six pixels of a 44px button
   showing, which reads as a rendering artefact rather than as "scroll for
   more". Subtracting the existing gap makes it evaluate to 20px at 667 and
   still open up on a tall screen, which is the only place the gap was for. */
/* One group, centred, with the caption bound to the control it belongs to.
   The resume note used to sit between the two buttons reading as a disclaimer
   inserted into the pair. */
.wrap-actions { margin-top: clamp(20px, calc(5vh - 20px), 40px); }

/* The frame while the summary is being read. Deliberately empty and the same
   height as nothing at all: it exists so the PREVIOUS gig's summary is not
   what is on screen, not to announce a wait. */
.wrap-waiting { min-height: 40vh; }

.wrap-h2 { margin: 0 0 4px; font-size: var(--db-t-mid); font-weight: var(--weight-strong); }
.wrap-known { display: flex; flex-direction: column; gap: 10px; }
/* The heading and the tick-all control share a row. The control had a row of
   its own with 250px of empty gutter beside it — the leftover skeleton of a
   deleted sub-line, and the most obviously unfinished thing on the screen. */
.wrap-known-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}
.wrap-known-head .wrap-h2 { margin: 0; }
.wrap-known-sub { margin: 0; color: var(--text-secondary); font-size: var(--db-t-small); }
.wrap-all { flex: none; }
.wrap-empty { margin: 0; color: var(--text-secondary); }

.wrap-list { list-style: none; margin: 0; padding: 0; }
.wrap-list > li + li { border-top: 1px solid var(--border); }
/* The list had no bottom edge: the last row ran straight into the add button
   with nothing closing it. */
.wrap-list { border-bottom: 1px solid var(--border); }

/* THE WHOLE ROW IS THE TARGET, not the 20px box. This is tapped by somebody
   winding a cable round their arm. */
.wrap-item {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 48px;
  padding: 8px 2px;
  cursor: pointer;
}
/* DRAWN, NOT LEFT TO THE BROWSER. accent-color only paints the CHECKED state,
   so an unchecked box fell through to the user agent's dark-mode control: a
   solid grey fill, which is the universal "on" affordance. On Dark and Stage
   the two leftover songs read as already ticked, and Add songs then looked
   broken for not responding. */
.wrap-item input {
  flex: none;
  width: 22px;
  height: 22px;
  margin: 0;
  /* padding: 0 IS LOAD-BEARING AND ITS ABSENCE WAS A REAL BUG. css/shared.css
     sets `input, select, textarea { padding: 0 16px }` for form fields, and
     under border-box the used width floors at padding plus border — so a box
     asked for 22px rendered 36px wide and 22px tall. A landscape pill is the
     silhouette of an OFF toggle switch, which is a different wrong affordance
     from the one this rule was written to fix. Measured at 36x22 in all
     fifteen screenshots before this line existed. */
  padding: 0;
  appearance: none;
  border: 2px solid var(--border-strong);
  border-radius: 6px;
  background: transparent;
  cursor: pointer;
}
.wrap-item input:checked {
  border-color: var(--action);
  background: var(--action);
  /* The tick is drawn rather than a glyph, so it is the same shape at any size
     and needs no font to be present. */
  background-image: linear-gradient(transparent, transparent);
  position: relative;
}
.wrap-item input:checked::after {
  content: '';
  position: absolute;
  left: 6px;
  top: 2px;
  width: 5px;
  height: 10px;
  border: solid var(--action-text);
  border-width: 0 2.5px 2.5px 0;
  transform: rotate(45deg);
}
.wrap-item input:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.wrap-item-text { min-width: 0; display: flex; flex-direction: column; }
.wrap-item-title { font-weight: var(--weight-medium); }
.wrap-item-by { color: var(--text-secondary); font-size: var(--db-t-small); }

.wrap-add { width: 100%; }
/* KEEPS ITS OUTLINE WHEN DISABLED. At .6 opacity the border measured 2.1:1 on
   Dark and Stage, so the control had almost no shape and the label was carrying
   it alone. Muted ink says unavailable; the border says there is something
   here. */
.wrap-add:disabled { opacity: 1; color: var(--text-muted); cursor: default; }

/* NO RULE, AND NO POCKET. The rule sat 55px below the list and 9px above the
   button, so it read as the button's lid rather than as a section break - and
   once the buttons stopped being full width it was a 680px line introducing a
   159px control. The list already ends in a border of its own. Air separates
   these the way air separates the quiet row, and for the same reason. */
.wrap-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 12px 20px;
  padding-top: 0;
  border-top: 0;
}
.wrap-back { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; }
/* Under the button it qualifies, not above it: the button is what is being
   read, and the limit is the footnote. */
/* Bound to the button above it rather than floating between the two. It had
   12px above and 18px below, which is not a grouping signal. */
.wrap-resume-note {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
}
/* A LINK, NOT A SECOND PILL. Keeps a 44px target: the padding is vertical and
   the underline is the affordance, so it reads as a link at a glance and is
   still hit reliably with a thumb. */
.wrap-resume {
  min-height: 44px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-primary);
  font: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.wrap-resume:hover { color: var(--action-on-surface); }
.wrap-resume:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 3px; }
/* DONE STOPS BEING THE SUBJECT OF THE SCREEN.
   Full width and filled, it was the only chroma on the page and carried roughly
   29,700 saturated pixels against the tip figure's 2,157 neutral ones - beating
   the thing the screen is about by about fourteen to one on area. Colour beats
   size every time. It keeps the fill, because a screen with no clear way out is
   worse, and loses the width. */
/* AND IT STOPS SPENDING THE ACCENT. Auto-width cut it from 29,700 saturated
   pixels to 5,978, but that is still 2.9x the area of the figure it is supposed
   to sit behind, in the same hue at higher saturation - so the subject of the
   screen was reading as a diluted quote of the dismiss control. Worst on Stage,
   where the two ambers are closest.
 *
 * Filled and outlined, in the token pair the app already keeps for exactly this
 * (`--action-quiet`), so it stays plainly the primary action next to an
 * outlined-only secondary, and the only colour on the page is the money. */
/* AND IT IS FINDABLE AGAIN WITHOUT SPENDING THE ACCENT. `--action-quiet` put
   it at 1.12:1 against the button beside it on Light - below the point at which
   an edge is visible at all. Inverted instead: the page's text tone as the
   fill, the page's background as the label. That measures about 14:1 against
   the page, is unmistakably the primary action, and is not a colour. */
.wrap-done {
  width: auto;
  min-width: 180px;
  margin-left: auto;
  padding-left: 28px;
  padding-right: 28px;
  background: var(--text-primary);
  color: var(--bg);
  border: 0;
}
.wrap-done:hover:not(:disabled),
.wrap-done:active:not(:disabled) { background: var(--text-secondary); }

/* THE WAY INTO THE SUMMARY, HEADING THE SECTION RATHER THAN TRAILING IT.
 *
 * It sat under the whole block and read as one more thing to do after the
 * summary rather than as the way into it. Above the venue name now, and shaped
 * like a button rather than a pill: Mark's words. A pill reads as a tag - a
 * label describing what something is - and this does something. Square-ish
 * corners at the app's own button radius, a full border, and the caret that
 * says it opens rather than leaves. */
.wc-wrap-go {
  align-self: start;
  /* IT HEADS THE SECTION, SO IT NEEDS AIR ABOVE IT AND LESS BELOW. It went in
     with no margin at all and sat hard against the greeting on one side and the
     venue on the other. */
  margin: 18px 0 6px;
  min-height: 44px;
  border-radius: var(--radius-control, 10px);
  padding: 0 16px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: transparent;
  color: var(--text-primary);
  font: inherit;
  font-weight: var(--weight-medium);
  cursor: pointer;
}
.wc-wrap-go:hover { border-color: var(--action-on-surface); color: var(--action-on-surface); }
.wc-wrap-go:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* A ROW THAT WAS TIDIED SAYS SO, QUIETLY. It is not a warning and not an
   apology: the correction is almost always right, and the mark is there so the
   musician can check it rather than so they worry about it. Closed by default,
   because a repertoire of 800 rows with an open explanation on five of them is
   a list with five holes in it. */
.song-tidied { margin-top: 2px; }
.song-tidied-mark {
  display: inline-block;
  padding: 1px 8px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
  cursor: pointer;
  list-style: none;
}
.song-tidied-mark::-webkit-details-marker { display: none; }
.song-tidied[open] .song-tidied-mark { border-color: var(--action-on-surface); color: var(--action-on-surface); }
.song-tidied-mark:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.song-tidied-was, .song-tidied-why {
  margin: 6px 0 0;
  color: var(--text-secondary);
  font-size: var(--db-t-small);
}
.song-tidied-undo {
  margin-top: 8px;
  min-height: 36px;
  padding: 0 14px;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: transparent;
  color: var(--text-primary);
  font: inherit;
  font-size: var(--db-t-small);
  cursor: pointer;
}
.song-tidied-undo:hover { border-color: var(--action-on-surface); color: var(--action-on-surface); }
.song-tidied-undo:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.song-tidied-undo[disabled] { opacity: .5; cursor: default; }

/* THE OVERVIEW'S PERIOD SELECTOR. Above the tiles, because it frames them —
   a control under the figures reads as something you do next rather than
   something the figures already obey. Same pill shape as the trend chart's own
   range buttons, deliberately: two range controls on one screen that look
   different are two things to learn. */
.strip-periods {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 4px;
  /* THE PILLS WERE NOT ROUND BECAUSE OF THEIR RADIUS. This box is a grid item
     sitting in the same grid as the tiles, and it inherited two stretches in a
     row: the row grew to the tallest tile in it, this box stretched to the row,
     and its flex children stretched to this box. `min-height` set a floor and
     nothing set a ceiling, so a 73px-wide pill came out 79px tall at 1024 and
     62px at 1440 — as wide as it was tall, which is a circle. `start` makes it
     hug its own content; `center` stops the buttons stretching if it ever does
     get stretched again. Neither is decoration: both are load-bearing. */
  align-self: start;
  align-items: center;
}
/* NOT SIZED HERE. The shape, padding, border, type and states all come from
   the shared rule with .range-btn, so the two range controls cannot drift. What
   belongs to this one is only that it must not stretch: it sits in the stat
   grid, and the row it lands in is as tall as the tallest tile in it. */
.strip-period { flex: 0 0 auto; }
.strip-period:hover { border-color: var(--action-on-surface); color: var(--action-on-surface); }
.strip-period:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
/* A quiet window says so instead of printing five zeros. Full width, because it
   is a sentence and not a figure. */
.strip-empty {
  grid-column: 1 / -1;
  margin: 6px 0 0;
  color: var(--text-secondary);
}

/* ------------------------------------------- Build my list: moving batches */

/* THE MOVEMENT SAYS WHICH WAY YOU WENT. Forward comes in from the right, back
   from the left, so "where did the last twenty go" is answered by the movement
   rather than worked out from the titles.
 *
 * Short and shallow: this fires every twenty songs on a screen somebody is
 * working through, and an animation you notice on the second repetition is one
 * you resent by the tenth. */
@keyframes pick-in-forward {
  from { opacity: 0; transform: translateX(14px); }
  to   { opacity: 1; transform: none; }
}
@keyframes pick-in-back {
  from { opacity: 0; transform: translateX(-14px); }
  to   { opacity: 1; transform: none; }
}
.songs-picker.is-forward { animation: pick-in-forward 180ms ease-out; }
.songs-picker.is-back    { animation: pick-in-back 180ms ease-out; }

/* ASKED FOR LESS MOVEMENT MEANS LESS MOVEMENT, not a shorter version of it.
   The batch still changes; it just does not travel. */
@media (prefers-reduced-motion: reduce) {
  .songs-picker.is-forward,
  .songs-picker.is-back { animation: none; }
}

/* While the next batch is being fetched. The area went blank and stayed blank,
   which on a slow connection is indistinguishable from a crash. */
.pick-loading {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  padding: 18px 2px;
  color: var(--text-secondary);
}

/* Back sits at the left of the action row, away from Next Twenty, because the
   two go opposite ways and a pair of adjacent buttons that do opposite things
   is a mis-tap waiting to happen. */
#buildBackSlot { margin-right: auto; }

/* THE THREE DOTS, WHICH THE DASHBOARD HAS BEEN USING AND NOT STYLING.
 *
 * .pending-dots is defined in css/public.css, which is the punter page's
 * stylesheet and is not loaded here. The songbook builder's wait has been
 * rendering three empty <i> elements this whole time, and so was the picker's
 * new loading line until this. Same name, same numbers, same keyframes as the
 * punter copy so the two are literally the same animation rather than two that
 * drift; the two documents never load together, so there is no collision. */
.pending-dots { display: inline-flex; gap: 4px; flex: none; }
.pending-dots[hidden] { display: none; }
.pending-dots i {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: currentColor;
  opacity: .35;
  animation: sendDot 1s ease-in-out infinite;
}
.pending-dots i:nth-child(2) { animation-delay: .16s; }
.pending-dots i:nth-child(3) { animation-delay: .32s; }
@keyframes sendDot {
  0%, 100% { opacity: .3; transform: translateY(0); }
  40%      { opacity: 1;  transform: translateY(-4px); }
}
/* ASKED FOR LESS MOVEMENT: the dots stay, because a wait with no sign of life
   is the fault they exist to fix, but they pulse in place rather than travel. */
@media (prefers-reduced-motion: reduce) {
  .pending-dots i { animation-name: sendDotStill; }
  @keyframes sendDotStill {
    0%, 100% { opacity: .3; }
    40%      { opacity: 1; }
  }
}

/* THE ULTIMATE GUITAR HELP, in the song sheet. See chartHelpMarkup() in
   js/views/songs.js for why it is folded away and why the picture is a frame
   rather than a picture. */
.chart-help {
  margin-top: calc(12px * var(--density));
  border-top: 1px solid var(--border);
  padding-top: calc(12px * var(--density));
}

/* A question, not a heading. Somebody scanning this sheet should be able to
   tell in one read whether it is about them. */
.chart-help > summary {
  cursor: pointer;
  color: var(--text-secondary);
  font-size: var(--db-t-body, 15px);
  /* 44px of target from a line of text that is nothing like 44px tall.
     Measured, not guessed: at 10px it came out 41px. */
  padding: calc(12px * var(--density)) 0;
  list-style-position: outside;
}

.chart-help > summary:hover { color: var(--text-primary); }
.chart-help > summary:focus-visible { outline: 3px solid var(--focus-ring); }
.chart-help[open] > summary { color: var(--text-primary); font-weight: var(--weight-medium); }

.chart-help-body { color: var(--text-secondary); font-size: var(--db-t-body, 15px); }
.chart-help-body p { margin: 0 0 calc(10px * var(--density)); }

.chart-help-body h4 {
  margin: calc(14px * var(--density)) 0 calc(6px * var(--density));
  color: var(--text-primary);
  font-size: var(--db-t-body, 15px);
  font-weight: var(--weight-strong);
}

/* Numbered, and the numbers matter: step two is the one nobody finds. */
.chart-help-steps {
  margin: 0;
  padding-left: 1.4em;
  display: grid;
  gap: calc(6px * var(--density));
}

/* THE FRAME FOR AN ASSET THAT DOES NOT EXIST YET. Dashed and unfilled so it
   cannot be mistaken for a loaded image that failed, and captioned with what
   belongs in it. Drop the file in and swap the figcaption for an <img>. */
.chart-help-shot {
  margin: calc(10px * var(--density)) 0;
  padding: calc(14px * var(--density));
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  min-height: 96px;
  display: grid;
  place-items: center;
  text-align: center;
}

.chart-help-shot figcaption { color: var(--text-muted); font-size: 13px; max-width: 32ch; }

.chart-help-undo {
  margin-top: calc(12px * var(--density));
  color: var(--text-muted);
  font-size: 13px;
}

/* ===========================================================================
   THE AI TREATMENT. Built once, applied wherever a model is involved.

   Mark asked for one look for anything AI-powered: blue, a left-to-right
   gradient, and a magic wand.

   IT COLLIDES WITH A RECORDED DECISION AND THE COLLISION IS RESOLVED HERE.
   css/tip.css says the tip control "is the ONLY thing in the app that carries a
   gradient, which is what makes it recognisable as itself on a screen full of
   flat rectangles". That is no longer true, so the two have to be separable at
   a glance, and they are separable on three axes at once:

     the tip control   top to BOTTOM, painted from --action, on a filled
                       48-52px button that spans its container
     the AI mark       LEFT to right, a fixed blue that no theme's --action can
                       ever be, on a 20px pill that never spans anything

   Direction, hue and size. Any one of them alone would be thin; all three
   together mean a tip control and an AI badge cannot be confused even at a
   glance in poor light, and the check is in tests/ai-treatment.mjs rather than
   in this comment.

   AND IT IS NEVER A PRIMARY CONTROL. The mark labels things; it does not do
   things. Nothing that spends money and nothing that is the main action on a
   screen carries it, so it cannot be mistaken for a payment control by
   position either.

   COLOURS COME FROM THE THEME, not from here. See the AI block in
   js/themes/app/*.js for why a single fixed blue cannot clear 3:1 on every
   palette and how the light-fill-on-dark convention answers it.
   =========================================================================== */

/* THE PILL. A badge, not a button: it says what made this, and tapping it does
   nothing because it is not a control. */
/* margin-left, because the one call site concatenates this straight onto a
   heading with no separator and the chip landed on the final "g" of "Describe
   the gig" — 0px of gap in Dark and Stage, 1px in Light, at every size. */
.ai-mark {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin-left: 8px;
  padding: 2px 8px 2px 6px;
  border-radius: 999px;
  background-image: linear-gradient(to right, var(--ai-a), var(--ai-b));
  color: var(--ai-text);
  font-size: var(--db-t-micro, 11px);
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  /* Sits on a line of text, so it must not stretch the line it is on. */
  line-height: 1.5;
  vertical-align: middle;
  white-space: nowrap;
}

.ai-sparkle { width: 12px; height: 12px; flex: none; fill: currentColor; }

/* THE SPARKLE ON A FILLED GRADIENT. Mark: the treatment "was done as an outline
   which is not good enough" and "should be immediately obvious an element is
   AI-powered". A 24px filled chip is that; a hairline was not. */
.ai-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  flex: none;
  border-radius: 7px;
  background-image: linear-gradient(to right, var(--ai-a), var(--ai-b));
  vertical-align: -6px;
  margin-right: 8px;
}
.ai-badge-glyph { width: 15px; height: 15px; fill: var(--ai-text); }

/* Still available for a sparkle drawn straight onto a page with no fill under
   it. Takes the on-surface blue, solved for text contrast. */
.ai-glyph { width: 14px; height: 14px; flex: none; fill: var(--ai-on-surface); }

/* A PANEL THE MODEL IS INVOLVED IN. A FILLED bar across the top, left to right,
   not a border-image hairline. border-image draws a one-pixel line and reads as
   an outline however saturated it is; this is 5px of solid gradient and reads
   as a mark. The panel keeps its own background: a full gradient fill behind
   body text would be a contrast problem in three themes rather than a
   treatment. */
.is-ai-panel {
  position: relative;
  border: 0;
  border-radius: var(--radius);
  overflow: hidden;
  padding-top: calc(9px * var(--density) + 5px);
}
.is-ai-panel::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 5px;
  background-image: linear-gradient(to right, var(--ai-a), var(--ai-b));
}

/* The notice that says text is going to a model. It was a --notice panel with
   an amber left edge, which is the app's colour for "read this" and said
   nothing about what was reading it. Amber goes; the gradient bar and the
   sparkle say who.

   TINTED FROM THE GRADIENT, NOT --surface-raised. On Light, --surface-raised
   and the drawer's --surface-sunken are BOTH #FFFFFF, so the panel measured
   1.00:1 against its own parent and simply did not exist: the 5px bar read as
   a stray rule under the paragraph above it. One of three themes had no panel
   at all, on a treatment whose whole point is being obvious.
   A few per cent of the AI blue over the surface gives a plane in every theme
   without touching the ink, and the side and bottom borders guarantee an edge
   even where the tint is subtle. */
.songs-ai.is-ai-panel {
  background: color-mix(in srgb, var(--ai-a) 9%, var(--surface-raised));
  border-right: 1px solid color-mix(in srgb, var(--ai-a) 30%, transparent);
  border-bottom: 1px solid color-mix(in srgb, var(--ai-a) 30%, transparent);
  border-left: 1px solid color-mix(in srgb, var(--ai-a) 30%, transparent);
}

.songs-ai .ai-glyph { vertical-align: -2px; margin-right: 5px; }

/* A ROW THAT LEADS SOMEWHERE AI. The left edge rather than the top, because
   these sit in a list where a top rule would read as a divider between rows.
   Filled, for the same reason as the panel above.

   TO RIGHT, NOT TO BOTTOM, AND THAT IS THE WHOLE SEPARATION FROM THE TIP
   CONTROL. Sixty lines above, this file reserves top-to-bottom for the tip
   control and left-to-right for this — and then this rule ran top-to-bottom,
   so the file contradicted itself and the one axis keeping a payment control
   and an AI mark apart was gone. tests/ai-treatment.mjs only checked the row
   carried a mark at all, never its direction, so nothing caught it.
   8px rather than 5, because a horizontal ramp across 5px is not a ramp. */
.is-ai-row {
  position: relative;
  border-left: 0;
  overflow: hidden;
}
.is-ai-row::before {
  content: '';
  position: absolute;
  inset: 0 auto 0 0;
  width: 8px;
  background-image: linear-gradient(to right, var(--ai-a), var(--ai-b));
}

/* NOTHING MOVES. There is no animation on any of this, so there is nothing for
   prefers-reduced-motion to turn off — which is the honest way to satisfy that
   requirement rather than adding a shimmer and then suppressing it. A mark that
   glitters would also be the loudest thing on a screen whose theming rules
   reserve emphasis for actions. */

/* THE COUNTS BEFORE THEY ARE COUNTED. Dashes rather than zeros: a zero is a
   figure and would be read as one, and this state is on screen for a fraction
   of a second while the queries land. It must not spend that time telling a
   musician the gig had no requests. */
.wrap-stat.is-waiting b { color: var(--text-muted); }

/* THE LAST GIG, ON THE TOP CARD. Venue, then when, then what it came to.
   Three short lines rather than a sentence: read on the way past. */
.wc-wrap-venue {
  margin: 0;
  color: var(--text-primary);
  font-weight: var(--weight-medium);
}
.wc-wrap-counts { margin-top: 0; }

/* A SHORT SCREEN GETS ITS PIXELS BACK.
 *
 * 375x667 is the reference small phone and it has been marginal in every round
 * of this screen's review: 16px of clearance at best, and one round where the
 * Done button ended six pixels visible at the bottom edge. A sliver of a
 * primary control bleeding off the fold reads as a rendering fault, not as
 * "scroll for more".
 *
 * So the gaps that exist to stop a tall screen looking empty are given back on
 * a screen that has no room to spare. Nothing here changes above 700px tall,
 * which is every phone in landscape, every tablet and every desktop.
 *
 * With three or more leftover songs this screen scrolls whatever it does, and
 * that is fine. What must not happen is the fold landing mid-button. */
@media (max-height: 700px) {
  .wrap-screen {
    padding-top: calc(14px + var(--safe-top, 0px));
    padding-bottom: calc(14px + var(--safe-bottom, 0px));
    gap: 14px;
  }
  .wrap-actions { margin-top: 8px; }
  .wrap-stats { margin-top: 10px; }
  .wrap-quiet { margin-top: 16px; }
  .wrap-stat span { min-height: 0; }
  /* The lead figure keeps its size and gives back its air: on a 667px phone
     every reserved pixel comes off the bottom, and the point of the hero is the
     size, not the margin. */
  /* `.wrap-head .wrap-hero`, MATCHING THE RULE IT HAS TO BEAT.
   *
   * This override has never once fired. It was written as `.wrap-hero`, which
   * scores 0,1,0, against a `.wrap-head .wrap-hero` at 0,2,0 outside any query
   * — and specificity does not care that this one is in a media block. So the
   * guard that exists to protect the 667px phone has been dead for its whole
   * life, and every round of this review broke the fold at 375 while the thing
   * meant to prevent that sat here looking correct.
   *
   * 56/60 rather than the 12/4 this used to claim: the figure still needs to
   * own the largest air on the page, it just cannot own 84/85 of it in a
   * viewport with a 39px deficit. */
  .wrap-head .wrap-hero { margin: 56px 0 60px; }
}


/* ---------------------------------- the last gig's summary, on the dashboard */

/* IT EXPANDS RATHER THAN NAVIGATING, and the animation is a grid row rather
 * than a height. `height: auto` cannot be transitioned, and the usual answers -
 * measuring scrollHeight, or animating max-height to a guess - either force a
 * layout on every open or overshoot and coast. `grid-template-rows: 0fr -> 1fr`
 * animates to the content's own height with the browser doing the measuring,
 * so it settles exactly rather than arriving early and waiting.
 *
 * 220ms. Quick enough not to be waited for, long enough to be seen as an
 * opening rather than a repaint, which is what tells the musician the page did
 * not change under them. */
.wc-summary {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 220ms cubic-bezier(.2, .7, .3, 1);
}
.wc-summary.is-open { grid-template-rows: 1fr; }
.wc-summary-inner { overflow: hidden; min-height: 0; }
.wc-summary.is-open .wc-summary-inner { padding-top: 4px; }

/* AN ANIMATION NOBODY ASKED FOR IS WORSE THAN NONE. The panel still opens; it
   simply arrives rather than travelling. */
@media (prefers-reduced-motion: reduce) {
  .wc-summary { transition: none; }
}

/* The figures inside the card are the same build as the standalone screen, so
   they arrive with that screen's spacing, which is composed for a page it owns
   end to end. Inside a card they need to sit closer. */
.wc-summary .wrap-head .wrap-hero,
.wc-summary .wrap-hero { margin: 20px 0 22px; }
.wc-summary .wrap-quiet { margin-top: 18px; }
.wc-summary-full {
  margin-top: 18px;
  align-self: start;
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-control, 10px);
  background: none;
  color: var(--text-primary);
  font: inherit;
  cursor: pointer;
}
.wc-summary-full:hover { border-color: var(--action-on-surface); color: var(--action-on-surface); }
.wc-summary-full:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* ------------------------------------------- tips, the last four weeks

   A SEPARATE BLOCK UNDER THE OVERVIEW TILES, NOT A SIXTH TILE.
 *
 * Two reasons, and both are about not lying. The tiles obey the period
 * selector above them and this does not — it is always the last four weeks —
 * so it must not take the tiles' shape. And .stat-strip spans its LAST CHILD
 * across the row, so a sixth item dropped into that grid would silently take
 * the span off the fifth tile and leave the played share half a label wide.
 *
 * Separated by a rule rather than by space alone: at 390px the tiles stack one
 * per row, and space on its own would have read as one more tile with a bigger
 * gap above it. */
.tips4w {
  margin-top: calc(14px * var(--density));
  padding-top: calc(12px * var(--density));
  border-top: 1px solid var(--border);
}
.tips4w-head {
  margin: 0;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}
/* THE SAME SIZE AS A TILE'S FIGURE, deliberately. It is the same KIND of fact
   as the numbers above it and giving it a larger type size would have made the
   money the headline of the Overview, which it is not. */
.tips4w-big {
  margin: 2px 0 0;
  font-size: var(--db-t-fig);
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
}
.tips4w-sub {
  margin: 0;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}
/* A LINK-WEIGHT CONTROL, NOT A PILL. The pills on this screen are range
   controls — they pick which figures you are looking at. This one opens an
   explanation of the figure already shown, which is a different job, and giving
   it the same shape would have put a sixth range pill on a card that has four. */
.tips4w-toggle {
  display: inline-flex;
  align-items: center;
  /* 44px, because it is a real target on a phone. The negative margin keeps the
     text sitting where the eye expects while the box stays full height. */
  min-height: 44px;
  margin: 0 0 calc(-8px * var(--density));
  padding: 0;
  border: 0;
  background: none;
  color: var(--action-on-surface);
  font-family: inherit;
  font-size: var(--db-t-hint);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.tips4w-toggle:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

.tips4w-break { margin-top: calc(8px * var(--density)); }
/* Label left, figure hard right, on one baseline. tabular-nums so the column of
   amounts lines up on the decimal point rather than drifting with the digits. */
.tips4w-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin: 0 0 4px;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}
.tips4w-row b {
  font-variant-numeric: tabular-nums;
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
}
/* THE LINE THE OTHERS ADD UP TO, separated and in full-strength text, so the
   sum is visibly the answer rather than one more subtraction. */
.tips4w-row.is-total {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--border);
  color: var(--text-primary);
}
.tips4w-row.is-total b { color: var(--text-primary); font-weight: var(--weight-strong); }
.tips4w-note {
  margin: 8px 0 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  line-height: var(--line-height);
}
/* A QUIET FOUR WEEKS IS A SENTENCE, in the same voice and the same colour as
   the Overview's own empty states one card up. No figure, because a £0.00 in
   large type is a verdict. */
.tips4w.is-empty { padding-top: calc(12px * var(--density)); }
.tips4w-none {
  margin: 0;
  font-size: var(--db-t-small);
  color: var(--text-secondary);
}

/* ------------------------------------------------- the venue sort pills

   THE SHAPE, THE TYPE AND BOTH STATES COME FROM THE SHARED RULE with
   .range-btn and .strip-period, which this joins rather than restates. Three
   range-style controls on one screen that look different are three things to
   learn. What belongs to this one is only where it sits and that it must not
   stretch.

   UNDER THE HEADING, ABOVE THE TABLE, in place of the sentence that used to
   name a fixed sort order. Same position the Overview's period selector takes
   relative to its own heading.

   THE SELECTED PILL IS FILLED, not merely tinted: --action behind
   --action-text, which is the same "this is the one you have chosen" the range
   and period pills already use, and it survives greyscale because the contrast
   between filled and outlined does not depend on hue. */
.venue-sorts {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0 0 10px;
}
/* Three pills at 390px come to about 190px of a 358px card, so they never
   wrap; flex-wrap is there for a large font scale rather than for the default. */
.venue-sort { flex: 0 0 auto; }
.venue-sort:hover { border-color: var(--action-on-surface); color: var(--action-on-surface); }
.venue-sort.is-on:hover { color: var(--action-text); }
.venue-sort:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* ------------------------------ choosing which part of the header shows

   THE FRAME IS THE SHAPE THE AUDIENCE ACTUALLY SEES, AND FOR TWO DAYS IT WAS
   NOT. It said 39:10, which was right when their header was 150px tall on a
   390px phone. The header became 220px on 25 August and this did not move, so
   the real thing was about 16:10 while the musician composed against a letterbox
   half its height. Everything the editor told them was confidently wrong: a face
   centred here sat outside the crop there.

   BUILT FROM THE SAME TWO NUMBERS THE HEADER IS, in css/shared.css, which both
   pages load. Change the height there and this follows. A literal here is what
   allowed the drift, so there is no literal here.

   aspect-ratio rather than a fixed height, so it holds its shape at every width
   this panel is drawn at.

   touch-action: none IS LOAD-BEARING. Without it a drag on a phone scrolls the
   page instead of moving the picture, and the frame is about forty pixels tall
   in a long form, so a vertical drag would almost always be read as a scroll. */
.hdr-crop-wrap { margin-top: 12px; }
.hdr-crop-wrap.hidden { display: none; }
.hdr-crop {
  position: relative;
  width: 100%;
  aspect-ratio: var(--header-ratio);
  min-height: 64px;
  overflow: hidden;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--surface-sunken);
  touch-action: none;
  cursor: grab;
  user-select: none;
}
.hdr-crop.is-dragging { cursor: grabbing; }
/* At cover there is nothing hidden to drag to, so the frame stops pretending
   it can be moved. The hint beside it says why. */
.hdr-crop.is-static { cursor: default; }
.hdr-crop:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.hdr-crop-img {
  position: absolute;
  inset: 0;
  /* background-position and background-size are written from the draft by
     headerCropStyle(), which is the same function that paints the small live
     preview — one arithmetic, two places. */
  background-repeat: no-repeat;
}
/* THE SAME DARKENING THE AUDIENCE GETS, at the same strength, so what is
   dragged into view here is judged against the treatment it will actually
   carry. A musician choosing a face against an undarkened preview would pick
   the wrong part of the picture. */
.hdr-crop-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom,
    transparent 0%,
    rgba(0, 0, 0, 0.22) 38%,
    rgba(0, 0, 0, 0.72) 68%,
    rgba(0, 0, 0, 0.82) 100%);
  pointer-events: none;
}
/* Inside the frame, over the darkest part of the scrim, so it is legible over
   any photo for the same reason the audience's own title is. */
.hdr-crop-hint {
  position: absolute;
  inset: auto 0 0;
  margin: 0;
  padding: 0 10px 6px;
  color: #fff;
  font-size: var(--db-t-hint);
  text-align: center;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
  pointer-events: none;
}
.hdr-crop-controls {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 10px;
}
.hdr-crop-zoom-label { flex: 0 0 auto; margin: 0; font-size: var(--db-t-small); color: var(--text-secondary); }
.hdr-crop-zoom { flex: 1 1 auto; min-width: 0; min-height: 44px; }
.hdr-crop-reset { flex: 0 0 auto; min-height: 44px; padding: 0 14px; border-radius: var(--radius-md); font-size: 13px; }

/* ------------------------------------- going back into the last gig

   THE RETURN BUTTON IS THE FILLED ONE WHILE THE WINDOW IS OPEN, and Go Live
   steps down beside it. Inside sixty minutes of ending a gig, a musician on
   this screen is far more likely to have ended it by accident, or to have been
   asked for one more, than to be starting a different gig somewhere else.

   Go Live does not move, does not lose its words and never drops below its
   touch target. It stops being filled and becomes outlined, which is the same
   pair this app already uses everywhere a primary and a secondary sit together.
   Once the window closes this button is gone and Go Live is filled again, which
   is exactly how the card looked before any of this existed. */
/* SMALL, AND SUBORDINATE IN EVERY STATE.
 *
 * It shipped as a full-width solid button in the accent colour — the same
 * shape, the same size and the same fill as Go Live, directly above it. Two
 * primary actions is no primary action, and the one that lost was the one that
 * matters.
 *
 * So it is an outlined button that hugs its own words, inside the last gig's
 * card where it belongs. It is still 44px tall, because it is a real control on
 * a phone and the touch target is not the thing to economise on: the weight
 * comes off through fill, width and type size, not through making it hard to
 * hit. */
.wc-resume {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  align-self: flex-start;
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--action-on-surface);
  font-family: inherit;
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  cursor: pointer;
}
.wc-resume:hover { border-color: var(--action-on-surface); }
.wc-resume:active { background: var(--action-quiet, transparent); }
.wc-resume:disabled { opacity: 0.7; cursor: default; }
.wc-resume:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
/* WHICH GIG, AND HOW LONG THERE IS TO DECIDE. Directly under the button rather
   than above it: it is the small print of the thing that has just been offered,
   and above the button it would be read before anybody knew what it described. */
/* PULLED UP AGAINST ITS OWN BUTTON. The card's gap separates unrelated things;
   these two are one thing, so the negative margin cancels most of it and the
   line reads as the button's own small print rather than as the next item. */
.wc-resume-sub {
  margin: calc(-2px * var(--density)) 0 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
}
/* GO LIVE NEVER STANDS DOWN.
 *
 * A `.wc-golive.is-second` rule stood here and made the button outlined
 * whenever a gig could still be resumed. That is backwards: it turned the most
 * important control on the dashboard into a secondary one at exactly the moment
 * a second button appeared next to it. Mark's instruction is that Go Live is
 * the solid purple button in every state, with no exceptions, so there is no
 * conditional rule to be found here any more. The resume button is what is
 * quiet; see .wc-resume. */

/* THE FULL-WIDTH SLAB, ONLY WHERE IT IS ONE.
 *
 * A container query rather than a viewport one, because the welcome card is
 * `panel-wide` and its width is set by the dashboard's grid, not by the window.
 * 640px is where the card stops being roughly phone-shaped; below it these stay
 * full width, which is what a phone wants and what Mark is not complaining
 * about. Above it they take their own width and sit at the start of the card,
 * so the accent colour is a button rather than a horizon.
 *
 * min-width keeps them a deliberate size rather than shrink-wrapping to the
 * word "Go Live", which would read as a minor control on the screen's most
 * important action. */
@container (min-width: 640px) {
  /* Go Live only. The resume button already hugs its own words at every width;
     giving it a 260px floor here is what made it a slab beside Go Live. */
  .wc-golive {
    width: auto;
    min-width: 260px;
    align-self: flex-start;
  }
}

/* ------------------------------------- the last gig, as a card of its own

   NESTED INSIDE THE WELCOME CARD, NOT LOOSE IN IT. It was four separate things
   in a row — a button, three lines about the gig, an accordion, and the way
   back — with nothing tying them together. A musician reading down the card met
   them one at a time and had to work out that they were all about one gig.

   ONE STEP QUIETER THAN THE CARD AROUND IT, which is the same relationship
   .stat-tile has to its panel: --surface-sunken against the welcome card's
   --surface. It reads as a thing inside a thing rather than a second card
   competing with the first.

   GO LIVE IS DELIBERATELY OUTSIDE IT. This is the record of a gig that has
   finished; Go Live is what happens next, and it is the primary action on the
   screen whatever is in here. */
.wc-lastgig {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: calc(6px * var(--density));
  padding: calc(12px * var(--density)) calc(14px * var(--density));
  border-radius: var(--radius);
  background: var(--surface-sunken);
}
/* The heading button loses its own top margin: the card's padding is the space
   now, and both would read as a gap nobody chose. */
.wc-lastgig .wc-wrap-go { margin-top: 0; }

/* ------------------------------------------- the header overlay controls

   WITH THE PREVIEW FRAME, NOT IN A SETTINGS LIST. The whole point of an overlay
   is what it looks like, so the control that sets it has to be next to the
   thing it changes. */
.hdr-overlay {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin-top: 12px;
}
.hdr-overlay-label { font-size: var(--db-t-small); color: var(--text-secondary); }
.hdr-overlay-pills { display: flex; gap: 6px; }
/* The same pill the range and period controls use, so a third set of range-ish
   buttons on this screen is not a third thing to learn. */
.hdr-ov {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 5px 14px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-family: inherit;
  font-size: var(--db-t-hint);
  cursor: pointer;
}
.hdr-ov.is-on {
  background: var(--action);
  border-color: var(--action);
  color: var(--action-text);
  font-weight: var(--weight-medium);
}
.hdr-ov:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.hdr-overlay-strength {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1 1 200px;
  min-width: 0;
}
.hdr-overlay-strength[hidden] { display: none; }
.hdr-overlay-strength label { font-size: var(--db-t-small); color: var(--text-secondary); }
.hdr-overlay-strength input { flex: 1 1 auto; min-width: 0; min-height: 44px; }
.hdr-overlay-strength output {
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  min-width: 3.5ch;
  text-align: right;
}

/* -------------------------------------------- removing an image, confirmed

   .btn-danger is what this app already uses for a destructive action, and the
   two-answer question in place is the pattern the dashboard uses for deleting a
   venue or a gig. Neither is new. */
.img-remove { margin-top: 10px; }
.img-confirm {
  margin-top: 10px;
  padding: 10px 12px;
  border-radius: var(--radius-md);
  border: 1px solid var(--negative);
  background: var(--negative-surface, transparent);
}
.img-confirm-q { margin: 0 0 8px; font-size: var(--db-t-small); }
.img-confirm-acts { display: flex; gap: 8px; }

/* ============================================================================
   C5. THE PROFILE FORM'S CARDS ARE SECTIONS THAT OPEN
   ----------------------------------------------------------------------------
   APPENDED BLOCK. Everything the profile accordions need is here, in one place,
   and nothing above this line was touched. The behaviour and the header markup
   are in js/views/profile-accordions.js.

   WHY. The profile page arrived as six full cards down one column, about four
   thousand pixels of form on a phone, so a musician who came to change their
   tagline read past a theme grid, two file pickers, a crop frame and the whole
   tip editor to find it. Shut, each card is a named bar; open, it is the card
   it always was.

   THE ANIMATION IS A GRID ROW, NOT A HEIGHT, and it is the one the dashboard
   already uses for the last gig's summary (.wc-summary, further up this file).
   height: auto cannot be transitioned; measuring scrollHeight forces a layout
   on every open, and animating max-height to a guess overshoots and then
   coasts. 0fr to 1fr animates to the content's own height with the browser
   doing the measuring, so it settles exactly.

   220ms, and the same number is written into js/views/profile-accordions.js as
   DURATION. If one moves the other moves in the same commit, or the scrolling
   preview is told the layout has settled while it is still in flight.
   ========================================================================== */

/* The header row carries the padding, so a shut section is a compact bar rather
   than an empty card with twenty pixels of nothing under its title. */
.panel.acc { padding: 0; }

.acc > .acc-h { margin: 0; }

.acc-btn {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  /* 100%, not auto. A <button> shrinks to fit even as a block-level flex
     container, the way form controls do, so `auto` left the header the width of
     its own words: the chevron sat against the title instead of at the far end
     of the row, and three quarters of the card was not a tap target. */
  width: 100%;
  min-height: 44px;
  padding: 14px var(--db-pad);
  border: 0;
  border-radius: var(--radius-lg);
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.acc-title {
  font-size: var(--db-t-head);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}
/* Inside the card, not around it: an outline drawn outside a full-width header
   is clipped by the card's own rounded corners on three sides. */
.acc-btn:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: -3px; }
.acc-btn:hover .acc-title { color: var(--action-on-surface); }

.acc-chev {
  flex: none;
  width: 20px;
  height: 20px;
  color: var(--text-secondary);
  transition: transform 220ms cubic-bezier(.2, .7, .3, 1);
}
.acc.is-open .acc-chev { transform: rotate(180deg); }
.acc-btn:hover .acc-chev { color: var(--action-on-surface); }

.acc-body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 220ms cubic-bezier(.2, .7, .3, 1);
  /* SIDEWAYS ONLY, AND THAT IS THE POINT. Padding on the clipped child is
     padding a shut section still has to draw: overflow: hidden clips at the
     PADDING edge, not the content edge, so twenty pixels of bottom padding on
     .acc-inner showed twenty pixels of the section's first line under every
     shut header. Horizontal padding costs no height, so it can live out here. */
  padding-inline: var(--db-pad);
}
.acc-body.is-open { grid-template-rows: 1fr; }
.acc-inner {
  overflow: hidden;
  min-height: 0;
}
/* The bottom padding, as content rather than as padding, so it is inside the
   box that gets clipped and a shut section is exactly nothing tall. */
.acc-inner::after { content: ''; display: block; height: var(--db-pad); }
/* CLIPPING IS FOR THE ANIMATION AND FOR NOTHING ELSE. Left on after the section
   has settled it chops the focus ring off every field that sits against the
   card's edge, which is most of them. js/views/profile-accordions.js adds
   .is-settled when the movement stops. */
.acc-body.is-open.is-settled .acc-inner { overflow: visible; }
/* The first thing in a section must not add its own top margin under a header
   that has already spaced itself. */
.acc-inner > :first-child { margin-top: 0; }

/* AN ANIMATION NOBODY ASKED FOR IS WORSE THAN NONE. The section still opens; it
   simply arrives rather than travelling. The module reads the same query and
   announces the settle immediately rather than waiting for a transitionend that
   is never coming. */
@media (prefers-reduced-motion: reduce) {
  .acc-body, .acc-chev { transition: none; }
}

/* ---- the one card that is NOT a section that opens -------------------------
 *
 * Most requested is a single switch. A header that has to be tapped before the
 * switch can be tapped is two taps for one decision, so it stays flat: its
 * label sits where the other cards' titles sit, at the same size, and the
 * switch sits at the other end of the same row. */
.panel.profile-flat { padding: 14px var(--db-pad) var(--db-pad); }

.toggle.toggle-row {
  width: 100%;
  margin: 0;
  min-height: 44px;
  gap: 16px;
  justify-content: space-between;
}
/* The input has to stay immediately before the track for the :checked and
   :focus-visible rules further up this file to reach it, so the visual order is
   set here rather than by moving anything in the markup. */
.toggle.toggle-row .toggle-text {
  order: 1;
  font-size: var(--db-t-head);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}
.toggle.toggle-row .toggle-track { order: 2; }
.panel.profile-flat .field-hint { margin-top: 8px; }

/* ==========================================================================
   WHAT SITS INSIDE THE LOGO CIRCLE
   Appended as one block on purpose: several people are editing this file at
   once and scattered edits do not survive a merge. Everything here is logo.
   The behaviour is in js/views/logo-crop.js.
   ========================================================================== */

/* THE TWO SHAPE OPTIONS SAT WITH THEIR WORDS UNDER THEIR DOTS, and had done
   since the control was written. Nothing to do with the words: `.crop-option`
   asks for `display: flex` and `.crop-option input` for an 18px dot, and BOTH
   ARE OUTRANKED BY THE PANEL'S OWN FORM RULES a few lines further down the
   file — `.profile-grid label { display: block }` and `.profile-grid input
   { height: var(--db-field) }`. Same specificity, later in the file, so they
   win: the label stopped being a flex row and the radio grew to a 48px form
   control, which pushed its own text onto the next line.

   Fixed here rather than by editing either of those, because both are shared
   with every other field in the panel and this is the only control that wants
   a dot beside a word. THE TAP TARGET MOVES TO THE LABEL, which is where it
   belongs: the whole row is 44px and clickable, and the dot inside it is 18px,
   rather than an 18px dot pretending to be a 48px one. */
.profile-grid .crop-row .crop-option {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
}
.profile-grid .crop-row .crop-option input {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  min-height: 0;
  margin-top: 0;
}

/* THE FRAME IS THE CIRCLE ITSELF, not a square with a circle drawn on it. The
   corners of a square frame are corners the audience never sees, and a preview
   of a shape nobody gets is a preview of something else. 160px because the
   thing being judged is a disc about a hundred pixels across on a phone and a
   drag needs somewhere to happen; the crop does not depend on the size, only on
   the shape, and a circle is the same shape at every size.

   touch-action: none IS LOAD-BEARING. Without it a drag on a phone scrolls the
   page instead of moving the image, and this control sits well down a long
   form where a vertical drag would almost always be read as a scroll. */
.logo-crop-wrap { margin-top: 14px; }
.logo-crop-wrap.hidden { display: none; }
.logo-crop {
  position: relative;
  width: 160px;
  height: 160px;
  max-width: 100%;
  aspect-ratio: 1;
  overflow: hidden;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--surface-sunken);
  touch-action: none;
  cursor: grab;
  user-select: none;
}
.logo-crop.is-dragging { cursor: grabbing; }
/* Nothing hidden to drag to, so the frame stops pretending it can be moved.
   The hint below says why. */
.logo-crop.is-static { cursor: default; }
.logo-crop:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 3px; }
.logo-crop-img {
  position: absolute;
  inset: 0;
  /* background-position and background-size are written from the three numbers
     by js/views/logo-crop.js, through coverAtZoom() out of js/lib.js — the same
     per-axis cover rule the header's drag uses. */
  background-repeat: no-repeat;
}
/* OUTSIDE THE DISC, unlike the header's, which sits over its own scrim. There
   is no scrim here: a logo is often a transparent PNG and darkening a strip of
   it would misrepresent what the audience sees. */
.logo-crop-hint {
  margin: 8px 0 0;
  font-size: var(--db-t-hint);
  color: var(--text-secondary);
  max-width: 320px;
}
.logo-crop-controls {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 10px;
  max-width: 320px;
}
.logo-crop-zoom-label { flex: 0 0 auto; margin: 0; font-size: var(--db-t-small); color: var(--text-secondary); }
.logo-crop-zoom { flex: 1 1 auto; min-width: 0; min-height: 44px; }
.logo-crop-reset { flex: 0 0 auto; min-height: 44px; padding: 0 14px; border-radius: var(--radius-md); font-size: 13px; }

/* THE LIVE PREVIEW PAINTS THE CROP THE AUDIENCE GETS, by the same rule the
   audience page uses — see the block proposed for .logo.crop-circle in
   css/public.css. It cannot use background-size, because the preview's logo is
   an <img> exactly as the audience's is, so:

     object-view-box   insets each axis by (1 - 1/zoom) of itself, split between
                       the two sides in the focal point's proportion. That is a
                       view box of the SAME ASPECT as the image, so cover then
                       scales it by (box x zoom) / short-axis.
     object-position   moves it by the focal point's share of what is left over.

   Multiply the two out and the visible window is focal x (long - short/zoom)
   from the edge on both axes, which is exactly what background-position and
   background-size paint in the frame above. Checked side by side on a wide, a
   tall, a square and a transparent image at seven settings each.

   AND IT DEGRADES THE RIGHT WAY. A browser with no object-view-box drops the
   inset and keeps object-position, so the circle is still centred where the
   musician put it and simply is not zoomed in. There is no state in which it
   shows the wrong part of the image.

   The custom properties are written by paintLogoVars() in
   js/views/logo-crop.js. The fallbacks are the pre-migration rendering: dead
   centre at cover. */
.pv-logo.crop-circle {
  object-position:
    calc(var(--logo-focal-x, 50) * 1%)
    calc(var(--logo-focal-y, 50) * 1%);
}
@supports (object-view-box: inset(0%)) {
  .pv-logo.crop-circle {
    object-view-box: inset(
      calc((100 - 10000 / var(--logo-zoom, 100)) * var(--logo-focal-y, 50) / 100 * 1%)
      calc((100 - 10000 / var(--logo-zoom, 100)) * (100 - var(--logo-focal-x, 50)) / 100 * 1%)
      calc((100 - 10000 / var(--logo-zoom, 100)) * (100 - var(--logo-focal-y, 50)) / 100 * 1%)
      calc((100 - 10000 / var(--logo-zoom, 100)) * var(--logo-focal-x, 50) / 100 * 1%));
  }
}

/* ===========================================================================
   THE PREVIEW IS THE AUDIENCE PAGE, AT FULL SIZE, SHRUNK BY ONE TRANSFORM
   ===========================================================================

   WHAT WAS HERE AND WHY IT COULD NOT WORK.

   The mock-up was a set of small numbers chosen by eye: a 7px eyebrow, a 17px
   heading, a 26px search field, a logo capped at "82% and 48%". Every one of
   them was a guess at what some real number would look like small, and no two
   of the guesses were made at the same ratio. Measured against the real page
   at 390x844, the eyebrow was 20% too small relative to its neighbours, the
   heading 30%, the search field 34% too short, the logo 8% short in height and
   24% short in width, and the phone itself was 440px of screen where a phone
   has 844. A musician could not have seen a spacing fault from that pane, and
   D-F7 is the record of a preview that showed a layout which no longer existed
   at all.

   WHAT IS HERE NOW. .pv-page is 390 x 844 CSS pixels — the phone this page is
   opened on — and every rule below is the corresponding rule from
   css/public.css or css/shared.css with its numbers unchanged. The whole page
   is then scaled by one transform on .pv-viewport. So:

     · comparing the preview with the real page is comparing two numbers that
       should be IDENTICAL, rather than two numbers that should be in a ratio
       nobody wrote down;
     · --density, --radius, --tracking-* and --line-height arrive from the
       AUDIENCE theme (applyTheme writes them onto #previewRoot), so
       `calc(22px * var(--density))` in here means exactly what it means over
       there, on all seven presets;
     · one number, --pv-w, decides how big the mock-up is drawn. Nothing
       inside it knows.

   REM IS NOT USED IN HERE, AND THAT IS DELIBERATE. `rem` resolves against the
   DASHBOARD's root font size, which the app theme scales (Stage runs larger).
   The audience's own --font-scale is 1 on every punter preset, so the px
   values below are the rem values resolved once. If a punter preset ever takes
   a scale, this block needs a `calc(Npx * var(--font-scale))` pass and
   tests/preview-accuracy.mjs will say so.

   WHAT IS DELIBERATELY NOT DRAWN. The tip card (.tip-inline). It is a whole
   component out of js/tip.js with its own two-screen flow, and the Tips
   section of this form already carries a dedicated, larger mock of it. Drawing
   a second, smaller copy here would be two things to keep in step. The
   consequence is that on an artist with tips switched on, everything below the
   Popular list sits a little higher here than it does on the phone.
   =========================================================================== */

/* A device, so it is drawn like one: a hard bezel and a real drop shadow.
   --border-strong is the darkest line role in every theme, which is what a
   bezel wants to be. */
.phone {
  /* THE ONE NUMBER. The screen's drawn width; everything else follows.
     390 is the reference device css/shared.css already names (--header-ref-w-n)
     and 844 is its height.

     UNITLESS, FOR THE SAME REASON --header-h-n IS. `transform: scale()` takes a
     NUMBER, and calc(264px / 390) is 0.677px — which is not a number, so the
     whole transform is dropped and the mock-up silently draws at full size
     inside a two-thirds-width window. The px width is derived from the number
     rather than the other way round. */
  --pv-w-n: 264;
  --pv-w: calc(var(--pv-w-n) * 1px);
  --pv-scale: calc(var(--pv-w-n) / 390);
  width: calc(var(--pv-w) + 16px);
  border: 8px solid var(--border-strong);
  border-radius: 26px;
  overflow: hidden;
  box-shadow: var(--shadow-2);
}
.phone-screen {
  position: relative;
  width: var(--pv-w);
  height: calc(844px * var(--pv-scale));
  overflow: hidden;
  background: var(--bg);
  color: var(--text-primary);
  font-family: var(--font);
}
/* The whole audience page, at its own size, drawn small. transform-origin at
   the top left so the scale is a pure shrink with no offset to correct. */
.pv-viewport {
  width: 390px;
  height: 844px;
  transform: scale(var(--pv-scale));
  transform-origin: 0 0;
}

/* ------------------------------------------------------------- the page ---
   css/public.css .app, minus max-width (irrelevant at 390) and the safe-area
   insets (there is no notch on a mock-up). */
.pv-page {
  position: relative;
  width: 390px;
  min-height: 844px;
  padding: calc(28px * var(--density)) 20px calc(40px * var(--density));
  background: var(--bg);
  color: var(--text-primary);
  font-family: var(--font);
  font-weight: var(--weight-normal);
  letter-spacing: var(--tracking-normal);
  line-height: var(--line-height);
  font-size: 16px;
  will-change: transform;
}
.pv-page * { box-sizing: border-box; }
/* :where(), so this contributes NO specificity and every component rule below
   outranks it. As `.pv-page p` it was (0,1,1) and quietly beat .pv-explainer
   and .pv-note, which put 16px of margin where css/public.css says 0 and
   pushed everything under the search field 16px down the page. That is the
   exact class of fault this whole block exists to stop the preview having.

   `1em`, not `16px`, because that is the browser default the real page runs on
   and `em` is relative to the paragraph's OWN size. The form note is 12px type,
   so its margin is 12px there; written as a flat 16 the preview put four extra
   pixels above the Popular list and every element under it. */
.pv-page :where(p) { margin: 1em 0; }
.pv-muted { color: var(--text-secondary); }
/* Not `hidden`: several of these elements are REMOVED from the real page
   rather than hidden, and the difference the preview has to show is that the
   space goes with them. */
.pv-gone { display: none !important; }

/* ----------------------------------------------------------- the header ---
   css/public.css .header-image. The height and the ratio come from
   css/shared.css, which css/public.css also reads, so a change to
   --header-h-n moves the audience's header and this one together and the
   eyebrow below follows on its own. */
.pv-header {
  --header-blend: calc(18px * var(--density));
  position: relative;
  height: var(--header-h);
  margin: calc(-28px * var(--density)) -20px var(--header-blend);
  background-size: cover;
  background-position: center;
}
.pv-header::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom,
    color-mix(in srgb, var(--bg) 55%, transparent),
    var(--bg));
}
/* THE ORDER OF THESE TWO LAYERS IS LOAD-BEARING AND THE PREVIEW HAD IT WRONG.
 *
 * In the `background` shorthand the FIRST layer is painted ON TOP. The preview
 * listed the musician's overlay first, so the wash went over the fade — which
 * is precisely the fault A1 fixed on the public page in July, still sitting in
 * the pane the musician checks their work in. With an overlay set, the preview
 * drew a hard seam at the bottom of the photograph that the audience does not
 * get, in proportion to the overlay's strength.
 *
 * Fade on top, wash underneath, and the stops are css/public.css's own — six
 * of them beginning 132px above the photo's bottom edge (A2), not the three
 * beginning at 74% that were here. */
.pv-header.has-title::after {
  inset: 0 0 calc(-1 * var(--header-blend));
  background:
    linear-gradient(to bottom,
      transparent calc(100% - var(--header-blend) - 132px),
      color-mix(in srgb, var(--bg) 12%, transparent) calc(100% - var(--header-blend) - 104px),
      color-mix(in srgb, var(--bg) 30%, transparent) calc(100% - var(--header-blend) - 74px),
      color-mix(in srgb, var(--bg) 55%, transparent) calc(100% - var(--header-blend) - 46px),
      color-mix(in srgb, var(--bg) 78%, transparent) calc(100% - var(--header-blend) - 24px),
      color-mix(in srgb, var(--bg) 93%, transparent) calc(100% - var(--header-blend) - 9px),
      var(--bg) calc(100% - var(--header-blend))),
    linear-gradient(to bottom,
      rgba(var(--header-overlay, 0, 0, 0), var(--header-overlay-a, 0)) 0%,
      rgba(var(--header-overlay, 0, 0, 0), var(--header-overlay-a, 0)) 100%);
  background-size: 100% 100%, 100% calc(100% - var(--header-blend));
  background-repeat: no-repeat;
}
.pv-header-title {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(12px * var(--density)) 20px;
}
.pv-header-title.has-featured { flex-direction: column; }
.pv-featured-links {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  margin-top: clamp(4px, calc(var(--logo-scale, 100) * .05px), 8px);
}
.pv-artist-name + .pv-featured-links { margin-top: .35em; }
.pv-featured-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border: 1px solid var(--border-strong);
  border-radius: 50%;
  background: var(--surface-raised);
  color: var(--text-primary);
  box-shadow: var(--shadow-1);
}
.pv-featured-link svg { width: 15px; height: 15px; fill: currentColor; }
.pv-header .pv-featured-link {
  width: 28px;
  height: 28px;
  border-color: rgba(255, 255, 255, .82);
  background: rgba(15, 11, 22, .9);
  color: #fff;
  box-shadow: 0 1px 5px rgba(0, 0, 0, .7);
}

/* --------------------------------------------------------- the logo ------
   css/public.css .logo and its four header-image overrides, verbatim. The
   preview had "82% of the width and 48% of the height", which is 320/390 and
   105/220 rounded once and then measured against a padded box, so the logo
   came out 8% short in height and 24% short in width.

   J2: AND THE SIZE, VERBATIM AS WELL. --logo-scale is a percent on the box's
   two caps, exactly as in css/public.css, and the working is written out there
   rather than repeated here. It matters more in this file than in most of this
   block that the numbers are the same numbers: .pv-page IS 390 CSS px wide, so
   `100%` on the width and the 350px of content behind it mean the same thing in
   the mock-up as they do on the phone, and the point at which a wide logo runs
   out of room is the SAME point. js/views/logo-size.js works that point out
   against 390 for exactly this reason. */
.pv-logo {
  width: min(
    calc(280px * var(--logo-scale, 100) / 100),
    calc(80% * var(--logo-scale, 100) / 100),
    100%);
  max-height: calc(105px * var(--logo-scale, 100) / 100);
  object-fit: contain;
}
.pv-logo.crop-square { border-radius: var(--radius); }
.pv-logo.crop-circle {
  width: calc(108px * var(--logo-scale, 100) / 100);
  height: calc(108px * var(--logo-scale, 100) / 100);
  max-height: none;
  border-radius: 50%;
  object-fit: cover;
  border: 2px solid var(--border);
}
.pv-header.has-title .pv-logo {
  width: min(calc(320px * var(--logo-scale, 100) / 100), 100%);
  max-height: calc(105px * var(--logo-scale, 100) / 100);
  filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
.pv-header.has-title .pv-logo.crop-circle {
  width: calc(108px * var(--logo-scale, 100) / 100);
  height: calc(108px * var(--logo-scale, 100) / 100);
  max-height: none;
}
.pv-logo.is-inverted { filter: invert(1) hue-rotate(180deg); }
.pv-header.has-title .pv-logo.is-inverted {
  filter: invert(1) hue-rotate(180deg) drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
/* The plate is the artist's artwork mounting, so it keeps the real page's
   padding and radius rather than the small ones the miniature used. The raw
   white is deliberate and is explained where .pv-logo.has-plate is first
   declared further up this file; this restates the geometry only. */
.pv-logo.has-plate { padding: 12px 16px; border-radius: var(--radius); box-shadow: var(--shadow-2); }
.pv-logo.crop-circle.has-plate { padding: 8px; }
/* NOTHING IS DRAWN BEHIND A LOGO ON A PHOTOGRAPH. */
.pv-header.has-title .pv-logo.has-plate { background: none; padding: 0; box-shadow: none; }

/* The name, standing in for a logo. Two sizes, because the real page has two:
   28px over a photograph, 22px in the flow. */
.pv-artist-name {
  font-size: 22px;
  font-weight: var(--weight-strong);
  letter-spacing: -.02em;
}
.pv-header.has-title .pv-artist-name {
  margin: 0;
  max-width: 100%;
  color: #fff;
  font-size: 28px;
  line-height: 1.15;
  letter-spacing: normal;
  text-align: center;
  text-wrap: balance;
  /* The public rule's halo, as a custom property for the same reason: the
     Shadow control adds to it rather than replacing it. See css/public.css. */
  --typed-name-halo:
    0 1px 3px rgba(0, 0, 0, 0.55),
    0 0 2px rgba(0, 0, 0, 0.65),
    0 0 8px rgba(0, 0, 0, 0.45);
  text-shadow: var(--typed-name-halo);
}
.pv-artist-name[data-typed-name] {
  width: 100%;
  min-width: 0;
  font-family: var(--typed-name-font, var(--font));
  font-size: calc(22px * var(--typed-name-scale, 1) * var(--typed-name-fit, 1));
  text-align: var(--typed-name-align, center);
}
.pv-header.has-title .pv-artist-name[data-typed-name] {
  font-size: calc(28px * var(--typed-name-scale, 1) * var(--typed-name-fit, 1));
  /* The public rule retains .artist-name's -.02em over a photograph. The old
     preview wrote `normal` and lied by several pixels across a long name. */
  letter-spacing: -.02em;
  text-align: var(--typed-name-align, center);
}
.pv-artist-name[data-typed-name-lines]:not([data-typed-name-lines="0"]) { text-wrap: nowrap; }
.pv-artist-name .typed-name-line { display: block; white-space: nowrap; }
.pv-artist-name.has-typed-name-colour,
.pv-header.has-title .pv-artist-name.has-typed-name-colour { color: var(--typed-name-colour, inherit); }
/* Paired exactly as css/public.css pairs them, because the preview's whole job
   is to be the same rendering. In flow the choice is the entire shadow; over a
   photograph it is added to the halo. */
.pv-artist-name[data-typed-name-shadow="none"] { text-shadow: none; }
.pv-header.has-title .pv-artist-name[data-typed-name-shadow="none"] {
  text-shadow: var(--typed-name-halo);
}
.pv-artist-name[data-typed-name-shadow="soft"] { text-shadow: 0 2px 6px rgba(0, 0, 0, .55); }
.pv-header.has-title .pv-artist-name[data-typed-name-shadow="soft"] {
  text-shadow: var(--typed-name-halo), 0 2px 6px rgba(0, 0, 0, .55);
}
.pv-artist-name[data-typed-name-shadow="strong"] {
  text-shadow:
    0 2px 3px rgba(0, 0, 0, .9),
    0 0 8px rgba(0, 0, 0, .85),
    0 0 18px rgba(0, 0, 0, .65);
}
.pv-header.has-title .pv-artist-name[data-typed-name-shadow="strong"] {
  text-shadow:
    var(--typed-name-halo),
    0 2px 3px rgba(0, 0, 0, .9),
    0 0 8px rgba(0, 0, 0, .85),
    0 0 18px rgba(0, 0, 0, .65);
}
.pv-artist-name.hidden, .pv-logo.hidden { display: none; }

/* ------------------------------------------------------- eyebrow and h1 ---
   .eyebrow from css/shared.css plus its margin-top from css/public.css, and h1
   from css/shared.css. The eyebrow's position is therefore a consequence of
   --header-h and --header-blend rather than a number typed in here, which is
   what lets it follow the header when the header changes shape. */
.pv-head { text-align: center; }
.pv-eyebrow {
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  font-size: 13px;
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
  margin-top: calc(15px * var(--density));
}
.pv-h1 {
  font-size: 36px;
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-tight);
  line-height: var(--line-height);
  margin: 8px 0;
  text-wrap: balance;
}
.pv-tagline { margin: 1em 0; }

/* -------------------------------------------------------------- the card --
   .card from css/shared.css, .card's margin from css/public.css. */
.pv-card {
  display: block;
  margin-top: calc(24px * var(--density));
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: calc(22px * var(--density));
}
.pv-label { font-weight: var(--weight-medium); }
/* #forLabel, which is the one label on the page with a margin. */
.pv-forlabel { display: block; margin-top: calc(18px * var(--density)); }
/* .search-explainer */
.pv-explainer {
  margin: 0 0 8px;
  font-size: 13px;
  color: var(--text-secondary);
  line-height: var(--line-height);
}
/* .field-note, which is 11px and NOT 13. It is its own rule in css/public.css
   and its own rule here, because that is what it is on the page: the two were
   written as one treatment and Mark decided on the page that they are two.
   A preview whose helper text is two pixels bigger than the page's is a
   preview a musician has to learn to distrust, which is what this whole block
   exists to prevent. Kept as a full copy rather than a modifier on
   .pv-explainer, so the two cannot be read as variations of one thing. */
.pv-fornote {
  margin: 8px 2px 0;
  font-size: 11px;
  color: var(--text-secondary);
  line-height: var(--line-height);
}
/* `input` from css/shared.css, drawn as a div carrying its placeholder. 58px
   tall, not the 26 the miniature used. The margin-top is `.search-wrap input`;
   the second field has none, matching the real page. */
.pv-field {
  display: flex;
  align-items: center;
  /* A REAL <input> DOES NOT INHERIT THESE. The UA gives form controls
     `line-height: normal` and `letter-spacing: normal` whatever the page says,
     and css/shared.css sets only font-family and font-size on them. A div
     standing in for an input inherits both from the page, so its placeholder
     came out at a different width and a different height from the real one. */
  line-height: normal;
  letter-spacing: normal;
  width: 100%;
  height: 58px;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: var(--surface-sunken);
  color: var(--text-muted);
  padding: 0 16px;
  font-size: 16px;
}
#pvSongField { margin-top: calc(10px * var(--density)); }
/* #requestedFor has a margin of its own on the real page, and the preview did
   not have it, so everything from the Send button down sat 10px high. */
#pvForField { margin-top: calc(10px * var(--density)); }
/* #send: `button` and `.btn-primary` from css/shared.css, sized in
   css/public.css. The 44px floor is the touch-target rule and is copied with
   it, because a tight preset is exactly when the preview has to be honest. */
.pv-send {
  /* INLINE-flex, because #send is a <button> and a button is inline-block. An
     inline-block sits in a line box and the part of it below the text's
     baseline hangs out of that box, so the paragraph under it starts higher
     than the button's own bottom edge. Drawn as a block the preview put four
     pixels there that the phone does not have, and every element below
     inherited them. */
  display: inline-flex;
  width: 100%;
  align-items: center;
  justify-content: center;
  text-align: center;
  line-height: normal;
  letter-spacing: normal;
  height: max(44px, calc(56px * var(--density)));
  margin-top: calc(12px * var(--density));
  border-radius: var(--radius);
  background: var(--action);
  color: var(--action-text);
  font-size: 16px;
  font-weight: var(--weight-medium);
}
/* .note */
.pv-note { text-align: center; font-size: 12px; line-height: 1.5; }

/* ------------------------------------------------------- browse the list --
   `.browse-open` and `.browse-glyph` from css/public.css, verbatim, including
   the 44px touch-target floor. The audience has had this button under the
   search field since it stopped being strict-only, and the preview had never
   drawn it: seventy-one pixels of control that every account with a song list
   shows, missing from the one picture the musician gets of their own page.
   Everything below it in the mock-up therefore sat that much too high, which
   is what tests/preview-accuracy.mjs was reporting as a spread. */
.pv-browse {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  height: max(44px, calc(50px * var(--density)));
  margin-top: calc(12px * var(--density));
  background: var(--action-quiet);
  border: 1px solid var(--action);
  border-radius: var(--radius);
  color: var(--action-quiet-text);
  font-weight: var(--weight-strong);
  /* A <button> resets both; a div standing in for one inherits the page's. */
  line-height: normal;
  letter-spacing: normal;
  font-size: 16px;
}
.pv-browse svg { width: 17px; height: 17px; fill: currentColor; flex: none; }

/* ---------------------------------------------------------- the top list --
   .top-list, .top-row, .rank, .top-song, .top-artist and .vote, verbatim. The
   miniature had one row and no heading; the real page has a heading, five
   rows, the artist under each title and a vote pill on each. */
.pv-top { margin-top: calc(22px * var(--density)); }
.pv-topheading {
  font-size: 18.72px;
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-tight);
  /* h3's own default margin, which is 1em of an h3's 1.17em. */
  margin: 1em 0;
}
.pv-toprow {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: calc(10px * var(--density)) 0;
  border-bottom: 1px solid var(--border);
}
.pv-toprow:last-child { border-bottom: 0; }
.pv-rank { color: var(--text-secondary); font-weight: var(--weight-strong); }
.pv-song { flex: 1; min-width: 0; overflow-wrap: break-word; }
.pv-song-artist {
  display: block;
  font-size: 12px;
  line-height: 1.1;
  color: var(--text-secondary);
}
.pv-vote {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  min-width: 62px;
  min-height: 34px;
  padding: 0 10px;
  background: var(--surface-raised);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font-size: 13px;
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}
.pv-vote svg { width: 13px; height: 13px; fill: currentColor; }

/* --------------------------------------------------- links and footer -----
   .links-row, .link-chip and .punter-footer. The chips carry their NAME on the
   real page; the miniature drew bare glyphs, which is a different row. */
.pv-links {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  margin-top: calc(18px * var(--density));
}
.pv-chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: calc(9px * var(--density)) 13px;
  border-radius: var(--radius);
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--text-primary);
  font-size: 12px;
  font-weight: var(--weight-strong);
}
.pv-chip svg { width: 15px; height: 15px; fill: var(--text-secondary); }
.pv-footer {
  margin-top: calc(26px * var(--density));
  text-align: center;
  font-size: 11px;
  color: var(--text-secondary);
  line-height: 1.6;
}
.pv-footer a { color: var(--action-on-surface); text-decoration: underline; text-underline-offset: 2px; }

/* ===========================================================================
   C2 — WHILE IT IS ARRIVING

   NOT A NEW PATTERN. This is the skeleton the audience page already shows
   itself while an artist's branding loads: #skeleton in request.html, and
   `.skeleton .shimmer` in css/public.css, which is a token-coloured block
   breathing between .5 and 1 opacity over 1.2s. It is restated here rather
   than imported because the dashboard does not load css/public.css, and it is
   restated under a GENERIC name because the preview is not the only thing on
   this screen that has to wait: Track G's image processing needs the same
   treatment, and two shimmer patterns on one page would be two.

   So: `.skel` is a waiting block of any size. Give it a width and a height.
   That is the whole component.

   NOT the .pay-skeleton-bar sweep further up this file. That one is drawn in
   literal light greys (#eceff3) because it sits inside Stripe's own panel,
   which is always light; on a dark dashboard or over a Midnight preview it
   would be a pale slab. A waiting state has to follow the theme, and this one
   does because it is painted in --surface.
   =========================================================================== */
.skel {
  background: var(--surface);
  border-radius: var(--radius);
  animation: shimmer 1.2s ease-in-out infinite alternate;
}
@keyframes shimmer {
  from { opacity: .5; }
  to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .skel { animation: none; opacity: .7; }
}

/* The preview's own waiting state. Full-bleed at the top, because that is
   where the header photograph goes, and inset below it because that is where
   the page's 20px gutter starts. It sits OVER the phone screen rather than
   replacing it, so nothing has to be rebuilt when it goes. */
.pv-loading {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  /* THE SAME BOX AND THE SAME TRANSFORM AS THE PAGE IT COVERS, so its blocks
     are written in the page's own pixels — a 220px header is the header — and
     land exactly where the real thing will. Left at the drawn scale it was a
     220px band on a 571px screen: half the phone, waiting for a fifth of it. */
  width: 390px;
  height: 844px;
  transform: scale(var(--pv-scale));
  transform-origin: 0 0;
  background: var(--bg);
}
.pv-loading[hidden] { display: none; }
.pv-loading .skel-pad { padding: 0 20px; }
/* The page underneath is held still and unreadable while it is covered, so a
   half-painted frame cannot show through at the edges of the veil. */
.phone.is-loading .pv-viewport { visibility: hidden; }


/* ---------------------------------------------------------------- integration
   FOUR TYPE DIFFERENCES THAT ONLY APPEAR ONCE BOTH PAGES ARE IN ONE BUILD.

   css/shared.css gives every h1, h2 and h3 --tracking-tight, and BOTH pages
   load it. THE SENTENCE THAT USED TO FOLLOW WAS WRONG, and it is worth keeping
   the correction here rather than only in the block that noticed it: it said
   the audience page puts letter-spacing back to normal on these two headings.
   It does not. css/public.css declares letter-spacing exactly once in the
   whole file, on .artist-name, and never on the page heading or the Popular
   heading. So the audience gets --tracking-tight on both, and forcing the
   preview to `normal` was the thing making them differ.

   Measured on the live page: the heading is -0.504px and the Popular heading
   -0.26208px, against `normal` in the preview. tests/preview-accuracy.mjs was
   failing on both, and had been for some time.

   The rule that forced `normal` is gone rather than overridden. A later block
   in this file had already added a third rule putting the tracking back, so
   there were two contradictory declarations and one of them was documented as
   a mistake. Deleting the wrong one is what makes the file say one thing.

   The two fields keep their correction: they take the dashboard's own form
   weight rather than the audience page's 400, and that part was true.

   Sub-pixel and half a weight, so nothing here is visible at a glance. It is
   corrected anyway because the whole promise of this pane is that it is the
   page rather than a drawing of it, and a preview that is right about the
   header and wrong about the type is how the last one came to be trusted while
   it was showing a layout that no longer existed. Caught by
   tests/preview-accuracy.mjs comparing computed styles against the live page,
   which is exactly what it is for. */
#pvSongField, #pvForField { font-weight: 400; }


/* ==========================================================================
   THE CIRCLE CROP STOPPED ALWAYS CROPPING, AND THE FRAME HAD TO SAY SO
   Appended as one block on purpose: several people are editing this file at
   once and scattered edits do not survive a merge. Everything here is the
   editing frame in js/views/logo-crop.js.
   ==========================================================================

   WHAT IS BEHIND A LOGO THAT NO LONGER FILLS THE DISC.

   The zoom now goes below cover, so the artwork can be drawn smaller than the
   circle and sit inside it. On the audience's page what fills the rest is
   NOTHING: the part of the object-view-box that falls outside the image is
   transparent black, so the page colour, or the header photograph, shows
   through it. The working is in css/public.css beside .logo.crop-circle.

   THE FRAME WAS ABOUT TO PROMISE OTHERWISE. It is painted with
   `background: var(--surface-sunken)`, which at cover was never visible: the
   image covered every pixel of it. Zoom out and that panel colour becomes a
   ring around the logo, and a musician looking at a solid disc behind their
   artwork would reasonably conclude the audience gets one too. That is the
   exact misreading this feature is most likely to cause, and the one that
   matters most, because the commonest logo file there is is a transparent PNG.

   SO THE FRAME SAYS "NOTHING IS HERE" the way every image editor says it, with
   a chequer. It reads as absence rather than as a colour. It now also shows
   through the transparent parts of the artwork at every zoom, which tells the
   musician something true that the flat panel was hiding from them.

   A LITERAL GREY AT LOW ALPHA RATHER THAN A TOKEN, deliberately. Every surface
   token in the ladder is a colour with a job on this screen, and the chequer's
   job is to read as texture over both a light preset and a dark one without
   being taken for either. Mid grey at 0.16 lands about equally quietly on both;
   a token would tie the texture to a palette role it is not part of. Same
   reasoning as --logo-plate, which is set out where that one is declared.

   8px squares, in a 16px tile of four: small enough not to be mistaken for part
   of somebody's artwork inside a 160px frame, large enough to read as a chequer
   rather than as noise.

   ONLY THE LONGHANDS ARE RESTATED. The `background:` shorthand in the earlier
   logo block still sets the colour underneath; this rule is later in the file
   at the same specificity, so it wins on the two properties it names and leaves
   the rest of that rule alone. */
.logo-crop {
  background-color: var(--surface-sunken);
  background-image: conic-gradient(
    rgba(128, 128, 128, 0.16) 0 25%,
    rgba(128, 128, 128, 0) 0 50%,
    rgba(128, 128, 128, 0.16) 0 75%,
    rgba(128, 128, 128, 0) 0);
  background-size: 16px 16px;
}
/* ===========================================================================
   TRACK D — THE TWO SONG-LIST ROWS
   ===========================================================================

   APPENDED BLOCK. Owned by the two rows js/views/profile.js builds with
   songListRow(), and by nothing else on this form.

   WHAT THEY ARE. The block under the audience's request form is two lists, not
   one: "Popular" (the musician's most asked for songs from past gigs, no vote
   arrows) stands there until three requests have come in at the gig, and then
   "Most requested so far" (live, ranked, an arrow on every row) takes over.
   Two lists, so two switches, so two rows.

   WHY THEY LOOK LIKE THE ACCORDIONS AND ARE NOT ACCORDIONS. They sit in the
   same column, at the same height, with the same title size and the same
   chevron, because a row that looked like something else would read as a
   different KIND of thing rather than as a different setting. What differs is
   where the decision lives: on an accordion the header is a label and the work
   is inside; here the work, one switch, is ON the row, and what opens is the
   explanation. So the header is not the whole row, and the switch is reachable
   without opening anything.

   They deliberately reuse .acc-title and .acc-chev rather than restating those
   two rules, so the title size and the chevron cannot drift from the sections
   they sit between. Everything that is genuinely different is here.

   THE ANIMATION IS THE SAME GRID ROW the accordions use, 0fr to 1fr, at the
   same 220ms. It is not driven by profile-accordions.js and does not announce
   itself to the scrolling preview: these two open independently of the
   sections, they are two paragraphs rather than a card of controls, and the
   preview measures the form on scroll anyway.
   ========================================================================== */

/* The head row carries the padding, exactly as .panel.acc does, so a shut row
   is a compact bar rather than a card with nothing in it. */
.panel.songlist { padding: 0; }

.sl-head {
  display: flex;
  align-items: center;
}

/* NOT width: 100%, which is what .acc-btn needs and this must not have: the
   switch sits at the far end of the same row, so the button takes the space
   that is left rather than all of it. flex: 1 1 auto with min-width: 0 so a
   long title wraps inside the button instead of shoving the switch off the
   card. */
.sl-btn {
  display: flex;
  align-items: center;
  /* THE CHEVRON HUGS THE TITLE rather than sitting at the button's far end, and
     the button still fills the row so the whole middle of it opens the
     explanation. Pushed to the end it would land in a column of its own, a few
     pixels short of the sections' chevrons above and below, and two ragged
     columns of the same glyph read as a mistake. Beside the title it reads as
     what it is: this label opens. The far end of the row belongs to the switch,
     which is where every switch in this app already is. */
  justify-content: flex-start;
  gap: 8px;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 44px;
  /* Full padding on the left where the card's edge is; a short gap on the right
     where the switch follows. */
  padding: 14px 8px 14px var(--db-pad);
  border: 0;
  border-radius: var(--radius-lg);
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
/* Inside the card, for the reason the accordions' ring is: an outline drawn
   outside a control that meets the card's edge is clipped by its own rounded
   corners. */
.sl-btn:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: -3px; }
.sl-btn:hover .acc-title { color: var(--action-on-surface); }
.sl-btn:hover .acc-chev { color: var(--action-on-surface); }
.songlist.is-open .acc-chev { transform: rotate(180deg); }

/* The switch, at the far end of the head row. .toggle's own top margin is for a
   switch that follows a field; this one is a row of its own, and the vertical
   padding gives it the same 44px target the button has. */
.toggle.sl-switch {
  flex: none;
  margin: 0;
  padding: 14px var(--db-pad) 14px 0;
  gap: 0;
}

/* WHAT HAPPENED, UNDER THE ROW IT HAPPENED ON. These two switches save
   themselves, which is the one thing on this form with no Save button to press
   and therefore no button to watch, so the row has to say so itself.

   NO HEIGHT WHEN THERE IS NOTHING TO SAY. An empty line that reserved its own
   space would make both rows permanently taller for a message that is on screen
   for four seconds of a session. */
.sl-state {
  margin: 0;
  padding: 0 var(--db-pad);
  font-size: var(--db-t-small);
  color: var(--text-secondary);
  height: 0;
  overflow: hidden;
}
.sl-state.is-shown { height: auto; padding-bottom: 12px; }
.sl-state.is-ok { color: var(--positive-text); }
/* The failure stays until the next attempt, so it is the one that has to be
   readable at a glance: the switch has already gone back, which looks exactly
   like nothing having happened. */
.sl-state.is-bad { color: var(--negative-text); }

.sl-body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 220ms cubic-bezier(.2, .7, .3, 1);
  /* Sideways only. overflow: hidden clips at the padding edge, so bottom
     padding out here would show under every shut row. */
  padding-inline: var(--db-pad);
}
.sl-body.is-open { grid-template-rows: 1fr; }
.sl-inner {
  overflow: hidden;
  min-height: 0;
}
/* The bottom padding as content, so it is inside the box that gets clipped and
   a shut explanation is exactly nothing tall. */
.sl-inner::after { content: ''; display: block; height: var(--db-pad); }
/* These hints are paragraphs of prose rather than a note under a field, so they
   need the space between them that .field-hint's 5px does not give. min-height
   goes because it is there to stop a validation message shifting the layout,
   and there is no validation here. */
.sl-inner .field-hint {
  margin-top: 10px;
  min-height: 0;
  line-height: 1.5;
}
.sl-inner > .field-hint:first-child { margin-top: 0; }

@media (prefers-reduced-motion: reduce) {
  .sl-body, .songlist .acc-chev { transition: none; }
}

   TRACK V — THE PREVIEW'S PIXELS. ONE APPENDED BLOCK, PER THE CONTRACT.
   docs/CONTRACT-PREVIEW-2026-08-26.md and its run-3 addendum.

   Everything Track V adds to the preview lives in here, at the end of the
   file, rather than as edits scattered through the .pv-* block above. The
   orchestrator merges five agents into this stylesheet and a kept-both-sides
   merge already dropped a closing brace once; one contiguous block is one
   thing to check.

   Four sub-sections, in the order of the jobs:
     V0  two headings that were tracked wrong      (found by the suite)
     V1  the eyebrow's lift over a photograph      (A4a, and A4b's mirror)
     V2  the tip row and the tip screen            (A3)
     V3  the two song lists                        (D, preview side)
   ========================================================================== */

/* --- V0. THE TWO HEADINGS WERE DRAWN WITH THE WRONG TRACKING -------------

   NOT ONE OF TRACK V'S JOBS. tests/preview-accuracy.mjs was failing on it at
   the base commit, on both headings, on the account this run was measured
   against, and a suite that fails for a reason nobody owns is a suite people
   learn to ignore.

   WHAT IT WAS. The integration block immediately above says: "css/shared.css
   gives every h1, h2 and h3 --tracking-tight, and BOTH pages load it. The
   audience page then puts letter-spacing back to normal on these two
   headings." The first sentence is true. The second is not: css/public.css
   declares letter-spacing exactly once in the whole file, on .artist-name, and
   nowhere on #pageHeading or #topHeading. So the audience gets
   --tracking-tight on both and the preview was forced to `normal`.

   Measured at 390x844 on plum: the real heading is -0.504px and the real
   Popular heading -0.26208px, against `normal` in the preview. Sub-pixel per
   character, and about eight pixels across "Most requested so far" — which is
   the same order as the fault that block was written to fix.

   OVERRIDDEN RATHER THAN EDITED, because Track V appends and does not reach
   back into rules above it, and because the note up there is worth keeping as
   the record of what was believed. Same specificity, later in the file. */
.pv-h1, .pv-topheading { letter-spacing: var(--tracking-tight); }

/* AND IT HAS TO BE THE AUDIENCE'S TIGHT, NOT THE DASHBOARD'S.
   ==========================================================
   The line above was right and still measured wrong about half the time, which
   made it look like a race. It is not one. --tracking-tight is declared once,
   on :root in css/shared.css, as `calc(var(--tracking-normal) - .02em)`, and a
   custom property is COMPUTED WHERE IT IS DECLARED: :root's own
   --tracking-normal is substituted there, and every descendant inherits the
   answer. applyTheme() writes the audience's --tracking-normal onto
   #previewRoot, and that arrives far too late to change a value that was
   settled on the root element.

   So the preview's headings were tracked by the MUSICIAN'S DASHBOARD THEME.
   On Dark (.006em) they happened to agree with a dark audience preset and the
   suite passed; on Light (0em) they came out at -.02em against the audience's
   -.014em and it failed. Two accounts, same audience theme, different answers,
   and nothing on the page to say why.

   Re-declared here so the calc is evaluated against the tokens that are
   actually on this element. It is the one derived token in shared.css's :root
   that is built from a themed one; the other 41 are written whole by
   applyTheme and need nothing. */
#previewRoot { --tracking-tight: calc(var(--tracking-normal) - .02em); }

/* --- V1. THE EYEBROW SITS IN THE CROSSOVER HERE TOO ----------------------

   THE FAULT (A4a), MEASURED AT 390x844 ON PLUM, WITH A HEADER PHOTOGRAPH:

     the real page   the eyebrow's box begins 14.0px ABOVE the photograph's
                     bottom edge          (margin-top: -34.52px)
     the preview     it began 20.52px BELOW it, and everything under it —
                     heading, card, list, links, footer — with it
                     (margin-top: +17.10px)

     34.52px too low, which is (--header-blend + 14px) at --density 1.14.

   WHAT IT WAS. css/public.css carries TWO rules for this element: `.eyebrow`
   with its ordinary top margin, and `.header-image.has-title + header .eyebrow`
   (A3, July) which pulls it up into the last of the fade when — and only when —
   there is a photograph to sit in. The preview block above copied the first and
   not the second, so the preview drew the no-photograph spacing on a page that
   has a photograph.

   WHY THE ACCURACY SUITE DID NOT CATCH IT. tests/preview-accuracy.mjs compares
   the SPREAD of the per-element offsets, so a fault that moves the eyebrow and
   everything below it by the same amount cancels. The one element that would
   have broken the spread is the photograph itself, and that comparison is
   skipped on an account whose header image is absent or fails to load — which
   is every account the suite had been pointed at. It now runs against an
   account that has one, and asserts the eyebrow's position against the
   photograph's bottom edge directly rather than only against its neighbours.

   --header-blend IS DECLARED ON .pv-page, NOT ON .pv-header, and that is the
   same trap css/public.css records at its own declaration: the eyebrow is not
   inside the photograph's box, so a custom property set on .pv-header is not
   in scope where the pull is calculated. Set here it is inherited by both.
   .pv-header re-declares the identical value on itself, which changes nothing.

   z-index FOR THE SAME REASON AS OVER THERE: .pv-header is positioned, so it
   and its ::after paint above the static blocks that follow it. Without this
   the eyebrow would be pulled into the fade and then painted over by it.

   THE NUMBER IS 18, NOT 14, AND THAT IS A4b RATHER THAN A4a. The public page
   moved from 14 to 18 in the same pass, so the whole eyebrow now sits inside
   the fade instead of straddling the photograph's bottom edge, and the gap
   above it closes by 4px. The working — the seven-by-four contrast table, the
   worst case at each candidate, and why 19 and 20 were rejected — is at the
   declaration in css/public.css and in docs/decisions-track-v.md.

   BOTH SIDES OR NEITHER. If one of these two numbers is ever edited on its own
   the preview stops being the page, which is the exact fault A4a records.
   tests/preview-accuracy.mjs now reads the eyebrow's position against the
   photograph's bottom edge on both pages, so it fails the moment they part. */
.pv-page { --header-blend: calc(18px * var(--density)); }
.pv-header.has-title + .pv-head .pv-eyebrow {
  --eyebrow-into-fade: 18px;
  margin-top: calc(-1 * (var(--header-blend) + var(--eyebrow-into-fade)));
  position: relative;
  z-index: 1;
}


/* --- V2. THE TIP ROW AND THE TIP SCREEN (A3) -----------------------------

   THERE ARE ALMOST NO RULES HERE, AND THAT IS THE DESIGN.

   The note at the top of the .pv-* block above says the tip card is
   "deliberately not drawn" because it is a whole component out of js/tip.js
   with its own two-screen flow, and a second smaller copy would be two things
   to keep in step. The reasoning was right; the conclusion was not. The answer
   to "do not draw it twice" is to draw it ONCE — call the audience's own
   component and let css/tip.css style it — rather than leave the tips section
   of the form pointing at the request card behind it, which is what a musician
   editing their tip wording actually saw.

   css/tip.css IS ALREADY LOADED ON THIS PAGE (dashboard/index.html, beside
   dashboard.css) and every token it reads — --action, --action-text, --radius,
   --radius-lg, --density, --surface, --bg — is written onto #previewRoot by
   applyTheme() as the AUDIENCE's. So .tip-row and .tipcard inside the phone are
   drawn by the audience's own rules from the audience's own palette, at 390
   pixels, with nothing restated. That is the whole of the tip row: no rule at
   all.

   WHAT THE FOUR RULES BELOW ARE FOR. Only the dialog's outermost element
   differs from the real one, and only in two respects, both consequences of a
   mock-up not being a viewport.

   THE ID IS NOT DECORATION. css/tip.css loads AFTER css/dashboard.css in
   dashboard/index.html, so a class selector here loses to .tip-dialog on
   ordering at equal specificity, and the screen would stay position: fixed —
   pinned to the browser window, over the form, at full size. #pvTipScreen
   outranks it whatever the order. */

/* 1. THE SCREEN IS THE PHONE, NOT THE WINDOW. .tip-dialog is fixed to the
      viewport because that is where a real dialog belongs. Here the viewport
      is the mock-up: 390 x 844, inside .pv-viewport, so the one transform that
      scales the page scales this too and both are in the same pixels. Absolute
      rather than fixed also keeps it OFF .pv-page, which is what pvScrollTo()
      translates — inside it, the dialog would slide up the phone as the form
      scrolled, and a fixed dialog does not do that. */
   z-index 3 is read INSIDE .pv-viewport, which has a transform and so is its
   own stacking context. It puts the screen above .pv-page and cannot reach
   past the loading veil, which is .pv-viewport's sibling: C2 hides the whole
   viewport through `.phone.is-loading .pv-viewport { visibility: hidden }`, and
   this is inside it, so the skeleton still covers everything. */
#pvTipScreen.tip-dialog {
  position: absolute;
  inset: 0;
  z-index: 3;
}
/* 1b. AND IT IS TYPESET AS THE AUDIENCE PAGE, NOT AS THE DASHBOARD.
      Being outside .pv-page is what keeps the dialog still while the page
      scrolls under it, and it also puts it outside the one rule that gives the
      mock-up the audience's own typography. Every element inside the card that
      does NOT set its own line-height was inheriting the dashboard's 1.5
      instead of the audience's --line-height: measured at 24px against the real
      card's 25.28px on plum, on .tipcard, .tip-amounts, .tip-fee-options and
      .tip-pay. Nothing moved, because each of those is a grid or a block full
      of children that do set their own — which is exactly why it would have
      gone unnoticed until a preset with a taller line-height made it move.
      These are .pv-page's own five declarations, restated for the one part of
      the mock-up that is not inside it. */
#pvTipScreen {
  font-family: var(--font);
  font-size: 16px;
  font-weight: var(--weight-normal);
  letter-spacing: var(--tracking-normal);
  line-height: var(--line-height);
  color: var(--text-primary);
}
#pvTipScreen * { box-sizing: border-box; }
/* The UA default the real page runs on, contributing NO SPECIFICITY so every
   component rule beats it. Same reasoning as `.pv-page :where(p)` above, and
   the id is inside :where() for exactly that reason: written as
   `#pvTipScreen :where(p)` this is (1,0,0) and beats .tipcard-why's own
   `margin: 0 0 16px`, which put 14px above the line under the heading and
   made the card 8px taller than the real one. Caught by tests/preview-tip.mjs
   on its first run, which is the argument for measuring against the real
   dialog rather than looking at the two side by side. */
:where(#pvTipScreen) :where(p) { margin: 1em 0; }

/* 1c. AND ONE RULE THE AUDIENCE PAGE GETS FROM A STYLESHEET THIS PAGE DOES NOT
      LOAD.
      css/public.css still carries the old `.tip-sheet` block — the amount sheet
      the tip card replaced — and one of its selectors, `.tip-amounts`, is the
      live card's as well. css/tip.css does not declare a margin there, so the
      leftover `margin-top: calc(12px * var(--density))` reaches the real tip
      card. The dashboard does not load css/public.css, so the preview did not
      have it: measured 13.68px against 0 on plum.

      IT IS INVISIBLE TODAY, and mirrored anyway. The margin collapses under
      .tipcard-why's own 16px bottom margin, so on this screen nothing moves —
      but the card is also used on the played modal and the thank-you screen,
      and "invisible while the element above it happens to have a bigger
      margin" is not a property to build a preview on. The preview is the page,
      including the page's own leftovers.

      FLAGGED, NOT FIXED. css/public.css is not Track V's file and one stray
      rule is not worth a second region in it. Whoever owns that block should
      decide whether the live tip card is meant to take a margin from a
      component that was deleted. */
#pvTipScreen .tip-amounts { margin-top: calc(12px * var(--density)); }
/* 2. THE CARD'S CEILING IS THE PHONE'S HEIGHT. calc(100dvh - 32px) is the real
      browser window in here, which at the 1100px viewport the tests run at is
      1068px — a card free to grow taller than the phone it is drawn inside,
      and taller than it could ever be on the device. 844 is the phone. */
#pvTipScreen .tip-dialog-card { max-height: calc(844px - 32px); }
/* 3. NOTHING IN A PICTURE IS PRESSED. Every control inside is disabled by
      paintTip() as well, which is what takes them out of the tab order; this
      is the belt to that pair of braces, and it also stops the cursor turning
      into a pointer over a mock-up. */
#pvTipScreen, #pvTip { cursor: default; }
#pvTipScreen .tip-pay-veil { cursor: default; }
/* 4. THE ROW SITS WHERE #formTip SITS. .tip-inline gives it the same
      max-width and centring the real one has; the space above it is
      .tip-row's own margin, so there is nothing to add. This rule exists only
      to take the row out of the flow when the musician has no Stripe account,
      because then their audience has no tip control in any state — same
      .pv-gone the links row and the song list use. */
#pvTip.pv-gone { display: none !important; }

/* ============================================================================
   TRACK C, RUN 3 — AN OUTLINE ON EVERY SECTION, AND A SAVE INSIDE EACH ONE
   ============================================================================

   ONE APPENDED BLOCK, and nothing above this line is touched. A previous merge
   on this file dropped a closing brace and swallowed an entire block into the
   media query above it, so this one is written to be readable end to end and
   to be deletable in one cut.

   Owned by js/views/profile-accordions.js (the markup) and by the save block
   in js/views/profile.js (the state). Everything here is drawn from tokens
   already in the stylesheet — --border, --border-strong, --surface, --shadow-1,
   --notice and friends — so every theme gets it, including any theme added
   after this was written. There is not one literal colour below.

   ---- C2. WHICH SECTION IS OPEN, SAID BY THE EDGE ---------------------------

   THE PROBLEM. Panels on this dashboard deliberately carry no border: the note
   at the top of this file records that they separate from the page by a real
   luminance step, about 5 L* in every theme, and that borders are reserved for
   things that need a hit target. That reasoning holds for a dashboard of cards
   that are all the same kind of thing. It stops holding here, because these
   cards are not all the same kind of thing any more: one of them is OPEN and
   five are shut, and looked at on a 390px phone the only thing saying so was
   the contents — which is to say, the thing you have to have already found.

   THE ANSWER IS TWO WEIGHTS OF THE SAME EDGE, not a colour and not a fill:

     shut    1px --border          the hairline every input on this form has
     open    1px --border-strong   and --shadow-1 under it

   --border-strong against --surface is the same contrast the modal card uses
   against its own scrim, and it was chosen there against WCAG 1.4.11 for a
   boundary somebody has to find in order to read what it contains. That is
   exactly the job here.

   BOTH STATES ARE 1px, WHICH IS A CONSTRAINT AND NOT A PREFERENCE. Opening a
   section animates grid-template-rows from 0fr to 1fr and the scrolling
   preview reads the resulting heights at the settle. A border that grew from
   1px to 2px on open would change the panel's height by two pixels DURING the
   transition — a layout change inside the window where the preview has frozen
   its target. Only the colour moves.

   THE FIRST ATTEMPT WAS TWO PIXELS, drawn as an inset ring so it cost no
   layout, and it was rejected BY LOOKING AT IT. On Dark it was right; on Light
   --border-strong is #1A1815, so a two-pixel ring drew a heavy black frame
   round the open card that shouted over the fields inside it. One pixel of the
   same token is unmistakable in both — on Dark a pale edge where the shut
   cards have almost none, on Light a black one against soft grey — and does
   not turn the section anybody is working in into the loudest thing on the
   screen. Rejecting a theme-conditional weight was the other half of that: the
   day a fourth app theme is added, a rule that says "except on Light" is a
   rule nobody knows to write for it.

   THE FLAT CARD KEEPS THE QUIET EDGE. Most requested is always readable, so
   there is an argument for giving it the strong one. It gets the quiet one
   anyway: exactly one card on this page should carry the strong edge, because
   the whole point of the strong edge is to answer "which one is open" without
   being read. Two answers is no answer.

   LOOKED AT, NOT MEASURED, at 390px in Dark and in Light, which is what the
   brief asked for. Notes in docs/decisions-track-c.md.
   ========================================================================== */

.panel.acc {
  border: 1px solid var(--border);
  /* The colour animates with the section rather than snapping ahead of it.
     Same easing and duration as .acc-body's grid-template-rows above, and the
     reduced-motion rule further down turns both off together. */
  transition: border-color 220ms cubic-bezier(.2, .7, .3, 1),
              box-shadow 220ms cubic-bezier(.2, .7, .3, 1);
}

.panel.acc.is-open {
  border-color: var(--border-strong);
  box-shadow: var(--shadow-1);
}

/* The one card that never became a section reads as a shut one: same edge,
   same weight, so the column is one set of cards rather than two. */
.panel.profile-flat { border: 1px solid var(--border); }

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

/* ---- C1. WHAT A SECTION IS STILL HOLDING ----------------------------------

   On the bar itself, so a shut section can say it. --notice rather than
   --negative: an unsaved change is not an error, it is a thing still to do,
   and the two are different colours in every theme for that reason.

   display:none when the section is clean, which takes it out of the
   accessibility tree as well as off the screen — it sits inside the header
   button, so left in the tree it would append "Unsaved" to the accessible name
   of every section on the page. */
.acc-unsaved {
  flex: none;
  /* Pushed to the chevron rather than centred: .acc-h is space-between, and a
     third child would otherwise float in the middle of the bar. */
  margin-left: auto;
  padding: 2px 8px;
  border: 1px solid var(--notice);
  border-radius: 999px;
  background: var(--notice-surface);
  color: var(--notice-text);
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  white-space: nowrap;
}
.acc:not(.is-dirty) .acc-unsaved { display: none; }

/* ---- C1. THE SAVE AT THE END OF A SECTION ---------------------------------

   Separated from the fields by a rule, because it is not another field: it is
   the end of the section. Full width and the same height as the form-wide save
   at the foot of the page, so the two read as the same control at two scopes
   rather than as a big one and a small one. */
.acc-save {
  margin-top: 20px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
}
.acc-save-btn {
  width: 100%;
  height: calc(48px * var(--density));
}
.acc-save-btn,
.save-profile {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

/* CLEAN uses the ordinary disabled treatment. SAVING and SAVED remain truly
   disabled too, but carry their own visible state instead of looking merely
   unavailable. The label is real text and the live region repeats the state;
   neither answer depends on seeing this animation or tick. */
:is(.acc-save-btn, .save-profile).is-saving:disabled {
  opacity: 1;
  cursor: wait;
}
:is(.acc-save-btn, .save-profile).is-saving::before {
  content: '';
  width: 16px;
  height: 16px;
  flex: none;
  border: 2px solid color-mix(in srgb, currentColor 35%, transparent);
  border-top-color: currentColor;
  border-radius: 50%;
  animation: profile-save-spin .7s linear infinite;
}
:is(.acc-save-btn, .save-profile).is-saved:disabled {
  opacity: 1;
  color: var(--positive-text);
  background: var(--positive-surface);
  box-shadow: inset 0 0 0 1px var(--positive);
}
:is(.acc-save-btn, .save-profile).is-saved::before {
  content: '✓';
  flex: none;
  font-weight: var(--weight-strong);
}
@keyframes profile-save-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  :is(.acc-save-btn, .save-profile).is-saving::before { animation: none; }
}
/* The answer, beside the button that asked. :empty rather than [hidden] so
   nothing has to remember to hide it again; role="status" means it is spoken
   when it fills. */
.acc-save-note {
  margin: 8px 0 0;
  font-size: var(--db-t-small);
  line-height: var(--line-height);
  color: var(--text-secondary);
}
.acc-save-note:empty { display: none; }
.acc-save-note.is-busy { color: var(--text-secondary); }
.acc-save-note.is-saved { color: var(--positive-text); }
.acc-save-note.is-error { color: var(--negative-text); }

/* ===========================================================================
   TRACK P — THE PREVIEW BUBBLE ON A PHONE AND ON A TABLET IN PORTRAIT
   ===========================================================================

   APPENDED BLOCK. Owned by js/views/profile-preview-scroll.js and by nothing
   else. Nothing above this line is touched by it: the whole mechanism is the
   `data-pvbubble` attribute the module puts on <html>, so removing the module
   removes the behaviour and the page falls straight back to the layout the
   media query at .profile-layout already describes.

   THE BAR IS GONE, and so is its stylesheet. A 117-line block further up this
   file described a full-width dock pinned above the tab rail; nothing set
   `data-pvdock` or built `.pvdock-bar` any more, so every rule in it was
   unreachable. It was deliberately left in place by the track that replaced it,
   because a deletion hunk there would have carried context lines belonging to
   two other tracks' blocks, and a kept-both-sides merge in this file had
   already cost a closing brace once. Removed on its own afterwards, with
   scripts/check-css-braces.mjs run either side of it.

   WHY A BUBBLE. The bar cut across the page. It was 46px of every screen,
   permanently, and it named the view in words rather than showing it. What
   replaces it is a small live miniature that can be DRAGGED clear of whatever
   is being edited, and that is the whole difference: the musician moves it,
   rather than working around it.

   IT IS THE PREVIEW, DRAWN SMALL — NOT A COPY OF IT. .phone already exposes one
   number, --pv-w-n, that decides how wide the mock-up is drawn, with
   .pv-viewport scaling the real 390x844 audience page by --pv-w-n / 390. So the
   miniature is a CSS resize of the real thing: nothing to keep in step, and no
   blur, because it is drawn small rather than photographed and shrunk. The
   module never writes inside the preview; it sets --pvb-w-n on <html> and the
   one rule below carries it in.

   The reasoning in full, and what was rejected, is docs/decisions-track-p.md.
   ------------------------------------------------------------------------ */

/* ---------------------------------------------- THE PANEL ON A DESKTOP, L1

   THE PANEL STAYS WITH THE BROWSER VIEWPORT AND IS ALWAYS FULLY VISIBLE.

   It was neither. Measured on the real page before this block existed, at
   1440x900 with Tips open: at the top of the form the panel sits at y=107, and
   scrolled to the bottom it is at y=-809 — entirely off the top of the screen.
   It carried `position: sticky` all along and sticky was doing nothing, because
   .profile-layout sets `align-items: start`, so .preview-pane is only as tall as
   its own contents and a sticky child of a box its own size has nowhere to go.
   Stretching the pane to the grid row is the whole fix for that, and it is one
   line.

   `top` clears the sticky shell header. The stylesheet's own 20px would put the
   heading behind it, since a sticky element's offset is from the VIEWPORT and
   the header is over the top of that.

   AND IT DID NOT FIT. At 1280x700 the phone's bottom edge was at y=727 in a
   700px window, at scroll zero. --pvd-w-n is the one number .phone already
   takes, set from the room the window actually has, so the mock-up shrinks
   rather than being cut off. Capped at the 264 it has always been drawn at: a
   larger screen does not get a larger phone, because it is a phone.

   Nothing here can overlap the form: it is sticky inside its own grid column,
   which is not the form's column. See layoutDesktopPanel() in
   js/views/profile-preview-scroll.js for the arithmetic and for what was
   rejected. */
@media (min-width: 901px) {
  .preview-pane { align-self: stretch; }
  .preview-sticky { top: var(--pvd-top, 20px); }
  .preview-pane .phone { --pv-w-n: var(--pvd-w-n, 264); }
}

/* REGISTERED AS A NUMBER SO THE SIZE CAN BE ANIMATED. An unregistered custom
   property is a token, and a token cannot be interpolated: mini to open would
   snap. Registered, the browser knows it is a number and transitions it, and
   because .phone's --pv-scale and --pv-w are calc()ed from it, the bezel, the
   screen and the whole scaled audience page grow together in one movement. */
@property --pvb-w-n {
  syntax: '<number>';
  inherits: true;
  initial-value: 56;
}

html[data-pvbubble] {
  --pvb-bezel: 3px;
  --pvb-radius: 11px;
  transition: --pvb-w-n 220ms cubic-bezier(.22, .61, .36, 1);
}
html[data-pvbubble="open"] {
  --pvb-bezel: 8px;
  --pvb-radius: 24px;
}
html.pvb-dragging { transition: none; }

/* THE PANE LEAVES THE FLOW ENTIRELY. Under 900px .preview-pane is `order: -1`,
   which put a 587px drawing of a phone at the TOP of the form — more than half
   a handset screen spent before the first field, and then it scrolls away and
   is never seen again while anything is being edited. Out of the flow it costs
   nothing and is always there.

   pointer-events: none, because the pane is a drawing. Everything that can be
   pressed is on #pvBubble, which sits directly over it. */
html[data-pvbubble] .preview-pane {
  position: fixed;
  z-index: 25;
  left: var(--pvb-x, 10px);
  top: var(--pvb-y, 60vh);
  width: max-content;
  margin: 0;
  padding: 0;
  pointer-events: none;
  transition: left 220ms cubic-bezier(.22, .61, .36, 1),
              top 220ms cubic-bezier(.22, .61, .36, 1);
}
html.pvb-dragging .preview-pane { transition: none; }
/* The pane's own heading has nowhere to go on a 62px miniature. */
html[data-pvbubble] .preview-title { display: none; }
/* A3. AND THE INFO BUTTON GOES WITH IT.
   The title has been hidden in bubble mode since the bubble shipped; the button
   beside it was not, so on a phone it was left as a lone circled `i` floating
   above the bubble with nothing to label. It is also not reachable there - the
   bubble handles the pointer - so it was a control that looked live and did
   nothing. Asked for as a removal rather than a bigger tap target.

   display:none rather than visibility or opacity, deliberately: it has to leave
   the ACCESSIBILITY TREE as well as the screen, or a screen reader still
   announces a button that cannot be operated. That also takes the note with it,
   since a note nothing can open is dead markup. */
html[data-pvbubble] .preview-head { display: none; }
html[data-pvbubble] .preview-about,
html[data-pvbubble] .preview-about-body { display: none; }

html[data-pvbubble] .phone {
  --pv-w-n: var(--pvb-w-n, 56);
  margin: 0;
  /* .phone's own width is `--pv-w + 16px`, which is its 8px bezel written out
     as a number. Thinning the bezel for the miniature without restating the
     width left 10px of bare background inside the frame and a hit area 10px
     narrower than the drawing. Both numbers come from --pvb-bezel now, so they
     cannot part company. */
  width: calc(var(--pv-w) + var(--pvb-bezel) * 2);
  border-width: var(--pvb-bezel);
  border-radius: var(--pvb-radius);
  transition: border-width 220ms cubic-bezier(.22, .61, .36, 1),
              border-radius 220ms cubic-bezier(.22, .61, .36, 1);
}
html[data-pvbubble="mini"] .phone { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); }
html[data-pvbubble="open"] .phone { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.34); }

/* THE CONTROL. Transparent, exactly the size of the drawn phone, sitting on top
   of it. Everything visible belongs to the preview underneath; this element is
   the hit area, the focus ring and the close button, which is what keeps the
   module out of the preview's DOM.

   touch-action: none is what makes the drag possible at all — without it the
   browser claims the gesture for scrolling before the first pointermove. */
#pvBubble {
  position: fixed;
  z-index: 26;
  left: var(--pvb-x, 10px);
  top: var(--pvb-y, 60vh);
  width: calc(var(--pvb-w-n, 56) * 1px + var(--pvb-bezel) * 2);
  height: calc(var(--pvb-w-n, 56) * 1px * 844 / 390 + var(--pvb-bezel) * 2);
  border-radius: var(--pvb-radius);
  touch-action: none;
  cursor: grab;
  -webkit-tap-highlight-color: transparent;
  transition: left 220ms cubic-bezier(.22, .61, .36, 1),
              top 220ms cubic-bezier(.22, .61, .36, 1);
}
html.pvb-dragging #pvBubble { transition: none; cursor: grabbing; }
#pvBubble:focus-visible {
  /* --focus-ring, which is the token every other focus outline in this file
     uses. It read var(--focus, var(--accent)) and neither of those exists, so
     the fallback chain ended in nothing and the outline was whatever the
     browser's own default happens to be. */
  outline: 3px solid var(--focus-ring);
  outline-offset: 3px;
}

/* THE GRIP, AND ONLY WHERE THERE IS ROOM FOR ONE.
   The miniature's bezel is 3px. A 4px bar on it lands on the drawing, and on a
   62px-wide thumbnail that is a bar across the audience's own page — looked at
   on a real handset it was the one thing wrong with the miniature. The open
   panel's bezel is 8px and takes it comfortably.
   Losing it on the miniature costs nothing, because the whole miniature is the
   handle: a grip bar says "drag from here", which would be a smaller target and
   a false statement. */
.pvbubble-grip {
  position: absolute;
  left: 50%;
  bottom: 2px;
  transform: translateX(-50%);
  width: 34px;
  height: 4px;
  border-radius: 2px;
  background: var(--border-strong);
  opacity: 0.85;
  pointer-events: none;
  display: none;
}
html[data-pvbubble="open"] .pvbubble-grip { display: block; }

/* THE CLOSE CONTROL, only once there is something to close. 34px of target on a
   miniature that is 62px wide would swallow the whole thing, so it is only
   there in the open state — the miniature collapses by being tapped, which is
   the same gesture that opened it. */
.pvbubble-close {
  position: absolute;
  top: -12px;
  right: -12px;
  width: 34px;
  height: 34px;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 50%;
  background: var(--surface);
  color: var(--text-primary);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.24);
  cursor: pointer;
}
.pvbubble-close svg { width: 15px; height: 15px; }
html[data-pvbubble="open"] .pvbubble-close { display: flex; }

/* The Save button is the last thing on the page and the bubble parks near the
   bottom by default, so the form gets room to scroll clear of it. 96px is the
   miniature's own height less the 34px the shell already reserves. */
html[data-pvbubble] .shell-main {
  padding-bottom: calc(var(--nav-size) + var(--safe-bottom) + 34px + 96px);
}

@media (prefers-reduced-motion: reduce) {
  html[data-pvbubble],
  html[data-pvbubble] .preview-pane,
  html[data-pvbubble] .phone,
  #pvBubble { transition: none; }
}

/* ==========================================================================
   A5 — SAYING THAT THE PREVIEW IS A MOCK-UP
   Appended block. The preview now draws five real, well known songs with vote
   counts, which is a better preview and a worse ambiguity: it can read as the
   musician's own repertoire, or as requests that have come in.

   A button beside the title, not a caption under the preview: a permanent line
   costs a row of screen forever to answer a question asked once, and inside the
   phone bubble there is no room for one. It sits after the title so it does not
   compete with the preview itself, and it is 28px so it is a real target at a
   phone width without becoming a second heading.
   ========================================================================== */
.preview-head {
  display: flex;
  align-items: center;
  gap: 8px;
  /* A1. THE HEADING SAT FLUSH ON THE PREVIEW. .preview-title carries
     `margin: 20px 0 10px` everywhere else and that bottom margin is zeroed
     inside the head, so the row above the phone had NO space under it at all
     and the title read as part of the preview's own chrome rather than as a
     label for it. The space goes on the head rather than back on the title
     because the info button is the taller of the two and it is the button, not
     the text, that was touching the frame. */
  margin-bottom: 14px;
}
.preview-head .preview-title { margin: 0; }
/* The note opens between the head and the phone, so with it open the head's own
   margin would double up with the note's. */
.preview-head:has(+ .preview-about-body:not([hidden])) { margin-bottom: 0; }
.preview-about {
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-secondary);
  font: 600 14px/1 var(--font-body, inherit);
  font-style: italic;
  cursor: pointer;
}
.preview-about:hover { border-color: var(--border-strong); color: var(--text-primary); }
.preview-about:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.preview-about[aria-expanded="true"] {
  background: var(--surface-sunken);
  border-color: var(--border-strong);
  color: var(--text-primary);
}
/* Below the title rather than floating over the preview, because a note that
   covers the thing it is describing is worse than no note. */
.preview-about-body {
  margin: 8px 0 14px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  color: var(--text-secondary);
  font-size: 13px;
  line-height: 1.5;
  max-width: 44ch;
}

/* ===========================================================================
   TRACK J1 — THE IMAGE UPLOAD CONTROL ALSO TAKES A DROPPED FILE
   ===========================================================================

   ONE CONTROL, TWO ROUTES IN, AND IT HAS TO LOOK LIKE ONE CONTROL.

   The native file input already drew itself as a bordered, rounded, full-width
   box: a small grey button at the left and the chosen filename beside it. A
   second dashed rectangle above that would be two controls. Instead the border
   moves out one level and becomes the drop boundary. `.upload-well` contains
   the transparent, borderless native input and the approved sentence at its
   right-hand end. Read left to right the single field says "Choose file, no
   file chosen, or drop an image here".

   THE FOCUS RING HAD TO MOVE WITH THE BORDER. shared.css puts it on
   `input:focus`, and taking the input's border away would have taken the only
   visible sign of keyboard focus with it. It is on the well now, via
   :focus-within, so tabbing to the browse button rings the whole field.

   THE ARMED STATE IS AN INSET RING, NOT A THICKER BORDER. Going to 2px reflows
   the row by a pixel under the pointer at the moment somebody is aiming at it.
   A solid action border, tinted fill and inset shadow read as a landing state
   and move nothing.

   AND ON A PHONE THERE IS NOTHING TO SEE. A coarse pointer cannot drag a file
   onto a web page, so the sentence goes and the right hand padding goes with
   it, and what is left is exactly the control that shipped: same box, same
   height, same border, no empty space where an instruction used to be. The
   drop handlers stay wired, because a hybrid laptop has both. */

.profile-grid .upload-well {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-right: 14px;
  height: var(--db-field);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  transition: border-color .12s ease, background-color .12s ease, box-shadow .12s ease;
}

.profile-grid .upload-well .file-input {
  flex: 1 1 auto;
  min-width: 0;
  height: 100%;
  margin: 0;
  border: 0;
  border-radius: 0;
  background: transparent;
}

/* BOTH OF THE APP'S FOCUS MARKS BELONG TO THE FIELD NOW, NOT TO THE INPUT
   INSIDE IT, and this is the part that would have been easy to miss. A focused
   input in this app gets two things: shared.css's `input:focus` border and 3px
   wash, and shared.css's `:focus-visible` 2px outline that "every focusable
   thing" gets. The input is now only the left three quarters of the box, so
   both of them drew round three quarters of a field, with a bright vertical
   line down the middle of it where the input stopped. They move outwards to the
   well. Nothing loses a focus mark; they change shape to the shape of the
   control. */
.profile-grid .upload-well .file-input:focus { box-shadow: none; }
.profile-grid .upload-well .file-input:focus-visible { outline: none; }
.profile-grid .upload-well:focus-within {
  border-color: var(--action);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--action) 18%, transparent);
}
.profile-grid .upload-well:has(.file-input:focus-visible) {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.upload-drop-note {
  flex: 0 0 auto;
  font-size: var(--db-t-small);
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
  white-space: nowrap;
  user-select: none;
  /* Instruction, not a second target. The native input remains the browse
     route; the enclosing well remains the drop route. */
  pointer-events: none;
}

/* A file with something over it. */
.profile-grid .upload-well.is-armed {
  border-color: var(--action);
  border-style: solid;
  background: color-mix(in srgb, var(--action) 16%, var(--surface-sunken));
  box-shadow: inset 0 0 0 1px var(--action);
}
.profile-grid .upload-well.is-armed .upload-drop-note { color: var(--action-on-surface); }

/* pickImage disables the input while it shrinks and re-encodes. The sentence
   must not go on offering a route that is shut for the moment. */
.profile-grid .upload-well:has(.file-input:disabled) .upload-drop-note { opacity: .5; }

@media (hover: none), (pointer: coarse), (max-width: 640px) {
  .upload-drop-note { display: none; }
  .profile-grid .upload-well { padding-right: 0; gap: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .profile-grid .upload-well { transition: none; }
}

/* ===========================================================================
   J2. HOW BIG THE LOGO IS DRAWN
   ===========================================================================

   One block, appended, because these are new rules rather than edits to the
   logo's own. The rules that decide the SIZE are the logo's own and were edited
   in place: .pv-logo and its three header overrides further up this file, and
   .logo and its four overrides in css/public.css. This block is only the
   control that drives them. The behaviour is in js/views/logo-size.js.

   IT BORROWS THE CROP CONTROL'S GEOMETRY ON PURPOSE. The two sit one under the
   other in the same column of the Images section, both are a range with
   something on either side of it, and the 320px cap and the 44px touch target
   are the same decisions in both. Copied rather than shared because a selector
   list naming both would have to be found and split the first time one of them
   changed, and they are not the same control.
   =========================================================================== */

.logo-size-wrap { margin-top: 18px; }
.logo-size-wrap.hidden { display: none; }
.logo-size-controls {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 6px;
  max-width: 320px;
}
.logo-size-range { flex: 1 1 auto; min-width: 0; min-height: 44px; }
/* THE NUMBER, AND WHY IT IS A FIXED WIDTH. It runs from 70% to 150%, so it is
   three or four characters wide depending on where the slider is, and a readout
   that changes width drags the slider's right-hand end back and forth under the
   finger that is moving it. Tabular figures for the same reason: proportional
   digits make 111% narrower than 100%. */
.logo-size-value {
  flex: 0 0 auto;
  min-width: 4ch;
  text-align: right;
  font-size: var(--db-t-small);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}
.logo-size-reset {
  flex: 0 0 auto;
  min-height: 44px;
  padding: 0 14px;
  border-radius: var(--radius-md);
  font-size: 13px;
}
.logo-size-wrap .field-hint { max-width: 320px; }


/* ===========================================================================
   J — THE TIP FLOW IS STEPPED, AND THE THING THAT ADVANCES IT IS RINGED
   ===========================================================================

   The musician holds the step. Under the phone, because everything inside it
   is inert - the preview is a picture of somebody else's screen - and these are
   the musician's own controls, about the picture rather than in it.

   The ring inside the phone marks the control the AUDIENCE would tap to reach
   the next screen, so the flow shows what turns one screen into the next rather
   than only showing three screens. */
.pv-steps {
  margin: 12px auto 0;
  max-width: 320px;
  text-align: center;
}

/* ========================= DOCKED INTO THE BUBBLE =========================
 *
 * On a phone these two buttons are MOVED INSIDE #pvBubble - see the docking
 * note in js/views/profile-preview-scroll.js. They have to be reachable there,
 * which means undoing the pane's `pointer-events: none`, and they have to be
 * big enough and far enough from the bubble's own edges to hit with a thumb.
 *
 * Under the phone rather than over it: the tip screens fill the frame, and a
 * control laid over the thing it is stepping through hides the thing it is
 * stepping through. */
.pv-steps.is-docked {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  top: calc(100% + 8px);
  width: max-content;
  max-width: min(92vw, 320px);
  margin: 0;
  padding: 8px 10px;
  border-radius: 14px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  box-shadow: 0 8px 26px rgb(0 0 0 / .34);
  pointer-events: auto;
  z-index: 2;
  /* #pvBubble sets `touch-action: none` so it can own the drag. Inherited, that
     also kills the browser's own tap handling on anything inside it, so the
     docked buttons get their normal behaviour back explicitly. */
  touch-action: manipulation;
}
/* Only when the bubble is actually open. On the 62px miniature there is no room
   and nothing to step: the whole point of the miniature is that it is out of the
   way. */
html[data-pvbubble="mini"] .pv-steps.is-docked { display: none; }
/* Thumbs, not cursors. 44px is the smallest target that can be hit reliably
   without looking, which is the condition this is used in. */
html[data-pvbubble] .pv-steps.is-docked .pv-step-btn {
  min-height: 44px;
  min-width: 76px;
  padding: 10px 18px;
  font-size: 14px;
  touch-action: manipulation;
}
html[data-pvbubble] .pv-steps.is-docked .pv-steps-cue {
  font-size: 12px;
  min-height: 0;
  margin-bottom: 6px;
}
.pv-steps-cue {
  margin: 0 0 8px;
  font-size: 13px;
  line-height: 1.4;
  color: var(--text-secondary);
  min-height: 2.8em;
}
.pv-steps-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.pv-step-btn {
  flex: 0 0 auto;
  min-height: 36px;
  padding: 7px 16px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-primary);
  font: inherit;
  font-size: 13px;
  font-weight: var(--weight-medium);
  cursor: pointer;
}
.pv-step-btn:hover:not(:disabled) { border-color: var(--border-strong); }
.pv-step-btn:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.pv-step-btn:disabled { opacity: .4; cursor: default; }
.pv-step-btn.is-primary {
  border-color: var(--action);
  background: color-mix(in oklab, var(--action) 16%, var(--surface));
}
.pv-steps-dots { display: flex; gap: 6px; }
.pv-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--border-strong);
}
.pv-dot.is-on { background: var(--action); }

/* THE RING, INSIDE THE PHONE. An outline rather than a fill: the control it
   marks already carries the artist's own accent, and tinting it would show a
   colour their audience never sees. It sits OUTSIDE the box via outline-offset
   so it cannot change the layout of the screen it is drawn over. */
#previewRoot .pv-cue {
  outline: 2.5px solid var(--action);
  outline-offset: 3px;
  border-radius: var(--radius);
  animation: pv-cue-pulse 1.8s ease-in-out infinite;
}
@keyframes pv-cue-pulse {
  0%, 100% { outline-color: var(--action); }
  50% { outline-color: color-mix(in oklab, var(--action) 35%, transparent); }
}
/* Nothing pulses for somebody who asked for less motion. The ring stays: it is
   the information, and it does not need to move to carry it. */
@media (prefers-reduced-motion: reduce) {
  #previewRoot .pv-cue { animation: none; }
}

/* THE WALLET BUTTONS, DRAWN RATHER THAN MOUNTED.
   ============================================
   The real pay screen mounts Apple Pay or Google Pay through Stripe, which
   needs a live payment session and a real device that supports one. Neither
   exists inside a preview, so the preview DRAWS them: the same two buttons at
   the same size in the same place, so the musician sees the screen their
   audience actually gets rather than a gap where the wallets go.

   Marked as a drawing in the markup (data-pv-wallet) so nothing mistakes these
   for a mounted Stripe element. */
.pv-wallet-mock {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 10px;
}
.pv-wallet-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 44px;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: .01em;
}
.pv-wallet-btn.is-apple { background: #000; color: #fff; }
.pv-wallet-btn.is-google {
  background: #fff;
  color: #3c4043;
  border: 1px solid #dadce0;
}
.pv-wallet-btn svg { width: 17px; height: 17px; }


/* ============ SENDING THE WHOLE LIST TO SOMEBODY ============
 *
 * Two quiet buttons at the top of My songs, with the search and the select
 * control, because somebody who came here to send a list has not come here to
 * scroll three hundred rows to reach the thing that answers them. Quiet,
 * because adding songs is what this screen is for and this is the errand
 * somebody runs occasionally.
 *
 * The same shape as the pair inside an open songbook: it is the same two files
 * from the same document, and a musician who has done it once should recognise
 * it. */
.songs-export {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: 0 0 12px;
}
.songs-export .field-hint { flex: 1 1 100%; margin: 0; }
/* Same floor as the row above, for the same reason: this pair carries
   "Download spreadsheet", which is the longest label on the tab. */
.songs-export button { min-width: max-content; }

/* AND THE SAME PAIR AT THE TOP OF AN OPEN BOOK. They used to sit under every
   song in it: a musician whose booker has just asked what they play had to
   scroll two hundred rows to reach the control that answers them. */
.book-actions { flex-wrap: wrap; }
/* The three at the top of an open book are the row that broke: at 390px they
   were 98px each. See .songs-drawer-actions above. */
.book-tools { display: flex; flex-wrap: wrap; gap: 10px; }
/* NOT flex-grow. These two are the tidying-up controls at the foot of a book,
   and stretching Duplicate to four hundred and fifty pixels because there is
   room makes it look like the main thing on the screen. They take their own
   width, never shrink below their label, and wrap when they have to. */
.book-tools button { flex: 0 1 auto; min-width: min(100%, max-content); }

/* ============ STOPPING ONE PHONE FOR THE REST OF A GIG ============
 *
 * The mark is DISCREET on purpose. It sits on a person in the panel that
 * answers "who is this", not on the card, because a card in the live queue is
 * read at arm's length from a stand by somebody holding an instrument, and a
 * control that stops a person sending anything else does not belong next to
 * Played and Skip where a mis-tap costs somebody their evening.
 *
 * ============ IT IS RED, WHICH REVERSES WHAT THIS COMMENT USED TO SAY ========
 *
 * The argument for grey was that red on this screen means something has gone
 * wrong, and nothing has gone wrong yet. That reads well and was wrong in use:
 * a grey circle among grey name pills, at 28px, on a stand, in a room lit for
 * the audience, is not findable by somebody who needs it in the next four
 * seconds. It has to be the thing the eye lands on.
 *
 * Red here is not "an error". It is the colour every other stop control in
 * this app uses, and there is nothing else on this panel competing for it. */
.gm-block-mark {
  margin-left: 6px;
  padding: 0 4px;
  min-width: 28px;
  min-height: 28px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: var(--negative, #E5484D);
  line-height: 1;
  cursor: pointer;
}
/* The drawn mark, so it is one shape on every platform rather than whichever
   font happens to carry U+2298. See NO_ENTRY in js/views/gigmode.js. */
.gm-block-mark svg { width: 19px; height: 19px; display: block; margin: 0 auto; }
.gm-block-mark:hover { background: var(--negative-surface); }
.gm-block-mark:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
/* Already stopped, so the mark is the state rather than the offer. */
.gm-name-pill.is-blocked { opacity: .62; }
.gm-name-pill.is-blocked .gm-block-mark { color: var(--negative-text, #E5484D); }

/* SOMEBODY WHO TYPED NO NAME, in the panel that lists everyone on a card. Set
   quieter than a name because it is a statement about the absence of one, and
   the pill beside it may well have a real name in it. */
.gm-name-pill.is-nameless .gm-noname {
  color: var(--text-secondary);
  font-style: italic;
}
/* The name, where the device has given one, above what it has sent. */
.gm-block-who {
  margin: 12px 0 0;
  font-size: var(--db-t-body);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}

.gm-blockpanel { text-align: left; }
.gm-block-list {
  margin: 10px 0 0;
  padding: 0;
  list-style: none;
  max-height: 34vh;
  overflow-y: auto;
  font-size: var(--db-t-small);
}
.gm-block-list li {
  padding: 6px 0;
  border-bottom: 1px solid var(--border);
  color: var(--text-primary);
}
.gm-block-list li:last-child { border-bottom: 0; }
.gm-block-for {
  display: block;
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
}
.gm-block-more { color: var(--text-secondary); }
.gm-block-given { margin: 12px 0 0; font-size: var(--db-t-body); }
.gm-block-refund { margin-top: 8px; }
.gm-block-note {
  margin: 8px 0 0;
  font-size: var(--db-t-small);
  line-height: 1.35;
  color: var(--text-secondary);
}
.gm-block-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 14px;
}
.gm-block-actions button { min-height: 46px; flex: 1 1 auto; }


/* ============ WHAT THE COLUMNS MEAN ============
 *
 * One control per table, in the heading row, beside the first column's name.
 * Nothing else is added to the resting state: a musician who knows what the
 * columns are never has to look at anything but a small quiet mark.
 *
 * IN THE HEADING ROW rather than beside the panel's title, so it is in the
 * same place on a phone as on a desktop and a thumb can reach it without
 * opening anything else. */
.col-explain {
  display: inline-grid;
  place-items: center;
  /* flex: none BECAUSE .drow-main IS A FLEX BOX THAT OVERFLOWS HIDDEN. On the
     gig list at 390 the heading beside it took the room and squeezed this to
     18px wide: a control the size of the thing next to it rather than the size
     it was set to, and shaved rather than shrunk. */
  flex: none;
  width: 24px;
  height: 24px;
  margin-left: 6px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  vertical-align: -6px;
}
.col-explain svg { width: 16px; height: 16px; }
.col-explain:hover { color: var(--text-primary); background: var(--surface-raised); }
.col-explain:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* THE PANEL. Borrows the venue modal's own shell, so it is the same object a
   musician has already met on this screen rather than a second kind of panel
   that happens to hold text. */
.vm-explain { max-width: 460px; }
/* A <dl>'s own margin, reset. The inset comes from .vm-body around it, which
   is the panel body every other modal on this screen uses. */
.explain-list { margin: 0; padding: 0; }
/* THE FIRST ROW SITS UNDER THE HEAD'S RULE, not against it. .vm-body's top
   padding is zero because the venue modal's first child brings its own; a bare
   list does not, so it says so here. */
.vm-explain .vm-body { padding-top: 4px; }
.explain-row {
  display: grid;
  grid-template-columns: minmax(72px, auto) 1fr;
  gap: 4px 14px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}
.explain-row:last-child { border-bottom: 0; }
.explain-row dt {
  margin: 0;
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  font-size: var(--db-t-small);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
}
.explain-row dd {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--db-t-body);
  line-height: 1.4;
}
/* STACKED ON A PHONE. Two columns at 390 leaves the description about 200px
   wide, which sets "It is the figure that matches your bank" on four lines. */
@media (max-width: 480px) {
  .explain-row { grid-template-columns: 1fr; gap: 2px; }
}

/* ===================== THE CREDIT PILL =====================
 *
 * This had no styling at all. It was a bare <p> in the flow, which is exactly
 * how it read: a stray sentence somebody had left beside a button rather than
 * a thing the interface was telling you on purpose.
 *
 * A pill because it is a small self-contained fact, not prose, and because it
 * has to sit sensibly in three different places: under the drop zone on the
 * file card, under the paste box, and on the describe-a-gig modal. Inline-flex
 * so it shrinks to its content wherever it lands and never stretches to the
 * width of whatever contains it.
 *
 * Quiet by default, because a fair-use figure with plenty left is background
 * information. It only raises its voice at zero, which is the one moment it is
 * about to stop somebody. */
.ai-allowance {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  align-self: flex-start;
  margin: 8px 0 0;
  padding: 3px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text-secondary);
  font-size: var(--db-t-hint);
  font-weight: var(--weight-medium);
  line-height: 1.6;
  white-space: nowrap;
}

/* A small dot ahead of the figure, so the pill reads as a status rather than
   as a cropped sentence. Decorative, hence no text alternative. */
.ai-allowance::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  opacity: 0.55;
}

/* NONE LEFT IS THE ONE STATE WORTH INTERRUPTING FOR, and the only one that
   carries a full sentence rather than a figure, so it stops being a pill and
   becomes a line that can wrap. */
.ai-allowance.is-spent {
  display: block;
  white-space: normal;
  border-color: var(--border-strong);
  color: var(--text-primary);
}

.ai-allowance.is-spent::before { display: none; }

/* A <label> cannot be disabled, so while an import is running the drop zone and
   its button are marked instead. Pointer events go, because a label's whole
   purpose is to forward a click to the input behind it and that input being
   disabled is not obvious to somebody tapping the big target. */
.import-drop.is-busy,
.import-choose.is-busy {
  opacity: 0.5;
  pointer-events: none;
}

/* ============ "NOT RIGHT?": CORRECTING A SHARED VENUE ============
 *
 * One card, three screens, no map. It is opened from the Go Live form and from
 * a venue's own card on the dashboard, so it lives in dashboard.css, which both
 * screens load, and it is portalled to <body> by js/modal-portal.js for the
 * reason that file sets out at length: a view's section carries
 * container-type, and in WebKit that makes it the containing block for fixed
 * descendants, so `inset: 0` can resolve against a section thousands of pixels
 * tall and the card lands off the top of the window.
 *
 * NO MAP, and no room left for one. A map is a second interface for a question
 * that is answered by standing somewhere, and phase 1 answers it by asking. */
.vnr-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, .55);
  /* A DIALOGUE, AND IT IS LAUNCHED FROM THE VENUE MODAL. It was 60 against the
     modal's 90, so it opened behind the card it was about and looked like a
     dead button. See the ladder in css/shared.css. */
  z-index: var(--layer-dialog);
}
.vnr-card {
  position: fixed;
  z-index: calc(var(--layer-dialog) + 1);
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: min(92vw, 420px);
  max-height: 86vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg, 16px);
  padding: 20px 18px 18px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, .35);
}
.vnr-title {
  margin: 0 32px 14px 0;
  font-size: var(--db-t-h3, 1.05rem);
}
.vnr-x {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: none;
  border: 0;
  color: var(--text-secondary);
  font-size: 15px;
}
/* THE THREE THINGS, AS FULL-WIDTH TARGETS. A title and a line each, which is
   the shape the delete choices on the venues table already use, so this reads
   as the same kind of decision rather than as a new one. */
.vnr-option {
  display: block;
  width: 100%;
  text-align: left;
  padding: 12px 14px;
  margin-bottom: 8px;
  border-radius: var(--radius);
  background: var(--surface-sunken);
  border: 1px solid var(--border-strong);
  color: var(--text-primary);
  min-height: 56px;
}
.vnr-option-title { display: block; font-weight: var(--weight-medium); }
.vnr-option-body {
  display: block;
  margin-top: 2px;
  font-size: .85rem;
  color: var(--text-secondary);
}
.vnr-body { margin: 0 0 12px; color: var(--text-secondary); }
.vnr-ask { margin: 0 0 12px; font-weight: var(--weight-medium); }
.vnr-label { display: block; margin-bottom: 6px; font-size: .85rem; }
.vnr-input {
  width: 100%;
  height: 48px;
  padding: 0 12px;
  border-radius: var(--radius);
  background: var(--surface-sunken);
  border: 1px solid var(--border-strong);
  color: var(--text-primary);
  font-size: 16px;   /* never below 16px, or iOS zooms the page on focus */
  margin-bottom: 12px;
}
.vnr-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.vnr-row button { flex: 1 1 130px; min-height: 48px; }
.vnr-note { margin: 12px 0 0; font-size: .85rem; color: var(--text-secondary); }

/* The link on a venue's own card. Quiet, under the sub-line, because it is a
   rare action on a card that is mostly figures. */
.vm-notright {
  margin-top: 6px;
  padding: 0;
  min-height: 0;
  background: none;
  font-size: .85rem;
  text-decoration: underline;
}
